diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index d081ca1f..9ebcfc39 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -59,15 +59,21 @@ thin: it proxies OpenAI requests with the server-held API key and writes study l - **Tool launcher** (`src/toolGrants.ts`): the handoff grant flow for launching external writing tools from the sidebar (see `docs/tool-launcher-plan.md`, Phase 1). The signed-in taskpane mints a single-use, short-TTL grant (`POST /api/handoff`) - carrying the user, the tool's `client_id`, requested scopes, and an optional - read-only document snapshot; the tool — on its own foreign origin — swaps the - grant_id for a `wtk_`-prefixed bearer token (`POST /api/handoff/exchange`). That + carrying the user, the tool's `client_id`, the **origin being launched** (derived + from a required `tool_url`), requested scopes, and an optional read-only document + snapshot; the tool — on its own foreign origin — swaps the grant_id for a + `wtk_`-prefixed bearer token (`POST /api/handoff/exchange`). That token is our own credential (a `tool_grant` row, not a Better Auth session): `app.ts` `resolveUser` recognizes the `wtk_` prefix and resolves it here, so the tool's proxy/log calls run under the user's account, attributed to the tool. - `GET /api/handoff/doc` re-fetches the snapshot (gated by `doc:read`); - `POST /api/handoff/revoke` disconnects a token. Scope enforcement beyond the doc - re-fetch is deferred (Phase 3). + The exchange refuses any caller whose `Origin` doesn't match the grant's + `tool_origin` — an application check, deliberately *not* CORS, which stays + permissive because the whole surface is bearer-only. **There is no server-side + registry of tool origins**, and adding one would be a regression: a tool's origin + differs per deployment (dev launches localhost), so the grant records what was + actually launched. `GET /api/handoff/doc` re-fetches the snapshot (gated by + `doc:read`); `POST /api/handoff/revoke` disconnects a token. Scope enforcement + beyond the doc re-fetch is deferred (Phase 3). - **Auth** (`src/auth.ts`): Better Auth (Google sign-in + device-code flow for the add-in), on the shared `app.db`, enabled by `BETTER_AUTH_ENABLED=true`. Carries the user's `loggingConsent` level as a user field; `beforeDelete` purges study logs and diff --git a/backend/src/__tests__/db.test.ts b/backend/src/__tests__/db.test.ts index 5493ec99..6cbee105 100644 --- a/backend/src/__tests__/db.test.ts +++ b/backend/src/__tests__/db.test.ts @@ -22,7 +22,7 @@ afterEach(() => { describe('migrations', () => { it('creates our schema and records the version', () => { const conn = db(); - expect(conn.pragma('user_version', { simple: true })).toBe(3); + expect(conn.pragma('user_version', { simple: true })).toBe(4); // The table is usable, not merely declared. const table = conn @@ -58,7 +58,7 @@ describe('migrations', () => { // Reopening must not drop or recreate the table (CREATE TABLE would throw). const conn = db(); - expect(conn.pragma('user_version', { simple: true })).toBe(3); + expect(conn.pragma('user_version', { simple: true })).toBe(4); const rows = conn.prepare(`SELECT COUNT(*) AS n FROM llm_usage`).get() as { n: number; }; @@ -87,7 +87,7 @@ describe('legacy auth.db rename', () => { expect(user).toEqual({ email: 'a@b.c' }); // ...and our migrations then run on top of the adopted database. - expect(conn.pragma('user_version', { simple: true })).toBe(3); + expect(conn.pragma('user_version', { simple: true })).toBe(4); }); it('leaves an existing app.db alone when a stray auth.db is also present', async () => { diff --git a/backend/src/__tests__/handoff.test.ts b/backend/src/__tests__/handoff.test.ts index b9c4a0ff..6a0bc48b 100644 --- a/backend/src/__tests__/handoff.test.ts +++ b/backend/src/__tests__/handoff.test.ts @@ -36,17 +36,54 @@ function authApp(user: { id: string; email?: string; loggingConsent?: string } | const SIGNED_IN = { id: 'usr-1', email: 'a@calvin.edu', loggingConsent: 'usage' }; -function post(app: ReturnType, url: string, body: unknown, token?: string) { +/** Where the tool is launched, and the origin a grant for it is therefore bound to. */ +const TOOL_URL = 'https://tool.example/app/?x=1'; +const TOOL_ORIGIN = 'https://tool.example'; + +function post( + app: ReturnType, + url: string, + body: unknown, + token?: string, + origin?: string, +) { return app.request(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(origin ? { Origin: origin } : {}), }, body: JSON.stringify(body), }); } +/** Mint a grant as the signed-in taskpane would. */ +async function mintGrant( + body: Record = {}, +): Promise<{ grant_id: string }> { + const res = await post(authApp(SIGNED_IN), '/api/handoff', { + tool_client_id: 'mindmap', + tool_url: TOOL_URL, + ...body, + }); + return (await res.json()) as { grant_id: string }; +} + +/** + * Redeem a grant the way the launched tool's browser page does: with an Origin. + * `null` stands for "send no Origin header at all". + */ +function exchange(grantId: string, origin: string | null = TOOL_ORIGIN) { + return post( + createApp(), + '/api/handoff/exchange', + { grant_id: grantId }, + undefined, + origin ?? undefined, + ); +} + describe('POST /api/handoff', () => { it('rejects an unauthenticated request', async () => { const res = await post(authApp(null), '/api/handoff', { @@ -58,6 +95,7 @@ describe('POST /api/handoff', () => { it('rejects a tool_client_id outside the allowlist', async () => { const res = await post(authApp(SIGNED_IN), '/api/handoff', { tool_client_id: 'not-registered', + tool_url: TOOL_URL, }); expect(res.status).toBe(400); }); @@ -65,14 +103,27 @@ describe('POST /api/handoff', () => { it('rejects invalid scopes', async () => { const res = await post(authApp(SIGNED_IN), '/api/handoff', { tool_client_id: 'mindmap', + tool_url: TOOL_URL, scopes: ['openai:chat', 'root:everything'], }); expect(res.status).toBe(400); }); + it.each([undefined, 'not a url', 'javascript:alert(1)', 'file:///etc/passwd'])( + 'rejects a launch url of %p — there would be no origin to bind the grant to', + async (toolUrl) => { + const res = await post(authApp(SIGNED_IN), '/api/handoff', { + tool_client_id: 'mindmap', + tool_url: toolUrl, + }); + expect(res.status).toBe(400); + }, + ); + it('mints a grant for a signed-in user', async () => { const res = await post(authApp(SIGNED_IN), '/api/handoff', { tool_client_id: 'mindmap', + tool_url: TOOL_URL, }); expect(res.status).toBe(200); const body = (await res.json()) as { grant_id: string; expires_in: number }; @@ -83,18 +134,12 @@ describe('POST /api/handoff', () => { describe('POST /api/handoff/exchange', () => { it('swaps a grant for a token + doc snapshot, and is single-use', async () => { - const app = authApp(SIGNED_IN); - const created = (await ( - await post(app, '/api/handoff', { - tool_client_id: 'mindmap', - doc: { beforeCursor: 'hi', selectedText: '', afterCursor: '' }, - }) - ).json()) as { grant_id: string }; + const created = await mintGrant({ + doc: { beforeCursor: 'hi', selectedText: '', afterCursor: '' }, + }); // Exchange needs no session — the grant_id is the credential. - const res = await post(createApp(), '/api/handoff/exchange', { - grant_id: created.grant_id, - }); + const res = await exchange(created.grant_id); expect(res.status).toBe(200); const body = (await res.json()) as { access_token: string; @@ -107,31 +152,45 @@ describe('POST /api/handoff/exchange', () => { expect(body.doc).toEqual({ beforeCursor: 'hi', selectedText: '', afterCursor: '' }); // Replaying the grant fails. - const replay = await post(createApp(), '/api/handoff/exchange', { - grant_id: created.grant_id, - }); - expect(replay.status).toBe(400); + expect((await exchange(created.grant_id)).status).toBe(400); }); it('400s a missing grant_id', async () => { const res = await post(createApp(), '/api/handoff/exchange', {}); expect(res.status).toBe(400); }); + + // The grant is bound to the origin the taskpane launched, so a grant that leaks + // (or is relayed into an attacker's page) can't be redeemed anywhere else. + it.each([ + ['a different origin', 'https://evil.example'], + ['a sibling host', 'https://tool.example.evil.test'], + ['a scheme downgrade', 'http://tool.example'], + ['a non-default port', 'https://tool.example:8443'], + ['no Origin at all', null], + ])('refuses an exchange from %s', async (_label, origin) => { + const created = await mintGrant(); + + const res = await exchange(created.grant_id, origin); + expect(res.status).toBe(400); + expect((await res.json()) as { error: string }).toMatchObject({ + error: 'origin_mismatch', + }); + + // A refused attempt must not burn the grant — the real tool still redeems it. + expect((await exchange(created.grant_id)).status).toBe(200); + }); }); describe('tool token end-to-end', () => { async function tokenFor(scopes?: string[]): Promise { - const app = authApp(SIGNED_IN); - const created = (await ( - await post(app, '/api/handoff', { - tool_client_id: 'mindmap', - scopes, - doc: { beforeCursor: 'doc', selectedText: '', afterCursor: '' }, - }) - ).json()) as { grant_id: string }; - const ex = (await ( - await post(createApp(), '/api/handoff/exchange', { grant_id: created.grant_id }) - ).json()) as { access_token: string }; + const created = await mintGrant({ + scopes, + doc: { beforeCursor: 'doc', selectedText: '', afterCursor: '' }, + }); + const ex = (await (await exchange(created.grant_id)).json()) as { + access_token: string; + }; return ex.access_token; } diff --git a/backend/src/__tests__/toolGrants.test.ts b/backend/src/__tests__/toolGrants.test.ts index 81c99316..dd3f0d1f 100644 --- a/backend/src/__tests__/toolGrants.test.ts +++ b/backend/src/__tests__/toolGrants.test.ts @@ -8,6 +8,7 @@ import { exchangeToolGrant, GRANT_TTL_MS, getToolTokenDoc, + originOfLaunchUrl, resolveToolToken, revokeToolToken, TOKEN_TTL_MS, @@ -32,19 +33,30 @@ const USER = { isAllowed: true, }; +const TOOL_ORIGIN = 'https://tool.example'; + function mint(docSnapshot: unknown = undefined) { return createToolGrant({ user: USER, toolClientId: 'mindmap', + toolOrigin: TOOL_ORIGIN, scopes: ['openai:chat', 'doc:read'], docSnapshot, }); } +/** + * Redeem from the origin the grant was minted for, unless a test says otherwise. + * `null` stands for "the request carried no Origin header at all". + */ +function redeem(grantId: string, origin: string | null = TOOL_ORIGIN) { + return exchangeToolGrant(grantId, origin); +} + describe('createToolGrant / exchangeToolGrant', () => { it('exchanges a fresh grant for a wtk_ token carrying the identity, scopes and doc', () => { const { grantId } = mint({ beforeCursor: 'hello', selectedText: '', afterCursor: '' }); - const result = exchangeToolGrant(grantId); + const result = redeem(grantId); expect(result.ok).toBe(true); if (!result.ok) return; @@ -58,16 +70,16 @@ describe('createToolGrant / exchangeToolGrant', () => { it('is single-use — a second exchange is refused', () => { const { grantId } = mint(); - expect(exchangeToolGrant(grantId).ok).toBe(true); + expect(redeem(grantId).ok).toBe(true); - const second = exchangeToolGrant(grantId); + const second = redeem(grantId); expect(second.ok).toBe(false); if (second.ok) return; expect(second.error).toBe('already_used'); }); it('rejects an unknown grant', () => { - const r = exchangeToolGrant('wtg_nope'); + const r = redeem('wtg_nope'); expect(r).toEqual({ ok: false, error: 'not_found' }); }); @@ -76,21 +88,57 @@ describe('createToolGrant / exchangeToolGrant', () => { const { grantId } = mint(); vi.advanceTimersByTime(GRANT_TTL_MS + 1); - const r = exchangeToolGrant(grantId); + const r = redeem(grantId); expect(r).toEqual({ ok: false, error: 'expired' }); }); it('returns doc: null when no snapshot was shared', () => { const { grantId } = mint(); // no doc - const r = exchangeToolGrant(grantId); + const r = redeem(grantId); expect(r.ok && r.doc).toBeNull(); }); + + it.each([ + ['a different origin', 'https://evil.example'], + ['a scheme downgrade', 'http://tool.example'], + ['a non-default port', 'https://tool.example:8443'], + ['a subdomain', 'https://sub.tool.example'], + ['an empty Origin', ''], + ['no Origin header', null], + ])('refuses %s', (_label, origin) => { + const { grantId } = mint(); + expect(redeem(grantId, origin)).toEqual({ ok: false, error: 'origin_mismatch' }); + }); + + it('leaves the grant redeemable after a refused attempt', () => { + const { grantId } = mint(); + expect(redeem(grantId, 'https://evil.example').ok).toBe(false); + expect(redeem(grantId).ok).toBe(true); + }); +}); + +describe('originOfLaunchUrl', () => { + it.each([ + ['https://tool.example/app/?q=1#frag', 'https://tool.example'], + ['http://localhost:5181/', 'http://localhost:5181'], + // Default ports normalize away, so the stored and compared forms agree. + ['https://tool.example:443/x', 'https://tool.example'], + ])('reduces %s to %s', (url, origin) => { + expect(originOfLaunchUrl(url)).toBe(origin); + }); + + it.each([undefined, null, 42, '', 'not a url', 'javascript:alert(1)', 'file:///etc/passwd'])( + 'rejects %p', + (url) => { + expect(originOfLaunchUrl(url)).toBeNull(); + }, + ); }); describe('resolveToolToken', () => { it('resolves a live token to the user with the tool as clientId', () => { const { grantId } = mint(); - const ex = exchangeToolGrant(grantId); + const ex = redeem(grantId); if (!ex.ok) throw new Error('exchange failed'); const resolved = resolveToolToken(ex.accessToken); @@ -110,7 +158,7 @@ describe('resolveToolToken', () => { it('returns null once the token has expired', () => { vi.useFakeTimers(); const { grantId } = mint(); - const ex = exchangeToolGrant(grantId); + const ex = redeem(grantId); if (!ex.ok) throw new Error('exchange failed'); vi.advanceTimersByTime(TOKEN_TTL_MS + 1); @@ -121,7 +169,7 @@ describe('resolveToolToken', () => { describe('getToolTokenDoc / revokeToolToken', () => { it('re-fetches the stored snapshot for a live token', () => { const { grantId } = mint({ beforeCursor: 'ctx', selectedText: '', afterCursor: '' }); - const ex = exchangeToolGrant(grantId); + const ex = redeem(grantId); if (!ex.ok) throw new Error('exchange failed'); expect(getToolTokenDoc(ex.accessToken)).toEqual({ @@ -132,7 +180,7 @@ describe('getToolTokenDoc / revokeToolToken', () => { it('revokes a token so it no longer resolves', () => { const { grantId } = mint(); - const ex = exchangeToolGrant(grantId); + const ex = redeem(grantId); if (!ex.ok) throw new Error('exchange failed'); expect(revokeToolToken(ex.accessToken)).toBe(true); diff --git a/backend/src/app.ts b/backend/src/app.ts index ba89c4d6..cdce4a0d 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -25,6 +25,7 @@ import { exchangeToolGrant, getToolTokenDoc, isToolScope, + originOfLaunchUrl, resolveToolToken, revokeToolToken, type ToolScope, @@ -373,6 +374,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { const body = (await c.req.json().catch(() => ({}))) as { tool_client_id?: unknown; + tool_url?: unknown; scopes?: unknown; doc?: unknown; }; @@ -387,6 +389,14 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { ); } + // The grant is bound to the origin the taskpane is about to open, so only that + // origin can redeem it. We take the launch URL and derive the origin here so + // both sides of the later comparison are normalized identically. + const toolOrigin = originOfLaunchUrl(body.tool_url); + if (!toolOrigin) { + return c.json({ detail: 'tool_url must be an http(s) URL.' }, 400); + } + // Scopes: default the common read-only set; otherwise every requested scope // must be one we recognize. let scopes: ToolScope[]; @@ -409,6 +419,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { isAllowed: user.isAllowed, }, toolClientId, + toolOrigin, scopes, docSnapshot: body.doc, }); @@ -417,7 +428,8 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { // Exchange a grant_id for a bearer token (+ the document snapshot). Unauthenticated // by design — the grant_id itself is the credential, and the tool has no session - // yet. Single-use and short-lived (see toolGrants.ts). + // yet. Single-use, short-lived, and redeemable only from the origin the grant was + // minted for (see toolGrants.ts). app.post('/api/handoff/exchange', async (c) => { const { grant_id } = (await c.req.json().catch(() => ({}))) as { grant_id?: unknown; @@ -426,7 +438,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { return c.json({ detail: 'Missing grant_id.' }, 400); } - const result = exchangeToolGrant(grant_id); + const result = exchangeToolGrant(grant_id, c.req.header('Origin')); if (!result.ok) { // A consumed/expired/unknown grant is all a 400 to the tool — it can only // restart the flow. The distinct `error` code aids debugging. diff --git a/backend/src/db.ts b/backend/src/db.ts index 3dc4a73c..f2305e33 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -118,6 +118,15 @@ const MIGRATIONS: Array<(conn: Database.Database) => void> = [ CREATE INDEX tool_grant_user ON tool_grant (json_extract(user_snapshot, '$.id')); `); }, + // v4 — bind each grant to the origin it was launched at, so the exchange can check + // the caller's `Origin` against it (see toolGrants.ts). Nullable only because + // SQLite can't add a NOT NULL column without a default: every grant minted after + // this migration carries one, and a NULL is refused at exchange rather than + // waved through. No backfill — grants live ~2 minutes, so any pre-existing row is + // already unexchangeable. + (conn) => { + conn.exec(`ALTER TABLE tool_grant ADD COLUMN tool_origin TEXT;`); + }, ]; function migrate(conn: Database.Database): void { diff --git a/backend/src/toolGrants.ts b/backend/src/toolGrants.ts index 38ead5e3..3835a35e 100644 --- a/backend/src/toolGrants.ts +++ b/backend/src/toolGrants.ts @@ -6,12 +6,21 @@ * sees our cookies. To give it access "under the user's account" without an * interactive login, the taskpane (already signed in) mints a **launch grant**: * - * 1. taskpane → POST /api/handoff (authenticated): stash { user, tool, scopes, - * optional document snapshot }, get back a single-use grant_id with a short TTL. + * 1. taskpane → POST /api/handoff (authenticated): stash { user, tool, tool origin, + * scopes, optional document snapshot }, get back a single-use grant_id with a + * short TTL. * 2. taskpane opens the tool at `https://tool.example/#wt_grant=` — a URL * *fragment*, so the grant never reaches the tool's server or intermediary logs. * 3. tool → POST /api/handoff/exchange { grant_id }: swap the grant for a bearer - * token (scoped to that tool) plus the document snapshot. + * token (scoped to that tool) plus the document snapshot. The caller's `Origin` + * must match the origin recorded in step 1. + * + * The grant is bound to a *tool at an origin*, and the origin comes from the launch + * URL the taskpane actually resolved — not from a server-side registry of tool + * origins. A registry would have to answer "what origin is `mindmap`?", which has no + * single answer (dev builds launch localhost, prod launches the deployed host), so it + * would duplicate the frontend's own URL resolution and drift from it. Recording what + * was launched is both lighter and more accurate. * * The bearer token issued here is our own opaque credential, prefixed `wtk_` so * app.ts's resolveUser can tell it apart from a Better Auth session token and @@ -30,7 +39,7 @@ import { db } from './db.js'; /** * Scopes a tool may request. Recorded and surfaced now (the future consent screen - * and Phase 3 enforcement read them); in this first-party phase the proxy still + * and Phase 3 enforcement read them); in this sa phase the proxy still * treats a valid tool token as a full session token, except where a scope check is * the natural gate — the document re-fetch requires `doc:read`. */ @@ -55,6 +64,24 @@ export function isToolScope(value: unknown): value is ToolScope { ); } +/** + * Normalize a launch URL to the origin a grant is bound to, or null if it isn't an + * http(s) URL. The taskpane sends the URL it is about to open and we derive the origin + * here, so normalization (default ports, case, trailing path) happens in exactly one + * place and both sides of the comparison are produced the same way. + */ +export function originOfLaunchUrl(url: unknown): string | null { + if (typeof url !== 'string') return null; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null; + return parsed.origin; +} + /** How long a freshly minted grant_id may be exchanged for a token. */ export const GRANT_TTL_MS = 2 * 60 * 1000; // 2 minutes /** How long the exchanged bearer token stays valid. */ @@ -71,6 +98,12 @@ function randomId(prefix: string): string { export interface CreateGrantInput { user: GrantUser; toolClientId: string; + /** + * Origin the taskpane is launching the tool at (from `originOfLaunchUrl`). The + * exchange only honours this grant when the caller's `Origin` matches, so a grant + * that leaks — or is relayed to an attacker's page — can't be redeemed elsewhere. + */ + toolOrigin: string; scopes: ToolScope[]; /** DocContext snapshot to hand the tool, or null. Stored opaquely as JSON. */ docSnapshot: unknown; @@ -90,14 +123,15 @@ export function createToolGrant(input: CreateGrantInput): CreatedGrant { db() .prepare( `INSERT INTO tool_grant ( - grant_id, access_token, user_snapshot, tool_client_id, scopes, + grant_id, access_token, user_snapshot, tool_client_id, tool_origin, scopes, doc_snapshot, created_at, expires_at - ) VALUES (?, NULL, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)`, ) .run( grantId, JSON.stringify(input.user), input.toolClientId, + input.toolOrigin, JSON.stringify(input.scopes), input.docSnapshot === undefined ? null : JSON.stringify(input.docSnapshot), now, @@ -111,6 +145,7 @@ interface GrantRow { access_token: string | null; user_snapshot: string; tool_client_id: string; + tool_origin: string | null; scopes: string; doc_snapshot: string | null; created_at: number; @@ -130,15 +165,30 @@ export type ExchangeResult = scopes: ToolScope[]; doc: unknown; } - | { ok: false; error: 'not_found' | 'expired' | 'already_used' }; + | { + ok: false; + error: 'not_found' | 'expired' | 'already_used' | 'origin_mismatch'; + }; /** * Swap a grant_id for a bearer token. Single-use: the first successful exchange * mints the token and marks the grant; any later attempt is refused. Called from * the unauthenticated POST /api/handoff/exchange route — the grant_id itself is the * credential, so no session is required (and the tool doesn't have one yet). + * + * `requestOrigin` is the caller's `Origin` header, which must equal the origin the + * grant was minted for. That binding is what stops a relayed grant being redeemed by + * a page other than the tool the user launched. Every caller of this route is a + * browser (the grant only ever arrives via a URL fragment, which nothing but a + * navigation can deliver) and the request is a JSON POST, so it is preflighted and + * `Origin` is always present — a missing one is refused rather than waved through. + * A mismatch deliberately does *not* consume the grant: the legitimate tool must + * still be able to redeem it. */ -export function exchangeToolGrant(grantId: string): ExchangeResult { +export function exchangeToolGrant( + grantId: string, + requestOrigin: string | null | undefined, +): ExchangeResult { const conn = db(); // Serialize the read-decide-write so two racing exchanges can't both succeed. const tx = conn.transaction((): ExchangeResult => { @@ -148,6 +198,9 @@ export function exchangeToolGrant(grantId: string): ExchangeResult { if (!row) return { ok: false, error: 'not_found' }; if (row.exchanged_at !== null) return { ok: false, error: 'already_used' }; if (Date.now() > row.expires_at) return { ok: false, error: 'expired' }; + if (!row.tool_origin || !requestOrigin || requestOrigin !== row.tool_origin) { + return { ok: false, error: 'origin_mismatch' }; + } const now = Date.now(); const accessToken = randomId('wtk_'); diff --git a/docs/tool-launcher-plan.md b/docs/tool-launcher-plan.md index e3544427..7f5436c4 100644 --- a/docs/tool-launcher-plan.md +++ b/docs/tool-launcher-plan.md @@ -24,6 +24,14 @@ features. > row), not a Better Auth session — Better Auth has no server-side session-mint > primitive, and a parallel token keeps the tool's scopes and per-token revoke > explicit. Revocation is per-token, not Better Auth session revocation. +> - **Phase 1.1 — origin binding + self-describing launches** (see "The grant names +> a tool at an origin" below). A grant now records the origin the taskpane +> launched (`tool_grant.tool_origin`, db v4, from a required `tool_url` on +> `POST /api/handoff`) and the exchange refuses any caller whose `Origin` doesn't +> match. The launch fragment also carries `wt_api`, the platform's own API base, +> so a tool talks to the backend that granted it rather than one baked in at build +> time. `prototype-mindmap` consumes both: its session records its issuing backend +> (`PlatformSession.backendUrl`, storage v2) and every later call uses it. > > Everything below Phase 1 (rooms, panel tools, scoped tokens/quotas/manifests) is > still exploration. Scope enforcement on tool tokens is deferred to Phase 3: a @@ -120,21 +128,80 @@ Friction shortcut for tools launched *from the sidebar* (where the user is already signed in): the taskpane mints a **launch grant** — 1. Taskpane: `POST /api/handoff` (authenticated) → - `{ grant_id }`, stashing `{ user, tool_client_id, scopes, doc_snapshot?, ttl≈2min, single-use }`. -2. Taskpane opens `https://tool.example/#wt_grant=` in the - browser. URL **fragment**, not query: fragments aren't sent to the + `{ grant_id }`, stashing `{ user, tool_client_id, tool_origin, scopes, doc_snapshot?, ttl≈2min, single-use }`. +2. Taskpane opens `https://tool.example/#wt_grant=&wt_api=` + in the browser. URL **fragment**, not query: fragments aren't sent to the tool's server or logged in intermediary access logs. 3. Tool: `POST /api/handoff/exchange {grant_id}` → bearer token - (a Better Auth session scoped/tagged for that tool) + the doc snapshot. + (an opaque `wtk_` credential tagged for that tool) + the doc snapshot. + The caller's `Origin` must match the grant's `tool_origin`. The pure device flow remains the fallback when a tool is opened directly (bookmark, returning participant), which longitudinal studies will need anyway. -Teardown: grants are single-use with a short TTL; tokens expire on the -normal session schedule; a "Disconnect" row per tool in the sidebar calls -Better Auth session revocation. Nothing needs the taskpane to stay open -after launch *unless* a live document channel is granted (below). +Teardown: grants are single-use with a short TTL; tokens expire an hour after +exchange; `POST /api/handoff/revoke` disconnects one. Nothing needs the taskpane +to stay open after launch *unless* a live document channel is granted (below). + +### The grant names a tool at an origin + +Two properties fall out of the same idea — that a grant is a statement about a +*specific launch*, not a lookup key into a registry of tools. + +**The exchange is bound to the launched origin.** The taskpane sends the URL it +is about to open; the backend reduces it to an origin, stores it on the grant, +and refuses any exchange whose `Origin` header doesn't match. A grant that leaks, +or is relayed into a page the user didn't launch, can't be redeemed there. + +We considered a server-side registry (`client_id → allowed origin`) instead and +rejected it. It has to answer "what origin is `mindmap`?", which has no single +answer — `resolveMindmapToolUrl` already returns localhost in dev and the +deployed host otherwise — so the registry would duplicate the frontend's URL +resolution and drift from it. Recording what was actually launched is lighter +*and* more accurate. The security delta is nil: both designs block grant relay, +both fail to block grant injection (an attacker minting for their own account and +naming the genuine origin), and neither touches the hostile-backend case below. +The only thing a registry adds is constraining a *compromised taskpane*, which is +already inside the surface that reads the document directly. + +`Origin` is always present here: the grant can only arrive via a URL fragment, so +every caller is a browser page, and a JSON POST is preflighted. A missing `Origin` +is refused rather than waved through. Two future collisions worth remembering — +opaque-origin sandboxes send `Origin: null` (relevant if the `sandbox="allow-scripts"` +bundle hosting under "Registration UX" ever lands), and a tool that redirects +post-launch (`foo.github.io` → custom domain) will present the wrong origin; the +fix there is a manifest declaring the exchange origin. + +**The granting backend is the backend the tool talks to.** The launch fragment +carries `wt_api`, the platform's own API base, and the tool uses it for the +exchange and everything after. This is the honest arrangement regardless — a grant +is only redeemable at its issuer, the token is only valid there, and the snapshot +only lives there, so a tool talking to any other backend is always a bug or an +attack. It also removes per-deployment tool builds, which is what Phase 3 needs: a +researcher hosts one bundle and prod, staging, and a developer's localhost each +launch it against themselves. + +The cost is that `wt_api` is attacker-supplyable — anyone can craft a link to the +tool's page. That is survivable but not free. A hostile base can't mint a real +token, but it can serve a fake document and collect whatever the writer does next. +Note the baseline: an attacker can already host their own copy of a +client-side bundle pointed at their own backend, so what injection actually buys +them is the genuine tool's origin (its persisted state, and a URL bar that looks +right). Hence the containment, which is the tool's job, not the platform's: + +- **The session records its own backend.** `PlatformSession.backendUrl` is stored + with the token and used for every later call, so a session obtained from one + platform can never be replayed against another, and a hostile launch can't reuse + a genuine session. This is what a naive tool would get wrong by keeping one + global storage key. +- **No plaintext.** `normalizePlatformApiBase` refuses anything but https, except + loopback for dev, so the base can't be downgraded in transit. + +A tool-side allowlist of acceptable platform origins would also close the URL-bar +phishing residue, but only by giving back the deployment-agnostic property that +was the point. Displaying the connected identity and platform is the cheaper +mitigation, and is the right home for it (see the consent screen in Phase 3). ### Prerequisite: the proxy must actually check tokens — done @@ -263,7 +330,11 @@ Keep it far short of an app store: - **Phase 1:** hardcoded first-party list (mindmap, …) on a new "Tools" page in the sidebar (`PageName.Tools` next to Chat/Draft/Revise), plus a - "paste a URL" field for ad-hoc launches. + "paste a URL" field for ad-hoc launches. The pasted-URL path currently just + opens the URL — no grant, so such a tool must sign in via the device flow — + only because there was no `client_id` to mint against. Now that a grant is + bound to the launched origin, a pasted URL could carry one bound to the origin + the user typed, which is a decent consent signal in itself. Not implemented. - **Later:** a tool manifest — small JSON (name, launch URL, requested scopes, contact) fetched from `/.well-known/writing-tool.json` or pasted. Requested scopes drive a consent screen ("Mindmap wants: @@ -289,8 +360,11 @@ Fine to skip for first-party-only Phase 1, but designed-for now: | Proxy abuse | auth required | per-user + per-tool quotas/rate limits; model allowlist | | Doc data leaving trust boundary | trusted tools | consent screen naming scopes; snapshot-only default; patch-with-confirm writes | | Same-origin code execution | avoided by design (browser launch) | stays avoided; sandbox origin if bundle hosting ever lands | +| Grant redemption | bound to the launched origin (`tool_origin` vs. `Origin`), single-use, ~2min TTL | unchanged; manifest declares the exchange origin for tools that redirect | +| Grant injection (attacker's grant in the victim's tab) | **open** — visible only because the doc snapshot is wrong | consent screen naming the connected identity + platform | +| Hostile `wt_api` in a crafted link | session records its issuing backend; https-only | plus identity display, so a fake platform is visible | | Log tenancy | shared JSONL as today | logs keyed `(user, tool)`; per-study export scoped to the study's own logs | -| Revocation | session revoke via sidebar | plus per-room member list ("this phone, this mindmap tab") with per-member revoke | +| Revocation | per-token via `POST /api/handoff/revoke` | plus per-room member list ("this phone, this mindmap tab") with per-member revoke | Also worth stating: permissive CORS is *compatible* with the bearer-token model (no cookies to steal cross-site), but cookie-authenticated routes @@ -308,6 +382,11 @@ origins. field; browser launch with handoff grant (token + read-only doc snapshot); device-flow fallback for direct visits. (Porting the mindmap to actually consume the grant is tracked separately — it lives on `feat/uist`.) +2. **Phase 1.1 — done:** grants bound to the launched origin and checked against + `Origin` at exchange; the launch fragment names the platform's API base so a + tool follows the backend that granted it. See "The grant names a tool at an + origin". Still open from that discussion: grant injection, whose mitigation is + a connected-identity display and so belongs with the Phase 3 consent screen. 3. **Phase 2:** rooms — WebSocket switchboard with membership + scopes (`doc:read` / `doc:write`, patch-with-confirm writes), `rpc` / `broadcast` / `presence` message types, room list + QR/short-URL join. diff --git a/frontend/src/api/__tests__/handoff.test.ts b/frontend/src/api/__tests__/handoff.test.ts index 37ec2f11..60a8b6b3 100644 --- a/frontend/src/api/__tests__/handoff.test.ts +++ b/frontend/src/api/__tests__/handoff.test.ts @@ -23,23 +23,33 @@ afterEach(() => { }); describe('withGrantFragment', () => { - it('adds wt_grant to a URL with no fragment', () => { - expect(withGrantFragment('https://tool.example/', 'g1')).toBe( - 'https://tool.example/#wt_grant=g1', + const API = 'https://app.example/api'; + + it('adds the grant and the platform API base to a URL with no fragment', () => { + expect(withGrantFragment('https://tool.example/', 'g1', API)).toBe( + 'https://tool.example/#wt_grant=g1&wt_api=https%3A%2F%2Fapp.example%2Fapi', ); }); it('appends to an existing fragment instead of clobbering it', () => { - expect(withGrantFragment('https://tool.example/#/route', 'g1')).toBe( - 'https://tool.example/#/route&wt_grant=g1', + expect(withGrantFragment('https://tool.example/#/route', 'g1', API)).toBe( + 'https://tool.example/#/route&wt_grant=g1&wt_api=https%3A%2F%2Fapp.example%2Fapi', ); }); it('url-encodes the grant id', () => { - expect(withGrantFragment('https://tool.example/', 'a b')).toContain( + expect(withGrantFragment('https://tool.example/', 'a b', API)).toContain( 'wt_grant=a%20b', ); }); + + // The tool talks to whichever backend granted it, so one hosted bundle can serve + // several deployments — the base has to survive the round trip intact. + it('round-trips the API base through the fragment', () => { + const url = new URL(withGrantFragment('https://tool.example/', 'g1', API)); + const params = new URLSearchParams(url.hash.slice(1)); + expect(params.get('wt_api')).toBe(API); + }); }); describe('createHandoff', () => { @@ -50,6 +60,7 @@ describe('createHandoff', () => { const out = await createHandoff('tok', { toolClientId: 'mindmap', + toolUrl: 'https://tool.example/app', scopes: ['openai:chat', 'doc:read'], doc: { beforeCursor: 'x', selectedText: '', afterCursor: '' }, }); @@ -68,10 +79,14 @@ describe('createHandoff', () => { expect(init.headers.Authorization).toBe('Bearer tok'); const body = JSON.parse(init.body) as { tool_client_id: string; + tool_url: string; scopes: string[]; doc: { beforeCursor: string }; }; expect(body.tool_client_id).toBe('mindmap'); + // The backend binds the grant to this URL's origin, so the launch URL has to + // travel with the request. + expect(body.tool_url).toBe('https://tool.example/app'); expect(body.scopes).toEqual(['openai:chat', 'doc:read']); expect(body.doc.beforeCursor).toBe('x'); }); @@ -81,7 +96,10 @@ describe('createHandoff', () => { resp({ detail: 'Unknown tool_client_id.' }, false, 400), ); await expect( - createHandoff('tok', { toolClientId: 'nope' }), + createHandoff('tok', { + toolClientId: 'nope', + toolUrl: 'https://tool.example/', + }), ).rejects.toThrow('Unknown tool_client_id.'); }); }); diff --git a/frontend/src/api/handoff.ts b/frontend/src/api/handoff.ts index 55672113..4b32b204 100644 --- a/frontend/src/api/handoff.ts +++ b/frontend/src/api/handoff.ts @@ -16,6 +16,12 @@ export type ToolScope = 'openai:chat' | 'log:write' | 'doc:read' | 'doc:write'; export interface HandoffRequest { /** A tool client_id registered in the backend device allowlist. */ toolClientId: string; + /** + * The URL this launch will open. The backend reduces it to an origin and binds the + * grant to it, then refuses any exchange whose `Origin` doesn't match — so a grant + * that leaks can't be redeemed by a page other than the tool being launched. + */ + toolUrl: string; /** Scopes to grant; the backend defaults a read-only set when omitted. */ scopes?: ToolScope[]; /** Read-only document snapshot to hand the tool, or omit to share none. */ @@ -40,6 +46,7 @@ export async function createHandoff( }, body: JSON.stringify({ tool_client_id: req.toolClientId, + tool_url: req.toolUrl, scopes: req.scopes, doc: req.doc, }), @@ -55,12 +62,41 @@ export async function createHandoff( return { grantId: body.grant_id, expiresIn: body.expires_in }; } -/** Append `wt_grant=` to a URL's fragment without clobbering an existing hash. */ -export function withGrantFragment(url: string, grantId: string): string { +/** + * Our API base as an absolute URL, for handing to a tool on another origin. `SERVER_URL` + * is relative (`/api`) everywhere but the Google Docs sidebar, which is fine for our own + * pages and useless to a tool in a different tab. + */ +export function platformApiBase(): string { + // No window (tests, SSR-ish contexts): nothing to resolve a relative base against, + // so hand back what we have rather than throwing mid-launch. + if (typeof window === 'undefined') return SERVER_URL; + return new URL(SERVER_URL, window.location.origin).toString().replace(/\/+$/, ''); +} + +/** + * Append the launch parameters to a URL's fragment without clobbering an existing hash. + * + * A fragment (not a query) because browsers never send it to the tool's server or write + * it to intermediary access logs, so the grant doesn't leak in transit. + * + * `wt_api` names the platform that issued the grant, so the tool talks to the backend + * that granted it instead of one baked in at build time. That is what lets a single + * hosted bundle serve prod, staging and a developer's localhost — and it is the honest + * arrangement regardless, since a grant is only ever redeemable at its issuer. + */ +export function withGrantFragment( + url: string, + grantId: string, + apiBase: string = platformApiBase(), +): string { const u = new URL(url); const existing = u.hash.replace(/^#/, ''); - const param = `wt_grant=${encodeURIComponent(grantId)}`; - u.hash = existing ? `${existing}&${param}` : param; + const params = [ + `wt_grant=${encodeURIComponent(grantId)}`, + `wt_api=${encodeURIComponent(apiBase)}`, + ].join('&'); + u.hash = existing ? `${existing}&${params}` : params; return u.toString(); } diff --git a/frontend/src/pages/tools/index.tsx b/frontend/src/pages/tools/index.tsx index e8447514..c38d7466 100644 --- a/frontend/src/pages/tools/index.tsx +++ b/frontend/src/pages/tools/index.tsx @@ -110,6 +110,7 @@ export async function launchFirstPartyTool( : undefined; const { grantId } = await dependencies.createGrant(token, { toolClientId: tool.id, + toolUrl: tool.url, scopes: tool.scopes, doc, }); diff --git a/prototype-mindmap/src/PlatformBootstrap.test.ts b/prototype-mindmap/src/PlatformBootstrap.test.ts index 04162e76..14ac3f48 100644 --- a/prototype-mindmap/src/PlatformBootstrap.test.ts +++ b/prototype-mindmap/src/PlatformBootstrap.test.ts @@ -74,7 +74,8 @@ describe("AppErrorBoundary", () => { it("routes an expired stored token to relaunch guidance without clearing saved work", () => { const session: PlatformSession = { - version: 1, + version: 2, + backendUrl: "https://app.example/api", accessToken: "wtk_expired", expiresAt: Date.now() - 1, scopes: ["openai:chat"], diff --git a/prototype-mindmap/src/PlatformBootstrap.tsx b/prototype-mindmap/src/PlatformBootstrap.tsx index 568ef264..f08879b4 100644 --- a/prototype-mindmap/src/PlatformBootstrap.tsx +++ b/prototype-mindmap/src/PlatformBootstrap.tsx @@ -12,12 +12,13 @@ import { import { clearPlatformSession, exchangeGrant, + apiBaseFromHash, grantFromHash, GrantExchangeError, launchRequired, PLATFORM_SESSION_STORAGE_KEY, readPlatformSession, - scrubGrantFromUrl, + scrubLaunchParamsFromUrl, snapshotText, writePlatformSession, type PlatformSession, @@ -223,6 +224,9 @@ function PlatformBootstrapContent() { const [accessDenied, setAccessDenied] = useState(false); const exchangeStarted = useRef(false); const capturedGrant = useRef(null); + // Held alongside the grant because Retry runs long after the fragment was scrubbed, + // so re-reading the launch parameters from the URL is no longer possible. + const capturedApiBase = useRef(null); const commitDecision = useCallback( (session: PlatformSession, decision: "continue_saved" | "start_new") => { @@ -257,7 +261,11 @@ function PlatformBootstrapContent() { ); const beginExchange = useCallback( - async (grant: string) => { + // `launchApiBase` is read from the fragment by the caller, before the fragment is + // scrubbed. The launch names its own platform, so one hosted bundle serves prod, + // staging and a developer's localhost; a direct visit names none and falls back to + // the build-time backend. + async (grant: string, launchApiBase: string | null) => { const storage = browserStorage(); if (!storage) { setBoot({ kind: "blocked", reason: "storage_unavailable" }); @@ -265,7 +273,9 @@ function PlatformBootstrapContent() { } setBoot({ kind: "connecting" }); try { - const session = await exchangeGrant(grant); + const session = await exchangeGrant(grant, { + ...(launchApiBase ? { backendUrl: launchApiBase } : {}), + }); try { writePlatformSession(storage.session, session); } catch (error) { @@ -306,13 +316,16 @@ function PlatformBootstrapContent() { if (!grant || exchangeStarted.current) return; exchangeStarted.current = true; capturedGrant.current = grant; + // Read before the scrub below strips it along with the grant. + const launchApiBase = apiBaseFromHash(window.location.hash); + capturedApiBase.current = launchApiBase; try { - scrubGrantFromUrl(window.location, window.history); + scrubLaunchParamsFromUrl(window.location, window.history); } catch { // The grant is already captured in memory; an unusual history wrapper must // not force a reload-based retry or expose the document a second time. } - void beginExchange(grant); + void beginExchange(grant, launchApiBase); }, [beginExchange]); const chooseContinue = useCallback((session: PlatformSession) => { @@ -350,7 +363,11 @@ function PlatformBootstrapContent() { const providerRuntime = useMemo(() => { if (boot.kind !== "ready") return undefined; return { - ...(boot.session ? { bearerToken: boot.session.accessToken } : {}), + // Talk to the platform that issued this session, never a build-time constant — + // a token is only ever presented to the backend that minted it. + ...(boot.session + ? { bearerToken: boot.session.accessToken, backendUrl: boot.session.backendUrl } + : {}), onAccessError, }; }, [boot, onAccessError]); @@ -370,7 +387,9 @@ function PlatformBootstrapContent() {