Skip to content
Open
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
18 changes: 12 additions & 6 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions backend/src/__tests__/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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 () => {
Expand Down
111 changes: 85 additions & 26 deletions backend/src/__tests__/handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createApp>, 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<typeof createApp>,
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<string, unknown> = {},
): 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', {
Expand All @@ -58,21 +95,35 @@ 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);
});

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 };
Expand All @@ -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;
Expand All @@ -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<string> {
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;
}

Expand Down
68 changes: 58 additions & 10 deletions backend/src/__tests__/toolGrants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
exchangeToolGrant,
GRANT_TTL_MS,
getToolTokenDoc,
originOfLaunchUrl,
resolveToolToken,
revokeToolToken,
TOKEN_TTL_MS,
Expand All @@ -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;
Expand All @@ -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' });
});

Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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({
Expand All @@ -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);
Expand Down
Loading
Loading