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

Merged opened this

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

Merged

scout/forge:scout/2348-mcp-oauth-part-1c-post-oauth-register-re → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/2348-mcp-oauth-part-1c-post-oauth-register-re was merged into main as 37d69ec.

Review

Sign in to review this change.
2 files changed +85 −4
src/core/oauth.ts +4 −4
@@ -75,9 +75,9 @@ export function parseRegistration(
7575 );
7676 }
7777 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();
8181 if (name.length > NAME_MAX)
8282 return refuse(
8383 "invalid_client_metadata",
@@ -93,7 +93,7 @@ export function parseRegistration(
9393 'only token_endpoint_auth_method "none" (a public client with PKCE) is offered',
9494 );
9595 return ok({
96 name: name === "" ? "MCP client" : name,
96 name,
9797 redirectUris: [...new Set(uris as string[])],
9898 });
9999 }
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);

Nit: toBe(1) depends on the first test having run and stored exactly one client. Take const before = count() before the request and assert count() equals before, so this test stands on its own.

80 });
81 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs