From df54a143609475f4e95d4716bb67803c83f392b7 Mon Sep 17 00:00:00 2001 From: Max Gerber Date: Wed, 8 Apr 2026 18:24:31 -0700 Subject: [PATCH 1/2] 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 3f86339e..efabcab1 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -27,6 +27,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'; /** @@ -149,11 +150,24 @@ 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 ); +// 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 c8c4ecd2..11ec07b7 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 e18a4671..745ca1dc 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -14,6 +14,7 @@ import { runClient as partialScopesClient } from '../../../../examples/clients/t import { runClient as ignore403Client } from '../../../../examples/clients/typescript/auth-test-ignore-403'; import { runClient as noRetryLimitClient } from '../../../../examples/clients/typescript/auth-test-no-retry-limit'; import { runClient as noPkceClient } from '../../../../examples/clients/typescript/auth-test-no-pkce'; +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'; @@ -29,7 +30,11 @@ const allowClientErrorScenarios = new Set([ // Client is expected to give up (error) after limited retries, but check should pass 'auth/scope-retry-limit', // Client is expected to error when PRM resource doesn't match server URL - 'auth/resource-mismatch' + 'auth/resource-mismatch', + // Client is expected to error when iss validation fails + 'auth/iss-supported-missing', + 'auth/iss-wrong-issuer', + 'auth/iss-unexpected' ]); describe('Client Auth Scenarios', () => { @@ -146,4 +151,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 7f65aa0f..a1fc76c9 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -28,6 +28,13 @@ import { OfflineAccessScopeScenario, OfflineAccessNotSupportedScenario } from './offline-access'; +import { + IssParameterSupportedScenario, + IssParameterNotAdvertisedScenario, + IssParameterSupportedMissingScenario, + IssParameterWrongIssuerScenario, + IssParameterUnexpectedScenario +} from './issuer-parameter'; // Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ @@ -61,5 +68,10 @@ export const extensionScenariosList: Scenario[] = [ export const draftScenariosList: Scenario[] = [ new ResourceMismatchScenario(), new OfflineAccessScopeScenario(), - new OfflineAccessNotSupportedScenario() + new OfflineAccessNotSupportedScenario(), + 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 b91850fd8a2f280e646d621273d8c392d3c98927 Mon Sep 17 00:00:00 2001 From: Max Gerber Date: Thu, 9 Apr 2026 09:21:20 -0700 Subject: [PATCH 2/2] 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 11ec07b7..85d3635d 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;