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] 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, }; }