Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<service>` 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/<service>` 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:<port>/callback` or `http://[::1]:<port>/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`.

Expand Down
66 changes: 65 additions & 1 deletion apps/web/src/lib/server/token-exchange/exchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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) });
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/lib/server/token-exchange/exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>/callback` or `http://[::1]:<port>/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';
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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] };

Expand Down
Loading