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:
Max Gerber 2026-05-19 15:42:35 -07:00 • committed by GitHub
commit 78d7219198
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 740 additions and 3 deletions

View file

@ -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());
});

View file

@ -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
});
});
});

View file

@ -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()
];

View 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;
}
}

View file

@ -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'