Public: anyone can read this; the crew can change it.
MCP OAuth, part 1c: POST /oauth/register registers a client by name and redirect URIs #2369
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2348-mcp-oauth-part-1c-post-oauth-register-re → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +85
−4
src/core/oauth.ts +4 −4
| @@ -75,9 +75,9 @@ export function parseRegistration( | ||
| 75 | 75 | ); |
| 76 | 76 | } |
| 77 | 77 | const rawName = input["client_name"]; |
| 78 | if (rawName !== undefined && typeof rawName !== "string") | |
| 79 | return refuse("invalid_client_metadata", "client_name must be a string"); | |
| 80 | const name = (rawName ?? "").trim(); | |
| 78 | if (typeof rawName !== "string" || rawName.trim() === "") | |
| 79 | return refuse("invalid_client_metadata", "client_name is required"); | |
| 80 | const name = rawName.trim(); | |
| 81 | 81 | if (name.length > NAME_MAX) |
| 82 | 82 | return refuse( |
| 83 | 83 | "invalid_client_metadata", |
| @@ -93,7 +93,7 @@ export function parseRegistration( | ||
| 93 | 93 | 'only token_endpoint_auth_method "none" (a public client with PKCE) is offered', |
| 94 | 94 | ); |
| 95 | 95 | return ok({ |
| 96 | name: name === "" ? "MCP client" : name, | |
| 96 | name, | |
| 97 | 97 | redirectUris: [...new Set(uris as string[])], |
| 98 | 98 | }); |
| 99 | 99 | } |
test/oauth-register.test.ts added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | /** | |
| 2 | * POST /oauth/register (RFC 7591): one good registration, and the refusals | |
| 3 | * the issue names — a remote http redirect, a missing name, an oversized body. | |
| 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 { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | ||
| 13 | describe("oauth client registration", () => { | |
| 14 | let dataDir: string; | |
| 15 | let app: App; | |
| 16 | let servers: Servers; | |
| 17 | ||
| 18 | beforeAll(async () => { | |
| 19 | dataDir = await mkdtemp(join(tmpdir(), "forge-oauth-register-")); | |
| 20 | app = createApp({ dataDir, port: 0 }); | |
| 21 | servers = await serve(app); | |
| 22 | }); | |
| 23 | afterAll(async () => { | |
| 24 | servers.stop(); | |
| 25 | await rm(dataDir, { recursive: true, force: true }); | |
| 26 | }); | |
| 27 | ||
| 28 | const register = (body: string) => | |
| 29 | fetch(`${servers.url}/oauth/register`, { | |
| 30 | method: "POST", | |
| 31 | headers: { "content-type": "application/json" }, | |
| 32 | body, | |
| 33 | }); | |
| 34 | const count = () => | |
| 35 | app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM oauth_client").get()!.n; | |
| 36 | ||
| 37 | test("a name with https and loopback redirects is stored", async () => { | |
| 38 | const res = await register( | |
| 39 | JSON.stringify({ | |
| 40 | client_name: "Inspector", | |
| 41 | redirect_uris: ["https://client.example/cb", "http://127.0.0.1:6274/cb"], | |
| 42 | }), | |
| 43 | ); | |
| 44 | expect(res.status).toBe(201); | |
| 45 | const out = (await res.json()) as any; | |
| 46 | expect(getClient(app.db, out.client_id)?.name).toBe("Inspector"); | |
| 47 | expect(getClient(app.db, out.client_id)?.redirectUris).toEqual([ | |
| 48 | "https://client.example/cb", | |
| 49 | "http://127.0.0.1:6274/cb", | |
| 50 | ]); | |
| 51 | }); | |
| 52 | ||
| 53 | test("an http redirect to a remote host is refused", async () => { | |
| 54 | const res = await register( | |
| 55 | JSON.stringify({ client_name: "x", redirect_uris: ["http://evil.example/cb"] }), | |
| 56 | ); | |
| 57 | expect(res.status).toBe(400); | |
| 58 | expect(((await res.json()) as any).error).toBe("invalid_redirect_uri"); | |
| 59 | }); | |
| 60 | ||
| 61 | test("a missing or blank name is refused", async () => { | |
| 62 | for (const name of [undefined, "", " ", 7]) { | |
| 63 | const res = await register( | |
| 64 | JSON.stringify({ client_name: name, redirect_uris: ["https://a.example/cb"] }), | |
| 65 | ); | |
| 66 | expect(res.status).toBe(400); | |
| 67 | expect(((await res.json()) as any).error).toBe("invalid_client_metadata"); | |
| 68 | } | |
| 69 | }); | |
| 70 | ||
| 71 | test("an oversized body is refused", async () => { | |
| 72 | const res = await register( | |
| 73 | JSON.stringify({ | |
| 74 | client_name: "x".repeat(20_000), | |
| 75 | redirect_uris: ["https://a.example/cb"], | |
| 76 | }), | |
| 77 | ); | |
| 78 | expect(res.status).toBe(413); | |
| 79 | expect(count()).toBe(1); | |
| 80 | }); | |
| 81 | }); |
new above
offline
Nit:
toBe(1)depends on the first test having run and stored exactly one client. Takeconst before = count()before the request and assertcount()equalsbefore, so this test stands on its own.