From 01e237325579246c953055387150b7d4c4f79b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 01:00:45 +0200 Subject: [PATCH 1/2] feat(mcp): identify the authorization-server issuer per RFC 9207 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advertise authorization_response_iss_parameter_supported in the authorization-server metadata and echo the iss parameter on every authorization response — both the success redirect carrying the code and the access_denied error redirect. A client can now pin the response to this server's issuer and reject a code substituted by a look-alike authorization server in a multi-server deployment. Additive and backward-compatible: existing clients that ignore iss are unaffected; PKCE S256-only, audience binding, redirect-URI exact match and the rate limits are untouched. --- src/app/api/mcp/oauth/__tests__/authorize.test.ts | 4 ++++ src/app/api/mcp/oauth/authorize/route.ts | 7 +++++++ src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts | 6 ++++++ src/lib/mcp/oauth/metadata.ts | 6 ++++++ 4 files changed, 23 insertions(+) diff --git a/src/app/api/mcp/oauth/__tests__/authorize.test.ts b/src/app/api/mcp/oauth/__tests__/authorize.test.ts index 7079af430..dce582e1c 100644 --- a/src/app/api/mcp/oauth/__tests__/authorize.test.ts +++ b/src/app/api/mcp/oauth/__tests__/authorize.test.ts @@ -286,6 +286,8 @@ describe("POST /authorize — decision", () => { const code = new URL(loc).searchParams.get("code"); expect(code).toMatch(/^hlac_/); expect(new URL(loc).searchParams.get("state")).toBe("xyz"); + // RFC 9207 — the issuer identifier is echoed on the success redirect. + expect(new URL(loc).searchParams.get("iss")).toBe("https://health.example"); }); it("302s with access_denied on deny", async () => { @@ -305,5 +307,7 @@ describe("POST /authorize — decision", () => { expect(res.status).toBe(302); const loc = res.headers.get("location") ?? ""; expect(new URL(loc).searchParams.get("error")).toBe("access_denied"); + // RFC 9207 — the issuer is echoed on the error redirect too. + expect(new URL(loc).searchParams.get("iss")).toBe("https://health.example"); }); }); diff --git a/src/app/api/mcp/oauth/authorize/route.ts b/src/app/api/mcp/oauth/authorize/route.ts index f4c0ccfdc..906d4677a 100644 --- a/src/app/api/mcp/oauth/authorize/route.ts +++ b/src/app/api/mcp/oauth/authorize/route.ts @@ -414,10 +414,16 @@ export async function POST(request: NextRequest): Promise { } const { v } = result; + // RFC 9207 — the authorization-server issuer identifier. Echoed on EVERY + // authorization response (success AND error) so the client can pin the + // response to this AS and reject a code from a substituted one. + const iss = resolveBaseOrigin(request.url); + if (params.decision !== "allow") { annotate({ action: { name: "mcp.oauth.authorize.denied" } }); return redirectBack(v.redirectUri, { error: "access_denied", + iss, ...(v.state !== undefined ? { state: v.state } : {}), }); } @@ -450,6 +456,7 @@ export async function POST(request: NextRequest): Promise { return redirectBack(v.redirectUri, { code, + iss, ...(v.state !== undefined ? { state: v.state } : {}), }); }); diff --git a/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts b/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts index ce94e51c3..29b6a6a99 100644 --- a/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts +++ b/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts @@ -230,6 +230,12 @@ describe("metadata + audience", () => { expect(as.grant_types_supported).toContain("refresh_token"); }); + it("AS metadata advertises the RFC 9207 iss parameter and a matching issuer", () => { + const as = authorizationServerMetadata(); + expect(as.authorization_response_iss_parameter_supported).toBe(true); + expect(as.issuer).toBe("https://health.example"); + }); + it("the WWW-Authenticate challenge points at the PRM", () => { expect(wwwAuthenticateChallenge()).toMatch( /resource_metadata="https:\/\/health\.example\/\.well-known\/oauth-protected-resource"/, diff --git a/src/lib/mcp/oauth/metadata.ts b/src/lib/mcp/oauth/metadata.ts index ef2f4412a..4530f0acb 100644 --- a/src/lib/mcp/oauth/metadata.ts +++ b/src/lib/mcp/oauth/metadata.ts @@ -42,6 +42,7 @@ export interface AuthorizationServerMetadata { token_endpoint_auth_methods_supported: string[]; code_challenge_methods_supported: string[]; client_id_metadata_document_supported: boolean; + authorization_response_iss_parameter_supported: boolean; } /** RFC 9728 — the Protected Resource Metadata for the `/mcp` resource. */ @@ -76,6 +77,11 @@ export function authorizationServerMetadata( code_challenge_methods_supported: ["S256"], // CIMD is the preferred registration path (SEP-991); DCR is the fallback. client_id_metadata_document_supported: true, + // RFC 9207 — the authorization response carries an `iss` parameter the + // client validates against this metadata's `issuer`, so a code from a + // look-alike AS in a multi-AS deployment cannot be substituted (mix-up + // defence). The `/authorize` redirect always includes it. + authorization_response_iss_parameter_supported: true, }; } From 660dffef37e12c5aa0336a1acb0b24e3a360c181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 01:00:51 +0200 Subject: [PATCH 2/2] feat(mcp): accept the OIDC application_type in dynamic client registration Honour the OIDC dynamic-registration application_type field on the register endpoint: validate it to the closed set (web/native) when present, reject a malformed value with invalid_client_metadata, default to web when omitted, and echo the resolved value back in the client-information response. Forward-compatible with OIDC-style clients without changing the redirect-URI rules (still HTTPS-or-loopback, authoritatively matched at authorize) or any other registration behaviour. --- .../api/mcp/oauth/__tests__/register.test.ts | 33 +++++++++++++++++++ src/app/api/mcp/oauth/register/route.ts | 9 +++++ 2 files changed, 42 insertions(+) diff --git a/src/app/api/mcp/oauth/__tests__/register.test.ts b/src/app/api/mcp/oauth/__tests__/register.test.ts index 9bacbea67..e64105985 100644 --- a/src/app/api/mcp/oauth/__tests__/register.test.ts +++ b/src/app/api/mcp/oauth/__tests__/register.test.ts @@ -77,6 +77,39 @@ describe("POST /register", () => { expect(res.status).toBe(400); }); + it("honours a valid OIDC application_type and echoes it back", async () => { + const res = await POST( + jsonReq({ + client_name: "Native app", + application_type: "native", + redirect_uris: ["http://127.0.0.1:8976/callback"], + }) as never, + ); + expect(res.status).toBe(201); + expect((await res.json()).application_type).toBe("native"); + }); + + it("defaults application_type to web when omitted", async () => { + const res = await POST( + jsonReq({ + redirect_uris: ["https://chatgpt.com/cb"], + }) as never, + ); + expect(res.status).toBe(201); + expect((await res.json()).application_type).toBe("web"); + }); + + it("rejects an invalid application_type with invalid_client_metadata", async () => { + const res = await POST( + jsonReq({ + application_type: "robot", + redirect_uris: ["https://chatgpt.com/cb"], + }) as never, + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_client_metadata"); + }); + it("returns 503 when the API is globally disabled (M4)", async () => { vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); const res = await POST( diff --git a/src/app/api/mcp/oauth/register/route.ts b/src/app/api/mcp/oauth/register/route.ts index 513bf1b85..046453158 100644 --- a/src/app/api/mcp/oauth/register/route.ts +++ b/src/app/api/mcp/oauth/register/route.ts @@ -41,6 +41,12 @@ const registrationSchema = z.object({ message: "redirect_uris must be HTTPS or http loopback URLs", }), client_name: z.string().min(1).max(200).optional(), + // OIDC dynamic-registration `application_type` (forward-compat with OIDC-style + // clients): honoured by echoing it back, validated to the closed set so a + // malformed value is rejected cleanly rather than silently dropped. Absent ⇒ + // the OIDC default `web`. It does not alter the redirect-URI rules — those are + // already shape-validated below and authoritatively matched at `/authorize`. + application_type: z.enum(["web", "native"]).optional(), // Accepted-and-ignored RFC 7591 fields (we never read them but tolerate them). grant_types: z.array(z.string()).optional(), response_types: z.array(z.string()).optional(), @@ -111,6 +117,8 @@ export async function POST(request: NextRequest): Promise { ); } + const applicationType = parsed.data.application_type ?? "web"; + const client = registerDcrClient({ clientName: parsed.data.client_name ?? "MCP client", redirectUris: parsed.data.redirect_uris, @@ -128,6 +136,7 @@ export async function POST(request: NextRequest): Promise { client_id_issued_at: Math.floor(Date.now() / 1000), redirect_uris: client.redirectUris, client_name: client.clientName, + application_type: applicationType, token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"],