diff --git a/apps/web/README.md b/apps/web/README.md index 10cb453a..3c8ab00d 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -284,7 +284,7 @@ Until the host answers, locallibrary signs in as a local development app instead ### Token exchange -Some services a locallibrary plugin signs in to (Mixcloud, SoundCloud, Google) want a client secret when a sign-in's code is traded for tokens, or when they're refreshed, and a secret can't ship inside an app people download. A plugin's account names `https://localnetwork.computer/api/token-exchange/` as its `token_exchange` instead of a `token_url`, and locallibrary posts that address what it would have sent the service, as JSON. This app adds its client id and secret for the service from the environment, sends the request on the way the service takes it, and answers what the service answered. +Some services a locallibrary plugin signs in to (Mixcloud, SoundCloud, Google) want a client secret when a sign-in's code is traded for tokens, or when they're refreshed, and a secret can't ship inside an app people download. A plugin's account names `https://localnetwork.computer/api/token-exchange/` as its `token_exchange` instead of a `token_url`, and locallibrary posts that address what it would have sent the service, as JSON. This app adds its client id and secret for the service from the environment, sends the request on the way the service takes it, and answers what the service answered. It refuses a `redirect_uri` other than locallibrary's listener, `http://127.0.0.1:/callback` or `http://[::1]:/callback`, so a code sent to another site with Local Network's client can't be traded for that account's tokens. The services are in [`src/lib/server/token-exchange/services.json`](src/lib/server/token-exchange/services.json) (schema beside it): each one's token endpoint, how it takes a request (`form`, the usual POST, or `query`, Mixcloud's GET), and the environment variables holding its client id and secret. Adding a service is an entry there and its two variables. The exchange sends nothing anywhere but those token endpoints, logs which service and grant and the status, never a code, token or secret, reads at most 64 KiB of a request or an answer, and counts against the per-address write limit. Until a service's variables are set, it answers `503` with `temporarily_unavailable`, which locallibrary shows as the reason the sign-in failed. Regenerate the schema after changing what an entry has with `SCHEMA_BLESS=1 pnpm -C apps/web vitest run --project server src/lib/server/token-exchange/services.test.ts`. diff --git a/apps/web/src/lib/server/token-exchange/exchange.test.ts b/apps/web/src/lib/server/token-exchange/exchange.test.ts index 6868c881..84a3ddb4 100644 --- a/apps/web/src/lib/server/token-exchange/exchange.test.ts +++ b/apps/web/src/lib/server/token-exchange/exchange.test.ts @@ -81,7 +81,7 @@ const code = { grant_type: 'authorization_code', code: 'c0de', code_verifier: 'v3rifier', - redirect_uri: 'http://127.0.0.1:4/' + redirect_uri: 'http://127.0.0.1:4/callback' }; describe('exchangeToken', () => { @@ -216,6 +216,70 @@ describe('exchangeToken', () => { expect(sent).toEqual([]); }); + it.each(['http://127.0.0.1:47110/callback', 'http://[::1]:5/callback'])( + 'trades a code sent back to %s, locallibrary on this computer', + async (redirect) => { + const { sent, fetch } = services(); + + const answer = await exchangeToken('mixcloud', post({ ...code, redirect_uri: redirect }), { + log: capture().log, + env, + fetch + }); + + expect(answer.status).toBe(200); + expect(sent.map((s) => s.form.get('redirect_uri'))).toEqual([redirect]); + } + ); + + it.each([ + 'https://attacker.example/cb', + 'https://127.0.0.1:4/callback', + 'http://localhost:4/callback', + 'http://127.0.0.1/callback', + 'http://127.0.0.1:4/', + 'http://127.0.0.1:4/callback/', + 'http://127.0.0.1:4/callback?next=x', + 'http://127.0.0.1:4/callback#x', + 'http://a@127.0.0.1:1/callback', + 'http://127.0.0.1.attacker.example:1/callback', + 'http://0x7f.0.0.1:4/callback', + 'not a url' + ])('refuses a code sent back to %s, sending nothing on', async (redirect) => { + const { sent, fetch } = services(); + const { lines, log } = capture(); + + for (const service of ['mixcloud', 'soundcloud']) { + const answer = await exchangeToken(service, post({ ...code, redirect_uri: redirect }), { log, env, fetch }); + + expect([answer.status, await answer.json()]).toEqual([ + 400, + { + error: 'invalid_request', + error_description: 'Local Network only trades codes sent back to locallibrary on this computer' + } + ]); + } + + expect(sent).toEqual([]); + expect(lines.join('\n')).not.toContain(redirect); + }); + + it("refuses a refresh whose redirect_uri is somewhere else, and refreshes with locallibrary's", async () => { + const { sent, fetch } = services(); + const { log } = capture(); + + const refresh = (redirect: string) => + post({ grant_type: 'refresh_token', refresh_token: 'rt-1', redirect_uri: redirect }); + + const elsewhere = await exchangeToken('soundcloud', refresh('https://attacker.example/cb'), { log, env, fetch }); + expect(elsewhere.status).toBe(400); + expect(sent).toEqual([]); + + const home = await exchangeToken('soundcloud', refresh('http://127.0.0.1:47110/callback'), { log, env, fetch }); + expect(await home.json()).toMatchObject({ access_token: 'at-2' }); + }); + it('reads no more of a request than a token request needs', async () => { const { sent, fetch } = services(); const huge = post({ ...code, code: 'c'.repeat(70_000) }); diff --git a/apps/web/src/lib/server/token-exchange/exchange.ts b/apps/web/src/lib/server/token-exchange/exchange.ts index 7d695e41..508d7e42 100644 --- a/apps/web/src/lib/server/token-exchange/exchange.ts +++ b/apps/web/src/lib/server/token-exchange/exchange.ts @@ -3,6 +3,12 @@ // what it would have sent the service, and this adds the client id and secret from the // environment, sends it on to the service's token endpoint (only ever one in services.json), and // answers what the service answered. Codes, verifiers, tokens and secrets are never logged. +// +// It only trades codes sent back to locallibrary's listener on this computer: a redirect_uri, when +// a grant has one, must be `http://127.0.0.1:/callback` or `http://[::1]:/callback`, +// as locallibrary sends it. A service that only compares the redirect_uri with the authorize +// request's (Mixcloud, without PKCE) would otherwise trade a code that a link with Local Network's +// client sent to someone else's site, giving them that account's token. import { z } from 'zod'; import { parseJson } from '../db'; import type { Logger } from '../logger'; @@ -37,6 +43,17 @@ const answerSchema = z.looseObject({ access_token: z.string().optional(), token_ const NO_STORE = { 'cache-control': 'no-store' }; +const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]']); + +/** Whether `redirect` is locallibrary's listener, written as it writes it: no user, query, fragment or other spelling. */ +function isLoopbackCallback(redirect: string): boolean { + const url = URL.parse(redirect); + + return ( + url !== null && LOOPBACK_HOSTS.has(url.hostname) && url.port !== '' && redirect === `http://${url.host}/callback` + ); +} + /** An OAuth error answer (RFC 6749 section 5.2), which locallibrary shows as what the service said. */ function refuse(status: number, error: string, description: string): Response { return Response.json({ error, error_description: description }, { status, headers: NO_STORE }); @@ -99,6 +116,9 @@ export async function exchangeToken(id: string, request: Request, options: Excha if (!parsed.success) return refuse(400, 'invalid_request', "That isn't a token request locallibrary sends"); const grant = parsed.data; + + if (grant.redirect_uri !== undefined && !isLoopbackCallback(grant.redirect_uri)) + return refuse(400, 'invalid_request', 'Local Network only trades codes sent back to locallibrary on this computer'); const at = { service: id, grant: grant.grant_type }; const client = { client_id: env[service.clientIdEnv], client_secret: env[service.clientSecretEnv] };