feat: add conformance tests for iss parameter (SEP-2468) (#220)
* 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.
* update scenarios
* fix: createAuthServer iss option type/guard and NotAdvertised scenario duplication
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.
* 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.
* 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.
* refactor: replace harness-config checks with client-proceeded checks
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.
* 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.
* 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.
* 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.
* 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.
---------
Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
parent
12252466e6
commit
78d7219198
8 changed files with 740 additions and 3 deletions
230
examples/clients/typescript/auth-test-iss-validation.ts
Normal file
230
examples/clients/typescript/auth-test-iss-validation.ts
Normal file
|
|
@ -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<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'],
|
||||
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<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>');
|
||||
|
|
@ -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<void> {
|
|||
|
||||
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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>([
|
|||
'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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
];
|
||||
|
|
|
|||
408
src/scenarios/client/auth/issuer-parameter.ts
Normal file
408
src/scenarios/client/auth/issuer-parameter.ts
Normal file
|
|
@ -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<ScenarioUrls> {
|
||||
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<ScenarioUrls> {
|
||||
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<ScenarioUrls> {
|
||||
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<ScenarioUrls> {
|
||||
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<ScenarioUrls> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
23
src/seps/sep-2468.yaml
Normal file
23
src/seps/sep-2468.yaml
Normal file
|
|
@ -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'
|
||||
Loading…
Reference in a new issue