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

Merged opened this

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

Merged

scout/forge:scout/573-oauth-for-the-mcp-step-one-discovery-doc → main 1 commit fork

  • Merged. scout/573-oauth-for-the-mcp-step-one-discovery-doc was merged into main as 9ebb334.

Review

Sign in to review this change.
7 files changed +402 −1
scripts/gate.ts +1
@@ -80,6 +80,7 @@ export const GATE: readonly string[] = [
8080 "test/told-by.test.ts",
8181 "test/highlight.test.ts",
8282 "test/check-list.test.ts",
83 "test/oauth-discovery.test.ts",
8384 ];
8485
8586 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[] = [
11791179 CHECK (close_reason IN ('done','dropped','split'));
11801180 ALTER TABLE issue_dependency ADD COLUMN confirmed INTEGER NOT NULL DEFAULT 0;
11811181 `,
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 `,
11821192 ];
src/http/mcp.ts +2 −1
@@ -56,6 +56,7 @@ import { convergeFork, convergeState } from "../crew/converge.ts";
5656 import { mapJson, marksFor } from "../web/pages/map.ts";
5757 import { appsOf } from "../web/studio/asks.ts";
5858 import type { Ctx, Handler } from "./context.ts";
59 import { resourceMetadataUrl } from "./oauth.ts";
5960 import { body, json } from "./respond.ts";
6061 import type { Router } from "./router.ts";
6162
@@ -757,7 +758,7 @@ export function mcpRoutes(router: Router<Handler>): void {
757758 status: 401,
758759 headers: {
759760 "content-type": "application/json",
760 "www-authenticate": 'Bearer realm="forge"',
761 "www-authenticate": `Bearer realm="forge", resource_metadata="${resourceMetadataUrl(ctx.url.origin)}"`,
761762 },
762763 },
763764 );
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";
282282 import { apiRoutes } from "./api.ts";
283283 import { feedRoutes } from "./feed.ts";
284284 import { mcpRoutes } from "./mcp.ts";
285 import { notePeer, oauthRoutes } from "./oauth.ts";
285286 import {
286287 cookieValue,
287288 publicUrl,
@@ -668,6 +669,7 @@ export function buildRouter(): Router<Handler> {
668669 // reclaim a path under `/api/`.
669670 // The forge as an MCP server: the studio's verbs for an assistant.
670671 mcpRoutes(router);
672 oauthRoutes(router);
671673
672674 // Issues (web). The literal /new is registered before /:number so it can
673675 // never be captured as a number.
@@ -1317,6 +1319,7 @@ export async function serve(app: App): Promise<Servers> {
13171319 if (target === "unknown")
13181320 return securityFloor(new Response("not found", { status: 404 }));
13191321 }
1322 notePeer(request, server.requestIP(request)?.address);
13201323 // Everything the forge answers for itself leaves through this one
13211324 // door, so a responder added next year cannot forget the floor.
13221325 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs