diff --git a/src/scenarios/client/auth/basic-dcr.ts b/src/scenarios/client/auth/basic-dcr.ts deleted file mode 100644 index 902fbdb..0000000 --- a/src/scenarios/client/auth/basic-dcr.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { Scenario, ConformanceCheck } from '../../../types.js'; -import { ScenarioUrls } from '../../../types.js'; -import { createAuthServer } from './helpers/createAuthServer.js'; -import { createServer } from './helpers/createServer.js'; -import { ServerLifecycle } from './helpers/serverLifecycle.js'; -import { Request, Response } from 'express'; -import { SpecReferences } from './spec-references.js'; - -export class AuthBasicDCRScenario implements Scenario { - name = 'auth/basic-dcr'; - description = - 'Tests Basic OAuth flow with DCR, PRM at path-based location, OAuth metadata at root location, and no scopes required'; - private authServer = new ServerLifecycle(); - private server = new ServerLifecycle(); - private checks: ConformanceCheck[] = []; - - async start(): Promise { - this.checks = []; - - const authApp = createAuthServer(this.checks, this.authServer.getUrl); - await this.authServer.start(authApp); - - const app = createServer( - this.checks, - this.server.getUrl, - this.authServer.getUrl - ); - - // For this scenario, reject PRM requests at root location since we have the path-based PRM. - app.get( - '/.well-known/oauth-protected-resource', - (req: Request, res: Response) => { - this.checks.push({ - id: 'prm-priority-order', - name: 'PRM Priority Order', - description: - 'Client requested PRM metadata at root location on a server with path-based PRM', - status: 'FAILURE', - timestamp: new Date().toISOString(), - specReferences: [ - SpecReferences.RFC_PRM_DISCOVERY, - SpecReferences.MCP_PRM_DISCOVERY - ], - details: { - url: req.url, - path: req.path - } - }); - - // Return 404 to indicate PRM is not available at root location - res.status(404).json({ - error: 'not_found', - error_description: 'PRM metadata not available at root location' - }); - } - ); - - await this.server.start(app); - - return { serverUrl: `${this.server.getUrl()}/mcp` }; - } - - async stop() { - await this.authServer.stop(); - await this.server.stop(); - } - - getChecks(): ConformanceCheck[] { - const expectedSlugs = [ - 'prm-pathbased-requested', - 'authorization-server-metadata', - 'client-registration', - 'authorization-request', - 'token-request' - ]; - - for (const slug of expectedSlugs) { - if (!this.checks.find((c) => c.id === slug)) { - this.checks.push({ - id: slug, - // TODO: these are redundant... - name: `Expected Check Missing: ${slug}`, - description: `Expected Check Missing: ${slug}`, - status: 'FAILURE', - timestamp: new Date().toISOString() - // TODO: ideally we'd add the spec references - }); - } - } - - return this.checks; - } -} diff --git a/src/scenarios/client/auth/basic-metadata.ts b/src/scenarios/client/auth/basic-metadata.ts deleted file mode 100644 index 828f756..0000000 --- a/src/scenarios/client/auth/basic-metadata.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { Scenario, ConformanceCheck } from '../../../types.js'; -import { ScenarioUrls } 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'; - -export class AuthBasicMetadataVar1Scenario implements Scenario { - name = 'auth/basic-metadata-var1'; - description = - 'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at OpenID discovery path, and no scopes required'; - private authServer = new ServerLifecycle(); - private server = new ServerLifecycle(); - private checks: ConformanceCheck[] = []; - - async start(): Promise { - this.checks = []; - - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { - metadataPath: '/.well-known/openid-configuration', - isOpenIdConfiguration: true - }); - await this.authServer.start(authApp); - - const app = createServer( - this.checks, - this.server.getUrl, - this.authServer.getUrl, - { - prmPath: '/.well-known/oauth-protected-resource' - } - ); - await this.server.start(app); - - return { serverUrl: `${this.server.getUrl()}/mcp` }; - } - - async stop() { - await this.authServer.stop(); - await this.server.stop(); - } - - getChecks(): ConformanceCheck[] { - const expectedSlugs = [ - 'authorization-server-metadata', - 'client-registration', - 'authorization-request', - 'token-request' - ]; - - for (const slug of expectedSlugs) { - if (!this.checks.find((c) => c.id === slug)) { - this.checks.push({ - id: slug, - name: `Expected Check Missing: ${slug}`, - description: `Expected Check Missing: ${slug}`, - status: 'FAILURE', - timestamp: new Date().toISOString() - }); - } - } - - return this.checks; - } -} - -export class AuthBasicMetadataVar2Scenario implements Scenario { - name = 'auth/basic-metadata-var2'; - description = - 'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at path-based OAuth discovery path'; - private authServer = new ServerLifecycle(); - private server = new ServerLifecycle(); - private checks: ConformanceCheck[] = []; - - async start(): Promise { - this.checks = []; - - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { - metadataPath: '/tenant1/.well-known/openid-configuration', - isOpenIdConfiguration: true, - routePrefix: '/tenant1' - }); - - authApp.get('/.well-known/oauth-authorization-server', (req, res) => { - this.checks.push({ - id: 'authorization-server-metadata-wrong-path', - name: 'AuthorizationServerMetadataWrongPath', - description: - 'Client requested authorization server at the root path when the AS URL has a path-based location', - status: 'FAILURE', - timestamp: new Date().toISOString(), - specReferences: [ - SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST, - SpecReferences.MCP_AUTH_DISCOVERY - ], - details: { - url: req.url - } - }); - res.status(404).send('Not Found'); - }); - - await this.authServer.start(authApp); - - const app = createServer( - this.checks, - this.server.getUrl, - () => `${this.authServer.getUrl()}/tenant1`, - { - prmPath: '/.well-known/oauth-protected-resource' - } - ); - await this.server.start(app); - - return { serverUrl: `${this.server.getUrl()}/mcp` }; - } - - async stop() { - await this.authServer.stop(); - await this.server.stop(); - } - - getChecks(): ConformanceCheck[] { - const expectedSlugs = [ - 'authorization-server-metadata', - 'client-registration', - 'authorization-request', - 'token-request' - ]; - - for (const slug of expectedSlugs) { - if (!this.checks.find((c) => c.id === slug)) { - this.checks.push({ - id: slug, - name: `Expected Check Missing: ${slug}`, - description: `Expected Check Missing: ${slug}`, - status: 'FAILURE', - timestamp: new Date().toISOString() - }); - } - } - - return this.checks; - } -} - -export class AuthBasicMetadataVar3Scenario implements Scenario { - name = 'auth/basic-metadata-var3'; - description = - 'Tests Basic OAuth flow with DCR, PRM at custom location listed in WWW-Authenticate header, OAuth metadata is at nested OpenID discovery path, and no scopes required'; - private authServer = new ServerLifecycle(); - private server = new ServerLifecycle(); - private checks: ConformanceCheck[] = []; - - async start(): Promise { - this.checks = []; - - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { - metadataPath: '/tenant1/.well-known/openid-configuration', - isOpenIdConfiguration: true, - routePrefix: '/tenant1' - }); - await this.authServer.start(authApp); - - const app = createServer( - this.checks, - this.server.getUrl, - () => { - return `${this.authServer.getUrl()}/tenant1`; - }, - { - // This is a custom path, so unable to get via probing, it's only available - // via following the `resource_metadata_url` in the WWW-Authenticate header. - // The resource must match the original request URL per RFC 9728. - prmPath: '/custom/metadata/location.json' - } - ); - await this.server.start(app); - - return { serverUrl: `${this.server.getUrl()}/mcp` }; - } - - async stop() { - await this.authServer.stop(); - await this.server.stop(); - } - - getChecks(): ConformanceCheck[] { - const expectedSlugs = [ - 'authorization-server-metadata', - 'client-registration', - 'authorization-request', - 'token-request' - ]; - - for (const slug of expectedSlugs) { - if (!this.checks.find((c) => c.id === slug)) { - this.checks.push({ - id: slug, - name: `Expected Check Missing: ${slug}`, - description: `Expected Check Missing: ${slug}`, - status: 'FAILURE', - timestamp: new Date().toISOString() - }); - } - } - - return this.checks; - } -} diff --git a/src/scenarios/client/auth/discovery-metadata.ts b/src/scenarios/client/auth/discovery-metadata.ts new file mode 100644 index 0000000..56a9694 --- /dev/null +++ b/src/scenarios/client/auth/discovery-metadata.ts @@ -0,0 +1,219 @@ +/** + * OAuth Metadata Discovery Scenarios + * + * These scenarios test different combinations of PRM and OAuth metadata locations. + * The configurations are defined in SCENARIO_CONFIGS below and scenarios are + * generated from them. + */ + +import type { Scenario, ConformanceCheck } from '../../../types.js'; +import { ScenarioUrls } 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 { Request, Response } from 'express'; + +/** + * Configuration for a metadata discovery scenario. + */ +interface MetadataScenarioConfig { + name: string; + prmLocation: string; + inWwwAuth: boolean; + oauthMetadataLocation: string; + /** Route prefix for the auth server (e.g., '/tenant1') */ + authRoutePrefix?: string; + /** If true, add a trap for root PRM requests */ + trapRootPrm?: boolean; +} + +/** + * Scenario configurations table: + * + * | Scenario | PRM Location | In WWW-Auth | OAuth Metadata Location | + * |------------------|-------------------------------------------|-------------|------------------------------------------------| + * | metadata-default | /.well-known/oauth-protected-resource/mcp | Yes | /.well-known/oauth-authorization-server | + * | metadata-var1 | /.well-known/oauth-protected-resource/mcp | No | /.well-known/openid-configuration | + * | metadata-var2 | /.well-known/oauth-protected-resource | No | /.well-known/oauth-authorization-server/tenant1| + * | metadata-var3 | /custom/metadata/location.json | Yes | /tenant1/.well-known/openid-configuration | + */ +const SCENARIO_CONFIGS: MetadataScenarioConfig[] = [ + { + name: 'metadata-default', + prmLocation: '/.well-known/oauth-protected-resource/mcp', + inWwwAuth: true, + oauthMetadataLocation: '/.well-known/oauth-authorization-server', + trapRootPrm: true + }, + { + name: 'metadata-var1', + prmLocation: '/.well-known/oauth-protected-resource/mcp', + inWwwAuth: false, + oauthMetadataLocation: '/.well-known/openid-configuration' + }, + { + name: 'metadata-var2', + prmLocation: '/.well-known/oauth-protected-resource', + inWwwAuth: false, + oauthMetadataLocation: '/.well-known/oauth-authorization-server/tenant1', + authRoutePrefix: '/tenant1' + }, + { + name: 'metadata-var3', + prmLocation: '/custom/metadata/location.json', + inWwwAuth: true, + oauthMetadataLocation: '/tenant1/.well-known/openid-configuration', + authRoutePrefix: '/tenant1' + } +]; + +/** + * Creates a metadata discovery scenario from configuration. + */ +function createMetadataScenario(config: MetadataScenarioConfig): Scenario { + const authServer = new ServerLifecycle(); + const server = new ServerLifecycle(); + let checks: ConformanceCheck[] = []; + + const routePrefix = config.authRoutePrefix || ''; + const isOpenIdConfiguration = config.oauthMetadataLocation.includes( + 'openid-configuration' + ); + + // Determine if PRM is at path-based location + const isPathBasedPrm = + config.prmLocation === '/.well-known/oauth-protected-resource/mcp'; + + return { + name: `auth/${config.name}`, + description: `Tests Basic OAuth metadata discovery flow. + +**PRM:** ${config.prmLocation}${config.inWwwAuth ? '' : ' (not in WWW-Authenticate)'} +**OAuth metadata:** ${config.oauthMetadataLocation} +`, + + async start(): Promise { + checks = []; + + const authApp = createAuthServer(checks, authServer.getUrl, { + metadataPath: config.oauthMetadataLocation, + isOpenIdConfiguration, + ...(routePrefix && { routePrefix }) + }); + + // If path-based OAuth metadata, trap root requests + if (routePrefix) { + authApp.get('/.well-known/oauth-authorization-server', (req, res) => { + checks.push({ + id: 'authorization-server-metadata-wrong-path', + name: 'AuthorizationServerMetadataWrongPath', + description: + 'Client requested authorization server at the root path when the AS URL has a path-based location', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST, + SpecReferences.MCP_AUTH_DISCOVERY + ], + details: { + url: req.url + } + }); + res.status(404).send('Not Found'); + }); + } + + await authServer.start(authApp); + + const getAuthServerUrl = routePrefix + ? () => `${authServer.getUrl()}${routePrefix}` + : authServer.getUrl; + + const app = createServer(checks, server.getUrl, getAuthServerUrl, { + prmPath: config.prmLocation, + includePrmInWwwAuth: config.inWwwAuth + }); + + // Add trap for root PRM requests if configured + if (config.trapRootPrm) { + app.get( + '/.well-known/oauth-protected-resource', + (req: Request, res: Response) => { + checks.push({ + id: 'prm-priority-order', + name: 'PRM Priority Order', + description: + 'Client requested PRM metadata at root location on a server with path-based PRM', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.RFC_PRM_DISCOVERY, + SpecReferences.MCP_PRM_DISCOVERY + ], + details: { + url: req.url, + path: req.path + } + }); + + res.status(404).json({ + error: 'not_found', + error_description: 'PRM metadata not available at root location' + }); + } + ); + } + + await server.start(app); + + return { serverUrl: `${server.getUrl()}/mcp` }; + }, + + async stop() { + await authServer.stop(); + await server.stop(); + }, + + getChecks(): ConformanceCheck[] { + const expectedSlugs = [ + ...(isPathBasedPrm ? ['prm-pathbased-requested'] : []), + 'authorization-server-metadata', + 'client-registration', + 'authorization-request', + 'token-request' + ]; + + for (const slug of expectedSlugs) { + if (!checks.find((c) => c.id === slug)) { + checks.push({ + id: slug, + name: `Expected Check Missing: ${slug}`, + description: `Expected Check Missing: ${slug}`, + status: 'FAILURE', + timestamp: new Date().toISOString() + }); + } + } + + return checks; + } + }; +} + +// Generate scenario instances from configurations +export const AuthMetadataDefaultScenario = createMetadataScenario( + SCENARIO_CONFIGS[0] +); +export const AuthMetadataVar1Scenario = createMetadataScenario( + SCENARIO_CONFIGS[1] +); +export const AuthMetadataVar2Scenario = createMetadataScenario( + SCENARIO_CONFIGS[2] +); +export const AuthMetadataVar3Scenario = createMetadataScenario( + SCENARIO_CONFIGS[3] +); + +// Export all scenarios as an array for convenience +export const metadataScenarios = SCENARIO_CONFIGS.map(createMetadataScenario); diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index 8e26944..168f9ac 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -18,6 +18,7 @@ export interface ServerOptions { prmPath?: string | null; requiredScopes?: string[]; scopesSupported?: string[]; + includePrmInWwwAuth?: boolean; includeScopeInWwwAuth?: boolean; authMiddleware?: express.RequestHandler; tokenVerifier?: MockTokenVerifier; @@ -33,6 +34,7 @@ export function createServer( prmPath = '/.well-known/oauth-protected-resource/mcp', requiredScopes = [], scopesSupported, + includePrmInWwwAuth = true, includeScopeInWwwAuth = false, tokenVerifier } = options; @@ -136,9 +138,10 @@ export function createServer( verifier, // Only pass requiredScopes if we want them in the WWW-Authenticate header requiredScopes: includeScopeInWwwAuth ? requiredScopes : [], - ...(prmPath !== null && { - resourceMetadataUrl: `${getBaseUrl()}${prmPath}` - }) + ...(includePrmInWwwAuth && + prmPath !== null && { + resourceMetadataUrl: `${getBaseUrl()}${prmPath}` + }) }); authMiddleware(req, res, async (err?: any) => { diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 7ac4beb..addbe23 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -42,7 +42,7 @@ describe('Client Auth Scenarios', () => { describe('Negative tests', () => { test('bad client requests root PRM location', async () => { const runner = new InlineClientRunner(badPrmClient); - await runClientAgainstScenario(runner, 'auth/basic-dcr', [ + await runClientAgainstScenario(runner, 'auth/metadata-default', [ 'prm-priority-order' ]); }); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 24bce85..f78d2e1 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -1,10 +1,5 @@ import { Scenario } from '../../../types'; -import { AuthBasicDCRScenario } from './basic-dcr.js'; -import { - AuthBasicMetadataVar1Scenario, - AuthBasicMetadataVar2Scenario, - AuthBasicMetadataVar3Scenario -} from './basic-metadata.js'; +import { metadataScenarios } from './discovery-metadata.js'; import { Auth20250326OAuthMetadataBackcompatScenario, Auth20250326OEndpointFallbackScenario @@ -17,10 +12,7 @@ import { } from './scope-handling.js'; export const authScenariosList: Scenario[] = [ - new AuthBasicDCRScenario(), - new AuthBasicMetadataVar1Scenario(), - new AuthBasicMetadataVar2Scenario(), - new AuthBasicMetadataVar3Scenario(), + ...metadataScenarios, new Auth20250326OAuthMetadataBackcompatScenario(), new Auth20250326OEndpointFallbackScenario(), new ScopeFromWwwAuthenticateScenario(),