Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ registerScenarios(
'auth/token-endpoint-auth-none',
// Resource mismatch (client should error when PRM resource doesn't match)
'auth/resource-mismatch',
// Issuer mismatch (client should error when AS metadata issuer doesn't match, RFC 8414 §3.3)
'auth/issuer-mismatch',
// SEP-2207: Offline access / refresh token guidance (draft)
'auth/offline-access-scope',
'auth/offline-access-not-supported'
Expand Down
18 changes: 9 additions & 9 deletions src/scenarios/client/auth/discovery-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ interface MetadataScenarioConfig {
prmLocation: string;
inWwwAuth: boolean;
oauthMetadataLocation: string;
/** Route prefix for the auth server (e.g., '/tenant1') */
authRoutePrefix?: string;
/** Issuer path component for the auth server (e.g., '/tenant1' for multi-tenant) */
authIssuerPath?: string;
/** If true, add a trap for root PRM requests */
trapRootPrm?: boolean;
}
Expand Down Expand Up @@ -57,14 +57,14 @@ const SCENARIO_CONFIGS: MetadataScenarioConfig[] = [
prmLocation: '/.well-known/oauth-protected-resource',
inWwwAuth: false,
oauthMetadataLocation: '/.well-known/oauth-authorization-server/tenant1',
authRoutePrefix: '/tenant1'
authIssuerPath: '/tenant1'
},
{
name: 'metadata-var3',
prmLocation: '/custom/metadata/location.json',
inWwwAuth: true,
oauthMetadataLocation: '/tenant1/.well-known/openid-configuration',
authRoutePrefix: '/tenant1'
authIssuerPath: '/tenant1'
}
];

