diff --git a/src/scenarios/authorization-server/auth/spec-references.ts b/src/scenarios/authorization-server/auth/spec-references.ts index 38a1beec..19806284 100644 --- a/src/scenarios/authorization-server/auth/spec-references.ts +++ b/src/scenarios/authorization-server/auth/spec-references.ts @@ -5,6 +5,18 @@ export const SpecReferences: { [key: string]: SpecReference } = { id: 'MCP-Authorization-metadata-discovery', url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery' }, + MCP_AUTH_RESPONSE_VALIDATION: { + id: 'MCP-Authorization-response-validation', + url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-response-validation' + }, + RFC_9207_ISS_PARAMETER: { + id: 'RFC-9207-iss-parameter', + url: 'https://www.rfc-editor.org/rfc/rfc9207.html#section-2' + }, + RFC_9207_AS_METADATA: { + id: 'RFC-9207-authorization-server-metadata', + url: 'https://www.rfc-editor.org/rfc/rfc9207.html#section-2.3' + }, OAUTH_2_1_AUTHORIZATION_CODE_GRANT: { id: 'OAUTH-2.1-authorization-code-grant', url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-4.1' diff --git a/src/scenarios/authorization-server/authorization-code-grant.test.ts b/src/scenarios/authorization-server/authorization-code-grant.test.ts index e2eaf06a..482aaeed 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.test.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.test.ts @@ -100,7 +100,7 @@ describe('AuthorizationCodeGrantScenario', () => { const checks = await scenario.run(OPTIONS, DETAILS); - expect(checks).toHaveLength(1); + expect(checks).toHaveLength(3); const check = checks[0]; @@ -132,7 +132,7 @@ describe('AuthorizationCodeGrantScenario', () => { const checks = await scenario.run(OPTIONS, DETAILS); - expect(checks).toHaveLength(1); + expect(checks).toHaveLength(3); const check = checks[0]; @@ -154,7 +154,7 @@ describe('AuthorizationCodeGrantScenario', () => { const checks = await scenario.run(OPTIONS, DETAILS); - expect(checks).toHaveLength(1); + expect(checks).toHaveLength(3); const check = checks[0]; @@ -173,7 +173,7 @@ describe('AuthorizationCodeGrantScenario', () => { const checks = await scenario.run(OPTIONS, DETAILS); - expect(checks).toHaveLength(1); + expect(checks).toHaveLength(3); const check = checks[0]; @@ -197,7 +197,7 @@ describe('AuthorizationCodeGrantScenario', () => { const checks = await scenario.run(OPTIONS, DETAILS); - expect(checks).toHaveLength(1); + expect(checks).toHaveLength(3); const check = checks[0]; @@ -219,7 +219,7 @@ describe('AuthorizationCodeGrantScenario', () => { const checks = await scenario.run(OPTIONS, DETAILS); - expect(checks).toHaveLength(1); + expect(checks).toHaveLength(3); const check = checks[0]; @@ -241,7 +241,7 @@ describe('AuthorizationCodeGrantScenario', () => { const checks = await scenario.run(OPTIONS, DETAILS); - expect(checks).toHaveLength(1); + expect(checks).toHaveLength(3); const check = checks[0]; @@ -273,7 +273,7 @@ describe('AuthorizationCodeGrantScenario', () => { const checks = await scenario.run(OPTIONS, DETAILS); - expect(checks).toHaveLength(1); + expect(checks).toHaveLength(3); const check = checks[0]; @@ -305,7 +305,7 @@ describe('AuthorizationCodeGrantScenario', () => { const checks = await scenario.run(OPTIONS, DETAILS); - expect(checks).toHaveLength(1); + expect(checks).toHaveLength(3); const check = checks[0]; @@ -426,4 +426,114 @@ describe('AuthorizationCodeGrantScenario', () => { expect(check.status).toBe('SKIPPED'); }); + + describe('SEP-2468 iss parameter (2026-07-28)', () => { + const DETAILS_ISS_SUPPORTED = { + 'authorization-server-metadata-endpoint': { + body: { + ...METADATA, + authorization_response_iss_parameter_supported: true + } + } + }; + + const statusOf = (checks: { id: string; status: string }[], id: string) => + checks.find((c) => c.id === id)?.status; + + beforeEach(() => { + mockTokenResponse({ access_token: 'access-token', token_type: 'Bearer' }); + }); + + it('passes when iss is included and advertised in metadata', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + mockCallbackServer( + scenario, + (state) => + `http://127.0.0.1:3000/callback?code=abc&state=${state}&iss=${SERVER_URL}` + ); + + const checks = await scenario.run(OPTIONS, DETAILS_ISS_SUPPORTED); + + expect(statusOf(checks, 'authorization-code-grant')).toBe('SUCCESS'); + expect(statusOf(checks, 'sep-2468-as-include-iss')).toBe('SUCCESS'); + expect(statusOf(checks, 'sep-2468-as-advertise-iss-supported')).toBe( + 'SUCCESS' + ); + for (const c of checks.slice(1)) { + expect(c.source).toEqual({ introducedIn: '2026-07-28' }); + } + }); + + it('warns when iss is omitted and skips the advertisement check', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + mockCallbackServer( + scenario, + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + const checks = await scenario.run(OPTIONS, DETAILS_ISS_SUPPORTED); + + expect(statusOf(checks, 'sep-2468-as-include-iss')).toBe('WARNING'); + expect(statusOf(checks, 'sep-2468-as-advertise-iss-supported')).toBe( + 'SKIPPED' + ); + }); + + it('fails when iss is included but not advertised in metadata', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + mockCallbackServer( + scenario, + (state) => + `http://127.0.0.1:3000/callback?code=abc&state=${state}&iss=${SERVER_URL}` + ); + + const checks = await scenario.run(OPTIONS, DETAILS); + + expect(statusOf(checks, 'sep-2468-as-include-iss')).toBe('SUCCESS'); + const advertise = checks.find( + (c) => c.id === 'sep-2468-as-advertise-iss-supported' + ); + expect(advertise?.status).toBe('FAILURE'); + expect(advertise?.errorMessage).toContain( + 'authorization_response_iss_parameter_supported' + ); + }); + + it('fails when iss is included but advertised as false', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + mockCallbackServer( + scenario, + (state) => + `http://127.0.0.1:3000/callback?code=abc&state=${state}&iss=${SERVER_URL}` + ); + + const checks = await scenario.run(OPTIONS, { + 'authorization-server-metadata-endpoint': { + body: { + ...METADATA, + authorization_response_iss_parameter_supported: false + } + } + }); + + expect(statusOf(checks, 'sep-2468-as-advertise-iss-supported')).toBe( + 'FAILURE' + ); + }); + + it('evaluates iss on error responses too', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + mockCallbackServer( + scenario, + () => 'http://127.0.0.1:3000/callback?error=access_denied' + ); + + const checks = await scenario.run(OPTIONS, DETAILS_ISS_SUPPORTED); + + expect(statusOf(checks, 'authorization-code-grant')).toBe('FAILURE'); + const includeIss = checks.find((c) => c.id === 'sep-2468-as-include-iss'); + expect(includeIss?.status).toBe('WARNING'); + expect(includeIss?.errorMessage).toContain('error response'); + }); + }); }); diff --git a/src/scenarios/authorization-server/authorization-code-grant.ts b/src/scenarios/authorization-server/authorization-code-grant.ts index 13f3ba04..c38b06a1 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -3,7 +3,8 @@ */ import { ClientScenarioForAuthorizationServer, - ConformanceCheck + ConformanceCheck, + DRAFT_PROTOCOL_VERSION } from '../../types'; import { startCallbackServer } from '../authorization-server/auth/helpers/createCallbackServer'; import { request } from 'undici'; @@ -58,12 +59,15 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz - HTTP response status code of token response MUST be 200 OK - Content-Type header of token response MUST be application/json - Cache-Control header of token response MUST be no-store -- Token response MUST return a JSON response including access_token and token_type`; +- Token response MUST return a JSON response including access_token and token_type +- (2026-07-28+, SEP-2468) SHOULD include the iss parameter in authorization responses, including error responses +- (2026-07-28+, SEP-2468) If the iss parameter is included, MUST set authorization_response_iss_parameter_supported to true in metadata`; async run( options: AuthorizationServerOptions, details: Record ): Promise { + let issChecks: ConformanceCheck[] = []; try { if (!options.clientId) { return [ @@ -116,6 +120,7 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz const authorizationResponseUrl = await callback.waitForCallback(300_000); + issChecks = this.checkIssParameter(authorizationResponseUrl, metadata); const errors: string[] = []; const code = this.validateAuthorizationResponse( @@ -134,7 +139,7 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz this.validateTokenResponse(tokenResponse, errors); if (errors.length > 0) { - return [this.failureCheck(errors.join(', '))]; + return [this.failureCheck(errors.join(', ')), ...issChecks]; } return [ @@ -142,16 +147,92 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz authorizationRequest, authorizationResponseUrl, body: redactTokens(tokenResponse.body) - }) + }), + ...issChecks ]; } finally { callback.close(); } } catch (error) { - return [this.failureCheck(error)]; + return [this.failureCheck(error), ...issChecks]; } } + // SEP-2468: evaluated on every authorization response, including error responses. + private checkIssParameter( + responseUrl: string, + metadata: Record + ): ConformanceCheck[] { + const url = new URL(responseUrl); + const iss = url.searchParams.getAll('iss'); + const issPresent = iss.length > 0; + const isErrorResponse = url.searchParams.has('error'); + const issSupported = + metadata.authorization_response_iss_parameter_supported; + const source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + const timestamp = new Date().toISOString(); + + const includeIss: ConformanceCheck = { + id: 'sep-2468-as-include-iss', + name: 'AuthorizationResponseIncludesIss', + description: + 'Authorization server includes the iss parameter in authorization responses, including error responses', + status: issPresent ? 'SUCCESS' : 'WARNING', + source, + timestamp, + ...(issPresent + ? {} + : { + errorMessage: `Authorization ${isErrorResponse ? 'error ' : ''}response does not include the iss parameter` + }), + specReferences: [ + SpecReferences.MCP_AUTH_RESPONSE_VALIDATION, + SpecReferences.RFC_9207_ISS_PARAMETER + ], + details: { iss, isErrorResponse } + }; + + const advertiseCheck = { + id: 'sep-2468-as-advertise-iss-supported', + name: 'AuthorizationServerAdvertisesIssSupported', + description: + 'Authorization server that includes iss sets authorization_response_iss_parameter_supported to true in its metadata', + source, + timestamp, + specReferences: [ + SpecReferences.MCP_AUTH_RESPONSE_VALIDATION, + SpecReferences.RFC_9207_AS_METADATA + ], + details: { + iss, + authorization_response_iss_parameter_supported: issSupported + } + }; + let advertiseIss: ConformanceCheck; + if (!issPresent) { + // The MUST is conditional on the AS including iss. + advertiseIss = { + ...advertiseCheck, + status: 'SKIPPED', + errorMessage: + 'Not applicable: authorization response does not include the iss parameter' + }; + } else if (issSupported === true) { + advertiseIss = { ...advertiseCheck, status: 'SUCCESS' }; + } else { + advertiseIss = { + ...advertiseCheck, + status: 'FAILURE', + errorMessage: + issSupported === undefined + ? 'Authorization response includes iss but metadata does not include "authorization_response_iss_parameter_supported"' + : `Authorization response includes iss but "authorization_response_iss_parameter_supported" is ${JSON.stringify(issSupported)}, expected true` + }; + } + + return [includeIss, advertiseIss]; + } + private buildAuthorizationRequest( metadata: any, options: AuthorizationServerOptions diff --git a/src/seps/sep-2468.yaml b/src/seps/sep-2468.yaml index 969f4a44..6ed43bbd 100644 --- a/src/seps/sep-2468.yaml +++ b/src/seps/sep-2468.yaml @@ -15,11 +15,9 @@ requirements: - check: sep-2468-client-no-normalization text: 'After decoding the iss value from the application/x-www-form-urlencoded response per RFC 9207 Section 2.4, clients MUST NOT apply scheme or host case folding, default-port elision, trailing-slash, or percent-encoding normalization (RFC 3986 Sections 6.2.2-6.2.3) before comparison.' - - text: 'MCP authorization servers SHOULD include the iss parameter in authorization responses, including error responses, as defined in RFC9207 Section 2.' - excluded: 'Targets the authorization server under test; observing iss in an authorization response requires driving an Authorization Code Grant against the AS, which the authorization-server suite does not implement yet (it only probes the metadata endpoint).' - issue: 'https://github.com/modelcontextprotocol/conformance/issues/208' - - text: 'Authorization servers that include the iss parameter MUST advertise this by setting authorization_response_iss_parameter_supported to true in their metadata (RFC9207 Section 2.3).' - excluded: 'Conditional on the AS actually including iss in an authorization response, which requires driving an Authorization Code Grant against the AS under test; the authorization-server suite does not implement that yet.' - issue: 'https://github.com/modelcontextprotocol/conformance/issues/208' + - check: sep-2468-as-include-iss + text: 'MCP authorization servers SHOULD include the iss parameter in authorization responses, including error responses, as defined in RFC9207 Section 2.' + - check: sep-2468-as-advertise-iss-supported + text: 'Authorization servers that include the iss parameter MUST advertise this by setting authorization_response_iss_parameter_supported to true in their metadata (RFC9207 Section 2.3).' - text: 'This validation applies equally to error responses - on mismatch the client MUST NOT act on or display error, error_description, or error_uri.' excluded: 'display is UI-facing; act-on has no protocol-observable signal beyond the existing reject-on-mismatch checks'