diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 3f86339..4314fd8 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -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' diff --git a/src/scenarios/client/auth/discovery-metadata.ts b/src/scenarios/client/auth/discovery-metadata.ts index 3dd64b7..bf64554 100644 --- a/src/scenarios/client/auth/discovery-metadata.ts +++ b/src/scenarios/client/auth/discovery-metadata.ts @@ -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; } @@ -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' } ]; @@ -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' ); @@ -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', @@ -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, { diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index c8c4ecd..5d5ef42 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -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[]; @@ -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'], @@ -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(); @@ -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 && { diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index e18a467..32195de 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -22,14 +22,19 @@ beforeAll(() => { }); const skipScenarios = new Set([ - // 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([ // 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', () => { @@ -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}`); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 7f65aa0..03a1296 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -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 { @@ -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() ]; diff --git a/src/scenarios/client/auth/issuer-mismatch.ts b/src/scenarios/client/auth/issuer-mismatch.ts new file mode 100644 index 0000000..1211846 --- /dev/null +++ b/src/scenarios/client/auth/issuer-mismatch.ts @@ -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: (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 { + 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; + } +} diff --git a/src/scenarios/client/auth/march-spec-backcompat.ts b/src/scenarios/client/auth/march-spec-backcompat.ts index 3bc857e..255318a 100644 --- a/src/scenarios/client/auth/march-spec-backcompat.ts +++ b/src/scenarios/client/auth/march-spec-backcompat.ts @@ -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, diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 768dd65..89ecc55 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -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'