From e9bd104b25c1f3039ff590da0afff7881820e7c7 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Wed, 29 Jul 2026 14:54:16 -0400 Subject: [PATCH 1/4] Send the launch URL and platform API base with a tool launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions to the handoff client, both inert until the backend and the tools read them (deliberately sequenced first so no intermediate commit breaks a launch): - `tool_url` on the grant request. The backend will reduce it to an origin and bind the grant to it, so a grant can only be redeemed by the tool that was actually launched. - `wt_api` in the launch fragment, naming our own API base. A tool is then talking to the backend that granted it rather than one baked in at build time — which is the honest arrangement anyway, since a grant is only ever redeemable at its issuer, and it frees one hosted bundle to serve prod, staging and a developer's localhost. `platformApiBase` resolves `SERVER_URL` (relative `/api` everywhere but the Google Docs sidebar) against the window origin, since a relative base is useless to a tool in another tab. Co-Authored-By: Claude Opus 5 --- frontend/src/api/__tests__/handoff.test.ts | 32 ++++++++++++---- frontend/src/api/handoff.ts | 44 ++++++++++++++++++++-- frontend/src/pages/tools/index.tsx | 1 + 3 files changed, 66 insertions(+), 11 deletions(-) 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, }); From a7813b02f01cddb0343a7c7ee3174ccdd933b514 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Wed, 29 Jul 2026 14:54:28 -0400 Subject: [PATCH 2/4] Bind launch grants to the origin they were launched at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A grant now records the origin the taskpane opened (`tool_grant.tool_origin`, appended migration v4, derived from the required `tool_url`), and the exchange refuses any caller whose `Origin` doesn't match. A grant that leaks, or is relayed into a page the user didn't launch, can't be redeemed there. Why not a server-side `client_id -> origin` registry: 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 a registry would duplicate the frontend's URL resolution and drift from it. Recording what was actually launched is lighter and more accurate, and the security delta is nil (both block grant relay; neither blocks an attacker minting a grant for their own account and naming the genuine origin). This is an application check, deliberately not CORS, which stays permissive because the surface is bearer-only with no cookies to steal cross-site. Notes on the edges: - `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. - A refused exchange does not consume the grant, so the real tool can still redeem it. - Nullable column only because SQLite can't add a NOT NULL column without a default; a NULL is refused at exchange. No backfill needed — grants live ~2 minutes, so any pre-existing row is already unexchangeable. Co-Authored-By: Claude Opus 5 --- backend/src/__tests__/db.test.ts | 6 +- backend/src/__tests__/handoff.test.ts | 111 +++++++++++++++++------ backend/src/__tests__/toolGrants.test.ts | 68 ++++++++++++-- backend/src/app.ts | 16 +++- backend/src/db.ts | 9 ++ backend/src/toolGrants.ts | 69 ++++++++++++-- 6 files changed, 230 insertions(+), 49 deletions(-) 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_'); From faf75b5f613f71a60b1b65b71c2ce259cb44ee31 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Wed, 29 Jul 2026 14:54:39 -0400 Subject: [PATCH 3/4] Have launched tools follow the platform that launched them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mindmap and the test-tool now read `wt_api` from the launch fragment and talk to that backend, instead of a `VITE_BACKEND_URL` fixed at build time. `wt_api` is attacker-supplyable — anyone can craft a link to the tool's page — which 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, though: an attacker can already host their own copy of a client-side bundle pointed at their own backend, so what injection buys them is the genuine tool's origin. Two things contain that, and they are the tool's job rather than the platform's: - `PlatformSession` records its issuing backend (storage v2) and every later call uses it, 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 gets wrong by keeping one global storage key. - `normalizePlatformApiBase` refuses anything but https, except loopback for dev, so the base can't be downgraded in transit. The launch base is captured before the fragment is scrubbed (the scrub runs first, so reading it later would always have found nothing) and held in a ref, because Retry runs long after that point. `hashWithoutGrant` becomes `hashWithoutLaunchParams` since it now strips both parameters. Co-Authored-By: Claude Opus 5 --- .../src/PlatformBootstrap.test.ts | 3 +- prototype-mindmap/src/PlatformBootstrap.tsx | 33 +++++-- .../src/platform-session.test.ts | 53 ++++++++++- prototype-mindmap/src/platform-session.ts | 93 ++++++++++++++++--- sandbox/test-tool/index.html | 14 ++- 5 files changed, 166 insertions(+), 30 deletions(-) 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() {