From b7b94c8966a6a001d41b8148d3ae8da86d3e9f96 Mon Sep 17 00:00:00 2001 From: Max Gerber Date: Wed, 8 Apr 2026 18:24:31 -0700 Subject: [PATCH 01/10] feat: add conformance tests for iss parameter (SEP-2468) Adds 5 draft conformance scenarios testing RFC 9207 issuer parameter validation in OAuth authorization responses: - auth/iss-supported: server advertises support and sends correct iss - auth/iss-not-advertised: server omits iss parameter entirely - auth/iss-supported-missing: client must reject missing iss when required - auth/iss-wrong-issuer: client must reject mismatched iss value - auth/iss-unexpected: client must reject iss when not advertised Also adds auth-test-iss-validation.ts, a reference client that correctly validates iss per RFC 9207, and negative tests confirming the standard client fails all three rejection scenarios. TODO: Update RFC_9207_ISS_PARAMETER spec reference once SEP-2468 (modelcontextprotocol/modelcontextprotocol#2468) is merged. --- .../typescript/auth-test-iss-validation.ts | 228 +++++++++ .../clients/typescript/everything-client.ts | 16 +- .../client/auth/helpers/createAuthServer.ts | 16 + src/scenarios/client/auth/index.test.ts | 31 +- src/scenarios/client/auth/index.ts | 14 +- src/scenarios/client/auth/issuer-parameter.ts | 433 ++++++++++++++++++ src/scenarios/client/auth/spec-references.ts | 5 + 7 files changed, 740 insertions(+), 3 deletions(-) create mode 100644 examples/clients/typescript/auth-test-iss-validation.ts create mode 100644 src/scenarios/client/auth/issuer-parameter.ts diff --git a/examples/clients/typescript/auth-test-iss-validation.ts b/examples/clients/typescript/auth-test-iss-validation.ts new file mode 100644 index 00000000..52ef2d00 --- /dev/null +++ b/examples/clients/typescript/auth-test-iss-validation.ts @@ -0,0 +1,228 @@ +#!/usr/bin/env node + +/** + * Well-behaved client that validates the iss parameter in authorization responses. + * + * Per RFC 9207: + * - If the AS advertises authorization_response_iss_parameter_supported: true, + * the client MUST require iss in the redirect and MUST validate it against + * the AS metadata issuer. + * - If the AS does NOT advertise support, the client MUST reject any redirect + * that unexpectedly contains an iss parameter. + */ + +import { createHash, randomBytes } from 'crypto'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { Middleware } from '@modelcontextprotocol/sdk/client/middleware.js'; +import { runAsCli } from './helpers/cliRunner'; +import { logger } from './helpers/logger'; + +interface OAuthTokens { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + scope?: string; +} + +function generateCodeVerifier(): string { + return randomBytes(32) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function computeS256Challenge(codeVerifier: string): string { + const hash = createHash('sha256').update(codeVerifier).digest(); + return hash + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** + * OAuth flow that correctly validates the iss parameter per RFC 9207. + */ +async function oauthFlowWithIssValidation( + _serverUrl: string | URL, + resourceMetadataUrl: string | URL, + fetchFn: FetchLike +): Promise { + // 1. Fetch Protected Resource Metadata + const prmResponse = await fetchFn(resourceMetadataUrl); + if (!prmResponse.ok) { + throw new Error(`Failed to fetch PRM: ${prmResponse.status}`); + } + const prm = await prmResponse.json(); + const authServerUrl = prm.authorization_servers?.[0]; + if (!authServerUrl) { + throw new Error('No authorization server in PRM'); + } + + // 2. Fetch Authorization Server Metadata + const asMetadataUrl = new URL( + '/.well-known/oauth-authorization-server', + authServerUrl + ); + const asResponse = await fetchFn(asMetadataUrl.toString()); + if (!asResponse.ok) { + throw new Error(`Failed to fetch AS metadata: ${asResponse.status}`); + } + const asMetadata = await asResponse.json(); + + const expectedIssuer: string = asMetadata.issuer; + const issParameterSupported: boolean = + asMetadata.authorization_response_iss_parameter_supported === true; + + // 3. Register client (DCR) + const dcrResponse = await fetchFn(asMetadata.registration_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'test-auth-client-iss-validation', + redirect_uris: ['http://localhost:3000/callback'] + }) + }); + if (!dcrResponse.ok) { + throw new Error(`DCR failed: ${dcrResponse.status}`); + } + const clientInfo = await dcrResponse.json(); + + // 4. Build authorization URL with PKCE + const codeVerifier = generateCodeVerifier(); + const codeChallenge = computeS256Challenge(codeVerifier); + + const authUrl = new URL(asMetadata.authorization_endpoint); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('client_id', clientInfo.client_id); + authUrl.searchParams.set('redirect_uri', 'http://localhost:3000/callback'); + authUrl.searchParams.set('state', 'test-state'); + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + + // 5. Fetch authorization endpoint (simulates redirect) + const authResponse = await fetchFn(authUrl.toString(), { + redirect: 'manual' + }); + const location = authResponse.headers.get('location'); + if (!location) { + throw new Error('No redirect from authorization endpoint'); + } + const redirectUrl = new URL(location); + const authCode = redirectUrl.searchParams.get('code'); + if (!authCode) { + throw new Error('No auth code in redirect'); + } + + // 6. Validate iss parameter per RFC 9207 + const issInRedirect = redirectUrl.searchParams.get('iss'); + + if (issParameterSupported) { + // Server advertised support: iss MUST be present and MUST match metadata issuer + if (!issInRedirect) { + throw new Error( + 'Server advertised authorization_response_iss_parameter_supported but iss is absent from redirect' + ); + } + if (issInRedirect !== expectedIssuer) { + throw new Error( + `iss mismatch: expected '${expectedIssuer}', got '${issInRedirect}'` + ); + } + } else { + // Server did NOT advertise support: iss MUST NOT be present + if (issInRedirect) { + throw new Error( + `Unexpected iss parameter in redirect: server did not advertise authorization_response_iss_parameter_supported` + ); + } + } + + // 7. Exchange code for token with PKCE code_verifier + const tokenResponse = await fetchFn(asMetadata.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: authCode, + redirect_uri: 'http://localhost:3000/callback', + client_id: clientInfo.client_id, + code_verifier: codeVerifier + }).toString() + }); + + if (!tokenResponse.ok) { + const error = await tokenResponse.text(); + throw new Error(`Token request failed: ${tokenResponse.status} - ${error}`); + } + + return tokenResponse.json(); +} + +/** + * Creates a fetch wrapper that uses OAuth with iss parameter validation. + */ +function withOAuthIssValidation(baseUrl: string | URL): Middleware { + let tokens: OAuthTokens | undefined; + + return (next: FetchLike) => { + return async ( + input: string | URL, + init?: RequestInit + ): Promise => { + const makeRequest = async (): Promise => { + const headers = new Headers(init?.headers); + if (tokens) { + headers.set('Authorization', `Bearer ${tokens.access_token}`); + } + return next(input, { ...init, headers }); + }; + + let response = await makeRequest(); + + if (response.status === 401) { + const { resourceMetadataUrl } = extractWWWAuthenticateParams(response); + if (!resourceMetadataUrl) { + throw new Error('No resource_metadata in WWW-Authenticate'); + } + tokens = await oauthFlowWithIssValidation( + baseUrl, + resourceMetadataUrl, + next + ); + response = await makeRequest(); + } + + return response; + }; + }; +} + +export async function runClient(serverUrl: string): Promise { + const client = new Client( + { name: 'test-auth-client-iss-validation', version: '1.0.0' }, + { capabilities: {} } + ); + + const oauthFetch = withOAuthIssValidation(new URL(serverUrl))(fetch); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +runAsCli(runClient, import.meta.url, 'auth-test-iss-validation '); diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 71c4582d..a9cf90cc 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -32,6 +32,7 @@ import { handle401 } from './helpers/withOAuthRetry.js'; import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; +import { runClient as issValidationClient } from './auth-test-iss-validation.js'; import { logger } from './helpers/logger.js'; /** @@ -270,7 +271,10 @@ registerScenarios( 'auth/resource-mismatch', // SEP-2207: Offline access / refresh token guidance (draft) 'auth/offline-access-scope', - 'auth/offline-access-not-supported' + 'auth/offline-access-not-supported', + // SEP-2468: ISS parameter - positive scenarios (standard client is fine) + 'auth/iss-supported', + 'auth/iss-not-advertised' ], runAuthClient ); @@ -342,6 +346,16 @@ async function runAuthMigrationClient(serverUrl: string): Promise { registerScenario('auth/authorization-server-migration', runAuthMigrationClient); +// SEP-2468: ISS parameter - rejection scenarios use iss-validating client +registerScenarios( + [ + 'auth/iss-supported-missing', + 'auth/iss-wrong-issuer', + 'auth/iss-unexpected' + ], + issValidationClient +); + // ============================================================================ // Elicitation defaults scenario // ============================================================================ diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 00aa3dbc..2494cc09 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -43,6 +43,10 @@ export interface AuthServerOptions { disableDynamicRegistration?: boolean; /** PKCE code_challenge_methods_supported. Set to null to omit from metadata. Default: ['S256'] */ codeChallengeMethodsSupported?: string[] | null; + /** Advertise authorization_response_iss_parameter_supported in AS metadata. Default: not included */ + issParameterSupported?: boolean; + /** What iss value to include in authorization redirect. Default: not included */ + issInRedirect?: 'correct' | 'wrong' | 'omit'; tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { scope?: string; @@ -86,6 +90,8 @@ export function createAuthServer( clientIdMetadataDocumentSupported, disableDynamicRegistration = false, codeChallengeMethodsSupported = ['S256'], + issParameterSupported, + issInRedirect = 'omit', tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -146,6 +152,9 @@ export function createAuthServer( ...(codeChallengeMethodsSupported !== null && { code_challenge_methods_supported: codeChallengeMethodsSupported }), + ...(issParameterSupported !== undefined && { + authorization_response_iss_parameter_supported: issParameterSupported + }), token_endpoint_auth_methods_supported: tokenEndpointAuthMethodsSupported, ...(tokenEndpointAuthSigningAlgValuesSupported && { token_endpoint_auth_signing_alg_values_supported: @@ -244,6 +253,13 @@ export function createAuthServer( redirectUrl.searchParams.set('state', state); } + // ISS: Include iss parameter in redirect if configured + if (issInRedirect === 'correct') { + redirectUrl.searchParams.set('iss', `${getAuthBaseUrl()}${routePrefix}`); + } else if (issInRedirect === 'wrong') { + redirectUrl.searchParams.set('iss', 'https://evil.example.com'); + } + res.redirect(redirectUrl.toString()); }); diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index c5d79811..67b07650 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -16,6 +16,7 @@ import { runClient as noRetryLimitClient } from '../../../../examples/clients/ty import { runClient as noPkceClient } from '../../../../examples/clients/typescript/auth-test-no-pkce'; import { runClient as reuseCredsClient } from '../../../../examples/clients/typescript/auth-test-reuse-credentials'; import { runClient as noAppTypeClient } from '../../../../examples/clients/typescript/auth-test-no-application-type'; +import { runClient as noIssValidationClient } from '../../../../examples/clients/typescript/auth-test'; import { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; @@ -34,7 +35,11 @@ const allowClientErrorScenarios = new Set([ 'auth/resource-mismatch', // The post-migration retry path may surface as a client error after // re-registering; the SEP-2352 checks are evaluated in getChecks() - 'auth/authorization-server-migration' + 'auth/authorization-server-migration', + // Client is expected to error when iss validation fails + 'auth/iss-supported-missing', + 'auth/iss-wrong-issuer', + 'auth/iss-unexpected' ]); describe('Client Auth Scenarios', () => { @@ -174,4 +179,28 @@ describe('Negative tests', () => { ] }); }); + + test('client does not reject missing iss when server requires it', async () => { + const runner = new InlineClientRunner(noIssValidationClient); + await runClientAgainstScenario(runner, 'auth/iss-supported-missing', { + expectedFailureSlugs: ['iss-client-rejected-missing'], + allowClientError: true + }); + }); + + test('client does not reject mismatched iss', async () => { + const runner = new InlineClientRunner(noIssValidationClient); + await runClientAgainstScenario(runner, 'auth/iss-wrong-issuer', { + expectedFailureSlugs: ['iss-client-rejected-wrong-issuer'], + allowClientError: true + }); + }); + + test('client does not reject unexpected iss', async () => { + const runner = new InlineClientRunner(noIssValidationClient); + await runClientAgainstScenario(runner, 'auth/iss-unexpected', { + expectedFailureSlugs: ['iss-client-rejected-unexpected'], + allowClientError: true + }); + }); }); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 85b3f3f9..92e87f69 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -29,6 +29,13 @@ import { OfflineAccessNotSupportedScenario } from './offline-access'; import { AuthorizationServerMigrationScenario } from './authorization-server-migration'; +import { + IssParameterSupportedScenario, + IssParameterNotAdvertisedScenario, + IssParameterSupportedMissingScenario, + IssParameterWrongIssuerScenario, + IssParameterUnexpectedScenario +} from './issuer-parameter'; // Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ @@ -63,5 +70,10 @@ export const draftScenariosList: Scenario[] = [ new ResourceMismatchScenario(), new OfflineAccessScopeScenario(), new OfflineAccessNotSupportedScenario(), - new AuthorizationServerMigrationScenario() + new AuthorizationServerMigrationScenario(), + new IssParameterSupportedScenario(), + new IssParameterNotAdvertisedScenario(), + new IssParameterSupportedMissingScenario(), + new IssParameterWrongIssuerScenario(), + new IssParameterUnexpectedScenario() ]; diff --git a/src/scenarios/client/auth/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts new file mode 100644 index 00000000..01d99ea1 --- /dev/null +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -0,0 +1,433 @@ +import type { Scenario, ConformanceCheck } from '../../../types.js'; +import { ScenarioUrls, SpecVersion } from '../../../types.js'; +import { createAuthServer } from './helpers/createAuthServer.js'; +import { createServer } from './helpers/createServer.js'; +import { ServerLifecycle } from './helpers/serverLifecycle.js'; +import { SpecReferences } from './spec-references.js'; +import { MockTokenVerifier } from './helpers/mockTokenVerifier.js'; + +const specRefs = [SpecReferences.RFC_9207_ISS_PARAMETER]; + +/** + * Scenario: ISS Parameter Supported (positive) + * + * Server advertises authorization_response_iss_parameter_supported: true and + * includes the correct iss value in the authorization redirect. A conformant + * client should validate iss and proceed normally. + */ +export class IssParameterSupportedScenario implements Scenario { + name = 'auth/iss-supported'; + specVersions: SpecVersion[] = ['draft']; + description = + 'Tests that client accepts authorization response when server advertises and sends correct iss parameter'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + issParameterSupported: true, + issInRedirect: 'correct', + onAuthorizationRequest: ({ timestamp }) => { + this.checks.push({ + id: 'iss-advertised-in-metadata', + name: 'ISS Parameter Advertised', + description: + 'Server advertised authorization_response_iss_parameter_supported: true in AS metadata', + status: 'SUCCESS', + timestamp, + specReferences: specRefs + }); + this.checks.push({ + id: 'iss-sent-in-redirect', + name: 'ISS Sent in Redirect', + description: + 'Server included correct iss value in authorization redirect', + status: 'SUCCESS', + timestamp, + specReferences: specRefs + }); + } + }); + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { requiredScopes: [], tokenVerifier } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + + if (!this.checks.some((c) => c.id === 'iss-advertised-in-metadata')) { + this.checks.push({ + id: 'iss-advertised-in-metadata', + name: 'ISS Parameter Advertised', + description: + 'Client did not reach authorization endpoint — could not verify iss parameter handling', + status: 'FAILURE', + timestamp, + specReferences: specRefs + }); + } + + if (!this.checks.some((c) => c.id === 'iss-sent-in-redirect')) { + this.checks.push({ + id: 'iss-sent-in-redirect', + name: 'ISS Sent in Redirect', + description: + 'Client did not reach authorization endpoint — could not verify iss in redirect', + status: 'FAILURE', + timestamp, + specReferences: specRefs + }); + } + + return this.checks; + } +} + +/** + * Scenario: ISS Parameter Not Advertised (positive) + * + * Server does not advertise authorization_response_iss_parameter_supported and + * does not include iss in the redirect. A conformant client should proceed normally. + */ +export class IssParameterNotAdvertisedScenario implements Scenario { + name = 'auth/iss-not-advertised'; + specVersions: SpecVersion[] = ['draft']; + description = + 'Tests that client accepts authorization response when server does not advertise or send iss parameter'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + // issParameterSupported not set — omitted from metadata + // issInRedirect defaults to 'omit' + onAuthorizationRequest: ({ timestamp }) => { + this.checks.push({ + id: 'iss-not-advertised-in-metadata', + name: 'ISS Parameter Not Advertised', + description: + 'Client accepted authorization response from server that does not advertise iss parameter support', + status: 'SUCCESS', + timestamp, + specReferences: specRefs + }); + this.checks.push({ + id: 'iss-not-sent-in-redirect', + name: 'ISS Not Sent in Redirect', + description: + 'Client accepted authorization response that does not include an iss parameter', + status: 'SUCCESS', + timestamp, + specReferences: specRefs + }); + } + }); + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { requiredScopes: [], tokenVerifier } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + + if (!this.checks.some((c) => c.id === 'iss-not-advertised-in-metadata')) { + this.checks.push({ + id: 'iss-not-advertised-in-metadata', + name: 'ISS Parameter Not Advertised', + description: + 'Client did not reach authorization endpoint — could not verify iss-absent handling', + status: 'FAILURE', + timestamp, + specReferences: specRefs + }); + } + + if (!this.checks.some((c) => c.id === 'iss-not-sent-in-redirect')) { + this.checks.push({ + id: 'iss-not-sent-in-redirect', + name: 'ISS Not Sent in Redirect', + description: + 'Client did not reach authorization endpoint — could not verify absent iss handling', + status: 'FAILURE', + timestamp, + specReferences: specRefs + }); + } + + return this.checks; + } +} + +/** + * Scenario: ISS Parameter Advertised but Missing from Redirect (client must reject) + * + * Server advertises authorization_response_iss_parameter_supported: true but + * omits iss from the redirect. A conformant client MUST reject this response. + */ +export class IssParameterSupportedMissingScenario implements Scenario { + name = 'auth/iss-supported-missing'; + specVersions: SpecVersion[] = ['draft']; + description = + 'Tests that client rejects authorization response when server advertised iss support but omitted iss from redirect'; + allowClientError = true; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private tokenRequestMade = false; + + async start(): Promise { + this.checks = []; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + issParameterSupported: true, + issInRedirect: 'omit', // advertise support but don't send iss + onTokenRequest: () => { + this.tokenRequestMade = true; + return { token: `test-token-${Date.now()}`, scopes: [] }; + } + }); + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { requiredScopes: [], tokenVerifier } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + + if (!this.checks.some((c) => c.id === 'iss-client-rejected-missing')) { + const correctlyRejected = !this.tokenRequestMade; + this.checks.push({ + id: 'iss-client-rejected-missing', + name: 'Client rejects missing iss when required', + description: correctlyRejected + ? 'Client correctly rejected authorization response missing required iss parameter' + : 'Client MUST reject authorization response when server advertised iss support but iss is absent from redirect', + status: correctlyRejected ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + serverAdvertisedSupport: true, + issSentInRedirect: false, + tokenRequestMade: this.tokenRequestMade + } + }); + } + + return this.checks; + } +} + +/** + * Scenario: ISS Parameter Has Wrong Value (client must reject) + * + * Server advertises authorization_response_iss_parameter_supported: true and + * includes an iss value that does not match the server's actual issuer. A + * conformant client MUST reject this response. + */ +export class IssParameterWrongIssuerScenario implements Scenario { + name = 'auth/iss-wrong-issuer'; + specVersions: SpecVersion[] = ['draft']; + description = + 'Tests that client rejects authorization response when iss does not match the authorization server issuer'; + allowClientError = true; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private tokenRequestMade = false; + + async start(): Promise { + this.checks = []; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + issParameterSupported: true, + issInRedirect: 'wrong', // send iss that doesn't match metadata issuer + onTokenRequest: () => { + this.tokenRequestMade = true; + return { token: `test-token-${Date.now()}`, scopes: [] }; + } + }); + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { requiredScopes: [], tokenVerifier } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + + if (!this.checks.some((c) => c.id === 'iss-client-rejected-wrong-issuer')) { + const correctlyRejected = !this.tokenRequestMade; + this.checks.push({ + id: 'iss-client-rejected-wrong-issuer', + name: 'Client rejects mismatched iss', + description: correctlyRejected + ? 'Client correctly rejected authorization response with mismatched iss parameter' + : 'Client MUST reject authorization response when iss does not match the authorization server issuer', + status: correctlyRejected ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + serverAdvertisedSupport: true, + issSentInRedirect: 'https://evil.example.com', + tokenRequestMade: this.tokenRequestMade + } + }); + } + + return this.checks; + } +} + +/** + * Scenario: ISS Parameter Sent but Not Advertised (client must reject) + * + * Server does not advertise authorization_response_iss_parameter_supported but + * includes an iss value in the redirect anyway. A conformant client MUST reject + * this unexpected parameter to prevent downgrade attacks. + */ +export class IssParameterUnexpectedScenario implements Scenario { + name = 'auth/iss-unexpected'; + specVersions: SpecVersion[] = ['draft']; + description = + 'Tests that client rejects authorization response when server sends iss but did not advertise support'; + allowClientError = true; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private tokenRequestMade = false; + + async start(): Promise { + this.checks = []; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + // issParameterSupported not set — omitted from metadata + issInRedirect: 'correct', // but send iss anyway + onTokenRequest: () => { + this.tokenRequestMade = true; + return { token: `test-token-${Date.now()}`, scopes: [] }; + } + }); + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { requiredScopes: [], tokenVerifier } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + + if (!this.checks.some((c) => c.id === 'iss-client-rejected-unexpected')) { + const correctlyRejected = !this.tokenRequestMade; + this.checks.push({ + id: 'iss-client-rejected-unexpected', + name: 'Client rejects unexpected iss', + description: correctlyRejected + ? 'Client correctly rejected authorization response containing unexpected iss parameter' + : 'Client MUST reject authorization response when server sends iss without advertising authorization_response_iss_parameter_supported', + status: correctlyRejected ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + serverAdvertisedSupport: false, + issSentInRedirect: true, + tokenRequestMade: this.tokenRequestMade + } + }); + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 768dd65f..908a04eb 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -89,6 +89,11 @@ export const SpecReferences: { [key: string]: SpecReference } = { id: 'MCP-PKCE-requirement', url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection' }, + // TODO: Update to MCP spec URL once SEP-2468 (modelcontextprotocol/modelcontextprotocol#2468) is merged + RFC_9207_ISS_PARAMETER: { + id: 'RFC-9207-iss-parameter', + url: 'https://www.rfc-editor.org/rfc/rfc9207.html#section-2' + }, RFC_8693_TOKEN_EXCHANGE: { id: 'RFC-8693-Token-Exchange', url: 'https://datatracker.ietf.org/doc/html/rfc8693' From eaa3f2e119ec468741248cb6d2027c6aac242312 Mon Sep 17 00:00:00 2001 From: Max Gerber Date: Thu, 9 Apr 2026 09:21:20 -0700 Subject: [PATCH 02/10] update scenarios --- src/scenarios/client/auth/helpers/createAuthServer.ts | 4 ++-- src/scenarios/client/auth/issuer-parameter.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 2494cc09..40fa919f 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -90,8 +90,8 @@ export function createAuthServer( clientIdMetadataDocumentSupported, disableDynamicRegistration = false, codeChallengeMethodsSupported = ['S256'], - issParameterSupported, - issInRedirect = 'omit', + issParameterSupported = true, + issInRedirect = 'correct', tokenVerifier, onTokenRequest, onAuthorizationRequest, diff --git a/src/scenarios/client/auth/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts index 01d99ea1..19c46cbf 100644 --- a/src/scenarios/client/auth/issuer-parameter.ts +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -381,7 +381,8 @@ export class IssParameterUnexpectedScenario implements Scenario { const authApp = createAuthServer(this.checks, this.authServer.getUrl, { tokenVerifier, - // issParameterSupported not set — omitted from metadata + // issParameterSupported omitted from metadata + issParameterSupported: false, issInRedirect: 'correct', // but send iss anyway onTokenRequest: () => { this.tokenRequestMade = true; From d355fa5c3ad09181e81c74b2ecf31e560f3f36e0 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 15 May 2026 15:45:17 +0100 Subject: [PATCH 03/10] fix: createAuthServer iss option type/guard and NotAdvertised scenario duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc comments said 'Default: not included' but the destructure defaulted to true/'correct', and the `!== undefined` guard at L155 was unreachable — so there was no way to omit the metadata field, and IssParameterNotAdvertised silently advertised support (a duplicate of IssParameterSupported). Kept the on-by-default behavior (mock AS models a well-behaved server) but made issParameterSupported `boolean | null` so callers pass null to omit, matching the codeChallengeMethodsSupported pattern. Doc comments now match. Scenarios that need omission pass null/'omit' explicitly. --- src/scenarios/client/auth/helpers/createAuthServer.ts | 8 ++++---- src/scenarios/client/auth/issuer-parameter.ts | 9 ++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 40fa919f..5138e36a 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -43,9 +43,9 @@ export interface AuthServerOptions { disableDynamicRegistration?: boolean; /** PKCE code_challenge_methods_supported. Set to null to omit from metadata. Default: ['S256'] */ codeChallengeMethodsSupported?: string[] | null; - /** Advertise authorization_response_iss_parameter_supported in AS metadata. Default: not included */ - issParameterSupported?: boolean; - /** What iss value to include in authorization redirect. Default: not included */ + /** Advertise authorization_response_iss_parameter_supported in AS metadata. Default: true. Pass null to omit. */ + issParameterSupported?: boolean | null; + /** What iss value to include in authorization redirect. Default: 'correct'. */ issInRedirect?: 'correct' | 'wrong' | 'omit'; tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { @@ -152,7 +152,7 @@ export function createAuthServer( ...(codeChallengeMethodsSupported !== null && { code_challenge_methods_supported: codeChallengeMethodsSupported }), - ...(issParameterSupported !== undefined && { + ...(issParameterSupported !== null && { authorization_response_iss_parameter_supported: issParameterSupported }), token_endpoint_auth_methods_supported: tokenEndpointAuthMethodsSupported, diff --git a/src/scenarios/client/auth/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts index 19c46cbf..361bc6f6 100644 --- a/src/scenarios/client/auth/issuer-parameter.ts +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -127,8 +127,8 @@ export class IssParameterNotAdvertisedScenario implements Scenario { const authApp = createAuthServer(this.checks, this.authServer.getUrl, { tokenVerifier, - // issParameterSupported not set — omitted from metadata - // issInRedirect defaults to 'omit' + issParameterSupported: null, + issInRedirect: 'omit', onAuthorizationRequest: ({ timestamp }) => { this.checks.push({ id: 'iss-not-advertised-in-metadata', @@ -381,9 +381,8 @@ export class IssParameterUnexpectedScenario implements Scenario { const authApp = createAuthServer(this.checks, this.authServer.getUrl, { tokenVerifier, - // issParameterSupported omitted from metadata - issParameterSupported: false, - issInRedirect: 'correct', // but send iss anyway + issParameterSupported: null, + issInRedirect: 'correct', // send iss without advertising support onTokenRequest: () => { this.tokenRequestMade = true; return { token: `test-token-${Date.now()}`, scopes: [] }; From 85fd5a25e6dfb66dee057914c0c66956e51fc44d Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 15 May 2026 15:46:06 +0100 Subject: [PATCH 04/10] fix: rejection scenarios silently pass when client never reaches auth endpoint correctlyRejected = !tokenRequestMade reports SUCCESS if the client errors out before hitting /authorize. Gate on authReached so a setup failure shows as FAILURE with authReached:false in details. --- src/scenarios/client/auth/issuer-parameter.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/scenarios/client/auth/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts index 361bc6f6..4c388992 100644 --- a/src/scenarios/client/auth/issuer-parameter.ts +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -215,10 +215,12 @@ export class IssParameterSupportedMissingScenario implements Scenario { private authServer = new ServerLifecycle(); private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; + private authReached = false; private tokenRequestMade = false; async start(): Promise { this.checks = []; + this.authReached = false; this.tokenRequestMade = false; const tokenVerifier = new MockTokenVerifier(this.checks, []); @@ -227,6 +229,9 @@ export class IssParameterSupportedMissingScenario implements Scenario { tokenVerifier, issParameterSupported: true, issInRedirect: 'omit', // advertise support but don't send iss + onAuthorizationRequest: () => { + this.authReached = true; + }, onTokenRequest: () => { this.tokenRequestMade = true; return { token: `test-token-${Date.now()}`, scopes: [] }; @@ -254,7 +259,7 @@ export class IssParameterSupportedMissingScenario implements Scenario { const timestamp = new Date().toISOString(); if (!this.checks.some((c) => c.id === 'iss-client-rejected-missing')) { - const correctlyRejected = !this.tokenRequestMade; + const correctlyRejected = this.authReached && !this.tokenRequestMade; this.checks.push({ id: 'iss-client-rejected-missing', name: 'Client rejects missing iss when required', @@ -267,6 +272,7 @@ export class IssParameterSupportedMissingScenario implements Scenario { details: { serverAdvertisedSupport: true, issSentInRedirect: false, + authReached: this.authReached, tokenRequestMade: this.tokenRequestMade } }); @@ -293,10 +299,12 @@ export class IssParameterWrongIssuerScenario implements Scenario { private authServer = new ServerLifecycle(); private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; + private authReached = false; private tokenRequestMade = false; async start(): Promise { this.checks = []; + this.authReached = false; this.tokenRequestMade = false; const tokenVerifier = new MockTokenVerifier(this.checks, []); @@ -305,6 +313,9 @@ export class IssParameterWrongIssuerScenario implements Scenario { tokenVerifier, issParameterSupported: true, issInRedirect: 'wrong', // send iss that doesn't match metadata issuer + onAuthorizationRequest: () => { + this.authReached = true; + }, onTokenRequest: () => { this.tokenRequestMade = true; return { token: `test-token-${Date.now()}`, scopes: [] }; @@ -332,7 +343,7 @@ export class IssParameterWrongIssuerScenario implements Scenario { const timestamp = new Date().toISOString(); if (!this.checks.some((c) => c.id === 'iss-client-rejected-wrong-issuer')) { - const correctlyRejected = !this.tokenRequestMade; + const correctlyRejected = this.authReached && !this.tokenRequestMade; this.checks.push({ id: 'iss-client-rejected-wrong-issuer', name: 'Client rejects mismatched iss', @@ -345,6 +356,7 @@ export class IssParameterWrongIssuerScenario implements Scenario { details: { serverAdvertisedSupport: true, issSentInRedirect: 'https://evil.example.com', + authReached: this.authReached, tokenRequestMade: this.tokenRequestMade } }); @@ -371,10 +383,12 @@ export class IssParameterUnexpectedScenario implements Scenario { private authServer = new ServerLifecycle(); private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; + private authReached = false; private tokenRequestMade = false; async start(): Promise { this.checks = []; + this.authReached = false; this.tokenRequestMade = false; const tokenVerifier = new MockTokenVerifier(this.checks, []); @@ -383,6 +397,9 @@ export class IssParameterUnexpectedScenario implements Scenario { tokenVerifier, issParameterSupported: null, issInRedirect: 'correct', // send iss without advertising support + onAuthorizationRequest: () => { + this.authReached = true; + }, onTokenRequest: () => { this.tokenRequestMade = true; return { token: `test-token-${Date.now()}`, scopes: [] }; @@ -410,7 +427,7 @@ export class IssParameterUnexpectedScenario implements Scenario { const timestamp = new Date().toISOString(); if (!this.checks.some((c) => c.id === 'iss-client-rejected-unexpected')) { - const correctlyRejected = !this.tokenRequestMade; + const correctlyRejected = this.authReached && !this.tokenRequestMade; this.checks.push({ id: 'iss-client-rejected-unexpected', name: 'Client rejects unexpected iss', @@ -423,6 +440,7 @@ export class IssParameterUnexpectedScenario implements Scenario { details: { serverAdvertisedSupport: false, issSentInRedirect: true, + authReached: this.authReached, tokenRequestMade: this.tokenRequestMade } }); From 3633026e2de36cdc806829473d6233f379970498 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 15 May 2026 15:46:47 +0100 Subject: [PATCH 05/10] fix: iss-unexpected scenario contradicts SEP-2468 spec table row 3 The spec table says: supported=false/absent + iss present -> *Compare* to the recorded issuer (not reject). The scenario sent a *correct* iss and FAILed compliant clients for proceeding after a successful comparison. Now sends a mismatched iss so the comparison fails and rejection is the spec-required outcome. Reference client updated to compare-when-present instead of throw-on-presence. --- .../typescript/auth-test-iss-validation.ts | 7 ++++--- src/scenarios/client/auth/issuer-parameter.ts | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/examples/clients/typescript/auth-test-iss-validation.ts b/examples/clients/typescript/auth-test-iss-validation.ts index 52ef2d00..542f0613 100644 --- a/examples/clients/typescript/auth-test-iss-validation.ts +++ b/examples/clients/typescript/auth-test-iss-validation.ts @@ -135,10 +135,11 @@ async function oauthFlowWithIssValidation( ); } } else { - // Server did NOT advertise support: iss MUST NOT be present - if (issInRedirect) { + // Server did NOT advertise support: if iss is present, compare anyway + // (SEP-2468 spec table row 3 — local-policy provision per RFC 9207 §2.4) + if (issInRedirect && issInRedirect !== expectedIssuer) { throw new Error( - `Unexpected iss parameter in redirect: server did not advertise authorization_response_iss_parameter_supported` + `iss mismatch: expected '${expectedIssuer}', got '${issInRedirect}'` ); } } diff --git a/src/scenarios/client/auth/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts index 4c388992..70d136fc 100644 --- a/src/scenarios/client/auth/issuer-parameter.ts +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -367,17 +367,18 @@ export class IssParameterWrongIssuerScenario implements Scenario { } /** - * Scenario: ISS Parameter Sent but Not Advertised (client must reject) + * Scenario: ISS Parameter Sent but Not Advertised, Mismatched (client must reject) * * Server does not advertise authorization_response_iss_parameter_supported but - * includes an iss value in the redirect anyway. A conformant client MUST reject - * this unexpected parameter to prevent downgrade attacks. + * includes a mismatched iss value in the redirect. Per the SEP-2468 spec table + * row 3, a conformant client MUST compare a present iss against the recorded + * issuer regardless of metadata advertisement, and reject on mismatch. */ export class IssParameterUnexpectedScenario implements Scenario { name = 'auth/iss-unexpected'; specVersions: SpecVersion[] = ['draft']; description = - 'Tests that client rejects authorization response when server sends iss but did not advertise support'; + 'Tests that client compares iss against recorded issuer even when not advertised, and rejects on mismatch'; allowClientError = true; private authServer = new ServerLifecycle(); @@ -396,7 +397,7 @@ export class IssParameterUnexpectedScenario implements Scenario { const authApp = createAuthServer(this.checks, this.authServer.getUrl, { tokenVerifier, issParameterSupported: null, - issInRedirect: 'correct', // send iss without advertising support + issInRedirect: 'wrong', // send mismatched iss without advertising support onAuthorizationRequest: () => { this.authReached = true; }, @@ -430,16 +431,16 @@ export class IssParameterUnexpectedScenario implements Scenario { const correctlyRejected = this.authReached && !this.tokenRequestMade; this.checks.push({ id: 'iss-client-rejected-unexpected', - name: 'Client rejects unexpected iss', + name: 'Client compares unadvertised iss and rejects mismatch', description: correctlyRejected - ? 'Client correctly rejected authorization response containing unexpected iss parameter' - : 'Client MUST reject authorization response when server sends iss without advertising authorization_response_iss_parameter_supported', + ? 'Client correctly compared unadvertised iss against recorded issuer and rejected the mismatch' + : 'Client MUST compare a present iss against the recorded issuer regardless of metadata advertisement, and reject on mismatch', status: correctlyRejected ? 'SUCCESS' : 'FAILURE', timestamp, specReferences: specRefs, details: { serverAdvertisedSupport: false, - issSentInRedirect: true, + issSentInRedirect: 'https://evil.example.com', authReached: this.authReached, tokenRequestMade: this.tokenRequestMade } From 4378aef3c31558a2b0e9c0c59442125c8325c6f8 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 15 May 2026 15:48:09 +0100 Subject: [PATCH 06/10] refactor: replace harness-config checks with client-proceeded checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iss-advertised-in-metadata / iss-sent-in-redirect (and the not-* variants) fired in onAuthorizationRequest before the redirect happened, asserting only that the harness was configured correctly — a client that ignores iss passes identically. Replaced with one check per scenario keyed on tokenRequestMade, which observes that the client actually proceeded through the iss path. --- src/scenarios/client/auth/issuer-parameter.ts | 116 +++++------------- 1 file changed, 32 insertions(+), 84 deletions(-) diff --git a/src/scenarios/client/auth/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts index 70d136fc..c1ff0543 100644 --- a/src/scenarios/client/auth/issuer-parameter.ts +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -24,9 +24,11 @@ export class IssParameterSupportedScenario implements Scenario { private authServer = new ServerLifecycle(); private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; + private tokenRequestMade = false; async start(): Promise { this.checks = []; + this.tokenRequestMade = false; const tokenVerifier = new MockTokenVerifier(this.checks, []); @@ -34,25 +36,9 @@ export class IssParameterSupportedScenario implements Scenario { tokenVerifier, issParameterSupported: true, issInRedirect: 'correct', - onAuthorizationRequest: ({ timestamp }) => { - this.checks.push({ - id: 'iss-advertised-in-metadata', - name: 'ISS Parameter Advertised', - description: - 'Server advertised authorization_response_iss_parameter_supported: true in AS metadata', - status: 'SUCCESS', - timestamp, - specReferences: specRefs - }); - this.checks.push({ - id: 'iss-sent-in-redirect', - name: 'ISS Sent in Redirect', - description: - 'Server included correct iss value in authorization redirect', - status: 'SUCCESS', - timestamp, - specReferences: specRefs - }); + onTokenRequest: () => { + this.tokenRequestMade = true; + return { token: `test-token-${Date.now()}`, scopes: [] }; } }); await this.authServer.start(authApp); @@ -76,29 +62,17 @@ export class IssParameterSupportedScenario implements Scenario { getChecks(): ConformanceCheck[] { const timestamp = new Date().toISOString(); - if (!this.checks.some((c) => c.id === 'iss-advertised-in-metadata')) { - this.checks.push({ - id: 'iss-advertised-in-metadata', - name: 'ISS Parameter Advertised', - description: - 'Client did not reach authorization endpoint — could not verify iss parameter handling', - status: 'FAILURE', - timestamp, - specReferences: specRefs - }); - } - - if (!this.checks.some((c) => c.id === 'iss-sent-in-redirect')) { - this.checks.push({ - id: 'iss-sent-in-redirect', - name: 'ISS Sent in Redirect', - description: - 'Client did not reach authorization endpoint — could not verify iss in redirect', - status: 'FAILURE', - timestamp, - specReferences: specRefs - }); - } + this.checks.push({ + id: 'iss-client-accepted-supported', + name: 'Client accepts matching iss when advertised', + description: this.tokenRequestMade + ? 'Client compared advertised iss against recorded issuer and proceeded to token exchange' + : 'Client did not proceed to token exchange after receiving a correct iss from a server that advertised support', + status: this.tokenRequestMade ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { tokenRequestMade: this.tokenRequestMade } + }); return this.checks; } @@ -119,9 +93,11 @@ export class IssParameterNotAdvertisedScenario implements Scenario { private authServer = new ServerLifecycle(); private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; + private tokenRequestMade = false; async start(): Promise { this.checks = []; + this.tokenRequestMade = false; const tokenVerifier = new MockTokenVerifier(this.checks, []); @@ -129,25 +105,9 @@ export class IssParameterNotAdvertisedScenario implements Scenario { tokenVerifier, issParameterSupported: null, issInRedirect: 'omit', - onAuthorizationRequest: ({ timestamp }) => { - this.checks.push({ - id: 'iss-not-advertised-in-metadata', - name: 'ISS Parameter Not Advertised', - description: - 'Client accepted authorization response from server that does not advertise iss parameter support', - status: 'SUCCESS', - timestamp, - specReferences: specRefs - }); - this.checks.push({ - id: 'iss-not-sent-in-redirect', - name: 'ISS Not Sent in Redirect', - description: - 'Client accepted authorization response that does not include an iss parameter', - status: 'SUCCESS', - timestamp, - specReferences: specRefs - }); + onTokenRequest: () => { + this.tokenRequestMade = true; + return { token: `test-token-${Date.now()}`, scopes: [] }; } }); await this.authServer.start(authApp); @@ -171,29 +131,17 @@ export class IssParameterNotAdvertisedScenario implements Scenario { getChecks(): ConformanceCheck[] { const timestamp = new Date().toISOString(); - if (!this.checks.some((c) => c.id === 'iss-not-advertised-in-metadata')) { - this.checks.push({ - id: 'iss-not-advertised-in-metadata', - name: 'ISS Parameter Not Advertised', - description: - 'Client did not reach authorization endpoint — could not verify iss-absent handling', - status: 'FAILURE', - timestamp, - specReferences: specRefs - }); - } - - if (!this.checks.some((c) => c.id === 'iss-not-sent-in-redirect')) { - this.checks.push({ - id: 'iss-not-sent-in-redirect', - name: 'ISS Not Sent in Redirect', - description: - 'Client did not reach authorization endpoint — could not verify absent iss handling', - status: 'FAILURE', - timestamp, - specReferences: specRefs - }); - } + this.checks.push({ + id: 'iss-client-proceed-no-iss', + name: 'Client proceeds when iss absent and not advertised', + description: this.tokenRequestMade + ? 'Client proceeded to token exchange when neither metadata advertised iss support nor redirect contained iss' + : 'Client did not proceed to token exchange — should proceed when iss is absent and not advertised', + status: this.tokenRequestMade ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { tokenRequestMade: this.tokenRequestMade } + }); return this.checks; } From 003987da9ecf2ff72e0a89a8143399ff642c8675 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 15 May 2026 15:48:42 +0100 Subject: [PATCH 07/10] refactor: rename check IDs to sep-2468-* and align with spec table rows One ID per spec table row; auth/iss-supported and auth/iss-wrong-issuer both emit sep-2468-client-compare-iss-supported (same comparison, opposite input) per the same-slug-for-SUCCESS-and-FAIL convention. --- src/scenarios/client/auth/index.test.ts | 6 ++--- src/scenarios/client/auth/issuer-parameter.ts | 24 ++++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 67b07650..cd09793a 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -183,7 +183,7 @@ describe('Negative tests', () => { test('client does not reject missing iss when server requires it', async () => { const runner = new InlineClientRunner(noIssValidationClient); await runClientAgainstScenario(runner, 'auth/iss-supported-missing', { - expectedFailureSlugs: ['iss-client-rejected-missing'], + expectedFailureSlugs: ['sep-2468-client-reject-missing-iss'], allowClientError: true }); }); @@ -191,7 +191,7 @@ describe('Negative tests', () => { test('client does not reject mismatched iss', async () => { const runner = new InlineClientRunner(noIssValidationClient); await runClientAgainstScenario(runner, 'auth/iss-wrong-issuer', { - expectedFailureSlugs: ['iss-client-rejected-wrong-issuer'], + expectedFailureSlugs: ['sep-2468-client-compare-iss-supported'], allowClientError: true }); }); @@ -199,7 +199,7 @@ describe('Negative tests', () => { test('client does not reject unexpected iss', async () => { const runner = new InlineClientRunner(noIssValidationClient); await runClientAgainstScenario(runner, 'auth/iss-unexpected', { - expectedFailureSlugs: ['iss-client-rejected-unexpected'], + expectedFailureSlugs: ['sep-2468-client-compare-iss-unadvertised'], allowClientError: true }); }); diff --git a/src/scenarios/client/auth/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts index c1ff0543..3d318292 100644 --- a/src/scenarios/client/auth/issuer-parameter.ts +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -63,7 +63,7 @@ export class IssParameterSupportedScenario implements Scenario { const timestamp = new Date().toISOString(); this.checks.push({ - id: 'iss-client-accepted-supported', + id: 'sep-2468-client-compare-iss-supported', name: 'Client accepts matching iss when advertised', description: this.tokenRequestMade ? 'Client compared advertised iss against recorded issuer and proceeded to token exchange' @@ -132,7 +132,7 @@ export class IssParameterNotAdvertisedScenario implements Scenario { const timestamp = new Date().toISOString(); this.checks.push({ - id: 'iss-client-proceed-no-iss', + id: 'sep-2468-client-proceed-no-iss', name: 'Client proceeds when iss absent and not advertised', description: this.tokenRequestMade ? 'Client proceeded to token exchange when neither metadata advertised iss support nor redirect contained iss' @@ -206,10 +206,12 @@ export class IssParameterSupportedMissingScenario implements Scenario { getChecks(): ConformanceCheck[] { const timestamp = new Date().toISOString(); - if (!this.checks.some((c) => c.id === 'iss-client-rejected-missing')) { + if ( + !this.checks.some((c) => c.id === 'sep-2468-client-reject-missing-iss') + ) { const correctlyRejected = this.authReached && !this.tokenRequestMade; this.checks.push({ - id: 'iss-client-rejected-missing', + id: 'sep-2468-client-reject-missing-iss', name: 'Client rejects missing iss when required', description: correctlyRejected ? 'Client correctly rejected authorization response missing required iss parameter' @@ -290,10 +292,12 @@ export class IssParameterWrongIssuerScenario implements Scenario { getChecks(): ConformanceCheck[] { const timestamp = new Date().toISOString(); - if (!this.checks.some((c) => c.id === 'iss-client-rejected-wrong-issuer')) { + if ( + !this.checks.some((c) => c.id === 'sep-2468-client-compare-iss-supported') + ) { const correctlyRejected = this.authReached && !this.tokenRequestMade; this.checks.push({ - id: 'iss-client-rejected-wrong-issuer', + id: 'sep-2468-client-compare-iss-supported', name: 'Client rejects mismatched iss', description: correctlyRejected ? 'Client correctly rejected authorization response with mismatched iss parameter' @@ -375,10 +379,14 @@ export class IssParameterUnexpectedScenario implements Scenario { getChecks(): ConformanceCheck[] { const timestamp = new Date().toISOString(); - if (!this.checks.some((c) => c.id === 'iss-client-rejected-unexpected')) { + if ( + !this.checks.some( + (c) => c.id === 'sep-2468-client-compare-iss-unadvertised' + ) + ) { const correctlyRejected = this.authReached && !this.tokenRequestMade; this.checks.push({ - id: 'iss-client-rejected-unexpected', + id: 'sep-2468-client-compare-iss-unadvertised', name: 'Client compares unadvertised iss and rejects mismatch', description: correctlyRejected ? 'Client correctly compared unadvertised iss against recorded issuer and rejected the mismatch' From 955748ddde8165d2f5ba2221f528f88d43f5e528 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 15 May 2026 15:49:04 +0100 Subject: [PATCH 08/10] feat: add sep-2468.yaml requirement traceability 8 check rows (4 client table-row checks, 1 metadata-issuer, 2 AS-side, 1 no-normalization), 1 excluded (error-display is UI-facing). The record-issuer MUST is merged into the compare-iss-supported row text since it has no independent wire observation. --- src/seps/sep-2468.yaml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/seps/sep-2468.yaml diff --git a/src/seps/sep-2468.yaml b/src/seps/sep-2468.yaml new file mode 100644 index 00000000..6558ae6e --- /dev/null +++ b/src/seps/sep-2468.yaml @@ -0,0 +1,23 @@ +sep: 2468 +spec_url: https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-response-validation +requirements: + - check: sep-2468-client-validate-metadata-issuer + text: 'After retrieving a metadata document, MCP clients MUST validate it as required by RFC8414 Section 3.3 or OpenID Connect Discovery Section 4.3: the issuer value in the document MUST be identical to the issuer identifier used to construct the well-known URL. If they differ, the client MUST NOT use the metadata.' + url: https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery + - 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).' + - check: sep-2468-client-compare-iss-supported + text: 'On receiving the authorization response, MCP clients MUST apply the validation in RFC9207 Section 2.4 before transmitting the authorization code to any token endpoint: authorization_response_iss_parameter_supported true, iss present -> Compare to the recorded issuer using simple string comparison / Before redirecting the user-agent, the client MUST record the issuer value from the selected authorization server validated metadata document and associate it with the same per-request record used to store the PKCE code verifier.' + - check: sep-2468-client-reject-missing-iss + text: 'On receiving the authorization response, MCP clients MUST apply the validation in RFC9207 Section 2.4 before transmitting the authorization code to any token endpoint: authorization_response_iss_parameter_supported true, iss absent -> Reject the response.' + - check: sep-2468-client-compare-iss-unadvertised + text: 'On receiving the authorization response, MCP clients MUST apply the validation in RFC9207 Section 2.4 before transmitting the authorization code to any token endpoint: authorization_response_iss_parameter_supported false or absent, iss present -> Compare to the recorded issuer using simple string comparison.' + - check: sep-2468-client-proceed-no-iss + text: 'On receiving the authorization response, MCP clients MUST apply the validation in RFC9207 Section 2.4 before transmitting the authorization code to any token endpoint: authorization_response_iss_parameter_supported false or absent, iss absent -> Proceed.' + - 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: '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' From 2ed879213a3bef979c91facc9e3b2214dc26ee50 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 15 May 2026 17:09:00 +0100 Subject: [PATCH 09/10] fix: migrate iss scenarios specVersions->source (post-#265) Replaces `specVersions: ['draft']` with `source: { introducedIn: DRAFT_PROTOCOL_VERSION }` in the 5 iss-parameter scenarios. This commit typechecks once the stack is rebased onto main >= #265 (the ScenarioSource migration). Adding it now so the rebase is mechanical. --- src/scenarios/client/auth/issuer-parameter.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/scenarios/client/auth/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts index 3d318292..d34b73d0 100644 --- a/src/scenarios/client/auth/issuer-parameter.ts +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -1,5 +1,5 @@ import type { Scenario, ConformanceCheck } from '../../../types.js'; -import { ScenarioUrls, SpecVersion } from '../../../types.js'; +import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types.js'; import { createAuthServer } from './helpers/createAuthServer.js'; import { createServer } from './helpers/createServer.js'; import { ServerLifecycle } from './helpers/serverLifecycle.js'; @@ -17,7 +17,7 @@ const specRefs = [SpecReferences.RFC_9207_ISS_PARAMETER]; */ export class IssParameterSupportedScenario implements Scenario { name = 'auth/iss-supported'; - specVersions: SpecVersion[] = ['draft']; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; description = 'Tests that client accepts authorization response when server advertises and sends correct iss parameter'; @@ -86,7 +86,7 @@ export class IssParameterSupportedScenario implements Scenario { */ export class IssParameterNotAdvertisedScenario implements Scenario { name = 'auth/iss-not-advertised'; - specVersions: SpecVersion[] = ['draft']; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; description = 'Tests that client accepts authorization response when server does not advertise or send iss parameter'; @@ -155,7 +155,7 @@ export class IssParameterNotAdvertisedScenario implements Scenario { */ export class IssParameterSupportedMissingScenario implements Scenario { name = 'auth/iss-supported-missing'; - specVersions: SpecVersion[] = ['draft']; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; description = 'Tests that client rejects authorization response when server advertised iss support but omitted iss from redirect'; allowClientError = true; @@ -241,7 +241,7 @@ export class IssParameterSupportedMissingScenario implements Scenario { */ export class IssParameterWrongIssuerScenario implements Scenario { name = 'auth/iss-wrong-issuer'; - specVersions: SpecVersion[] = ['draft']; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; description = 'Tests that client rejects authorization response when iss does not match the authorization server issuer'; allowClientError = true; @@ -328,7 +328,7 @@ export class IssParameterWrongIssuerScenario implements Scenario { */ export class IssParameterUnexpectedScenario implements Scenario { name = 'auth/iss-unexpected'; - specVersions: SpecVersion[] = ['draft']; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; description = 'Tests that client compares iss against recorded issuer even when not advertised, and rejects on mismatch'; allowClientError = true; From 28a83ae0abf94ff5ec58854aab5e745a63df5734 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 19 May 2026 23:33:37 +0100 Subject: [PATCH 10/10] fix: include application_type in iss-validation example DCR (post-#284) The SEP-837 application_type check now runs in every auth scenario; the hand-rolled DCR in auth-test-iss-validation.ts was omitting the field. --- examples/clients/typescript/auth-test-iss-validation.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/clients/typescript/auth-test-iss-validation.ts b/examples/clients/typescript/auth-test-iss-validation.ts index 542f0613..3fdd3f05 100644 --- a/examples/clients/typescript/auth-test-iss-validation.ts +++ b/examples/clients/typescript/auth-test-iss-validation.ts @@ -85,7 +85,8 @@ async function oauthFlowWithIssValidation( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_name: 'test-auth-client-iss-validation', - redirect_uris: ['http://localhost:3000/callback'] + redirect_uris: ['http://localhost:3000/callback'], + application_type: 'native' }) }); if (!dcrResponse.ok) {