From f4125ca69785589b24bc2dcef6a0083bc357fefe Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:31:06 -0400 Subject: [PATCH 1/2] feat(web): trade plugins' sign-in codes for tokens with Local Network's client secrets Mixcloud, SoundCloud and Google want a client secret when a code is traded for tokens (SoundCloud at every refresh too), and a secret can't ship inside an app people download. POST /api/token-exchange/ takes the token request locallibrary would have sent the service, as JSON, adds the client id and secret from the environment, sends it to the service's token endpoint the way that service takes it, and answers what the service answered. The services live in src/lib/server/token-exchange/services.json, with a schema generated from the zod schema that reads it. A service without its two environment variables answers 503 temporarily_unavailable. The exchange only sends to the token endpoints listed there, follows no redirect, reads at most 64 KiB of a request or an answer, and logs the service, grant and status, never a code, token or secret. --- apps/web/.env.example | 11 + apps/web/README.md | 9 + .../server/token-exchange/exchange.test.ts | 266 ++++++++++++++++++ .../src/lib/server/token-exchange/exchange.ts | 143 ++++++++++ .../lib/server/token-exchange/services.json | 26 ++ .../token-exchange/services.schema.json | 53 ++++ .../server/token-exchange/services.test.ts | 32 +++ .../src/lib/server/token-exchange/services.ts | 37 +++ .../api/token-exchange/[service]/+server.ts | 6 + 9 files changed, 583 insertions(+) create mode 100644 apps/web/src/lib/server/token-exchange/exchange.test.ts create mode 100644 apps/web/src/lib/server/token-exchange/exchange.ts create mode 100644 apps/web/src/lib/server/token-exchange/services.json create mode 100644 apps/web/src/lib/server/token-exchange/services.schema.json create mode 100644 apps/web/src/lib/server/token-exchange/services.test.ts create mode 100644 apps/web/src/lib/server/token-exchange/services.ts create mode 100644 apps/web/src/routes/api/token-exchange/[service]/+server.ts diff --git a/apps/web/.env.example b/apps/web/.env.example index 672840d9..cdcc086c 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -40,6 +40,17 @@ HAPPYVIEW_URL=https://happyview.localnetwork.computer # it, "Report a bug" opens GitHub's issue form for everyone. # USERINPUT_SPACE= +# Optional: Local Network's clients at the services locallibrary plugins sign in to +# through /api/token-exchange/, whose token endpoints want a client secret. +# src/lib/server/token-exchange/services.json lists the services and their variables. +# A service without both answers that Local Network isn't set up for it yet. +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= +# MIXCLOUD_CLIENT_ID= +# MIXCLOUD_CLIENT_SECRET= +# SOUNDCLOUD_CLIENT_ID= +# SOUNDCLOUD_CLIENT_SECRET= + # Identity resolution. PLC_URL=https://plc.directory # HANDLE_RESOLVER_URL= # dev only: a PDS used to resolve handles like alice.test diff --git a/apps/web/README.md b/apps/web/README.md index 352539a0..7d487e12 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -223,6 +223,7 @@ See [`.env.example`](.env.example) for every option. The essentials: - `HAPPYVIEW_API_KEY`: a HappyView API key with `lexicons:read`, `lexicons:create`, `scripts:read`, `scripts:manage`, `backfill:create`, and `backfill:read`. Optional; without it, upload the lexicons and scripts yourself, and the set picker only lists sets HappyView heard about over Jetstream. - `LOCALLIBRARY_HOST`: [locallibrary's own host](#tools-pages-and-hosts) (default `locallibrary.localnetwork.computer`). - `USERINPUT_SPACE`: Local Network's space on [userinput.app](https://userinput.app), as `at:///app.userinput.space/`. See [Bug reports](#bug-reports). Optional; without it, "Report a bug" opens GitHub's issue form. +- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`, `MIXCLOUD_CLIENT_ID` / `MIXCLOUD_CLIENT_SECRET`, `SOUNDCLOUD_CLIENT_ID` / `SOUNDCLOUD_CLIENT_SECRET`: Local Network's clients at the services locallibrary plugins sign in to through the [token exchange](#token-exchange). Optional; a service without both answers that it isn't set up yet. ## Deploying @@ -261,6 +262,12 @@ locallibrary account client-metadata -o src/lib/tools/locallibrary/oauth-client- Until the host answers, locallibrary signs in as a local development app instead (servers ask about it every time). +### 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. + +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`. + ## Security notes - OAuth uses DPoP-bound tokens and granular scopes: a permission set ([`permissions.json`](../../lexicons/computer/localnetwork/permissions.json), requested as `include:computer.localnetwork.permissions`) with write-only access to the user's own repos in the audit, moderation, booking, and backstage spaces and a self-authority planner space, and to the shows and follows in their public repo (plus deleting press kits made before the root held them, as they move), plus blob uploads limited to PNG, JPEG, and WebP avatars and JSON tracklists, and calendar events (`repo:community.lexicon.calendar.event`, asked for on its own since a permission set can only grant its own namespace). The PDS grants the set as the scopes it lists. Accounts without spaces support are asked for `atproto` plus the show, follow, and calendar event writes, and fall back to plain `atproto` if their PDS refuses those; they, and anyone who approves only part of the request, have the root account write their space records on their behalf. Sessions granted before the app asked for a new space or blob scope are sent back through sign-in on their next page load. Show, follow, and calendar event writes are asked for when they're needed instead, with a one-click "Sign in again"; a PDS that still won't grant them after that gets a plain "your account's server doesn't allow this yet". Posting bug reports to userinput.app is asked for separately, when someone first sends one, and only allows creating `app.userinput.discussion` records. Nothing lets the app read other users' data with a user's session; shared spaces are read with credentials issued to the root authority. @@ -269,6 +276,7 @@ Until the host answers, locallibrary signs in as a local development app instead - Service endpoints from DID documents are checked against private address ranges in production (SSRF guard). - Uploaded images are type- and magic-byte-checked and capped at 1 MB; images are served from a dedicated route with a locked-down CSP. - Rate limits on sign-in, writes, and city search. +- The [token exchange](#token-exchange) only sends requests to the token endpoints `services.json` lists, and never logs what it relays. - Spaces are access control, not end-to-end encryption: the hosting PDSes and this app can read the data they handle. ## Project layout @@ -285,6 +293,7 @@ src/lib/server/audit/ Hash-chained audit log with dual atproto deliver src/lib/server/happyview/ HappyView client, script bundling, and boot sync src/lib/server/services/ Projects, requests, staff, bookings, planner src/lib/server/sync/ Space syncer and record projections +src/lib/server/token-exchange/ The token exchange for locallibrary plugins, and the services it signs in to src/lib/server/tools/ The tools' own hosts, and their guides rendered to HTML src/lib/tools/ The tools: one entry per tool, with its guides bundled from apps// src/routes/ SvelteKit 3 pages, form actions, and endpoints diff --git a/apps/web/src/lib/server/token-exchange/exchange.test.ts b/apps/web/src/lib/server/token-exchange/exchange.test.ts new file mode 100644 index 00000000..6868c881 --- /dev/null +++ b/apps/web/src/lib/server/token-exchange/exchange.test.ts @@ -0,0 +1,266 @@ +import pino from 'pino'; +import { describe, expect, it } from 'vitest'; +import { exchangeToken } from './exchange'; + +const env = { + SOUNDCLOUD_CLIENT_ID: 'sc-client', + SOUNDCLOUD_CLIENT_SECRET: 'sc-secret', + MIXCLOUD_CLIENT_ID: 'mc-client', + MIXCLOUD_CLIENT_SECRET: 'mc-secret' +}; + +/** Everything here a log line must never hold. */ +const SECRETS = ['sc-secret', 'mc-secret', 'c0de', 'v3rifier', 'rt-1', 'at-1', 'at-2', 'rt-2']; + +type Sent = { method: string; url: URL; form: URLSearchParams }; + +/** + * Stand-ins for the services' token endpoints, at the addresses services.json names: SoundCloud + * takes a form with the client's secret and rotates refresh tokens, Mixcloud takes a GET with + * its query and answers only an access token. Each answers an error for a client it doesn't know. + */ +function services() { + const sent: Sent[] = []; + + const fetch = async (input: RequestInfo | URL): Promise => { + const request = new Request(input); + const url = new URL(request.url); + const form = request.method === 'POST' ? new URLSearchParams(await request.text()) : url.searchParams; + sent.push({ method: request.method, url, form }); + + if (url.origin === 'https://secure.soundcloud.com' && url.pathname === '/oauth/token') { + const known = form.get('client_id') === 'sc-client' && form.get('client_secret') === 'sc-secret'; + + if (!known) return Response.json({ error: 'invalid_client' }, { status: 401 }); + + if (form.get('grant_type') === 'refresh_token' && form.get('refresh_token') !== 'rt-1') + return Response.json({ error: 'invalid_grant', error_description: 'spent' }, { status: 400 }); + const n = form.get('grant_type') === 'refresh_token' ? 2 : 1; + + return Response.json({ + access_token: `at-${n}`, + token_type: 'bearer', + expires_in: 3600, + refresh_token: `rt-${n}` + }); + } + + if (url.origin === 'https://www.mixcloud.com' && url.pathname === '/oauth/access_token') { + const known = form.get('client_id') === 'mc-client' && form.get('client_secret') === 'mc-secret'; + + return known + ? Response.json({ access_token: 'at-1' }) + : Response.json({ error: 'incorrect_client_credentials' }, { status: 400 }); + } + + return new Response('not found', { status: 404 }); + }; + + return { sent, fetch }; +} + +/** A logger writing into `lines`, to check what it says and never says. */ +function capture() { + const lines: string[] = []; + + return { lines, log: pino({ level: 'debug' }, { write: (line: string) => lines.push(line) }) }; +} + +/** What locallibrary posts: `text`, said to be JSON. */ +function posted(text: string): Request { + return new Request('http://localnetwork.test/api/token-exchange/x', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: text + }); +} + +const post = (grant: Record) => posted(JSON.stringify(grant)); + +const code = { + grant_type: 'authorization_code', + code: 'c0de', + code_verifier: 'v3rifier', + redirect_uri: 'http://127.0.0.1:4/' +}; + +describe('exchangeToken', () => { + it("sends SoundCloud the code with Local Network's client and secret, and answers its tokens as they were", async () => { + const { sent, fetch } = services(); + const { lines, log } = capture(); + + const answer = await exchangeToken('soundcloud', post({ ...code, client_id: 'the-plugins' }), { log, env, fetch }); + + expect(answer.status).toBe(200); + expect(answer.headers.get('cache-control')).toBe('no-store'); + expect(await answer.json()).toEqual({ + access_token: 'at-1', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'rt-1' + }); + expect(sent.map((s) => [s.method, Object.fromEntries(s.form)])).toEqual([ + ['POST', { ...code, client_id: 'sc-client', client_secret: 'sc-secret' }] + ]); + expect(lines.join('\n')).toContain('"service":"soundcloud"'); + expect(SECRETS.filter((secret) => lines.join('\n').includes(secret))).toEqual([]); + }); + + it('refreshes, answering the new refresh token, and passes on what the service refuses with its status', async () => { + const { sent, fetch } = services(); + const { lines, log } = capture(); + const refresh = (token: string) => post({ grant_type: 'refresh_token', refresh_token: token }); + + const fresh = await exchangeToken('soundcloud', refresh('rt-1'), { log, env, fetch }); + expect(await fresh.json()).toMatchObject({ access_token: 'at-2', refresh_token: 'rt-2' }); + expect(Object.fromEntries(sent[0].form)).toEqual({ + grant_type: 'refresh_token', + refresh_token: 'rt-1', + client_id: 'sc-client', + client_secret: 'sc-secret' + }); + + const spent = await exchangeToken('soundcloud', refresh('rt-0'), { log, env, fetch }); + expect([spent.status, await spent.json()]).toEqual([400, { error: 'invalid_grant', error_description: 'spent' }]); + + const wrong = await exchangeToken('soundcloud', refresh('rt-1'), { + log, + env: { ...env, SOUNDCLOUD_CLIENT_SECRET: 'stale' }, + fetch + }); + + expect([wrong.status, await wrong.json()]).toEqual([401, { error: 'invalid_client' }]); + expect(SECRETS.filter((secret) => lines.join('\n').includes(secret))).toEqual([]); + }); + + it('asks Mixcloud with a GET and its query, filling in the token type, and never asks it to refresh', async () => { + const { sent, fetch } = services(); + const { log } = capture(); + + const answer = await exchangeToken('mixcloud', post(code), { log, env, fetch }); + + expect(await answer.json()).toEqual({ access_token: 'at-1', token_type: 'bearer' }); + expect(sent.map((s) => [s.method, s.url.origin + s.url.pathname, Object.fromEntries(s.form)])).toEqual([ + [ + 'GET', + 'https://www.mixcloud.com/oauth/access_token', + { client_id: 'mc-client', client_secret: 'mc-secret', redirect_uri: code.redirect_uri, code: 'c0de' } + ] + ]); + + const refused = await exchangeToken('mixcloud', post({ grant_type: 'refresh_token', refresh_token: 'rt-1' }), { + log, + env, + fetch + }); + + expect([refused.status, await refused.json()]).toEqual([ + 400, + { error: 'unsupported_grant_type', error_description: "Mixcloud doesn't refresh tokens" } + ]); + + const unknown = await exchangeToken('mixcloud', post(code), { + log, + env: { ...env, MIXCLOUD_CLIENT_ID: 'x' }, + fetch + }); + + expect([unknown.status, await unknown.json()]).toEqual([400, { error: 'incorrect_client_credentials' }]); + expect(sent).toHaveLength(2); + }); + + it.each(['bandcamp', '__proto__', 'constructor', 'toString'])( + 'refuses %s, a service it has no entry for', + async (id) => { + const { sent, fetch } = services(); + + const answer = await exchangeToken(id, post(code), { log: capture().log, env, fetch }); + + expect([answer.status, await answer.json()]).toEqual([ + 404, + { error: 'invalid_request', error_description: `Local Network doesn't sign in to "${id}"` } + ]); + expect(sent).toEqual([]); + } + ); + + it("says it isn't set up for a service whose client id or secret isn't in the environment", async () => { + const { sent, fetch } = services(); + const { lines, log } = capture(); + + for (const missing of [{}, { GOOGLE_CLIENT_ID: 'g-client' }, { GOOGLE_CLIENT_ID: 'g', GOOGLE_CLIENT_SECRET: '' }]) { + const answer = await exchangeToken('google', post(code), { log, env: missing, fetch }); + + expect([answer.status, await answer.json()]).toEqual([ + 503, + { error: 'temporarily_unavailable', error_description: "Local Network isn't set up to sign in to Google yet" } + ]); + expect(answer.headers.get('cache-control')).toBe('no-store'); + } + + expect(sent).toEqual([]); + expect(lines[0]).toContain('"env":["GOOGLE_CLIENT_ID","GOOGLE_CLIENT_SECRET"]'); + }); + + it.each([ + ['not JSON', 'code=c0de'], + ['another grant', JSON.stringify({ grant_type: 'password', username: 'a', password: 'b' })], + ['a code without its redirect_uri', JSON.stringify({ grant_type: 'authorization_code', code: 'c0de' })], + ['an empty refresh token', JSON.stringify({ grant_type: 'refresh_token', refresh_token: '' })] + ])('refuses %s', async (_, body) => { + const { sent, fetch } = services(); + + const answer = await exchangeToken('soundcloud', posted(body), { log: capture().log, env, fetch }); + + expect([answer.status, (await answer.json()).error]).toEqual([400, 'invalid_request']); + expect(sent).toEqual([]); + }); + + 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) }); + + const answer = await exchangeToken('soundcloud', huge, { log: capture().log, env, fetch }); + + expect([answer.status, (await answer.json()).error_description]).toEqual([413, 'The token request is too large']); + expect(sent).toEqual([]); + }); + + it("says when the service can't be reached or answers what isn't a token response, logging no secret", async () => { + const { lines, log } = capture(); + + // The address Mixcloud is asked at holds the code and secret, and an error may carry it. + const down = async (input: RequestInfo | URL) => { + const cause = new Error('connect ECONNREFUSED 127.0.0.1:443'); + throw Object.assign(new TypeError('fetch failed', { cause }), { request: new Request(input).url }); + }; + + const unreachable = await exchangeToken('mixcloud', post(code), { log, env, fetch: down }); + expect([unreachable.status, await unreachable.json()]).toEqual([ + 502, + { error: 'temporarily_unavailable', error_description: "Mixcloud isn't answering" } + ]); + expect(lines.join('\n')).toContain('fetch failed: connect ECONNREFUSED'); + + const slow = async () => { + throw new DOMException('The operation was aborted due to timeout', 'TimeoutError'); + }; + + const late = await exchangeToken('soundcloud', post(code), { log, env, fetch: slow }); + + expect(late.status).toBe(502); + expect(lines.join('\n')).toContain('"reason":"The operation was aborted due to timeout"'); + + for (const body of ['maintenance', JSON.stringify({ a: 'b'.repeat(70_000) }), '["at-1"]', null]) { + const odd = async () => new Response(body, { status: body === null ? 502 : 200 }); + const answer = await exchangeToken('soundcloud', post(code), { log, env, fetch: odd }); + + expect([answer.status, (await answer.json()).error_description]).toEqual([ + 502, + "SoundCloud answered something that isn't a token response" + ]); + } + + expect(SECRETS.filter((secret) => lines.join('\n').includes(secret))).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/server/token-exchange/exchange.ts b/apps/web/src/lib/server/token-exchange/exchange.ts new file mode 100644 index 00000000..7d695e41 --- /dev/null +++ b/apps/web/src/lib/server/token-exchange/exchange.ts @@ -0,0 +1,143 @@ +// Local Network's token exchange: locallibrary plugins sign in to services whose token endpoint +// wants a client secret, which can't ship inside an app people download. locallibrary sends here +// 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. +import { z } from 'zod'; +import { parseJson } from '../db'; +import type { Logger } from '../logger'; +import { type Service, services } from './services'; + +/** The most of a request, or of a service's answer, read: either is a few kilobytes. */ +const MAX_BYTES = 64 * 1024; + +const TIMEOUT_MS = 10_000; + +const value = z.string().min(1).max(8192); + +/** + * The token request locallibrary would have sent the service, as JSON (SvelteKit refuses a form + * posted without an Origin header, as it would a browser's from another site). Whatever else it + * has, its client_id among them, is dropped. + */ +const grantSchema = z.discriminatedUnion('grant_type', [ + z.object({ + grant_type: z.literal('authorization_code'), + code: value, + redirect_uri: value, + code_verifier: value.optional() + }), + z.object({ grant_type: z.literal('refresh_token'), refresh_token: value, redirect_uri: value.optional() }) +]); + +type Grant = z.infer; + +/** A token endpoint's answer, success or error: a JSON object, kept whole. */ +const answerSchema = z.looseObject({ access_token: z.string().optional(), token_type: z.string().optional() }); + +const NO_STORE = { 'cache-control': 'no-store' }; + +/** 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 }); +} + +/** `message`'s body as text, or undefined past `MAX_BYTES`, without reading more than that. */ +async function readCapped(message: Request | Response): Promise { + if (!message.body) return ''; + const chunks: Uint8Array[] = []; + let size = 0; + + for await (const chunk of message.body) { + size += chunk.byteLength; + + if (size > MAX_BYTES) return undefined; + chunks.push(chunk); + } + + return Buffer.concat(chunks).toString('utf8'); +} + +/** The request to `service`'s token endpoint for `grant`, the way the service takes it. */ +function upstream(service: Service, grant: Grant, client: { client_id: string; client_secret: string }): Request { + const headers = { accept: 'application/json' }; + + if (service.style === 'query' && grant.grant_type === 'authorization_code') { + const url = new URL(service.tokenUrl); + const query = { ...client, redirect_uri: grant.redirect_uri, code: grant.code }; + + for (const [key, param] of Object.entries(query)) url.searchParams.set(key, param); + + return new Request(url, { headers }); + } + + return new Request(service.tokenUrl, { + method: 'POST', + headers: { ...headers, 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ ...grant, ...client }) + }); +} + +export type ExchangeOptions = { + log: Logger; + /** Where the services' client ids and secrets are read from. */ + env?: Record; + fetch?: typeof fetch; +}; + +/** Trades a code or refresh token for tokens at the service `id` names, with Local Network's client there. */ +export async function exchangeToken(id: string, request: Request, options: ExchangeOptions): Promise { + const { log, env = process.env, fetch: send = fetch } = options; + const service = Object.hasOwn(services, id) ? services[id] : undefined; + + if (!service) return refuse(404, 'invalid_request', `Local Network doesn't sign in to "${id.slice(0, 64)}"`); + + const body = await readCapped(request); + + if (body === undefined) return refuse(413, 'invalid_request', 'The token request is too large'); + const parsed = grantSchema.safeParse(parseJson(body, undefined)); + + if (!parsed.success) return refuse(400, 'invalid_request', "That isn't a token request locallibrary sends"); + const grant = parsed.data; + const at = { service: id, grant: grant.grant_type }; + const client = { client_id: env[service.clientIdEnv], client_secret: env[service.clientSecretEnv] }; + + if (!client.client_id || !client.client_secret) { + log.warn({ ...at, env: [service.clientIdEnv, service.clientSecretEnv] }, 'token exchange not configured'); + + return refuse(503, 'temporarily_unavailable', `Local Network isn't set up to sign in to ${service.title} yet`); + } + + if (service.style === 'query' && grant.grant_type !== 'authorization_code') + return refuse(400, 'unsupported_grant_type', `${service.title} doesn't refresh tokens`); + + const started = performance.now(); + const configured = { client_id: client.client_id, client_secret: client.client_secret }; + let answer: Response; + + try { + // A token endpoint doesn't redirect, and following one would send the secret along. + answer = await send(upstream(service, grant, configured), { + redirect: 'error', + signal: AbortSignal.timeout(TIMEOUT_MS) + }); + } catch (error) { + // Only the messages: a request's address can hold the code and secret, which an error's other fields may carry. + const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : ''; + const reason = error instanceof Error ? `${error.message}${cause}` : 'unknown'; + log.warn({ ...at, reason }, 'token endpoint unreachable'); + + return refuse(502, 'temporarily_unavailable', `${service.title} isn't answering`); + } + + const tokens = answerSchema.safeParse(parseJson(await readCapped(answer), undefined)); + log.info({ ...at, status: answer.status, ms: Math.round(performance.now() - started) }, 'token exchange'); + + if (!tokens.success) + return refuse(502, 'temporarily_unavailable', `${service.title} answered something that isn't a token response`); + + // OAuth clients need a token_type, which Mixcloud doesn't document sending. + if (answer.ok && tokens.data.access_token !== undefined) tokens.data.token_type ??= 'bearer'; + + return Response.json(tokens.data, { status: answer.status, headers: NO_STORE }); +} diff --git a/apps/web/src/lib/server/token-exchange/services.json b/apps/web/src/lib/server/token-exchange/services.json new file mode 100644 index 00000000..8dacdb1d --- /dev/null +++ b/apps/web/src/lib/server/token-exchange/services.json @@ -0,0 +1,26 @@ +{ + "$schema": "./services.schema.json", + "services": { + "google": { + "title": "Google", + "tokenUrl": "https://oauth2.googleapis.com/token", + "style": "form", + "clientIdEnv": "GOOGLE_CLIENT_ID", + "clientSecretEnv": "GOOGLE_CLIENT_SECRET" + }, + "mixcloud": { + "title": "Mixcloud", + "tokenUrl": "https://www.mixcloud.com/oauth/access_token", + "style": "query", + "clientIdEnv": "MIXCLOUD_CLIENT_ID", + "clientSecretEnv": "MIXCLOUD_CLIENT_SECRET" + }, + "soundcloud": { + "title": "SoundCloud", + "tokenUrl": "https://secure.soundcloud.com/oauth/token", + "style": "form", + "clientIdEnv": "SOUNDCLOUD_CLIENT_ID", + "clientSecretEnv": "SOUNDCLOUD_CLIENT_SECRET" + } + } +} diff --git a/apps/web/src/lib/server/token-exchange/services.schema.json b/apps/web/src/lib/server/token-exchange/services.schema.json new file mode 100644 index 00000000..52d25d9b --- /dev/null +++ b/apps/web/src/lib/server/token-exchange/services.schema.json @@ -0,0 +1,53 @@ +{ + "$id": "services.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "services": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "description": "The service as people know it, for error messages: \"SoundCloud\"." + }, + "tokenUrl": { + "type": "string", + "format": "uri", + "description": "The service's token endpoint. The exchange sends nothing anywhere else." + }, + "style": { + "type": "string", + "enum": ["form", "query"], + "description": "How the service takes a token request. form: a POST with a form body, client_id and client_secret in it, for codes and refresh tokens (RFC 6749). query: a GET with client_id, client_secret, redirect_uri and code in the query, for codes only (Mixcloud)." + }, + "clientIdEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "description": "The environment variable holding Local Network's client id at the service." + }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "description": "The environment variable holding that client's secret." + } + }, + "required": ["title", "tokenUrl", "style", "clientIdEnv", "clientSecretEnv"], + "additionalProperties": false + }, + "description": "By the id a plugin's account names at the end of its token_exchange address." + } + }, + "required": ["services"], + "additionalProperties": false, + "description": "The services Local Network's token exchange (/api/token-exchange/) signs locallibrary plugins in to, adding the client secret only this server holds." +} diff --git a/apps/web/src/lib/server/token-exchange/services.test.ts b/apps/web/src/lib/server/token-exchange/services.test.ts new file mode 100644 index 00000000..f4ec4431 --- /dev/null +++ b/apps/web/src/lib/server/token-exchange/services.test.ts @@ -0,0 +1,32 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { expect, test } from 'vitest'; +import { z } from 'zod'; +import { services, servicesSchema } from './services'; + +/** services.schema.json is the registry's schema as services.ts reads it, for editors: regenerate it after changing that. */ +const RERUN = + 'SCHEMA_BLESS=1 pnpm -C apps/web vitest run --project server src/lib/server/token-exchange/services.test.ts'; + +const path = new URL('./services.schema.json', import.meta.url); + +test('services.schema.json matches the schema services.ts reads services.json with', () => { + const expected = { $id: 'services.schema.json', ...z.toJSONSchema(servicesSchema) }; + + if (process.env.SCHEMA_BLESS) writeFileSync(path, `${JSON.stringify(expected, null, '\t')}\n`); + expect( + JSON.parse(readFileSync(path, 'utf8')), + `services.schema.json is out of date. Regenerate it with \`${RERUN}\`` + ).toEqual(expected); +}); + +test('services.json has the services plugins sign in to, each at its https token endpoint', () => { + expect(Object.keys(services).toSorted()).toEqual(['google', 'mixcloud', 'soundcloud']); + expect(Object.values(services).map((service) => new URL(service.tokenUrl).protocol)).toEqual([ + 'https:', + 'https:', + 'https:' + ]); + expect(() => + servicesSchema.parse({ services: { x: { ...services.google, tokenUrl: 'http://a.example/t' } } }) + ).toThrow('Invalid URL'); +}); diff --git a/apps/web/src/lib/server/token-exchange/services.ts b/apps/web/src/lib/server/token-exchange/services.ts new file mode 100644 index 00000000..23e44334 --- /dev/null +++ b/apps/web/src/lib/server/token-exchange/services.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import registry from './services.json' with { type: 'json' }; + +const envName = z.string().regex(/^[A-Z][A-Z0-9_]*$/); + +const service = z + .object({ + title: z.string().min(1).describe('The service as people know it, for error messages: "SoundCloud".'), + tokenUrl: z + .url({ protocol: /^https$/ }) + .describe("The service's token endpoint. The exchange sends nothing anywhere else."), + style: z + .enum(['form', 'query']) + .describe( + 'How the service takes a token request. form: a POST with a form body, client_id and client_secret in it, for codes and refresh tokens (RFC 6749). query: a GET with client_id, client_secret, redirect_uri and code in the query, for codes only (Mixcloud).' + ), + clientIdEnv: envName.describe("The environment variable holding Local Network's client id at the service."), + clientSecretEnv: envName.describe("The environment variable holding that client's secret.") + }) + .strict(); + +export const servicesSchema = z + .object({ + $schema: z.string().optional(), + services: z + .record(z.string().regex(/^[a-z0-9-]+$/), service) + .describe("By the id a plugin's account names at the end of its token_exchange address.") + }) + .strict() + .describe( + "The services Local Network's token exchange (/api/token-exchange/) signs locallibrary plugins in to, adding the client secret only this server holds." + ); + +export type Service = z.infer; + +/** The checked-in registry: a bad edit fails its test, and every exchange until it is put right. */ +export const services: Record = servicesSchema.parse(registry).services; diff --git a/apps/web/src/routes/api/token-exchange/[service]/+server.ts b/apps/web/src/routes/api/token-exchange/[service]/+server.ts new file mode 100644 index 00000000..48cb4b81 --- /dev/null +++ b/apps/web/src/routes/api/token-exchange/[service]/+server.ts @@ -0,0 +1,6 @@ +import { exchangeToken } from '#lib/server/token-exchange/exchange.ts'; +import type { RequestHandler } from './$types'; + +// locallibrary's plugins sign in to services that want a client secret through here: see exchange.ts. +export const POST: RequestHandler = ({ params, request, locals }) => + exchangeToken(params.service, request, { log: locals.log.child({ component: 'token-exchange' }) }); From c58febf7b4a426975340f4c63aad6b58f54a7eb8 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:31:08 -0400 Subject: [PATCH 2/2] feat(loclib-plugin): let an account trade its tokens at Local Network's token exchange An account may name token_exchange in token_url's place: Local Network's https://localnetwork.computer/api/token-exchange/, or the same path on this computer for a test server. Any other address is refused, since the exchange is sent the user's codes and refresh tokens, and an account names exactly one of the two. The host sends the exchange the same fields it would have sent the token endpoint, as a JSON object, because SvelteKit refuses a form posted without an Origin header. Signing in, refreshing and what an error says work as they do with a token endpoint. The RPC contract, the crate README, the plugins guide and the changelog say so. --- apps/locallibrary/CHANGELOG.md | 4 +- apps/locallibrary/docs/plugins.md | 8 + apps/locallibrary/docs/rpc/locallibrary.d.ts | 15 +- apps/locallibrary/docs/rpc/schema.json | 17 ++- crates/locallibrary/src/plugin/accounts.rs | 26 +++- .../locallibrary/tests/it/plugins/accounts.rs | 78 ++++++++-- crates/loclib-plugin/README.md | 20 ++- crates/loclib-plugin/src/hooks/accounts.rs | 77 +++++++--- crates/loclib-plugin/src/manifest/tests.rs | 139 +++++++++++++++++- 9 files changed, 330 insertions(+), 54 deletions(-) diff --git a/apps/locallibrary/CHANGELOG.md b/apps/locallibrary/CHANGELOG.md index f4b95a9b..cf6df3e9 100644 --- a/apps/locallibrary/CHANGELOG.md +++ b/apps/locallibrary/CHANGELOG.md @@ -351,7 +351,9 @@ Windows builds are experimental and untested on hardware (see Platforms in the R - **Plugins' accounts and destinations.** A plugin may have accounts on other services, which locallibrary signs you in to in the browser (`plugin account PLUGIN ACCOUNT sign-in`, or the player's palette), keeping the tokens in the keychain and refreshing them itself: the plugin - never sees one. A plugin may keep a copy of the store (`store copy --to PLUGIN:DESTINATION`, + never sees one. A service that wants a client secret for its tokens (Mixcloud, SoundCloud, + Google) is signed in to through Local Network's token exchange, which holds the secret, so + none ships with locallibrary. A plugin may keep a copy of the store (`store copy --to PLUGIN:DESTINATION`, `store restore --from`, `drive verify --from`), every file it gives back checked against its hash, or publish a set with its recording (`sets publish SET --to`), after a dry run you confirm, the link it answers kept in the set. The sidecar has both as `plugins.account*`, diff --git a/apps/locallibrary/docs/plugins.md b/apps/locallibrary/docs/plugins.md index bdd93d2d..562402b5 100644 --- a/apps/locallibrary/docs/plugins.md +++ b/apps/locallibrary/docs/plugins.md @@ -86,6 +86,14 @@ and refreshed a minute before they expire. The plugin never sees them: a request the account as its `Authorization` header (`{account:ID}`), and locallibrary fills in the token once it has checked the request, never sending it on to another server a redirect leads to. +Some services (Mixcloud, SoundCloud, Google) want a client secret when the code is traded for +tokens, or when they're refreshed. A secret can't ship inside locallibrary, so a plugin's account +on one of those is signed in to through Local Network's token exchange +(`localnetwork.computer/api/token-exchange/`), which adds the secret and passes the +tokens back. The website never keeps them, and the exchange is the only server besides the +service that a sign-in's code goes to. Until the website is set up for a service, signing in +says so. + ## Analysis An analyzer may also work out a track's beat grid, phrases and waveform (fields of type `beats`, diff --git a/apps/locallibrary/docs/rpc/locallibrary.d.ts b/apps/locallibrary/docs/rpc/locallibrary.d.ts index 51a9cf02..309fc2d4 100644 --- a/apps/locallibrary/docs/rpc/locallibrary.d.ts +++ b/apps/locallibrary/docs/rpc/locallibrary.d.ts @@ -7914,12 +7914,21 @@ export type Account = { authorize_url: string; /** * Its token endpoint, where the host trades the browser's code for tokens and refreshes - * them (https). + * them (https). An account has this or `token_exchange`. */ - token_url: string; + token_url?: string | null; + /** + * Local Network's token exchange for its service + * (`https://localnetwork.computer/api/token-exchange/`), in `token_url`'s place, for + * a service that wants a client secret there. + */ + token_exchange?: string | null; /** What it asks the service for, as the service names them. */ scopes?: string[]; - /** The plugin's client at the service: a public one, without a secret. */ + /** + * The plugin's client at the service: a public one, without a secret, or Local Network's, + * whose secret its token exchange holds. + */ client_id: string; /** Signing in uses PKCE: always, and a manifest saying otherwise is refused. */ pkce: boolean; diff --git a/apps/locallibrary/docs/rpc/schema.json b/apps/locallibrary/docs/rpc/schema.json index 3447277b..091e2824 100644 --- a/apps/locallibrary/docs/rpc/schema.json +++ b/apps/locallibrary/docs/rpc/schema.json @@ -30789,8 +30789,18 @@ "description": "The service's authorization endpoint, where the browser signs in (https)." }, "token_url": { - "type": "string", - "description": "Its token endpoint, where the host trades the browser's code for tokens and refreshes\nthem (https)." + "type": [ + "string", + "null" + ], + "description": "Its token endpoint, where the host trades the browser's code for tokens and refreshes\nthem (https). An account has this or `token_exchange`." + }, + "token_exchange": { + "type": [ + "string", + "null" + ], + "description": "Local Network's token exchange for its service\n(`https://localnetwork.computer/api/token-exchange/`), in `token_url`'s place, for\na service that wants a client secret there." }, "scopes": { "type": "array", @@ -30801,7 +30811,7 @@ }, "client_id": { "type": "string", - "description": "The plugin's client at the service: a public one, without a secret." + "description": "The plugin's client at the service: a public one, without a secret, or Local Network's,\nwhose secret its token exchange holds." }, "pkce": { "type": "boolean", @@ -30820,7 +30830,6 @@ "id", "title", "authorize_url", - "token_url", "client_id", "pkce" ], diff --git a/crates/locallibrary/src/plugin/accounts.rs b/crates/locallibrary/src/plugin/accounts.rs index e5a12278..1e1893a7 100644 --- a/crates/locallibrary/src/plugin/accounts.rs +++ b/crates/locallibrary/src/plugin/accounts.rs @@ -6,7 +6,9 @@ //! `{account:}` gets `Bearer` and the access token instead, once it's checked (see //! [`super::net`]), refreshed first when it's within [`REFRESH_BEFORE`] of expiring. The service's //! endpoints are reached as `net/fetch` reaches servers: at public https addresses (servers on -//! this computer in tests), following no redirect. +//! this computer in tests), following no redirect. An account whose service wants a client secret +//! for its tokens names Local Network's token exchange instead of the service's token endpoint: +//! the host sends it the same request as JSON, and it adds the secret and answers as the service. use std::io::Read; use std::net::TcpListener; @@ -163,9 +165,10 @@ impl Plugin { let bad = |what: &str, e: oauth2::url::ParseError| { self.misbehaved(format!("its {} account's {what} can't be used: {e}", account.title)) }; + let token_url = account.token_url.as_ref().or(account.token_exchange.as_ref()).cloned().unwrap_or_default(); let client = BasicClient::new(ClientId::new(account.client_id.clone())) .set_auth_uri(AuthUrl::new(account.authorize_url.clone()).map_err(|e| bad("authorize_url", e))?) - .set_token_uri(TokenUrl::new(account.token_url.clone()).map_err(|e| bad("token_url", e))?); + .set_token_uri(TokenUrl::new(token_url).map_err(|e| bad("token_url", e))?); Ok(match redirect { Some(redirect) => client.set_redirect_uri(redirect), None => client, @@ -196,6 +199,21 @@ impl Plugin { answer.body(body).map_err(|e| Unanswered(e.to_string())) } + /// Sends a request for `account`'s tokens: to its service's token endpoint as it is, or to + /// Local Network's token exchange as a JSON object of the same fields, since SvelteKit refuses + /// a form posted without an `Origin` header (as it would a browser's from another site). + fn send_token(&self, account: &Account, request: HttpRequest) -> std::result::Result { + if account.token_exchange.is_none() { + return self.send(request); + } + let (mut parts, form) = request.into_parts(); + let fields: serde_json::Map = + form_urlencoded::parse(&form).map(|(k, v)| (k.into_owned(), v.into_owned().into())).collect(); + let json = oauth2::http::HeaderValue::from_static("application/json"); + parts.headers.insert(oauth2::http::header::CONTENT_TYPE, json); + self.send(HttpRequest::from_parts(parts, serde_json::Value::Object(fields).to_string().into_bytes())) + } + /// Asks `account`'s service to forget `tokens` (RFC 7009: the refresh token, which takes the /// access tokens with it, else the access token), when it has a revocation endpoint: whether /// it answered that it has. @@ -286,7 +304,7 @@ impl Plugin { } let Some(refresh) = tokens.refresh_token.clone() else { return Err(signed_out()) }; let client = self.sign_in_client(account, None).map_err(external)?; - let send = |r: HttpRequest| self.send(r); + let send = |r: HttpRequest| self.send_token(account, r); let answer = client.exchange_refresh_token(&RefreshToken::new(refresh)).request(&send); let answer = match answer { Ok(answer) => answer, @@ -415,7 +433,7 @@ impl Plugins { return Err(Error::Account(why)); } let code = query.code.clone().ok_or_else(|| Error::Account("the browser came back without a code".into()))?; - let send = |r: HttpRequest| slot.send(r); + let send = |r: HttpRequest| slot.send_token(account, r); let answer = p .client .exchange_code(AuthorizationCode::new(code)) diff --git a/crates/locallibrary/tests/it/plugins/accounts.rs b/crates/locallibrary/tests/it/plugins/accounts.rs index 7453180c..5263bf7a 100644 --- a/crates/locallibrary/tests/it/plugins/accounts.rs +++ b/crates/locallibrary/tests/it/plugins/accounts.rs @@ -1,7 +1,8 @@ //! Plugins' accounts: signing in through a mock OAuth server on this computer (the test plays the //! browser), the tokens kept with the plugin's secrets and filled into the requests that name the //! account, refreshed before they expire, never sent to another origin, never seen by the plugin, -//! its log or what the host answers; and signing out. +//! its log or what the host answers; and signing out. The tokens are traded at the service's token +//! endpoint or, for an account naming it, at Local Network's token exchange. use std::time::Duration; @@ -34,18 +35,35 @@ fn form(got: &Got) -> IndexMap { form_urlencoded::parse(got.body.as_bytes()).into_owned().collect() } +/// What a token request sent, by name: a form to a token endpoint, a JSON object to Local +/// Network's token exchange. +fn fields(got: &Got) -> IndexMap { + match got.target.starts_with(EXCHANGE) { + true => serde_json::from_str(&got.body).unwrap_or_default(), + false => form(got), + } +} + +/// Where the mock service plays Local Network's token exchange for itself. +const EXCHANGE: &str = "/api/token-exchange/mock"; + fn json_reply(status: u16, body: Value) -> Reply { Reply { status, headers: vec![("Content-Type", "application/json".into())], body: body.to_string().into_bytes() } } /// A mock OAuth service: `/token` trades a code (checked against the PKCE challenge) or a refresh -/// token for tokens, `/revoke` takes one back, and anything else answers what it was sent. +/// token for tokens, and so does Local Network's token exchange for it, taking the same as JSON; +/// `/revoke` takes one back, and anything else answers what it was sent. fn service(state: Arc>) -> Server { Server::new(move |base, got| { let mut s = state.lock().unwrap(); match got.target.as_str() { - "/token" => { - let f = form(got); + "/token" | EXCHANGE => { + let json = got.headers.get("content-type").is_some_and(|t| t == "application/json"); + if json != (got.target == EXCHANGE) { + return json_reply(415, json!({ "error": "invalid_request" })); + } + let f = fields(got); if let Some((error, description)) = &s.refuses { return json_reply(400, json!({ "error": error, "error_description": description })); } @@ -101,6 +119,12 @@ revoke_url = "{server}/revoke" format!("{}{account}", granting(&["net:127.0.0.1"])) } +/// [`with_account`], its tokens traded at Local Network's token exchange. +fn exchanging(server: &str) -> String { + with_account(server) + .replace(&format!("token_url = \"{server}/token\""), &format!("token_exchange = \"{server}{EXCHANGE}\"")) +} + /// The query of the address a sign-in opens in the browser, by name. fn opened(start: &SignIn) -> IndexMap { let url = reqwest::Url::parse(&start.url).unwrap(); @@ -207,6 +231,34 @@ fn signing_in_keeps_the_tokens_and_fills_them_into_requests_naming_the_account() assert_eq!(state.lock().unwrap().issued, 1, "a token valid for an hour isn't refreshed"); } +/// An account naming Local Network's token exchange trades its code and refresh tokens there, as +/// JSON (a form posted without an `Origin` is refused there), never at a token endpoint; and what +/// the exchange answers when it can't is what the plugin is told. +#[test] +fn an_account_naming_the_token_exchange_signs_in_and_refreshes_there() { + let state = Arc::new(Mutex::new(Service { expires_in: Some(30), ..Service::default() })); + let server = service(state.clone()); + let f = fixture(&exchanging(&server.url), &[], Options::default()); + let status = f.sign_in(&state).unwrap(); + assert_eq!((status.signed_in, status.refreshes), (true, true)); + let traded = server.got()[0].clone(); + assert_eq!((traded.method.as_str(), traded.target.as_str()), ("POST", EXCHANGE)); + let sent = fields(&traded); + assert!(has(&sent, "grant_type", "authorization_code") && has(&sent, "code", "c0de"), "the code isn't traded"); + assert!(sent["redirect_uri"].starts_with("http://127.0.0.1:") && sent.contains_key("code_verifier")); + + assert!(sent_token(&f.as_account(&server, "/a", "{account:main}"), "/a", 2), "not refreshed"); + let refreshed = fields(&server.got().iter().rev().nth(1).unwrap().clone()); + assert!(has(&refreshed, "grant_type", "refresh_token") && has(&refreshed, "refresh_token", "rt-1"), "refreshed"); + assert!(server.got().iter().all(|g| g.target != "/token"), "the service's token endpoint was asked"); + + let unset = "Local Network isn't set up to sign in to Mock yet"; + state.lock().unwrap().refuses = Some(("temporarily_unavailable".into(), unset.into())); + let e = f.as_account(&server, "/b", "{account:main}"); + assert_eq!(e["error"]["message"], format!("signing in to Mock: temporarily_unavailable: {unset}")); + assert!(f.plugins.account("echo", "main", Some(&f.store)).unwrap().signed_in, "signed out for the exchange"); +} + #[test] fn a_token_about_to_expire_is_refreshed_before_its_used() { let state = Arc::new(Mutex::new(Service { expires_in: Some(30), ..Service::default() })); @@ -382,18 +434,21 @@ enum Naming { Unknown, } -/// For tokens of any lifetime, refreshed with a new refresh token or not, and any requests: one -/// naming the account as its whole `Authorization` header reaches the server with the access -/// token the service gave last, refreshed first exactly when it's within a minute of expiring; -/// one naming it anywhere else, or naming another account, is refused before anything is sent; -/// and no token gets into the plugin's log, its answers or the store but its secrets. +/// For tokens of any lifetime, refreshed with a new refresh token or not, traded at the service's +/// token endpoint or at Local Network's token exchange, and any requests: one naming the account +/// as its whole `Authorization` header reaches the server with the access token the service gave +/// last, refreshed first exactly when it's within a minute of expiring, and only where the account +/// says; one naming it anywhere else, or naming another account, is refused before anything is +/// sent; and no token gets into the plugin's log, its answers or the store but its secrets. #[hegel::test(test_cases = 6, suppress_health_check = [hegel::HealthCheck::TooSlow])] fn a_token_goes_only_where_the_account_is_named(tc: TestCase) { let expires_in = tc.draw(gs::sampled_from(vec![None, Some(5), Some(59), Some(61), Some(3600)])); let rotates = tc.draw(gs::booleans()); + let exchanges = tc.draw(gs::booleans()); let state = Arc::new(Mutex::new(Service { expires_in, rotates, ..Service::default() })); let server = service(state.clone()); - let f = fixture(&with_account(&server.url), &[], Options::default()); + let manifest = if exchanges { exchanging(&server.url) } else { with_account(&server.url) }; + let f = fixture(&manifest, &[], Options::default()); f.sign_in(&state).unwrap(); let expiring = expires_in.is_some_and(|s| s <= 60); let (mut current, mut answers) = (1, Vec::new()); @@ -428,6 +483,9 @@ fn a_token_goes_only_where_the_account_is_named(tc: TestCase) { answers.push(answer["error"].clone()); } assert_eq!(state.lock().unwrap().issued, current, "refreshed when it needn't be, or not when it had to"); + let traded_at = if exchanges { EXCHANGE } else { "/token" }; + let traded = server.got().into_iter().filter(|g| [EXCHANGE, "/token"].contains(&g.target.as_str())); + assert!(traded.into_iter().all(|g| g.target == traded_at), "tokens traded where the account doesn't say"); let tokens: Vec = (1..=current).flat_map(|n| [format!("at-{n}"), format!("rt-{n}")]).collect(); let seen = f.seen(&answers.iter().collect::>()); assert!(tokens.iter().all(|t| !seen.contains(t.as_str())), "a token is out where it may not be"); diff --git a/crates/loclib-plugin/README.md b/crates/loclib-plugin/README.md index 060f133e..f075397f 100644 --- a/crates/loclib-plugin/README.md +++ b/crates/loclib-plugin/README.md @@ -177,11 +177,21 @@ token, once it's checked, and never on to another server a redirect leads to. A account that isn't signed in fails with kind `invalid`, saying how to sign in. The feature has no methods: it's the SDK's own. -- `[[contributes.accounts]]`: `id`, `title`, `authorize_url`, `token_url`, `scopes`, `client_id` - (a public client, without a secret), `pkce = true` (the default; nothing else is taken) and - `revoke_url` (RFC 7009, optional), where the tokens are revoked at signing out. Each endpoint - is an https address without a user name or password. A plugin with an account needs a `net:` - capability, for the requests its token goes into. +- `[[contributes.accounts]]`: `id`, `title`, `authorize_url`, `token_url` or `token_exchange` + (one of them), `scopes`, `client_id` (a public client, without a secret), `pkce = true` (the + default; nothing else is taken) and `revoke_url` (RFC 7009, optional), where the tokens are + revoked at signing out. Each endpoint is an https address without a user name or password. A + plugin with an account needs a `net:` capability, for the requests its token goes into. +- `token_exchange`, for a service whose token endpoint wants a client secret (SoundCloud, Mixcloud, + Google), is Local Network's token exchange for it: + `https://localnetwork.computer/api/token-exchange/`, nowhere else, since it's sent the + user's codes and refresh tokens. The host posts it what it would have sent the token endpoint + (`grant_type`, `code`, `code_verifier` and `redirect_uri`, or `refresh_token`) as a JSON object, + and the website adds its client id and secret for the service, sends the request on the way the + service takes it and answers what the service answered. `client_id` is then Local Network's + client at the service. The services it knows are in + [`apps/web/src/lib/server/token-exchange/services.json`](../../apps/web/src/lib/server/token-exchange/services.json); + one it doesn't know, or isn't set up for yet, fails the sign-in with what the website said. ### analysis diff --git a/crates/loclib-plugin/src/hooks/accounts.rs b/crates/loclib-plugin/src/hooks/accounts.rs index 08a43ed6..b94ebc61 100644 --- a/crates/loclib-plugin/src/hooks/accounts.rs +++ b/crates/loclib-plugin/src/hooks/accounts.rs @@ -3,7 +3,8 @@ //! keeps the tokens of. A plugin never sees a token: a `net/fetch` request names an account as its //! `Authorization` header's value, `{account:}`, which the host fills in with `Bearer` and the //! account's access token once the request is checked, refreshing it first when it's about to -//! expire. +//! expire. A service whose token endpoint wants a client secret is reached through Local Network's +//! token exchange, which holds the secret: a secret can't ship inside an app people download. use std::net::{Ipv4Addr, Ipv6Addr}; @@ -17,6 +18,12 @@ use crate::{Capability, Feature, Manifest, ManifestError}; /// What a `net/fetch` request's `Authorization` header says to name an account: `{account:}`. pub const ACCOUNT_PLACEHOLDER: &str = "{account:"; +/// Where Local Network's token exchange is, the only one an account may name: its path, then the +/// service's id. The host posts it what it would have sent the service's token endpoint, and the +/// website adds its client secret for the service and answers what the service did. +const EXCHANGE_ORIGIN: &str = "https://localnetwork.computer"; +const EXCHANGE_PATH: &str = "/api/token-exchange/"; + // Every plugin written with the SDK has it: what's an account in a request is the host's to fill. pub(crate) const FEATURE: Feature = Feature { name: "accounts", methods: &[], sends: &[], contributed: |m| !m.contributes.accounts.is_empty() }; @@ -30,12 +37,19 @@ pub struct Account { /// The service's authorization endpoint, where the browser signs in (https). pub authorize_url: String, /// Its token endpoint, where the host trades the browser's code for tokens and refreshes - /// them (https). - pub token_url: String, + /// them (https). An account has this or `token_exchange`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_url: Option, + /// Local Network's token exchange for its service + /// (`https://localnetwork.computer/api/token-exchange/`), in `token_url`'s place, for + /// a service that wants a client secret there. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_exchange: Option, /// What it asks the service for, as the service names them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub scopes: Vec, - /// The plugin's client at the service: a public one, without a secret. + /// The plugin's client at the service: a public one, without a secret, or Local Network's, + /// whose secret its token exchange holds. pub client_id: String, /// Signing in uses PKCE: always, and a manifest saying otherwise is refused. #[serde(default = "pkce")] @@ -49,30 +63,37 @@ fn pkce() -> bool { true } -/// A web address an account's endpoint may be at, as the host's HTTP client reads it: https, or +/// `url` as the host's HTTP client reads it, when an account's endpoint may be there: https, or /// http on this computer for a test server (which the host reaches only in tests), with no user /// name or password. -fn is_endpoint(url: &str) -> bool { +fn endpoint(url: &str) -> Option { let plain = !url.chars().any(|c| c.is_whitespace() || c.is_control()); - let Some(parsed) = Url::parse(url).ok().filter(|u| plain && u.username().is_empty() && u.password().is_none()) - else { - return false; - }; - let this_computer = match parsed.host() { - Some(Host::Domain(name)) => name == "localhost", - Some(Host::Ipv4(ip)) => ip == Ipv4Addr::LOCALHOST, - Some(Host::Ipv6(ip)) => ip == Ipv6Addr::LOCALHOST, - None => return false, + let parsed = Url::parse(url).ok().filter(|u| plain && u.username().is_empty() && u.password().is_none())?; + let this_computer = match parsed.host()? { + Host::Domain(name) => name == "localhost", + Host::Ipv4(ip) => ip == Ipv4Addr::LOCALHOST, + Host::Ipv6(ip) => ip == Ipv6Addr::LOCALHOST, }; match parsed.scheme() { - "https" => true, - "http" => this_computer, - _ => false, + "https" => Some(parsed), + "http" if this_computer => Some(parsed), + _ => None, } } -/// `[[contributes.accounts]]`: ids, titles, endpoints, scopes and PKCE, and a `net:` capability -/// for the requests its token goes into. +/// Whether `url` is Local Network's token exchange for a service, or the same path on this +/// computer for a test server. Nowhere else: the host sends it the user's codes and refresh +/// tokens, and any other server taking them would only be a token endpoint by another name. +fn is_exchange(url: &str) -> bool { + let Some(parsed) = endpoint(url) else { return false }; + let ours = parsed.scheme() == "http" || parsed.origin().ascii_serialization() == EXCHANGE_ORIGIN; + let service = parsed.path().strip_prefix(EXCHANGE_PATH).unwrap_or_default(); + let named = !service.is_empty() && service.bytes().all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-')); + ours && named && parsed.query().is_none() && parsed.fragment().is_none() +} + +/// `[[contributes.accounts]]`: ids, titles, endpoints (a token endpoint or Local Network's token +/// exchange), scopes and PKCE, and a `net:` capability for the requests its token goes into. pub(crate) fn validate(m: &Manifest) -> Result<(), ManifestError> { let (at, c) = ("contributes.accounts", &m.contributes); unique_names(at, "id", c.accounts.iter().map(|x| x.id.as_str()).enumerate())?; @@ -80,15 +101,27 @@ pub(crate) fn validate(m: &Manifest) -> Result<(), ManifestError> { let at = format!("{at}[{i}]"); shown(format!("{at}.title"), &x.title)?; shown(format!("{at}.client_id"), &x.client_id)?; - let urls = [("authorize_url", Some(&x.authorize_url)), ("token_url", Some(&x.token_url))]; + let urls = [("authorize_url", Some(&x.authorize_url)), ("token_url", x.token_url.as_ref())]; for (key, url) in urls.into_iter().chain([("revoke_url", x.revoke_url.as_ref())]) { - if let Some(url) = url.filter(|u| !is_endpoint(u)) { + if let Some(url) = url.filter(|u| endpoint(u).is_none()) { return Err(invalid( format!("{at}.{key}"), format!("{url:?} isn't an https:// address without a user name or password"), )); } } + match (&x.token_url, &x.token_exchange) { + (None, None) => return Err(invalid(format!("{at}.token_url"), "is missing, and so is token_exchange")), + (Some(_), Some(_)) => { + return Err(invalid(format!("{at}.token_exchange"), "goes in token_url's place, not beside it")); + } + (None, Some(url)) if !is_exchange(url) => { + let why = + format!("{url:?} isn't Local Network's token exchange ({EXCHANGE_ORIGIN}{EXCHANGE_PATH})"); + return Err(invalid(format!("{at}.token_exchange"), why)); + } + _ => {} + } if let Some((j, scope)) = x.scopes.iter().enumerate().find(|(_, s)| s.is_empty() || s.contains(' ')) { return Err(invalid(format!("{at}.scopes[{j}]"), format!("{scope:?} isn't a scope: a word, no spaces"))); } diff --git a/crates/loclib-plugin/src/manifest/tests.rs b/crates/loclib-plugin/src/manifest/tests.rs index ae777c4d..25a6e362 100644 --- a/crates/loclib-plugin/src/manifest/tests.rs +++ b/crates/loclib-plugin/src/manifest/tests.rs @@ -398,6 +398,91 @@ fn an_accounts_endpoints_are_read_as_the_client_reads_them() { } } +/// An account trades its tokens at its service's token endpoint or at Local Network's token +/// exchange, one of them, and an exchange is Local Network's (or a test server on this computer at +/// the same path): no other server is sent the user's codes in its name. +#[test] +fn an_account_trades_tokens_at_its_token_endpoint_or_local_networks_exchange_only() { + let account = |token: &str| { + problem(&format!( + "capabilities = [\"net:auth.example\"]\n[[contributes.accounts]]\nid = \"main\"\ntitle = \"Main\"\n\ + authorize_url = \"https://auth.example/authorize\"\n{token}client_id = \"demo\"\n" + )) + }; + let exchange = |url: &str| account(&format!("token_exchange = {url:?}\n")); + for url in [ + "https://localnetwork.computer/api/token-exchange/soundcloud", + "https://localnetwork.computer:443/api/token-exchange/google", + "https://LocalNetwork.computer/api/token-exchange/mixcloud-2", + "http://127.0.0.1:5173/api/token-exchange/soundcloud", + "http://localhost/api/token-exchange/x", + ] { + assert_eq!(exchange(url), "", "{url}"); + } + for url in [ + "https://evil.example/api/token-exchange/soundcloud", + "https://localnetwork.computer.evil.example/api/token-exchange/soundcloud", + "https://localnetwork.computer:8443/api/token-exchange/soundcloud", + "http://localnetwork.computer/api/token-exchange/soundcloud", + "https://someone@localnetwork.computer/api/token-exchange/soundcloud", + "https://localnetwork.computer/api/token-exchange/", + "https://localnetwork.computer/api/token-exchange/SoundCloud", + "https://localnetwork.computer/api/token-exchange/a/b", + "https://localnetwork.computer/api/token-exchange/../oauth/token", + "https://localnetwork.computer/api/token-exchange/soundcloud?to=evil.example", + "https://localnetwork.computer/api/token-exchange/soundcloud#x", + "https://localnetwork.computer/oauth/token", + ] { + let said = format!( + "contributes.accounts[0].token_exchange: {url:?} isn't Local Network's token exchange \ + (https://localnetwork.computer/api/token-exchange/)" + ); + assert_eq!(exchange(url), said, "{url}"); + } + assert_eq!(account(""), "contributes.accounts[0].token_url: is missing, and so is token_exchange"); + let both = "token_url = \"https://auth.example/token\"\ntoken_exchange = \"https://localnetwork.computer/api/token-exchange/x\"\n"; + assert_eq!(account(both), "contributes.accounts[0].token_exchange: goes in token_url's place, not beside it"); +} + +/// For any token endpoint and any Local Network token exchange (or a test server's), an account +/// naming one of them is valid; naming neither, both, or an exchange anywhere else (another +/// scheme, host, port or path, a user name, a query) is refused, saying which. +#[hegel::test(test_cases = 100)] +fn an_account_trades_its_tokens_at_one_place_and_exchanges_only_at_local_networks(tc: TestCase) { + let at = "contributes.accounts[0]"; + let (token, said) = match tc.draw(gs::integers::().max_value(4)) { + 0 => (format!("token_url = {:?}\n", endpoint(&tc)), String::new()), + 1 => (format!("token_exchange = {:?}\n", exchange(&tc)), String::new()), + 2 => { + let theirs = exchange(&tc); + let wrong = tc.draw(gs::sampled_from(vec![ + endpoint(&tc), + theirs.replacen("://", "://someone@", 1), + theirs.replace("/api/", "/apis/"), + format!("{theirs}?to=elsewhere"), + format!("{theirs}/more"), + "ftp://localnetwork.computer/api/token-exchange/x".into(), + "https://localnetwork.computer:8443/api/token-exchange/x".into(), + "https://localnetwork.computer.example/api/token-exchange/x".into(), + "http://localnetwork.computer/api/token-exchange/x".into(), + ])); + let rule = "https://localnetwork.computer/api/token-exchange/"; + let said = format!("{at}.token_exchange: {wrong:?} isn't Local Network's token exchange ({rule})"); + (format!("token_exchange = {wrong:?}\n"), said) + } + 3 => (String::new(), format!("{at}.token_url: is missing, and so is token_exchange")), + _ => { + let both = format!("token_url = {:?}\ntoken_exchange = {:?}\n", endpoint(&tc), exchange(&tc)); + (both, format!("{at}.token_exchange: goes in token_url's place, not beside it")) + } + }; + let manifest = format!( + "capabilities = [\"net:auth.example\"]\n[[contributes.accounts]]\nid = \"main\"\ntitle = \"Main\"\n\ + authorize_url = \"https://auth.example/authorize\"\n{token}client_id = \"demo\"\n" + ); + assert_eq!(problem(&manifest), said, "{token}"); +} + /// A library source reads files in the folders the user grants it: it's granted that. #[test] fn a_source_is_granted_reading_folders() { @@ -1097,14 +1182,31 @@ fn endpoint(tc: &TestCase) -> String { } } -/// An account with endpoints it may have, scopes and maybe a revocation endpoint. +/// Local Network's token exchange for a service, or the same path on a test server on this +/// computer. +#[hegel::test_helper] +fn exchange(tc: &TestCase) -> String { + let origin = tc.draw(gs::sampled_from(vec![ + "https://localnetwork.computer", + "https://localnetwork.computer:443", + "http://127.0.0.1:5173", + "http://localhost", + "http://[::1]:9", + ])); + format!("{origin}/api/token-exchange/{}", tc.draw(gs::from_regex("[a-z0-9-]{1,12}"))) +} + +/// An account with endpoints it may have (its token endpoint, or Local Network's token exchange), +/// scopes and maybe a revocation endpoint. #[hegel::test_helper] fn account(tc: &TestCase, id: String) -> Account { + let exchanges = tc.draw(gs::booleans()); Account { id, title: title(tc), authorize_url: endpoint(tc), - token_url: endpoint(tc), + token_url: (!exchanges).then(|| endpoint(tc)), + token_exchange: exchanges.then(|| exchange(tc)), scopes: tc.draw(gs::vecs(gs::from_regex("[!-~]{1,12}")).max_size(3)), client_id: title(tc), pkce: true, @@ -1509,7 +1611,8 @@ fn add(c: &mut Contributions, kind: &str, n: usize) { id: name, title, authorize_url: "https://auth.example/authorize".into(), - token_url: "https://auth.example/token".into(), + token_url: Some("https://auth.example/token".into()), + token_exchange: None, scopes: Vec::new(), client_id: "added".into(), pkce: true, @@ -1666,6 +1769,7 @@ fn each_kind_of_mistake_is_refused_saying_where(tc: TestCase) { "account client", "account scope", "account pkce", + "account exchange", "account grant", "check when", "export grant", @@ -2019,7 +2123,7 @@ fn each_kind_of_mistake_is_refused_saying_where(tc: TestCase) { } } } - "account endpoint" | "account client" | "account scope" | "account pkce" => { + "account endpoint" | "account client" | "account scope" | "account pkce" | "account exchange" => { add(c, "accounts", 1); if !m.capabilities.iter().any(|g| matches!(g, Capability::Net(_))) { m.capabilities.push(Capability::Net(ACCOUNT_HOST.into())); @@ -2055,7 +2159,7 @@ fn each_kind_of_mistake_is_refused_saying_where(tc: TestCase) { let key = tc.draw(gs::sampled_from(vec!["authorize_url", "token_url", "revoke_url"])); match key { "authorize_url" => x.authorize_url = wrong.clone(), - "token_url" => x.token_url = wrong.clone(), + "token_url" => x.token_url = Some(wrong.clone()), _ => x.revoke_url = Some(wrong.clone()), } (format!("{at}.{key}: {wrong:?} isn't an https:// address without a user name or password"), false) @@ -2064,6 +2168,31 @@ fn each_kind_of_mistake_is_refused_saying_where(tc: TestCase) { x.client_id = tc.draw(gs::text().alphabet(" \t\r\n").max_size(3)); (format!("{at}.client_id: is empty"), false) } + "account exchange" => match tc.draw(gs::integers::().max_value(2)) { + 0 => { + (x.token_url, x.token_exchange) = (None, None); + (format!("{at}.token_url: is missing, and so is token_exchange"), false) + } + 1 => { + (x.token_url, x.token_exchange) = (Some(endpoint(&tc)), Some(exchange(&tc))); + (format!("{at}.token_exchange: goes in token_url's place, not beside it"), false) + } + _ => { + // Somewhere else, or Local Network's site but not its exchange for a + // service. + let wrong = tc.draw(gs::sampled_from(vec![ + endpoint(&tc), + exchange(&tc).replace("/api/", "/apis/"), + exchange(&tc).replace("localnetwork.computer", "localnetwork.computer.example"), + format!("{}/x", exchange(&tc)), + format!("{}?x", exchange(&tc)), + "https://localnetwork.computer/api/token-exchange/".into(), + ])); + (x.token_url, x.token_exchange) = (None, Some(wrong.clone())); + let rule = "https://localnetwork.computer/api/token-exchange/"; + (format!("{at}.token_exchange: {wrong:?} isn't Local Network's token exchange ({rule})"), false) + } + }, "account scope" => { let wrong = tc.draw(gs::sampled_from(vec!["", "two words", " lead"])); let j = tc.draw(gs::integers::().max_value(x.scopes.len()));