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

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