Public: anyone can read this; the crew can change it.

OAuth for the MCP: the authorize page and PKCE token exchange from pull #818 land on today's main #1153

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/971-oauth-for-the-mcp-the-authorize-page-and → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/971-oauth-for-the-mcp-the-authorize-page-and was merged into main as 520a04a.

Review

Sign in to review this change.
8 files changed +725 −15
docs/MCP.md +34 −5
@@ -72,11 +72,40 @@ claude mcp add --transport http forge https://<your-forge>/mcp \
7272 --header "Authorization: Bearer <token>"
7373 ```
7474
75 Any MCP client that can send a header works the same way. The Claude apps on
76 the web and the phone add custom connectors through OAuth, which the forge
77 does not speak yet; that is the next step here, and it changes nothing above
78 — the same tools, a token the forge mints for the connector instead of one a
79 person pastes.
75 Any MCP client that can send a header works the same way.
76
77 ## Attaching with OAuth
78
79 Clients that cannot paste a header — the Claude apps, Grok's connectors —
80 attach by OAuth. The forge publishes its discovery documents
81 (`/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server`),
82 takes dynamic client registration at `/oauth/register`, and runs the
83 authorization-code flow with PKCE `S256` (public clients, no secret, no
84 refresh token).
85
86 1. The client sends you to `/oauth/authorize`. If you are not signed in you
87 sign in and come back. The page names the client and the host it will
88 return to, and asks for **Read** (`ro`) or **Read and act** (`rw`), a name
89 for the token, and how long it lasts (until revoked by default).
90 2. On **Allow** the forge redirects to the redirect address the client
91 registered — exactly, never a look-alike — with a one-time `code` and the
92 client's `state`. A code works once, for 60 seconds, and is stored hashed.
93 3. The client posts the code and its PKCE verifier to `/oauth/token` and
94 receives a bearer token: the same kind Settings mints. It is listed under
95 Settings and revoking it there cuts the connector off.
96
97 **The Claude app** (web or phone): Settings → Connectors → Add custom
98 connector, URL `https://<your forge>/mcp`. Claude registers itself with the
99 callback `https://claude.ai/api/mcp/auth_callback`; approve it on the forge's
100 page. **Grok**: Connectors → Add custom connector, the same `/mcp` URL;
101 approve the page it opens. Pick `ro` unless the connector should be able to
102 `tell` and `plan`.
103
104 The token's scope is the whole of its authority: a connector holding `ro`
105 can call `apps` and is refused `tell`.
106
107 `src/http/oauth.ts`, `src/core/oauth.ts`, `src/web/pages/oauth.ts`,
108 `test/oauth-flow.test.ts`.
80109
81110 `src/http/mcp.ts`, `src/actions/tell.ts`, `test/mcp.test.ts`.
82111
scripts/gate.ts +1
@@ -195,6 +195,7 @@ export const GATE: readonly string[] = [
195195 "test/hollow.test.ts",
196196 "test/home-feed.test.ts",
197197 "test/oauth-discovery.test.ts",
198 "test/oauth-flow.test.ts",
198199 "test/data-home.test.ts",
199200 "test/tree.test.ts",
200201 "test/reports.test.ts",
scripts/hollow-allow.ts +5
@@ -25,4 +25,9 @@ export const ALLOWED: readonly Allowed[] = [
2525 "the fleet graph shipped ahead of the fleet page that is to link to it",
2626 issue: 448,
2727 },
28 {
29 orphan: "GET /oauth/authorize",
30 reason: "reached only through an OAuth client's redirect",
31 issue: 971,
32 },
2833 ];
src/core/oauth.ts +163 −3
@@ -1,10 +1,13 @@
11 /**
2 * OAuth clients: who may later ask a person for a token. Step one stores the
3 * registration (RFC 7591) and nothing else — no grant, no code, no token.
2 * OAuth for the MCP: clients (RFC 7591), the authorization request a person
3 * consents to, the one-time code, and its PKCE exchange for the same token
4 * Settings mints.
45 */
56 import type { Database } from "bun:sqlite";
6 import { id } from "../kernel/id.ts";
7 import { sha256 } from "../kernel/hash.ts";
8 import { id, secret } from "../kernel/id.ts";
79 import { err, ok, type Result } from "../kernel/result.ts";
10 import { createToken } from "./tokens.ts";
811
912 export const NAME_MAX = 100;
1013 export const REDIRECTS_MAX = 10;
@@ -130,3 +133,160 @@ export function getClient(db: Database, clientId: string): OAuthClient | null {
130133 createdAt: row.created_at,
131134 };
132135 }
136
137 // ---- the authorization request, the code, and the exchange ----
138
139 /** A code is a minute's grace between consent and exchange, no more. */
140 export const CODE_TTL_MS = 60_000;
141 export const TOKEN_NAME_MAX = 100;
142 /** What a person may choose for how long the minted token lives, in seconds. */
143 export const LIFETIMES: Readonly<Record<string, number | null>> = {
144 never: null,
145 day: 86_400,
146 month: 30 * 86_400,
147 year: 365 * 86_400,
148 };
149
150 export type OAuthScope = "ro" | "rw";
151 export const isOAuthScope = (v: string): v is OAuthScope =>
152 v === "ro" || v === "rw";
153
154 /** A valid authorization request: the client is real and the redirect is one it registered. */
155 export interface AuthRequest {
156 readonly client: OAuthClient;
157 readonly redirectUri: string;
158 readonly challenge: string;
159 readonly state: string;
160 readonly scope: OAuthScope;
161 }
162
163 const CHALLENGE = /^[A-Za-z0-9_-]{43}$/;
164 const VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/;
165 const STATE_MAX = 1000;
166
167 /**
168 * Check the untrusted parameters of an authorization request. Anything wrong
169 * is a refusal shown to the person, never a redirect: an unregistered
170 * redirect must never be sent anywhere, and the rest are the client's bugs.
171 * The redirect must equal a registered one exactly — no prefix, no pattern.
172 */
173 export function parseAuthRequest(
174 db: Database,
175 get: (name: string) => string,
176 ): Result<AuthRequest, string> {
177 const client = getClient(db, get("client_id"));
178 if (client === null) return err("This client is not registered here.");
179 const redirectUri = get("redirect_uri");
180 if (!client.redirectUris.includes(redirectUri))
181 return err("The redirect address is not one this client registered.");
182 if (get("response_type") !== "code")
183 return err("Only response_type=code is offered.");
184 if (get("code_challenge_method") !== "S256")
185 return err("PKCE with code_challenge_method=S256 is required.");
186 const challenge = get("code_challenge");
187 if (!CHALLENGE.test(challenge))
188 return err("The code_challenge is not a base64url SHA-256.");
189 const state = get("state");
190 if (state.length > STATE_MAX) return err("The state is too long.");
191 const asked = get("scope").split(/\s+/).find(isOAuthScope);
192 return ok({ client, redirectUri, challenge, state, scope: asked ?? "ro" });
193 }
194
195 /** The name a person gave a token: plain text, bounded, never empty. */
196 export function tokenName(raw: string, fallback: string): string {
197 // eslint-disable-next-line no-control-regex
198 const name = raw.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
199 return (name === "" ? fallback : name).slice(0, TOKEN_NAME_MAX);
200 }
201
202 /** Where the person goes next: the registered redirect with the answer in its query. */
203 export function redirectWith(
204 redirectUri: string,
205 params: Record<string, string>,
206 ): string {
207 const url = new URL(redirectUri);
208 for (const [k, v] of Object.entries(params))
209 if (v !== "") url.searchParams.set(k, v);
210 return url.toString();
211 }
212
213 /** Record a person's consent and return the one-time code that proves it. */
214 export function issueCode(
215 db: Database,
216 userId: string,
217 request: AuthRequest,
218 grant: { scope: OAuthScope; name: string; lifetimeS: number | null },
219 ): string {
220 const code = secret();
221 db.query(
222 `INSERT INTO oauth_code (code_hash, client_id, user_id, redirect_uri, challenge, scope, name, lifetime_s, expires_at)
223 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)`,
224 ).run(
225 sha256(code),
226 request.client.id,
227 userId,
228 request.redirectUri,
229 request.challenge,
230 grant.scope,
231 grant.name,
232 grant.lifetimeS,
233 new Date(Date.now() + CODE_TTL_MS).toISOString(),
234 );
235 db.query("DELETE FROM oauth_code WHERE expires_at <= ?1").run(
236 new Date().toISOString(),
237 );
238 return code;
239 }
240
241 export interface Minted {
242 readonly token: string;
243 readonly scope: OAuthScope;
244 /** Seconds the token lives, or null for one that does not expire. */
245 readonly expiresIn: number | null;
246 }
247
248 /**
249 * Exchange a code for a token. The code is spent before anything else is
250 * decided, so a replay, a wrong verifier or a wrong redirect all find it
251 * gone: one try per code. Every refusal is `invalid_grant`.
252 */
253 export function redeemCode(
254 db: Database,
255 input: {
256 code: string;
257 clientId: string;
258 redirectUri: string;
259 verifier: string;
260 },
261 ): Result<Minted, "invalid_grant"> {
262 const row = db
263 .query<
264 {
265 client_id: string;
266 user_id: string;
267 redirect_uri: string;
268 challenge: string;
269 scope: OAuthScope;
270 name: string;
271 lifetime_s: number | null;
272 expires_at: string;
273 },
274 [string]
275 >("DELETE FROM oauth_code WHERE code_hash = ?1 RETURNING *")
276 .get(sha256(input.code));
277 if (row === null) return err("invalid_grant");
278 if (Date.parse(row.expires_at) <= Date.now()) return err("invalid_grant");
279 if (row.client_id !== input.clientId) return err("invalid_grant");
280 if (row.redirect_uri !== input.redirectUri) return err("invalid_grant");
281 if (!VERIFIER.test(input.verifier)) return err("invalid_grant");
282 const digest = new Bun.CryptoHasher("sha256")
283 .update(input.verifier)
284 .digest("base64url");
285 if (digest !== row.challenge) return err("invalid_grant");
286 const minted = createToken(db, row.user_id, row.name, row.scope, {
287 ...(row.lifetime_s === null
288 ? {}
289 : { expiresAt: new Date(Date.now() + row.lifetime_s * 1000).toISOString() }),
290 });
291 return ok({ token: minted.token, scope: row.scope, expiresIn: row.lifetime_s });
292 }
src/core/schema.ts +15
@@ -1563,6 +1563,21 @@ export const migrations: readonly string[] = [
15631563 WHERE id = NEW.id;
15641564 END;
15651565 `,
1566 // 73 — authorization codes for the MCP's OAuth (#971): one per consent,
1567 // kept by hash, spent by deleting. The code is never stored.
1568 `
1569 CREATE TABLE IF NOT EXISTS oauth_code (
1570 code_hash TEXT PRIMARY KEY,
1571 client_id TEXT NOT NULL REFERENCES oauth_client(id) ON DELETE CASCADE,
1572 user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
1573 redirect_uri TEXT NOT NULL,
1574 challenge TEXT NOT NULL,
1575 scope TEXT NOT NULL CHECK (scope IN ('ro','rw')),
1576 name TEXT NOT NULL,
1577 lifetime_s INTEGER,
1578 expires_at TEXT NOT NULL
1579 );
1580 `,
15661581 ];
15671582
15681583 /**
src/http/oauth.ts +57 −7
@@ -1,19 +1,22 @@
11 /**
2 * OAuth discovery for the MCP, step one: the two well-known documents and
3 * dynamic client registration. The authorize page and the token endpoint
4 * the documents name come next; until then a client can find the forge and
5 * register, and still signs in with a bearer token.
2 * OAuth for the MCP: the two well-known documents, dynamic client
3 * registration, the authorize page (a person's session and consent) and the
4 * token endpoint (a one-time code and its PKCE verifier for a forge token).
65 *
7 * Registration takes no cookie and grants nothing, so there is no ambient
8 * authority for a cross-site page to ride; it is limited per address instead.
6 * Registration and the token endpoint take no cookie, so there is no ambient
7 * authority for a cross-site page to ride; registration is limited per
8 * address instead. The authorize page is session-bound and same-origin
9 * checked on its POST.
910 */
1011 import type { App } from "../app.ts";
11 import { createClient, parseRegistration } from "../core/oauth.ts";
12 import { createClient, parseRegistration, redeemCode } from "../core/oauth.ts";
1213 import { RateLimiter } from "../core/ratelimit.ts";
1314 import type { Handler } from "./context.ts";
1415 import { clientBucket } from "./peer.ts";
16 import { readBounded } from "./limits.ts";
1517 import { boundedBody, json } from "./respond.ts";
1618 import type { Router } from "./router.ts";
19 import { authorizePage, authorizeSubmit } from "../web/pages/oauth.ts";
1720
1821 /** Registrations per address per minute. */
1922 const REGISTER_PER_MINUTE = 10;
@@ -40,7 +43,54 @@ export const resourceMetadataUrl = (origin: string): string =>
4043 const open = { "access-control-allow-origin": "*" };
4144 const docHeaders = { ...open, "cache-control": "public, max-age=300" };
4245
46 const noStore = { ...open, "cache-control": "no-store", pragma: "no-cache" };
47 const grantError = (error: string, status = 400) =>
48 json({ error }, status, noStore);
49
50 /** The token endpoint's parameters: a form (RFC 6749) or, leniently, JSON. */
51 async function tokenParams(
52 request: Request,
53 ): Promise<((n: string) => string) | null> {
54 const type = request.headers.get("content-type") ?? "";
55 if (type.includes("application/json")) {
56 const raw = await boundedBody(request, BODY_MAX);
57 if (raw === "too_large") return null;
58 return (n) => (typeof raw[n] === "string" ? (raw[n] as string) : "");
59 }
outdated

High: request.formData() reads the whole body with no cap. The Content-Length check on line 70 depends on a header the client controls, and a chunked request skips it. Read with readBounded(request, BODY_MAX) and parse a URLSearchParams from those bytes.

60 const bytes = await readBounded(request, BODY_MAX);
61 if (bytes === "too_large") return null;
62 const form = new URLSearchParams(new TextDecoder().decode(bytes));
63 return (n) => form.get(n) ?? "";
64 }
65
4366 export function oauthRoutes(router: Router<Handler>): void {
67 router.add("GET", "/oauth/authorize", authorizePage);
68 router.add("POST", "/oauth/authorize", authorizeSubmit);
69 router.add("POST", "/oauth/token", async (ctx) => {
70 const get = await tokenParams(ctx.request);
outdated

This is only a pre-check on a header the client wrote. The real cap has to be on the bytes actually read (see line 59).

71 if (get === null) return grantError("invalid_request", 413);
72 if (get("grant_type") !== "authorization_code")
73 return grantError("unsupported_grant_type");
74 const minted = redeemCode(ctx.app.db, {
75 code: get("code"),
76 clientId: get("client_id"),
77 redirectUri: get("redirect_uri"),
78 verifier: get("code_verifier"),
79 });
80 if (!minted.ok) return grantError(minted.error);
81 return json(
82 {
83 access_token: minted.value.token,
84 token_type: "Bearer",
85 scope: minted.value.scope,
86 ...(minted.value.expiresIn === null
87 ? {}
88 : { expires_in: minted.value.expiresIn }),
89 },
90 200,
91 noStore,
92 );
93 });
4494 router.add("GET", "/.well-known/oauth-protected-resource", (ctx) =>
4595 json(
4696 {
src/web/pages/oauth.ts added +173
@@ -0,0 +1,173 @@
1 /**
2 * `/oauth/authorize`: the page an MCP client sends a person to. It names the
3 * client and where it will return, asks which kind of token to mint and what
4 * to call it, and on consent hands back a one-time code at the redirect the
5 * client registered. A request that is not valid is shown, never followed:
6 * nothing here redirects to an address that was not registered.
7 */
8 import {
9 isOAuthScope,
10 issueCode,
11 LIFETIMES,
12 parseAuthRequest,
13 redirectWith,
14 tokenName,
15 type AuthRequest,
16 } from "../../core/oauth.ts";
17 import type { Ctx } from "../../http/context.ts";
18 import { pageHead } from "../chrome.ts";
19 import { field, readForm } from "../form.ts";
20 import { html, type Html } from "../html.ts";
21 import { page, redirect } from "../layout.ts";
22 import { btn, card, field as formField } from "../ui.ts";
23
24 const PARAMS = [
25 "client_id",
26 "redirect_uri",
27 "response_type",
28 "code_challenge",
29 "code_challenge_method",
30 "state",
31 "scope",
32 ] as const;
33
34 /** Nothing here is cacheable, and nothing here may be sent on as a referrer. */
35 const guard = (response: Response): Response => {

High: the page CSP from layout.ts includes form-action 'self', and Chromium applies form-action to the 303 that follows a form POST. So Allow → https://claude.ai/api/mcp/auth_callback is likely blocked in the browser. For this response, widen form-action to 'self' plus the registered redirect's origin, and add a test that checks the header.

36 response.headers.set("cache-control", "no-store");
37 response.headers.set("referrer-policy", "no-referrer");
38 return response;
39 };
40
41 /**
42 * Chromium checks `form-action` against the redirect that follows a form
43 * submit, using the policy of the page the form is on. The consent page lets
44 * its form go to the one origin the client registered, and nowhere else.
45 */
46 const allowReturnTo = (response: Response, redirectUri: string): Response => {
47 const csp = response.headers.get("content-security-policy") ?? "";
48 response.headers.set(
49 "content-security-policy",
50 csp.replace("form-action 'self'", `form-action 'self' ${new URL(redirectUri).origin}`),
51 );
52 return response;
53 };
54
55 const refused = (ctx: Ctx, message: string): Response =>
56 guard(
57 page(
58 {
59 ctx,
60 title: "Can't connect",
61 body: html`<div class="narrow">
62 ${pageHead("This connection can't be made")}
63 ${card(
64 html`<p>${message}</p>
65 <p class="muted small">
66 Nothing was granted. Go back to the app and start again.
67 </p>`,
68 )}
69 </div>`,
70 },
71 400,
72 ),
73 );
74
75 const hidden = (name: string, value: string): Html =>
76 html`<input type="hidden" name="${name}" value="${value}" />`;
77
78 function consent(ctx: Ctx, request: AuthRequest, values: Params): Html {

Nit: ctx is unused here. Drop it.

79 const host = new URL(request.redirectUri).host;
80 return html`<div class="narrow">
81 ${pageHead(`Connect ${request.client.name}`)}
82 ${card(
83 html`<p class="mb">
84 <strong>${request.client.name}</strong> wants to use the forge as you,
85 through a token. When you allow it, you are sent back to
86 <strong>${host}</strong>.
87 </p>
88 <form method="post" action="/oauth/authorize">
89 ${PARAMS.map((name) => hidden(name, values[name]))}
90 <fieldset class="field">
91 <legend>What it may do</legend>
92 <label
93 ><input type="radio" name="grant" value="ro"
94 ${request.scope === "ro" ? "checked" : ""} />
95 Read: see your apps, issues and runs</label
96 >
97 <label
98 ><input type="radio" name="grant" value="rw"
99 ${request.scope === "rw" ? "checked" : ""} />
100 Read and act: tell the crew, plan, and change things as you</label
101 >
102 </fieldset>
103 ${formField({
104 label: "Token name",
105 name: "name",
106 value: request.client.name,
107 required: true,
108 hint: "How it is listed under Settings, where you can revoke it.",
109 })}
110 <div class="field">
111 <label for="f-lifetime">Lasts</label>
112 <select class="input" id="f-lifetime" name="lifetime">
113 <option value="never" selected>Until I revoke it</option>
114 <option value="year">A year</option>
115 <option value="month">30 days</option>
116 <option value="day">A day</option>
117 </select>
118 </div>
119 <div class="form-actions">
120 ${btn("Allow", { variant: "primary", name: "decision", value: "allow" })}
121 ${btn("Deny", { name: "decision", value: "deny" })}
122 </div>
123 </form>`,
124 )}
125 </div>`;
126 }
127
128 type Params = Record<(typeof PARAMS)[number], string>;
129
130 const fromQuery = (ctx: Ctx): Params =>
131 Object.fromEntries(
132 PARAMS.map((p) => [p, ctx.url.searchParams.get(p) ?? ""]),
133 ) as Params;
134
135 export function authorizePage(ctx: Ctx): Response {
136 if (ctx.auth.user === undefined || ctx.auth.via === "token")
137 return guard(
138 redirect(
139 `/signin?next=${encodeURIComponent(ctx.url.pathname + ctx.url.search)}`,
140 ),
141 );
142 const values = fromQuery(ctx);
143 const request = parseAuthRequest(ctx.app.db, (n) => values[n as keyof Params] ?? "");
144 if (!request.ok) return refused(ctx, request.error);
145 return allowReturnTo(
146 guard(page({ ctx, title: "Connect", body: consent(ctx, request.value, values) })),
147 request.value.redirectUri,
148 );
149 }
150
151 export async function authorizeSubmit(ctx: Ctx): Promise<Response> {
152 const form = await readForm(ctx);
153 if (form instanceof Response) return form;
154 if (ctx.auth.user === undefined || ctx.auth.via === "token")
155 return new Response("forbidden", { status: 403 });
156 const request = parseAuthRequest(ctx.app.db, (n) => field(form, n));
157 if (!request.ok) return refused(ctx, request.error);
158 const { redirectUri, state } = request.value;
159 if (field(form, "decision") !== "allow")
160 return guard(
161 redirect(redirectWith(redirectUri, { error: "access_denied", state })),
162 );
163 const grant = field(form, "grant");
164 const lifetime = LIFETIMES[field(form, "lifetime")];
165 if (!isOAuthScope(grant) || lifetime === undefined)
166 return refused(ctx, "Choose what the token may do and how long it lasts.");
167 const code = issueCode(ctx.app.db, ctx.auth.user.id, request.value, {
168 scope: grant,
169 name: tokenName(field(form, "name"), request.value.client.name),
170 lifetimeS: lifetime,
171 });
172 return guard(redirect(redirectWith(redirectUri, { code, state })));
173 }
test/oauth-flow.test.ts added +277
@@ -0,0 +1,277 @@
1 /**
2 * OAuth for the MCP, step two: the whole dance with a fake client — register,
3 * consent, a one-time code, a PKCE exchange — and the refusals that make it
4 * safe: a wrong verifier, a replayed code, an unregistered redirect, a forged
5 * consent, an expired code.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { listTokens } from "../src/core/tokens.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { sha256 } from "../src/kernel/hash.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17
18 const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
19 const verifier = "v".repeat(60);
20 const challenge = new Bun.CryptoHasher("sha256")
21 .update(verifier)
22 .digest("base64url");
23
24 describe("oauth authorize and token", () => {
25 let dataDir: string;
26 let app: App;
27 let servers: Servers;
28 let base: string;
29 let alice: User;
30 let cookie: string;
31 let clientId: string;
32
33 beforeAll(async () => {
34 dataDir = await mkdtemp(join(tmpdir(), "forge-oauth-flow-"));
35 app = createApp({ dataDir, port: 0 });
36 servers = await serve(app);
37 base = servers.url;
38 const made = await createUser(app.db, {
39 trusted: true,
40 username: "alice",
41 email: "[email protected]",
42 password: "a-good-passphrase",
43 adminIfFirst: true,
44 });
45 if (!made.ok) throw new Error(made.error);
46 alice = made.value;
47 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
48 const res = await fetch(`${base}/oauth/register`, {
49 method: "POST",
50 headers: { "content-type": "application/json" },
51 body: JSON.stringify({ client_name: "Fake <b>Client</b>", redirect_uris: [REDIRECT] }),
52 });
53 clientId = ((await res.json()) as any).client_id;
54 });
55 afterAll(async () => {
56 servers.stop();
57 await rm(dataDir, { recursive: true, force: true });
58 });
59
60 const query = (over: Record<string, string> = {}) =>
61 new URLSearchParams({
62 response_type: "code",
63 client_id: clientId,
64 redirect_uri: REDIRECT,
65 code_challenge: challenge,
66 code_challenge_method: "S256",
67 state: "st-1",
68 ...over,
69 });
70 const get = (q: URLSearchParams, withCookie = true) =>
71 fetch(`${base}/oauth/authorize?${q}`, {
72 redirect: "manual",
73 headers: withCookie ? { cookie } : {},
74 });
75 const consent = (
76 q: URLSearchParams,
77 extra: Record<string, string> = {},
78 headers: Record<string, string> = {},
79 ) =>
80 fetch(`${base}/oauth/authorize`, {
81 method: "POST",
82 redirect: "manual",
83 headers: { cookie, origin: base, ...headers },
84 body: new URLSearchParams({
85 ...Object.fromEntries(q),
86 decision: "allow",
87 grant: "ro",
88 name: "my phone",
89 lifetime: "never",
90 ...extra,
91 }),
92 });
93 const exchange = (over: Record<string, string> = {}) =>
94 fetch(`${base}/oauth/token`, {
95 method: "POST",
96 body: new URLSearchParams({
97 grant_type: "authorization_code",
98 client_id: clientId,
99 redirect_uri: REDIRECT,
100 code_verifier: verifier,
101 ...over,
102 }),
103 });
104 const codeFrom = async (extra: Record<string, string> = {}) => {
105 const res = await consent(query(), extra);
106 expect(res.status).toBe(303);
107 const to = new URL(res.headers.get("location")!);
108 expect(`${to.origin}${to.pathname}`).toBe(REDIRECT);
109 expect(to.searchParams.get("state")).toBe("st-1");
110 return to.searchParams.get("code")!;
111 };
112 const mcp = async (token: string, name: string, args: unknown = {}) => {
113 const res = await fetch(`${base}/mcp`, {
114 method: "POST",
115 headers: {
116 "content-type": "application/json",
117 accept: "application/json, text/event-stream",
118 authorization: `Bearer ${token}`,
119 },
120 body: JSON.stringify({
121 jsonrpc: "2.0",
122 id: 1,
123 method: "tools/call",
124 params: { name, arguments: args },
125 }),
126 });
127 const out = (await res.json()) as any;
128 return { error: out.result.isError === true, text: out.result.content[0].text as string };
129 };
130
131 test("a signed-out visitor is sent to sign in, then back", async () => {
132 const res = await get(query(), false);
133 expect(res.status).toBe(303);
134 const to = res.headers.get("location")!;
135 expect(to.startsWith("/signin?next=")).toBe(true);
136 expect(decodeURIComponent(to)).toContain("/oauth/authorize?");
137 });
138
139 test("the page names the client (escaped) and the redirect host", async () => {
140 const res = await get(query());
141 expect(res.status).toBe(200);
142 const text = await res.text();
143 expect(text).toContain("Fake &lt;b&gt;Client&lt;/b&gt;");
144 expect(text).not.toContain("<b>Client</b>");
145 expect(text).toContain("claude.ai");
146 expect(text).toContain('name="grant"');
147 expect(res.headers.get("cache-control")).toBe("no-store");
148 });
149
150 test("the full dance mints a listed, revocable token that works", async () => {
151 const code = await codeFrom();
152 const res = await exchange({ code });
153 expect(res.status).toBe(200);
154 expect(res.headers.get("cache-control")).toBe("no-store");
155 const out = (await res.json()) as any;
156 expect(out.token_type).toBe("Bearer");
157 expect(out.scope).toBe("ro");
158 expect(out.expires_in).toBeUndefined();
159 const listed = listTokens(app.db, alice.id).find((t) => t.name === "my phone");
160 expect(listed?.scope).toBe("ro");
161 expect(listed?.expiresAt).toBeNull();
162
163 expect((await mcp(out.access_token, "apps")).error).toBe(false);
164 const tell = await mcp(out.access_token, "tell", { repo: "alice/x", text: "hi" });
165 expect(tell.error).toBe(true);
166 expect(tell.text).toContain("forbidden");
167 });
168
169 test("the code is stored hashed, never raw", async () => {
170 const code = await codeFrom();
171 const count = (hash: string) =>
172 app.db
173 .query<{ n: number }, [string]>("SELECT count(*) AS n FROM oauth_code WHERE code_hash = ?1")
174 .get(hash)!.n;
175 expect(count(code)).toBe(0);
176 expect(count(sha256(code))).toBe(1);
177 });
178
179 test("the consent page lets its form go to the registered origin only", async () => {
180 const csp = (await get(query())).headers.get("content-security-policy")!;
181 expect(csp).toContain("form-action 'self' https://claude.ai;");
182 });
183
184 test("a chunked token body past the cap is refused and not read in full", async () => {
185 let pulled = 0;
186 const chunk = new TextEncoder().encode("x".repeat(4096));
187 const body = new ReadableStream({
188 pull(c) {
189 pulled++;
190 if (pulled > 64) return c.close();
191 c.enqueue(chunk);
192 },
193 });
194 const res = await fetch(`${base}/oauth/token`, {
195 method: "POST",
196 headers: { "content-type": "application/x-www-form-urlencoded" },
197 body,
198 duplex: "half",
199 } as RequestInit);
200 expect(res.status).toBe(413);
201 expect(((await res.json()) as any).error).toBe("invalid_request");
202 });
203
204 test("an expiry is asked for and set", async () => {
205 const out = (await (await exchange({ code: await codeFrom({ lifetime: "day", grant: "rw", name: "short" }) })).json()) as any;
206 expect(out.expires_in).toBe(86400);
207 expect(out.scope).toBe("rw");
208 const listed = listTokens(app.db, alice.id).find((t) => t.name === "short");
209 expect(listed?.scope).toBe("rw");
210 expect(Date.parse(listed!.expiresAt!)).toBeGreaterThan(Date.now());
211 });
212
213 test("a wrong verifier is refused, and burns the code", async () => {
214 const code = await codeFrom();
215 const bad = await exchange({ code, code_verifier: "w".repeat(60) });
216 expect(bad.status).toBe(400);
217 expect(((await bad.json()) as any).error).toBe("invalid_grant");
218 expect((await exchange({ code })).status).toBe(400);
219 });
220
221 test("a replayed code is refused", async () => {
222 const code = await codeFrom();
223 expect((await exchange({ code })).status).toBe(200);
224 expect((await exchange({ code })).status).toBe(400);
225 });
226
227 test("a code for another client or redirect, or an expired one, is refused", async () => {
228 expect((await exchange({ code: await codeFrom(), client_id: "other" })).status).toBe(400);
229 expect((await exchange({ code: await codeFrom(), redirect_uri: "https://evil.example/cb" })).status).toBe(400);
230 const code = await codeFrom();
231 app.db.query("UPDATE oauth_code SET expires_at = ?1").run(new Date(Date.now() - 1000).toISOString());
232 expect((await exchange({ code })).status).toBe(400);
233 });
234
235 test("the token endpoint refuses other grants and junk", async () => {
236 expect(((await (await exchange({ grant_type: "password" })).json()) as any).error).toBe("unsupported_grant_type");
237 expect((await exchange({ code: "" })).status).toBe(400);
238 });
239
240 test("an unregistered redirect is shown, never followed", async () => {
241 for (const uri of ["https://evil.example/cb", `${REDIRECT}/x`, `${REDIRECT}?a=1`]) {
242 const q = query({ redirect_uri: uri });
243 expect((await get(q)).status).toBe(400);
244 const res = await consent(q);
245 expect(res.status).toBe(400);
246 expect(res.headers.get("location")).toBeNull();
247 }
248 expect((await get(query({ client_id: "nobody" }))).status).toBe(400);
249 });
250
251 test("PKCE S256 is required", async () => {
252 expect((await get(query({ code_challenge_method: "plain" }))).status).toBe(400);
253 expect((await get(query({ code_challenge: "short" }))).status).toBe(400);
254 });
255
256 test("a consent from another origin, or with no session, grants nothing", async () => {
257 const before = listTokens(app.db, alice.id).length;
258 expect((await consent(query(), {}, { origin: "https://evil.example" })).status).toBe(403);
259 const anon = await fetch(`${base}/oauth/authorize`, {
260 method: "POST",
261 redirect: "manual",
262 headers: { origin: base },
263 body: new URLSearchParams({ ...Object.fromEntries(query()), decision: "allow", grant: "ro" }),
264 });
265 expect(anon.status).toBe(403);
266 expect((await consent(query(), { grant: "admin" })).status).toBe(400);
267 expect(listTokens(app.db, alice.id).length).toBe(before);
268 });
269
270 test("deny returns access_denied with the state", async () => {
271 const res = await consent(query(), { decision: "deny" });
272 const to = new URL(res.headers.get("location")!);
273 expect(to.searchParams.get("error")).toBe("access_denied");
274 expect(to.searchParams.get("state")).toBe("st-1");
275 expect(to.searchParams.get("code")).toBeNull();
276 });
277 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs