feat(mcp): identify the authorization-server issuer per RFC 9207

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 01:00:45 +02:00
1 parent fb986bc09d
commit 01e2373255
4 files changed
+23

No files matched your search

@@ -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");
});
});
+7
View File
@@ -414,10 +414,16 @@ export async function POST(request: NextRequest): Promise<Response> {
}
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<Response> {
return redirectBack(v.redirectUri, {
code,
iss,
...(v.state !== undefined ? { state: v.state } : {}),
});
});
@@ -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"/,
+6
View File
@@ -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,
};
}