Merge branch 'wave/v124-ewe-oauth' into release/v1.24.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 01:22:14 +02:00
commit 391c78674e
6 files changed
+65

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");
});
});
@@ -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(
+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 } : {}),
});
});
+9
View File
@@ -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<Response> {
);
}
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<Response> {
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"],
@@ -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,
};
}