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 0000000..3fdd3f0 --- /dev/null +++ b/examples/clients/typescript/auth-test-iss-validation.ts @@ -0,0 +1,230 @@ +#!/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'], + application_type: 'native' + }) + }); + 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: 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( + `iss mismatch: expected '${expectedIssuer}', got '${issInRedirect}'` + ); + } + } + + // 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 71c4582..a9cf90c 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 00aa3db..5138e36 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: 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: { scope?: string; @@ -86,6 +90,8 @@ export function createAuthServer( clientIdMetadataDocumentSupported, disableDynamicRegistration = false, codeChallengeMethodsSupported = ['S256'], + issParameterSupported = true, + issInRedirect = 'correct', tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -146,6 +152,9 @@ export function createAuthServer( ...(codeChallengeMethodsSupported !== null && { code_challenge_methods_supported: codeChallengeMethodsSupported }), + ...(issParameterSupported !== null && { + 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 c5d7981..cd09793 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: ['sep-2468-client-reject-missing-iss'], + allowClientError: true + }); + }); + + test('client does not reject mismatched iss', async () => { + const runner = new InlineClientRunner(noIssValidationClient); + await runClientAgainstScenario(runner, 'auth/iss-wrong-issuer', { + expectedFailureSlugs: ['sep-2468-client-compare-iss-supported'], + allowClientError: true + }); + }); + + test('client does not reject unexpected iss', async () => { + const runner = new InlineClientRunner(noIssValidationClient); + await runClientAgainstScenario(runner, 'auth/iss-unexpected', { + expectedFailureSlugs: ['sep-2468-client-compare-iss-unadvertised'], + allowClientError: true + }); + }); }); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 85b3f3f..92e87f6 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 0000000..d34b73d --- /dev/null +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -0,0 +1,408 @@ +import type { Scenario, ConformanceCheck } 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'; +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'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + 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[] = []; + 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: 'correct', + 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(); + + this.checks.push({ + 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' + : '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; + } +} + +/** + * 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'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + 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[] = []; + 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: null, + issInRedirect: 'omit', + 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(); + + this.checks.push({ + 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' + : '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; + } +} + +/** + * 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'; + 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; + + 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, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + 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: [] }; + } + }); + 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 === 'sep-2468-client-reject-missing-iss') + ) { + const correctlyRejected = this.authReached && !this.tokenRequestMade; + this.checks.push({ + 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' + : '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, + authReached: this.authReached, + 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'; + 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; + + 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, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + 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: [] }; + } + }); + 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 === 'sep-2468-client-compare-iss-supported') + ) { + const correctlyRejected = this.authReached && !this.tokenRequestMade; + this.checks.push({ + id: 'sep-2468-client-compare-iss-supported', + 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', + authReached: this.authReached, + tokenRequestMade: this.tokenRequestMade + } + }); + } + + return this.checks; + } +} + +/** + * Scenario: ISS Parameter Sent but Not Advertised, Mismatched (client must reject) + * + * Server does not advertise authorization_response_iss_parameter_supported but + * 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'; + 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; + + 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, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + issParameterSupported: null, + issInRedirect: 'wrong', // send mismatched iss without advertising support + onAuthorizationRequest: () => { + this.authReached = true; + }, + 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 === 'sep-2468-client-compare-iss-unadvertised' + ) + ) { + const correctlyRejected = this.authReached && !this.tokenRequestMade; + this.checks.push({ + 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' + : '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: 'https://evil.example.com', + authReached: this.authReached, + 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 768dd65..908a04e 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' diff --git a/src/seps/sep-2468.yaml b/src/seps/sep-2468.yaml new file mode 100644 index 0000000..6558ae6 --- /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'