feat(sep-2468): cover the remaining untested traceability rows (#304)

Add two client scenarios for the SEP-2468 requirements that had no
conformance coverage:

- auth/metadata-issuer-mismatch: the AS metadata document declares an
  issuer that differs from the issuer identifier used to construct the
  well-known URL; the client must not use the metadata
  (sep-2468-client-validate-metadata-issuer).
- auth/iss-normalized: the authorization redirect carries an iss value
  that only matches the recorded issuer after URL normalization; the
  client must compare with simple string comparison and reject
  (sep-2468-client-no-normalization).

The two authorization-server-side rows (AS SHOULD include iss, AS MUST
advertise authorization_response_iss_parameter_supported) are excluded:
observing iss in an authorization response requires driving an
Authorization Code Grant against the AS under test, which the
authorization-server suite does not implement yet (#208).
This commit is contained in:
Paul Carleton 2026-05-23 00:04:55 +01:00 • committed by GitHub
commit f12baed382
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 518 additions and 13 deletions

View file

@ -0,0 +1,243 @@
#!/usr/bin/env node
/**
* Misbehaving client that normalizes URLs before comparing the iss parameter.
*
* Per RFC 9207 / SEP-2468, iss comparison must be a simple string comparison
* with no URL normalization. This client instead compares
* `new URL(received).href` against `new URL(expected).href`, so a
* normalization-equivalent variant of the issuer (e.g. a trailing slash on an
* empty path) is incorrectly accepted and the client proceeds to the token
* endpoint. It also does not validate the AS metadata issuer against the
* issuer identifier used to construct the well-known URL.
*
* Used as a negative fixture for the auth/iss-normalized scenario.
*/
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(/=+$/, '');
}
/**
* Returns true when the two URLs are equal after URL normalization. This is
* the incorrect comparison this fixture deliberately performs.
*/
function urlsMatchAfterNormalization(a: string, b: string): boolean {
return new URL(a).href === new URL(b).href;
}
/**
* OAuth flow that incorrectly normalizes URLs before comparing iss.
*/
async function oauthFlowWithIssNormalization(
_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();
// NOTE: deliberately no RFC 8414 §3.3 metadata-issuer validation here.
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-normalize',
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 — INCORRECTLY normalizing both sides first
const issInRedirect = redirectUrl.searchParams.get('iss');
if (issParameterSupported) {
// Server advertised support: iss must be present, but this client
// accepts any normalization-equivalent value.
if (!issInRedirect) {
throw new Error(
'Server advertised authorization_response_iss_parameter_supported but iss is absent from redirect'
);
}
if (!urlsMatchAfterNormalization(issInRedirect, expectedIssuer)) {
throw new Error(
`iss mismatch: expected '${expectedIssuer}', got '${issInRedirect}'`
);
}
} else if (
issInRedirect &&
!urlsMatchAfterNormalization(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 normalized iss comparison.
*/
function withOAuthIssNormalization(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 oauthFlowWithIssNormalization(
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-normalize', version: '1.0.0' },
{ capabilities: {} }
);
const oauthFetch = withOAuthIssNormalization(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-normalize <server-url>');

View file

@ -3,12 +3,19 @@
/**
* Well-behaved client that validates the iss parameter in authorization responses.
*
* Per RFC 8414 §3.3:
* - The issuer in the retrieved AS metadata document MUST be identical to the
* issuer identifier used to construct the well-known URL; otherwise the
* metadata MUST NOT be used.
*
* 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.
* - If the AS does NOT advertise support but the redirect contains iss anyway,
* the client MUST still compare it to the recorded issuer and reject on
* mismatch (SEP-2468 validation table row 3).
* - Comparison is simple string comparison — no URL normalization.
*/
import { createHash, randomBytes } from 'crypto';
@ -75,6 +82,15 @@ async function oauthFlowWithIssValidation(
}
const asMetadata = await asResponse.json();
// RFC 8414 §3.3: the issuer in the metadata document must be identical
// (simple string comparison) to the issuer identifier used to construct
// the well-known URL. On mismatch the metadata must not be used.
if (asMetadata.issuer !== authServerUrl) {
throw new Error(
`AS metadata issuer mismatch: expected '${authServerUrl}', got '${asMetadata.issuer}'`
);
}
const expectedIssuer: string = asMetadata.issuer;
const issParameterSupported: boolean =
asMetadata.authorization_response_iss_parameter_supported === true;

View file

@ -351,7 +351,9 @@ registerScenarios(
[
'auth/iss-supported-missing',
'auth/iss-wrong-issuer',
'auth/iss-unexpected'
'auth/iss-unexpected',
'auth/iss-normalized',
'auth/metadata-issuer-mismatch'
],
issValidationClient
);

View file

@ -45,8 +45,20 @@ export interface AuthServerOptions {
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';
/**
* What iss value to include in authorization redirect. Default: 'correct'.
* 'normalized' sends a normalization-equivalent variant of the correct
* issuer (trailing slash appended, exactly what `new URL(x).href`
* round-tripping produces) — equal under RFC 3986 §6.2.2–6.2.3
* normalization but not under simple string comparison.
*/
issInRedirect?: 'correct' | 'wrong' | 'omit' | 'normalized';
/**
* Override the `issuer` value served in the AS metadata document. Used to
* test that clients validate the metadata issuer against the issuer
* identifier used to construct the well-known URL (RFC 8414 §3.3).
*/
metadataIssuer?: string;
tokenVerifier?: MockTokenVerifier;
onTokenRequest?: (requestData: {
scope?: string;
@ -92,6 +104,7 @@ export function createAuthServer(
codeChallengeMethodsSupported = ['S256'],
issParameterSupported = true,
issInRedirect = 'correct',
metadataIssuer,
tokenVerifier,
onTokenRequest,
onAuthorizationRequest,
@ -140,7 +153,7 @@ export function createAuthServer(
});
const metadata: any = {
issuer: `${getAuthBaseUrl()}${routePrefix}`,
issuer: metadataIssuer ?? `${getAuthBaseUrl()}${routePrefix}`,
authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`,
token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`,
...(!disableDynamicRegistration && {
@ -258,6 +271,11 @@ export function createAuthServer(
redirectUrl.searchParams.set('iss', `${getAuthBaseUrl()}${routePrefix}`);
} else if (issInRedirect === 'wrong') {
redirectUrl.searchParams.set('iss', 'https://evil.example.com');
} else if (issInRedirect === 'normalized') {
// Normalization-equivalent variant of the correct issuer: identical
// after RFC 3986 scheme-based normalization (trailing slash on an
// empty path) but different under simple string comparison.
redirectUrl.searchParams.set('iss', `${getAuthBaseUrl()}${routePrefix}/`);
}
res.redirect(redirectUrl.toString());

View file

@ -17,6 +17,7 @@ import { runClient as noPkceClient } from '../../../../examples/clients/typescri
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 { runClient as issNormalizeClient } from '../../../../examples/clients/typescript/auth-test-iss-normalize';
import { runClient as echoScopeClient } from '../../../../examples/clients/typescript/auth-test-echo-scope';
import { getHandler } from '../../../../examples/clients/typescript/everything-client';
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger';
@ -40,7 +41,10 @@ const allowClientErrorScenarios = new Set<string>([
// Client is expected to error when iss validation fails
'auth/iss-supported-missing',
'auth/iss-wrong-issuer',
'auth/iss-unexpected'
'auth/iss-unexpected',
'auth/iss-normalized',
// Client is expected to error when AS metadata issuer validation fails
'auth/metadata-issuer-mismatch'
]);
describe('Client Auth Scenarios', () => {
@ -214,4 +218,20 @@ describe('Negative tests', () => {
allowClientError: true
});
});
test('client normalizes iss before comparison and accepts a trailing-slash variant', async () => {
const runner = new InlineClientRunner(issNormalizeClient);
await runClientAgainstScenario(runner, 'auth/iss-normalized', {
expectedFailureSlugs: ['sep-2468-client-no-normalization'],
allowClientError: true
});
});
test('client uses AS metadata whose issuer does not match the well-known URL', async () => {
const runner = new InlineClientRunner(noIssValidationClient);
await runClientAgainstScenario(runner, 'auth/metadata-issuer-mismatch', {
expectedFailureSlugs: ['sep-2468-client-validate-metadata-issuer'],
allowClientError: true
});
});
});

View file

@ -34,7 +34,9 @@ import {
IssParameterNotAdvertisedScenario,
IssParameterSupportedMissingScenario,
IssParameterWrongIssuerScenario,
IssParameterUnexpectedScenario
IssParameterUnexpectedScenario,
IssParameterNormalizedVariantScenario,
MetadataIssuerMismatchScenario
} from './issuer-parameter';
// Auth scenarios (required for tier 1)
@ -75,5 +77,7 @@ export const draftScenariosList: Scenario[] = [
new IssParameterNotAdvertisedScenario(),
new IssParameterSupportedMissingScenario(),
new IssParameterWrongIssuerScenario(),
new IssParameterUnexpectedScenario()
new IssParameterUnexpectedScenario(),
new IssParameterNormalizedVariantScenario(),
new MetadataIssuerMismatchScenario()
];

View file

@ -7,6 +7,10 @@ import { SpecReferences } from './spec-references.js';
import { MockTokenVerifier } from './helpers/mockTokenVerifier.js';
const specRefs = [SpecReferences.RFC_9207_ISS_PARAMETER];
const metadataSpecRefs = [
SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST,
SpecReferences.MCP_AUTH_DISCOVERY
];
/**
* Scenario: ISS Parameter Supported (positive)
@ -406,3 +410,199 @@ export class IssParameterUnexpectedScenario implements Scenario {
return this.checks;
}
}
/**
* Scenario: ISS Parameter Is a Normalization-Equivalent Variant (client must reject)
*
* Server advertises authorization_response_iss_parameter_supported: true and
* includes an iss value that differs from the recorded issuer only by RFC 3986
* normalization (a trailing slash on an empty path — exactly what
* `new URL(x).href` round-tripping produces). SEP-2468 requires simple string
* comparison with no scheme/host case folding, default-port elision,
* trailing-slash, or percent-encoding normalization, so a conformant client
* MUST treat the variant as a mismatch and reject the response.
*/
export class IssParameterNormalizedVariantScenario implements Scenario {
name = 'auth/iss-normalized';
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
description =
'Tests that client compares iss using simple string comparison without applying URL normalization';
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: 'normalized', // correct issuer + trailing slash
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-no-normalization')) {
const correctlyRejected = this.authReached && !this.tokenRequestMade;
this.checks.push({
id: 'sep-2468-client-no-normalization',
name: 'Client compares iss without URL normalization',
description: correctlyRejected
? 'Client rejected an iss value that only matches the recorded issuer after URL normalization'
: 'Client MUST NOT apply scheme/host case folding, default-port elision, trailing-slash, or percent-encoding normalization to iss before comparison; a trailing-slash variant of the issuer must be treated as a mismatch',
status: correctlyRejected ? 'SUCCESS' : 'FAILURE',
timestamp,
specReferences: specRefs,
details: {
recordedIssuer: this.authServer.getUrl(),
issSentInRedirect: `${this.authServer.getUrl()}/`,
authReached: this.authReached,
tokenRequestMade: this.tokenRequestMade
}
});
}
return this.checks;
}
}
/**
* Scenario: AS Metadata Issuer Mismatch (client must not use the metadata)
*
* The authorization server metadata document declares an `issuer` that is not
* identical to the issuer identifier used to construct the well-known URL.
* Per RFC 8414 §3.3 / OpenID Connect Discovery §4.3 (incorporated by
* SEP-2468), the client MUST validate the issuer and MUST NOT use the
* metadata on mismatch. The endpoints in the poisoned document live under a
* non-default route prefix, so any request that reaches them proves the
* client used the metadata.
*/
export class MetadataIssuerMismatchScenario implements Scenario {
name = 'auth/metadata-issuer-mismatch';
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
description =
'Tests that client rejects authorization server metadata whose issuer does not match the issuer used to construct the well-known URL';
allowClientError = true;
private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
private metadataEndpointsUsed = false;
async start(): Promise<ScenarioUrls> {
this.checks = [];
this.metadataEndpointsUsed = false;
const tokenVerifier = new MockTokenVerifier(this.checks, []);
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
tokenVerifier,
// The well-known URL is constructed from the AS URL advertised in PRM
// (the bare origin), so the document's issuer must equal that origin.
// Serve something else entirely.
metadataIssuer: 'https://attacker.example.com',
// Park the endpoints under a prefix only discoverable via the poisoned
// metadata, so "client used the metadata" is observable as a request.
routePrefix: '/mismatched-as',
onRegistrationRequest: () => {
this.metadataEndpointsUsed = true;
return {
clientId: 'test-client-id',
clientSecret: 'test-client-secret'
};
},
onAuthorizationRequest: () => {
this.metadataEndpointsUsed = true;
},
onTokenRequest: () => {
this.metadataEndpointsUsed = 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-validate-metadata-issuer'
)
) {
const metadataRequested = this.checks.some(
(c) => c.id === 'authorization-server-metadata'
);
const correctlyRejected =
metadataRequested && !this.metadataEndpointsUsed;
this.checks.push({
id: 'sep-2468-client-validate-metadata-issuer',
name: 'Client validates metadata issuer against well-known URL',
description: correctlyRejected
? 'Client rejected authorization server metadata whose issuer does not match the issuer identifier used to construct the well-known URL'
: metadataRequested
? 'Client MUST NOT use authorization server metadata whose issuer differs from the issuer identifier used to construct the well-known URL; client used endpoints from the mismatched metadata'
: 'Client never retrieved the authorization server metadata document, so issuer validation could not be observed',
status: correctlyRejected ? 'SUCCESS' : 'FAILURE',
timestamp,
specReferences: metadataSpecRefs,
details: {
expectedIssuer: this.authServer.getUrl(),
metadataIssuer: 'https://attacker.example.com',
metadataRequested,
metadataEndpointsUsed: this.metadataEndpointsUsed
}
});
}
return this.checks;
}
}

View file

@ -4,10 +4,6 @@ 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
@ -19,5 +15,11 @@ requirements:
- 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: 'MCP authorization servers SHOULD include the iss parameter in authorization responses, including error responses, as defined in RFC9207 Section 2.'
excluded: 'Targets the authorization server under test; observing iss in an authorization response requires driving an Authorization Code Grant against the AS, which the authorization-server suite does not implement yet (it only probes the metadata endpoint).'
issue: 'https://github.com/modelcontextprotocol/conformance/issues/208'
- 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).'
excluded: 'Conditional on the AS actually including iss in an authorization response, which requires driving an Authorization Code Grant against the AS under test; the authorization-server suite does not implement that yet.'
issue: 'https://github.com/modelcontextprotocol/conformance/issues/208'
- 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'