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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/971-oauth-for-the-mcp-the-authorize-page-and → main 2 commits fork 4 threads · 4 unresolved
docs/MCP.md +34 −5
| @@ -72,11 +72,40 @@ claude mcp add --transport http forge https://<your-forge>/mcp \ | ||
| 72 | 72 | --header "Authorization: Bearer <token>" |
| 73 | 73 | |
| 74 | 74 | |
| 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`. | |
| 80 | 109 | |
| 81 | 110 | `src/http/mcp.ts`, `src/actions/tell.ts`, `test/mcp.test.ts`. |
| 82 | 111 |
scripts/gate.ts +1
| @@ -195,6 +195,7 @@ export const GATE: readonly string[] = [ | ||
| 195 | 195 | "test/hollow.test.ts", |
| 196 | 196 | "test/home-feed.test.ts", |
| 197 | 197 | "test/oauth-discovery.test.ts", |
| 198 | "test/oauth-flow.test.ts", | |
| 198 | 199 | "test/data-home.test.ts", |
| 199 | 200 | "test/tree.test.ts", |
| 200 | 201 | "test/reports.test.ts", |
scripts/hollow-allow.ts +5
| @@ -25,4 +25,9 @@ export const ALLOWED: readonly Allowed[] = [ | ||
| 25 | 25 | "the fleet graph shipped ahead of the fleet page that is to link to it", |
| 26 | 26 | issue: 448, |
| 27 | 27 | }, |
| 28 | { | |
| 29 | orphan: "GET /oauth/authorize", | |
| 30 | reason: "reached only through an OAuth client's redirect", | |
| 31 | issue: 971, | |
| 32 | }, | |
| 28 | 33 | ]; |
src/core/oauth.ts +163 −3
| @@ -1,10 +1,13 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 4 | 5 | */ |
| 5 | 6 | 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"; | |
| 7 | 9 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 10 | import { createToken } from "./tokens.ts"; | |
| 8 | 11 | |
| 9 | 12 | export const NAME_MAX = 100; |
| 10 | 13 | export const REDIRECTS_MAX = 10; |
| @@ -130,3 +133,160 @@ export function getClient(db: Database, clientId: string): OAuthClient | null { | ||
| 130 | 133 | createdAt: row.created_at, |
| 131 | 134 | }; |
| 132 | 135 | } |
| 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[] = [ | ||
| 1563 | 1563 | WHERE id = NEW.id; |
| 1564 | 1564 | END; |
| 1565 | 1565 | `, |
| 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 | `, | |
| 1566 | 1581 | ]; |
| 1567 | 1582 | |
| 1568 | 1583 | /** |
src/http/oauth.ts +57 −7
| @@ -1,19 +1,22 @@ | ||
| 1 | 1 | /** |
| 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). | |
| 6 | 5 | * |
| 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. | |
| 9 | 10 | */ |
| 10 | 11 | import type { App } from "../app.ts"; |
| 11 | import { createClient, parseRegistration } from "../core/oauth.ts"; | |
| 12 | import { createClient, parseRegistration, redeemCode } from "../core/oauth.ts"; | |
| 12 | 13 | import { RateLimiter } from "../core/ratelimit.ts"; |
| 13 | 14 | import type { Handler } from "./context.ts"; |
| 14 | 15 | import { clientBucket } from "./peer.ts"; |
| 16 | import { readBounded } from "./limits.ts"; | |
| 15 | 17 | import { boundedBody, json } from "./respond.ts"; |
| 16 | 18 | import type { Router } from "./router.ts"; |
| 19 | import { authorizePage, authorizeSubmit } from "../web/pages/oauth.ts"; | |
| 17 | 20 | |
| 18 | 21 | /** Registrations per address per minute. */ |
| 19 | 22 | const REGISTER_PER_MINUTE = 10; |
| @@ -40,7 +43,54 @@ export const resourceMetadataUrl = (origin: string): string => | ||
| 40 | 43 | const open = { "access-control-allow-origin": "*" }; |
| 41 | 44 | const docHeaders = { ...open, "cache-control": "public, max-age=300" }; |
| 42 | 45 | |
| 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 | } | |
| 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 | ||
| 43 | 66 | 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 | }); | |
| 44 | 94 | router.add("GET", "/.well-known/oauth-protected-resource", (ctx) => |
| 45 | 95 | json( |
| 46 | 96 | { |
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 |
||
| 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: |
||
| 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 <b>Client</b>"); | |
| 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 | }); |
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 withreadBounded(request, BODY_MAX)and parse aURLSearchParamsfrom those bytes.