Skip to content
Closed
229 changes: 229 additions & 0 deletions examples/clients/typescript/auth-test-iss-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
#!/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<OAuthTokens> {
// 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: 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<Response> => {
const makeRequest = async (): Promise<Response> => {
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<void> {
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 <server-url>');
16 changes: 15 additions & 1 deletion examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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
// ============================================================================
Expand Down
16 changes: 16 additions & 0 deletions src/scenarios/client/auth/helpers/createAuthServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -86,6 +90,8 @@ export function createAuthServer(
clientIdMetadataDocumentSupported,
disableDynamicRegistration = false,
codeChallengeMethodsSupported = ['S256'],
issParameterSupported = true,
issInRedirect = 'correct',
tokenVerifier,
onTokenRequest,
onAuthorizationRequest,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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());
});

Expand Down
31 changes: 30 additions & 1 deletion src/scenarios/client/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -29,7 +30,11 @@ const allowClientErrorScenarios = new Set<string>([
// 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', () => {
Expand Down Expand Up @@ -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: ['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
});
});
});
14 changes: 13 additions & 1 deletion src/scenarios/client/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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()
];
Loading