Public: anyone can read this; the crew can change it.
OAuth for the MCP, step one: discovery documents, dynamic client registration, and a 401 that names them #579
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/573-oauth-for-the-mcp-step-one-discovery-doc → main 1 commit fork
7 files
changed +402
−1
scripts/gate.ts +1
| @@ -80,6 +80,7 @@ export const GATE: readonly string[] = [ | ||
| 80 | 80 | "test/told-by.test.ts", |
| 81 | 81 | "test/highlight.test.ts", |
| 82 | 82 | "test/check-list.test.ts", |
| 83 | "test/oauth-discovery.test.ts", | |
| 83 | 84 | ]; |
| 84 | 85 | |
| 85 | 86 | export const EXCLUDED: readonly Exclusion[] = [ |
src/core/oauth.ts added +132
| @@ -0,0 +1,132 @@ | ||
| 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. | |
| 4 | */ | |
| 5 | import type { Database } from "bun:sqlite"; | |
| 6 | import { id } from "../kernel/id.ts"; | |
| 7 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 8 | ||
| 9 | export const NAME_MAX = 100; | |
| 10 | export const REDIRECTS_MAX = 10; | |
| 11 | export const REDIRECT_MAX = 2000; | |
| 12 | ||
| 13 | export interface OAuthClient { | |
| 14 | readonly id: string; | |
| 15 | readonly name: string; | |
| 16 | readonly redirectUris: readonly string[]; | |
| 17 | readonly createdAt: string; | |
| 18 | } | |
| 19 | ||
| 20 | /** A refused registration: the RFC 7591 error code and a hint. */ | |
| 21 | export interface Refusal { | |
| 22 | readonly error: "invalid_redirect_uri" | "invalid_client_metadata"; | |
| 23 | readonly message: string; | |
| 24 | } | |
| 25 | ||
| 26 | const refuse = ( | |
| 27 | error: Refusal["error"], | |
| 28 | message: string, | |
| 29 | ): Result<never, Refusal> => err({ error, message }); | |
| 30 | ||
| 31 | const LOCAL = new Set(["localhost", "127.0.0.1", "[::1]"]); | |
| 32 | ||
| 33 | /** https anywhere; http only to the machine the client runs on. */ | |
| 34 | function redirectOk(raw: string): boolean { | |
| 35 | if (raw.length > REDIRECT_MAX) return false; | |
| 36 | let url: URL; | |
| 37 | try { | |
| 38 | url = new URL(raw); | |
| 39 | } catch { | |
| 40 | return false; | |
| 41 | } | |
| 42 | if (url.username !== "" || url.password !== "" || url.hash !== "") | |
| 43 | return false; | |
| 44 | if (url.protocol === "https:") return url.hostname !== ""; | |
| 45 | return url.protocol === "http:" && LOCAL.has(url.hostname); | |
| 46 | } | |
| 47 | ||
| 48 | export interface Registration { | |
| 49 | readonly name: string; | |
| 50 | readonly redirectUris: readonly string[]; | |
| 51 | } | |
| 52 | ||
| 53 | /** Check the untrusted metadata of a registration request. */ | |
| 54 | export function parseRegistration( | |
| 55 | input: Record<string, unknown>, | |
| 56 | ): Result<Registration, Refusal> { | |
| 57 | const uris = input["redirect_uris"]; | |
| 58 | if (!Array.isArray(uris) || uris.length === 0) | |
| 59 | return refuse("invalid_redirect_uri", "redirect_uris is required"); | |
| 60 | if (uris.length > REDIRECTS_MAX) | |
| 61 | return refuse( | |
| 62 | "invalid_redirect_uri", | |
| 63 | `at most ${REDIRECTS_MAX} redirect_uris`, | |
| 64 | ); | |
| 65 | for (const uri of uris) { | |
| 66 | if (typeof uri !== "string" || !redirectOk(uri)) | |
| 67 | return refuse( | |
| 68 | "invalid_redirect_uri", | |
| 69 | "each redirect_uri must be an https URL (http only for localhost) without credentials or a fragment", | |
| 70 | ); | |
| 71 | } | |
| 72 | const rawName = input["client_name"]; | |
| 73 | if (rawName !== undefined && typeof rawName !== "string") | |
| 74 | return refuse("invalid_client_metadata", "client_name must be a string"); | |
| 75 | const name = (rawName ?? "").trim(); | |
| 76 | if (name.length > NAME_MAX) | |
| 77 | return refuse( | |
| 78 | "invalid_client_metadata", | |
| 79 | `client_name is at most ${NAME_MAX} characters`, | |
| 80 | ); | |
| 81 | // eslint-disable-next-line no-control-regex | |
| 82 | if (/[\u0000-\u001f\u007f]/.test(name)) | |
| 83 | return refuse("invalid_client_metadata", "client_name has control characters"); | |
| 84 | const method = input["token_endpoint_auth_method"]; | |
| 85 | if (method !== undefined && method !== "none") | |
| 86 | return refuse( | |
| 87 | "invalid_client_metadata", | |
| 88 | 'only token_endpoint_auth_method "none" (a public client with PKCE) is offered', | |
| 89 | ); | |
| 90 | return ok({ | |
| 91 | name: name === "" ? "MCP client" : name, | |
| 92 | redirectUris: [...new Set(uris as string[])], | |
| 93 | }); | |
| 94 | } | |
| 95 | ||
| 96 | export function createClient( | |
| 97 | db: Database, | |
| 98 | registration: Registration, | |
| 99 | ): OAuthClient { | |
| 100 | const client: OAuthClient = { | |
| 101 | id: id(), | |
| 102 | name: registration.name, | |
| 103 | redirectUris: registration.redirectUris, | |
| 104 | createdAt: new Date().toISOString(), | |
| 105 | }; | |
| 106 | db.query( | |
| 107 | "INSERT INTO oauth_client (id, name, redirect_uris, created_at) VALUES (?1, ?2, ?3, ?4)", | |
| 108 | ).run( | |
| 109 | client.id, | |
| 110 | client.name, | |
| 111 | JSON.stringify(client.redirectUris), | |
| 112 | client.createdAt, | |
| 113 | ); | |
| 114 | return client; | |
| 115 | } | |
| 116 | ||
| 117 | export function getClient(db: Database, clientId: string): OAuthClient | null { | |
| 118 | const row = db | |
| 119 | .query< | |
| 120 | { id: string; name: string; redirect_uris: string; created_at: string }, | |
| 121 | [string] | |
| 122 | >("SELECT * FROM oauth_client WHERE id = ?1") | |
| 123 | .get(clientId); | |
| 124 | return row === null | |
| 125 | ? null | |
| 126 | : { | |
| 127 | id: row.id, | |
| 128 | name: row.name, | |
| 129 | redirectUris: JSON.parse(row.redirect_uris) as string[], | |
| 130 | createdAt: row.created_at, | |
| 131 | }; | |
| 132 | } |
src/core/schema.ts +10
| @@ -1179,4 +1179,14 @@ export const migrations: readonly string[] = [ | ||
| 1179 | 1179 | CHECK (close_reason IN ('done','dropped','split')); |
| 1180 | 1180 | ALTER TABLE issue_dependency ADD COLUMN confirmed INTEGER NOT NULL DEFAULT 0; |
| 1181 | 1181 | `, |
| 1182 | // 45 — OAuth clients registered dynamically (RFC 7591); step one of the | |
| 1183 | // MCP's OAuth. IF NOT EXISTS keeps a replay harmless. | |
| 1184 | ` | |
| 1185 | CREATE TABLE IF NOT EXISTS oauth_client ( | |
| 1186 | id TEXT PRIMARY KEY, | |
| 1187 | name TEXT NOT NULL, | |
| 1188 | redirect_uris TEXT NOT NULL, | |
| 1189 | created_at TEXT NOT NULL | |
| 1190 | ); | |
| 1191 | `, | |
| 1182 | 1192 | ]; |
src/http/mcp.ts +2 −1
| @@ -56,6 +56,7 @@ import { convergeFork, convergeState } from "../crew/converge.ts"; | ||
| 56 | 56 | import { mapJson, marksFor } from "../web/pages/map.ts"; |
| 57 | 57 | import { appsOf } from "../web/studio/asks.ts"; |
| 58 | 58 | import type { Ctx, Handler } from "./context.ts"; |
| 59 | import { resourceMetadataUrl } from "./oauth.ts"; | |
| 59 | 60 | import { body, json } from "./respond.ts"; |
| 60 | 61 | import type { Router } from "./router.ts"; |
| 61 | 62 | |
| @@ -757,7 +758,7 @@ export function mcpRoutes(router: Router<Handler>): void { | ||
| 757 | 758 | status: 401, |
| 758 | 759 | headers: { |
| 759 | 760 | "content-type": "application/json", |
| 760 | "www-authenticate": 'Bearer realm="forge"', | |
| 761 | "www-authenticate": `Bearer realm="forge", resource_metadata="${resourceMetadataUrl(ctx.url.origin)}"`, | |
| 761 | 762 | }, |
| 762 | 763 | }, |
| 763 | 764 | ); |
src/http/oauth.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 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. | |
| 6 | * | |
| 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. | |
| 9 | */ | |
| 10 | import type { App } from "../app.ts"; | |
| 11 | import { createClient, parseRegistration } from "../core/oauth.ts"; | |
| 12 | import { RateLimiter } from "../core/ratelimit.ts"; | |
| 13 | import type { Handler } from "./context.ts"; | |
| 14 | import { body, json } from "./respond.ts"; | |
| 15 | import type { Router } from "./router.ts"; | |
| 16 | ||
| 17 | /** Registrations per address per minute. */ | |
| 18 | const REGISTER_PER_MINUTE = 10; | |
| 19 | /** A registration is a few hundred bytes; refuse anything past this. */ | |
| 20 | const BODY_MAX = 16 * 1024; | |
| 21 | ||
| 22 | /** Peer addresses by request, set by the listener (`server.requestIP`). */ | |
| 23 | const peers = new WeakMap<Request, string>(); | |
| 24 | export const notePeer = (request: Request, address: string | undefined): void => { | |
| 25 | if (address !== undefined) peers.set(request, address); | |
| 26 | }; | |
| 27 | ||
| 28 | const limiters = new WeakMap<App, RateLimiter>(); | |
| 29 | const limiterOf = (app: App): RateLimiter => { | |
| 30 | let limiter = limiters.get(app); | |
| 31 | if (limiter === undefined) { | |
| 32 | limiter = new RateLimiter({ | |
| 33 | perMinute: REGISTER_PER_MINUTE, | |
| 34 | adminPerMinute: REGISTER_PER_MINUTE, | |
| 35 | enabled: true, | |
| 36 | }); | |
| 37 | limiters.set(app, limiter); | |
| 38 | } | |
| 39 | return limiter; | |
| 40 | }; | |
| 41 | ||
| 42 | const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]); | |
| 43 | ||
| 44 | /** The socket's peer; behind a proxy on this host, the hop it reports. */ | |
| 45 | function clientAddress(request: Request): string { | |
| 46 | const peer = peers.get(request) ?? "unknown"; | |
| 47 | if (!LOOPBACK.has(peer)) return peer; | |
| 48 | const hop = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(); | |
| 49 | return hop !== undefined && hop !== "" ? hop.slice(0, 64) : peer; | |
| 50 | } | |
| 51 | ||
| 52 | export const resourceMetadataUrl = (origin: string): string => | |
| 53 | `${origin}/.well-known/oauth-protected-resource`; | |
| 54 | ||
| 55 | const open = { "access-control-allow-origin": "*" }; | |
| 56 | const docHeaders = { ...open, "cache-control": "public, max-age=300" }; | |
| 57 | ||
| 58 | export function oauthRoutes(router: Router<Handler>): void { | |
| 59 | router.add("GET", "/.well-known/oauth-protected-resource", (ctx) => | |
| 60 | json( | |
| 61 | { | |
| 62 | resource: `${ctx.url.origin}/mcp`, | |
| 63 | authorization_servers: [ctx.url.origin], | |
| 64 | scopes_supported: ["ro", "rw"], | |
| 65 | bearer_methods_supported: ["header"], | |
| 66 | }, | |
| 67 | 200, | |
| 68 | docHeaders, | |
| 69 | ), | |
| 70 | ); | |
| 71 | router.add("GET", "/.well-known/oauth-authorization-server", (ctx) => | |
| 72 | json( | |
| 73 | { | |
| 74 | issuer: ctx.url.origin, | |
| 75 | authorization_endpoint: `${ctx.url.origin}/oauth/authorize`, | |
| 76 | token_endpoint: `${ctx.url.origin}/oauth/token`, | |
| 77 | registration_endpoint: `${ctx.url.origin}/oauth/register`, | |
| 78 | scopes_supported: ["ro", "rw"], | |
| 79 | response_types_supported: ["code"], | |
| 80 | grant_types_supported: ["authorization_code"], | |
| 81 | code_challenge_methods_supported: ["S256"], | |
| 82 | token_endpoint_auth_methods_supported: ["none"], | |
| 83 | }, | |
| 84 | 200, | |
| 85 | docHeaders, | |
| 86 | ), | |
| 87 | ); | |
| 88 | router.add("POST", "/oauth/register", async (ctx) => { | |
| 89 | const verdict = limiterOf(ctx.app).take(clientAddress(ctx.request), "ro"); | |
| 90 | if (!verdict.allowed) | |
| 91 | return json( | |
| 92 | { | |
| 93 | error: "rate_limited", | |
| 94 | message: `too many registrations — retry in ${verdict.retryAfter}s`, | |
| 95 | }, | |
| 96 | 429, | |
| 97 | { ...open, "retry-after": String(verdict.retryAfter), "cache-control": "no-store" }, | |
| 98 | ); | |
| 99 | const declared = Number(ctx.request.headers.get("content-length") ?? 0); | |
| 100 | const raw = declared > BODY_MAX ? {} : await body(ctx.request); | |
| 101 | const parsed = parseRegistration(raw); | |
| 102 | if (!parsed.ok) | |
| 103 | return json(parsed.error, 400, { ...open, "cache-control": "no-store" }); | |
| 104 | const client = createClient(ctx.app.db, parsed.value); | |
| 105 | return json( | |
| 106 | { | |
| 107 | client_id: client.id, | |
| 108 | client_id_issued_at: Math.floor(Date.parse(client.createdAt) / 1000), | |
| 109 | client_name: client.name, | |
| 110 | redirect_uris: client.redirectUris, | |
| 111 | token_endpoint_auth_method: "none", | |
| 112 | grant_types: ["authorization_code"], | |
| 113 | response_types: ["code"], | |
| 114 | }, | |
| 115 | 201, | |
| 116 | { ...open, "cache-control": "no-store" }, | |
| 117 | ); | |
| 118 | }); | |
| 119 | } |
src/http/server.ts +3
| @@ -282,6 +282,7 @@ import { targetForShortCode } from "../origins/oauth.ts"; | ||
| 282 | 282 | import { apiRoutes } from "./api.ts"; |
| 283 | 283 | import { feedRoutes } from "./feed.ts"; |
| 284 | 284 | import { mcpRoutes } from "./mcp.ts"; |
| 285 | import { notePeer, oauthRoutes } from "./oauth.ts"; | |
| 285 | 286 | import { |
| 286 | 287 | cookieValue, |
| 287 | 288 | publicUrl, |
| @@ -668,6 +669,7 @@ export function buildRouter(): Router<Handler> { | ||
| 668 | 669 | // reclaim a path under `/api/`. |
| 669 | 670 | // The forge as an MCP server: the studio's verbs for an assistant. |
| 670 | 671 | mcpRoutes(router); |
| 672 | oauthRoutes(router); | |
| 671 | 673 | |
| 672 | 674 | // Issues (web). The literal /new is registered before /:number so it can |
| 673 | 675 | // never be captured as a number. |
| @@ -1317,6 +1319,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 1317 | 1319 | if (target === "unknown") |
| 1318 | 1320 | return securityFloor(new Response("not found", { status: 404 })); |
| 1319 | 1321 | } |
| 1322 | notePeer(request, server.requestIP(request)?.address); | |
| 1320 | 1323 | // Everything the forge answers for itself leaves through this one |
| 1321 | 1324 | // door, so a responder added next year cannot forget the floor. |
| 1322 | 1325 | return securityFloor( |
test/oauth-discovery.test.ts added +135
| @@ -0,0 +1,135 @@ | ||
| 1 | /** | |
| 2 | * OAuth for the MCP, step one: the two discovery documents, the 401 that | |
| 3 | * names them, and dynamic client registration with its refusals. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { getClient } from "../src/core/oauth.ts"; | |
| 11 | import { migrations } from "../src/core/schema.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | ||
| 14 | describe("oauth discovery and registration", () => { | |
| 15 | let dataDir: string; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let base: string; | |
| 19 | ||
| 20 | beforeAll(async () => { | |
| 21 | dataDir = await mkdtemp(join(tmpdir(), "forge-oauth-")); | |
| 22 | app = createApp({ dataDir, port: 0 }); | |
| 23 | servers = await serve(app); | |
| 24 | base = servers.url; | |
| 25 | }); | |
| 26 | afterAll(async () => { | |
| 27 | servers.stop(); | |
| 28 | await rm(dataDir, { recursive: true, force: true }); | |
| 29 | }); | |
| 30 | ||
| 31 | const register = (payload: unknown, raw?: string) => | |
| 32 | fetch(`${base}/oauth/register`, { | |
| 33 | method: "POST", | |
| 34 | headers: { "content-type": "application/json" }, | |
| 35 | body: raw ?? JSON.stringify(payload), | |
| 36 | }); | |
| 37 | ||
| 38 | test("the protected-resource document names the resource and its server", async () => { | |
| 39 | const res = await fetch(`${base}/.well-known/oauth-protected-resource`); | |
| 40 | expect(res.status).toBe(200); | |
| 41 | const doc = (await res.json()) as any; | |
| 42 | expect(doc.resource).toBe(`${base}/mcp`); | |
| 43 | expect(doc.authorization_servers).toEqual([base]); | |
| 44 | expect(doc.scopes_supported).toEqual(["ro", "rw"]); | |
| 45 | }); | |
| 46 | ||
| 47 | test("the authorization-server document offers S256 only", async () => { | |
| 48 | const res = await fetch(`${base}/.well-known/oauth-authorization-server`); | |
| 49 | expect(res.status).toBe(200); | |
| 50 | const doc = (await res.json()) as any; | |
| 51 | expect(doc.issuer).toBe(base); | |
| 52 | expect(doc.authorization_endpoint).toBe(`${base}/oauth/authorize`); | |
| 53 | expect(doc.token_endpoint).toBe(`${base}/oauth/token`); | |
| 54 | expect(doc.registration_endpoint).toBe(`${base}/oauth/register`); | |
| 55 | expect(doc.code_challenge_methods_supported).toEqual(["S256"]); | |
| 56 | expect(doc.scopes_supported).toEqual(["ro", "rw"]); | |
| 57 | }); | |
| 58 | ||
| 59 | test("a 401 from /mcp names the resource metadata", async () => { | |
| 60 | const res = await fetch(`${base}/mcp`, { | |
| 61 | method: "POST", | |
| 62 | headers: { "content-type": "application/json" }, | |
| 63 | body: "{}", | |
| 64 | }); | |
| 65 | expect(res.status).toBe(401); | |
| 66 | const challenge = res.headers.get("www-authenticate") ?? ""; | |
| 67 | expect(challenge).toStartWith("Bearer "); | |
| 68 | expect(challenge).toContain( | |
| 69 | `resource_metadata="${base}/.well-known/oauth-protected-resource"`, | |
| 70 | ); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a valid registration is stored and returns a client_id", async () => { | |
| 74 | const res = await register({ | |
| 75 | client_name: "Inspector", | |
| 76 | redirect_uris: ["https://client.example/cb", "http://localhost:6274/cb"], | |
| 77 | }); | |
| 78 | expect(res.status).toBe(201); | |
| 79 | const out = (await res.json()) as any; | |
| 80 | expect(out.client_id).toBeString(); | |
| 81 | expect(out.token_endpoint_auth_method).toBe("none"); | |
| 82 | const stored = getClient(app.db, out.client_id); | |
| 83 | expect(stored?.name).toBe("Inspector"); | |
| 84 | expect(stored?.redirectUris).toEqual([ | |
| 85 | "https://client.example/cb", | |
| 86 | "http://localhost:6274/cb", | |
| 87 | ]); | |
| 88 | }); | |
| 89 | ||
| 90 | test("refused registrations", async () => { | |
| 91 | const bad: [string, unknown][] = [ | |
| 92 | ["javascript uri", { redirect_uris: ["javascript:alert(1)"] }], | |
| 93 | ["plain http to a remote host", { redirect_uris: ["http://evil.example/cb"] }], | |
| 94 | ["a fragment", { redirect_uris: ["https://client.example/cb#x"] }], | |
| 95 | ["missing redirect", { client_name: "x" }], | |
| 96 | ["empty redirects", { redirect_uris: [] }], | |
| 97 | ["a non-string redirect", { redirect_uris: [42] }], | |
| 98 | [ | |
| 99 | "an oversized name", | |
| 100 | { client_name: "n".repeat(101), redirect_uris: ["https://a.example/cb"] }, | |
| 101 | ], | |
| 102 | [ | |
| 103 | "a confidential client", | |
| 104 | { | |
| 105 | token_endpoint_auth_method: "client_secret_basic", | |
| 106 | redirect_uris: ["https://a.example/cb"], | |
| 107 | }, | |
| 108 | ], | |
| 109 | ]; | |
| 110 | for (const [label, payload] of bad) { | |
| 111 | const res = await register(payload); | |
| 112 | expect({ label, status: res.status }).toEqual({ label, status: 400 }); | |
| 113 | expect(((await res.json()) as any).error).toStartWith("invalid_"); | |
| 114 | } | |
| 115 | const garbage = await register(undefined, "not json"); | |
| 116 | expect(garbage.status).toBe(400); | |
| 117 | const count = app.db | |
| 118 | .query<{ n: number }, []>("SELECT count(*) AS n FROM oauth_client") | |
| 119 | .get()!.n; | |
| 120 | expect(count).toBe(1); | |
| 121 | }); | |
| 122 | ||
| 123 | test("registration is limited per address", async () => { | |
| 124 | let last = 0; | |
| 125 | for (let i = 0; i < 12; i++) last = (await register({})).status; | |
| 126 | expect(last).toBe(429); | |
| 127 | }); | |
| 128 | ||
| 129 | test("the migration is idempotent", () => { | |
| 130 | const ddl = migrations[migrations.length - 1]!; | |
| 131 | expect(ddl).toContain("oauth_client"); | |
| 132 | expect(() => app.db.exec(ddl)).not.toThrow(); | |
| 133 | expect(() => app.db.exec(ddl)).not.toThrow(); | |
| 134 | }); | |
| 135 | }); |
new above
offline