Expand All @@ -76,7 +76,7 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario {
const server = new ServerLifecycle();
let checks: ConformanceCheck[] = [];

const routePrefix = config.authRoutePrefix || '';
const issuerPath = config.authIssuerPath || '';
const isOpenIdConfiguration = config.oauthMetadataLocation.includes(
'openid-configuration'
);
Expand All @@ -100,11 +100,11 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario {
const authApp = createAuthServer(checks, authServer.getUrl, {
metadataPath: config.oauthMetadataLocation,
isOpenIdConfiguration,
...(routePrefix && { routePrefix })
...(issuerPath && { issuerPath })
});

// If path-based OAuth metadata, trap root requests
if (routePrefix) {
if (issuerPath) {
authApp.get('/.well-known/oauth-authorization-server', (req, res) => {
checks.push({
id: 'authorization-server-metadata-wrong-path',
Expand All @@ -127,8 +127,8 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario {

await authServer.start(authApp);

const getAuthServerUrl = routePrefix
? () => `${authServer.getUrl()}${routePrefix}`
const getAuthServerUrl = issuerPath
? () => `${authServer.getUrl()}${issuerPath}`
: authServer.getUrl;

const app = createServer(checks, server.getUrl, getAuthServerUrl, {
Expand Down
24 changes: 18 additions & 6 deletions src/scenarios/client/auth/helpers/createAuthServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,18 @@ export interface AuthServerOptions {
metadataPath?: string;
isOpenIdConfiguration?: boolean;
loggingEnabled?: boolean;
routePrefix?: string;
/**
* Path component of the issuer identifier (e.g., '/tenant1' for multi-tenant).
* Per RFC 8414, this must match the path used to construct the metadata URL.
* OAuth endpoints (/authorize, /token, /register) are mounted under this path.
*/
issuerPath?: string;
/**
* Override the issuer value in the metadata response. For negative testing
* of RFC 8414 §3.3 issuer validation — clients MUST reject when the issuer
* in the response doesn't match the one used to construct the metadata URL.
*/
issuerOverride?: string;
scopesSupported?: string[];
grantTypesSupported?: string[];
tokenEndpointAuthMethodsSupported?: string[];
Expand Down Expand Up @@ -78,7 +89,8 @@ export function createAuthServer(
metadataPath = '/.well-known/oauth-authorization-server',
isOpenIdConfiguration = false,
loggingEnabled = true,
routePrefix = '',
issuerPath = '',
issuerOverride,
scopesSupported,
grantTypesSupported = ['authorization_code', 'refresh_token'],
tokenEndpointAuthMethodsSupported = ['none'],
Expand All @@ -98,9 +110,9 @@ export function createAuthServer(
let storedCodeChallenge: string | undefined;

const authRoutes = {
authorization_endpoint: `${routePrefix}/authorize`,
token_endpoint: `${routePrefix}/token`,
registration_endpoint: `${routePrefix}/register`
authorization_endpoint: `${issuerPath}/authorize`,
token_endpoint: `${issuerPath}/token`,
registration_endpoint: `${issuerPath}/register`
};

const app = express();
Expand Down Expand Up @@ -134,7 +146,7 @@ export function createAuthServer(
});

const metadata: any = {
issuer: `${getAuthBaseUrl()}${routePrefix}`,
issuer: issuerOverride ?? `${getAuthBaseUrl()}${issuerPath}`,
authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`,
token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`,
...(!disableDynamicRegistration && {
Expand Down
12 changes: 10 additions & 2 deletions src/scenarios/client/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,19 @@ beforeAll(() => {
});

const skipScenarios = new Set<string>([
// Add scenarios that should be skipped here
// TS SDK does not yet validate that AS metadata issuer matches the issuer
// used to construct the metadata URL (RFC 8414 §3.3). Unskip once
// typescript-sdk implements validation.
'auth/issuer-mismatch'
]);

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 AS metadata issuer doesn't match (RFC 8414 §3.3)
'auth/issuer-mismatch'
]);

describe('Client Auth Scenarios', () => {
Expand Down Expand Up @@ -68,6 +73,9 @@ describe('Client Back-compat Scenarios', () => {
describe('Client Draft Scenarios', () => {
for (const scenario of draftScenariosList) {
test(`${scenario.name} passes`, async () => {
if (skipScenarios.has(scenario.name)) {
return;
}
const clientFn = getHandler(scenario.name);
if (!clientFn) {
throw new Error(`No handler registered for scenario: ${scenario.name}`);
Expand Down
2 changes: 2 additions & 0 deletions src/scenarios/client/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
ClientCredentialsBasicScenario
} from './client-credentials';
import { ResourceMismatchScenario } from './resource-mismatch';
import { IssuerMismatchScenario } from './issuer-mismatch';
import { PreRegistrationScenario } from './pre-registration';
import { CrossAppAccessCompleteFlowScenario } from './cross-app-access';
import {
Expand Down Expand Up @@ -60,6 +61,7 @@ export const extensionScenariosList: Scenario[] = [
// Draft scenarios (informational - not scored for tier assessment)
export const draftScenariosList: Scenario[] = [
new ResourceMismatchScenario(),
new IssuerMismatchScenario(),
new OfflineAccessScopeScenario(),
new OfflineAccessNotSupportedScenario()
];
101 changes: 101 additions & 0 deletions src/scenarios/client/auth/issuer-mismatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { Scenario, ConformanceCheck } from '../../../types.js';
import { ScenarioUrls, SpecVersion } 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';

/**
* Scenario: Authorization Server Issuer Mismatch Detection
*
* Tests that clients correctly detect and reject when the Authorization
* Server metadata response contains an `issuer` value that doesn't match
* the issuer identifier used to construct the metadata URL.
*
* Per RFC 8414 §3.3, clients MUST validate that the issuer in the metadata
* response matches the issuer used to construct the well-known metadata URL.
* Failing to do so enables mix-up attacks where a malicious AS impersonates
* another.
*
* Setup:
* - PRM advertises authorization server at http://localhost:<port> (root issuer)
* - Client constructs metadata URL /.well-known/oauth-authorization-server
* - AS responds with issuer: "https://evil.example.com" (mismatch)
*
* Expected behavior:
* - Client should NOT proceed with authorization
* - Client should abort due to issuer mismatch
* - Test passes if client does NOT make an authorization request
*/
export class IssuerMismatchScenario implements Scenario {
name = 'auth/issuer-mismatch';
specVersions: SpecVersion[] = ['draft'];
description =
'Tests that client rejects when AS metadata issuer does not match the issuer used to construct the metadata URL (RFC 8414 §3.3)';
allowClientError = true;

private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
private authorizationRequestMade = false;

async start(): Promise<ScenarioUrls> {
this.checks = [];
this.authorizationRequestMade = false;

const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
// Root issuer: metadata at /.well-known/oauth-authorization-server,
// so the expected issuer is just the base URL. Override it to a
// different origin to trigger the mismatch.
issuerOverride: 'https://evil.example.com',
onAuthorizationRequest: () => {
// If we get here, the client incorrectly proceeded past issuer validation
this.authorizationRequestMade = true;
}
});
await this.authServer.start(authApp);

const app = createServer(
this.checks,
this.server.getUrl,
this.authServer.getUrl,
{
prmPath: '/.well-known/oauth-protected-resource/mcp'
}
);
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 === 'issuer-mismatch-rejected')) {
const correctlyRejected = !this.authorizationRequestMade;
this.checks.push({
id: 'issuer-mismatch-rejected',
name: 'Client rejects mismatched issuer',
description: correctlyRejected
? 'Client correctly rejected authorization when AS metadata issuer does not match the metadata URL'
: 'Client MUST validate that the issuer in AS metadata matches the issuer used to construct the metadata URL (RFC 8414 §3.3)',
status: correctlyRejected ? 'SUCCESS' : 'FAILURE',
timestamp,
specReferences: [SpecReferences.RFC_AUTH_SERVER_METADATA_VALIDATION],
details: {
metadataIssuer: 'https://evil.example.com',
expectedIssuer: this.authServer.getUrl(),
expectedBehavior: 'Client should NOT proceed with authorization',
authorizationRequestMade: this.authorizationRequestMade
}
});

Check failure on line 96 in src/scenarios/client/auth/issuer-mismatch.ts

View check run for this annotation

Claude / Claude Code Review

issuer-mismatch test can false-pass if client never fetches AS metadata

This check can false-pass: `correctlyRejected` is derived solely from `\!authorizationRequestMade`, so a client that errors out *before* ever fetching AS metadata (e.g. fails PRM parsing or doesn't support OAuth at all) will be reported as having 'correctly rejected the mismatched issuer' — even though it never saw the issuer. Consider gating SUCCESS on `this.checks.some(c => c.id === 'authorization-server-metadata')` so the client must have actually fetched the metadata containing the bad issue
Comment on lines +79 to +96
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 This check can false-pass: correctlyRejected is derived solely from \!authorizationRequestMade, so a client that errors out before ever fetching AS metadata (e.g. fails PRM parsing or doesn't support OAuth at all) will be reported as having 'correctly rejected the mismatched issuer' — even though it never saw the issuer. Consider gating SUCCESS on this.checks.some(c => c.id === 'authorization-server-metadata') so the client must have actually fetched the metadata containing the bad issuer. (Note: resource-mismatch.ts has the analogous pre-existing weakness for PRM, but this PR adds a fresh instance.)

Extended reasoning...

What the bug is

IssuerMismatchScenario.getChecks() decides pass/fail purely on whether the /authorize endpoint was hit:

const correctlyRejected = \!this.authorizationRequestMade;
// ...
status: correctlyRejected ? 'SUCCESS' : 'FAILURE',

It never checks whether the client actually fetched the AS metadata document containing the mismatched issuer. Combined with allowClientError = true (line 35), any client that errors out early — for reasons completely unrelated to issuer validation — will be credited with a SUCCESS.

Code path that triggers it

  1. Client hits /mcp, receives 401 with WWW-Authenticate pointing at the PRM.
  2. Client fails at this stage — e.g. it doesn't implement OAuth, can't parse the PRM response, or hits any unrelated exception.
  3. Client process exits non-zero. Because allowClientError = true, the runner tolerates this.
  4. /authorize was never called → authorizationRequestMade is still false.
  5. /.well-known/oauth-authorization-server was also never called → no authorization-server-metadata check was pushed into this.checks.
  6. getChecks() computes correctlyRejected = \!false = true and pushes a SUCCESS for issuer-mismatch-rejected with the description "Client correctly rejected authorization when AS metadata issuer does not match the metadata URL".

The client never saw issuer: "https://evil.example.com", yet the conformance report says it correctly validated it.

Why existing code doesn't prevent it

Unlike discovery-metadata.ts and march-spec-backcompat.ts, this scenario has no expectedSlugs precondition requiring authorization-server-metadata to be present. The only signal used is the absence of an authorize call, which is necessary but not sufficient — it conflates "rejected after seeing the mismatch" with "never got that far".

createAuthServer already pushes an authorization-server-metadata check into this.checks when the metadata endpoint is hit (createAuthServer.ts:132-146), so the signal needed to disambiguate is already available — it's just not consulted.

Impact

For a conformance suite, false positives are arguably worse than false negatives: a client that doesn't implement RFC 8414 §3.3 issuer validation (or doesn't implement OAuth discovery at all) could be reported as conformant on this check. This is a draft/informational scenario and currently skipped in CI for the TS reference client, so the immediate blast radius is small — but third-party clients running the suite would get misleading results.

Fix

Gate the SUCCESS on the metadata having been fetched:

const sawMetadata = this.checks.some(
  (c) => c.id === 'authorization-server-metadata'
);
const correctlyRejected = sawMetadata && \!this.authorizationRequestMade;
// ...
description: \!sawMetadata
  ? 'Client never fetched AS metadata, so issuer validation could not be tested'
  : correctlyRejected
    ? 'Client correctly rejected authorization when AS metadata issuer does not match the metadata URL'
    : 'Client MUST validate that the issuer in AS metadata matches the issuer used to construct the metadata URL (RFC 8414 §3.3)',

resource-mismatch.ts has the same structural weakness (it should gate on the PRM endpoint having been hit), but that's pre-existing and out of scope for this PR.

}

return this.checks;
}
}
7 changes: 4 additions & 3 deletions src/scenarios/client/auth/march-spec-backcompat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario {
this.checks = [];
// Legacy server, so we create the auth server endpoints on the
// same URL as the main server (rather than separating AS / RS).
// Metadata at root well-known → issuer is the root URL (no path).
// Test integrity against fallback-bypass is ensured by expectedSlugs
// requiring 'authorization-server-metadata'.
const authApp = createAuthServer(this.checks, this.server.getUrl, {
// Disable logging since the main server will already have logging enabled
loggingEnabled: false,
// Add a prefix to auth endpoints to avoid being caught by auth fallbacks
routePrefix: '/oauth'
loggingEnabled: false
});
const app = createServer(
this.checks,
Expand Down
4 changes: 4 additions & 0 deletions src/scenarios/client/auth/spec-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export const SpecReferences: { [key: string]: SpecReference } = {
id: 'RFC-8414-metadata-request',
url: 'https://www.rfc-editor.org/rfc/rfc8414.html#section-3.1'
},
RFC_AUTH_SERVER_METADATA_VALIDATION: {
id: 'RFC-8414-metadata-validation',
url: 'https://www.rfc-editor.org/rfc/rfc8414.html#section-3.3'
},
LEGACY_2025_03_26_AUTH_DISCOVERY: {
id: 'MCP-2025-03-26-Authorization-metadata-discovery',
url: 'https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery'
Expand Down
Loading