diff --git a/package.json b/package.json index dab5ea1..cb755df 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "start": "tsx src/index.ts", "test": "vitest run", "test:watch": "vitest", - "build": "tsdown src/index.ts --minify --clean --target node20", + "build": "tsdown src/index.ts src/fake-auth-server.ts --minify --clean --target node20", "lint": "eslint src/ examples/ && prettier --check .", "lint:fix": "eslint src/ examples/ --fix && prettier --write .", "lint:fix_check": "npm run lint:fix && git diff --exit-code --quiet", @@ -26,7 +26,8 @@ "dist" ], "bin": { - "conformance": "dist/index.js" + "conformance": "dist/index.js", + "fake-auth-server": "dist/fake-auth-server.js" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/src/fake-auth-server.ts b/src/fake-auth-server.ts new file mode 100644 index 0000000..07febd5 --- /dev/null +++ b/src/fake-auth-server.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +import { Command } from 'commander'; +import { createAuthServer } from './scenarios/client/auth/helpers/createAuthServer'; +import { ServerLifecycle } from './scenarios/client/auth/helpers/serverLifecycle'; +import type { ConformanceCheck } from './types'; + +function printServerInfo(url: string): void { + console.log(`Fake Auth Server running at ${url}`); + console.log(''); + console.log('Endpoints:'); + console.log(` Metadata: ${url}/.well-known/oauth-authorization-server`); + console.log(` Authorization: ${url}/authorize`); + console.log(` Token: ${url}/token`); + console.log(` Registration: ${url}/register`); + console.log(` Introspection: ${url}/introspect`); + console.log(''); + console.log('Press Ctrl+C to stop'); +} + +const program = new Command(); + +program + .name('fake-auth-server') + .description( + 'Standalone fake OAuth authorization server for testing MCP clients' + ) + .option('--port ', 'Port to listen on (0 for random)', '0') + .action(async (options) => { + const port = parseInt(options.port, 10); + const checks: ConformanceCheck[] = []; + const lifecycle = new ServerLifecycle(); + + const app = createAuthServer(checks, lifecycle.getUrl, { + loggingEnabled: true + }); + + const url = await lifecycle.start(app, port !== 0 ? port : undefined); + printServerInfo(url); + + // Handle graceful shutdown + process.on('SIGINT', async () => { + console.log('\nShutting down...'); + await lifecycle.stop(); + process.exit(0); + }); + }); + +program.parse(); diff --git a/src/index.ts b/src/index.ts index b1e0dba..afa7fd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ import { runConformanceTest, printClientResults, runServerConformanceTest, + runServerAuthConformanceTest, + startFakeAuthServer, printServerResults, printServerSummary, runInteractiveMode @@ -16,7 +18,8 @@ import { listActiveClientScenarios, listPendingClientScenarios, listAuthScenarios, - listMetadataScenarios + listMetadataScenarios, + listServerAuthScenarios } from './scenarios'; import { ConformanceCheck } from './types'; import { ClientOptionsSchema, ServerOptionsSchema } from './schemas'; @@ -199,57 +202,61 @@ program program .command('server') .description('Run conformance tests against a server implementation') - .requiredOption('--url ', 'URL of the server to test') + .option( + '--url ', + 'URL of the server to test (for already-running servers)' + ) + .option( + '--command ', + 'Command to start the server (for auth suite: spawns fake AS and passes MCP_CONFORMANCE_AUTH_SERVER_URL)' + ) .option( '--scenario ', 'Scenario to test (defaults to active suite if not specified)' ) .option( '--suite ', - 'Suite to run: "active" (default, excludes pending), "all", or "pending"', + 'Suite to run: "active" (default, excludes pending), "all", "pending", or "auth"', 'active' ) + .option('--timeout ', 'Timeout in milliseconds', '30000') .option('--verbose', 'Show verbose output (JSON instead of pretty print)') + .option( + '--interactive', + 'Interactive auth mode: opens browser for login instead of auto-redirect' + ) .action(async (options) => { try { - // Validate options with Zod - const validated = ServerOptionsSchema.parse(options); - const verbose = options.verbose ?? false; + const timeout = parseInt(options.timeout, 10); + const suite = options.suite?.toLowerCase() || 'active'; - // If a single scenario is specified, run just that one - if (validated.scenario) { - const result = await runServerConformanceTest( - validated.url, - validated.scenario - ); + // Check if this is an auth test + const isAuthTest = + suite === 'auth' || options.scenario?.startsWith('server-auth/'); - const { failed } = printServerResults( - result.checks, - result.scenarioDescription, - verbose - ); - process.exit(failed > 0 ? 1 : 0); - } else { - // Run scenarios based on suite - const suite = options.suite?.toLowerCase() || 'active'; - let scenarios: string[]; + if (isAuthTest) { + // Auth testing mode - requires --url or --command + if (!options.url && !options.command) { + console.error( + 'For auth testing, either --url or --command is required' + ); + console.error('\n--url URL of already running server'); + console.error( + '--command Command to start the server (conformance spawns fake AS)' + ); + process.exit(1); + } - if (suite === 'all') { - scenarios = listClientScenarios(); - } else if (suite === 'active') { - scenarios = listActiveClientScenarios(); - } else if (suite === 'pending') { - scenarios = listPendingClientScenarios(); + // Get scenarios to run + let scenarios: string[]; + if (options.scenario) { + scenarios = [options.scenario]; } else { - console.error(`Unknown suite: ${suite}`); - console.error('Available suites: active, all, pending'); - process.exit(1); + scenarios = listServerAuthScenarios(); } - console.log( - `Running ${suite} suite (${scenarios.length} scenarios) against ${validated.url}\n` - ); + console.log(`Running auth suite (${scenarios.length} scenarios)...\n`); const allResults: { scenario: string; checks: ConformanceCheck[] }[] = []; @@ -257,11 +264,22 @@ program for (const scenarioName of scenarios) { console.log(`\n=== Running scenario: ${scenarioName} ===`); try { - const result = await runServerConformanceTest( - validated.url, - scenarioName - ); + const result = await runServerAuthConformanceTest({ + url: options.url, + command: options.command, + scenarioName, + timeout, + interactive: options.interactive + }); allResults.push({ scenario: scenarioName, checks: result.checks }); + + if (verbose) { + printServerResults( + result.checks, + result.scenarioDescription, + verbose + ); + } } catch (error) { console.error(`Failed to run scenario ${scenarioName}:`, error); allResults.push({ @@ -283,6 +301,85 @@ program const { totalFailed } = printServerSummary(allResults); process.exit(totalFailed > 0 ? 1 : 0); + } else { + // Standard server testing mode - requires --url + if (!options.url) { + console.error('--url is required for non-auth server testing'); + process.exit(1); + } + + // Validate options with Zod + const validated = ServerOptionsSchema.parse(options); + + // If a single scenario is specified, run just that one + if (validated.scenario) { + const result = await runServerConformanceTest( + validated.url, + validated.scenario + ); + + const { failed } = printServerResults( + result.checks, + result.scenarioDescription, + verbose + ); + process.exit(failed > 0 ? 1 : 0); + } else { + // Run scenarios based on suite + let scenarios: string[]; + + if (suite === 'all') { + scenarios = listClientScenarios(); + } else if (suite === 'active') { + scenarios = listActiveClientScenarios(); + } else if (suite === 'pending') { + scenarios = listPendingClientScenarios(); + } else { + console.error(`Unknown suite: ${suite}`); + console.error('Available suites: active, all, pending, auth'); + process.exit(1); + } + + console.log( + `Running ${suite} suite (${scenarios.length} scenarios) against ${validated.url}\n` + ); + + const allResults: { scenario: string; checks: ConformanceCheck[] }[] = + []; + + for (const scenarioName of scenarios) { + console.log(`\n=== Running scenario: ${scenarioName} ===`); + try { + const result = await runServerConformanceTest( + validated.url, + scenarioName + ); + allResults.push({ + scenario: scenarioName, + checks: result.checks + }); + } catch (error) { + console.error(`Failed to run scenario ${scenarioName}:`, error); + allResults.push({ + scenario: scenarioName, + checks: [ + { + id: scenarioName, + name: scenarioName, + description: 'Failed to run scenario', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + error instanceof Error ? error.message : String(error) + } + ] + }); + } + } + + const { totalFailed } = printServerSummary(allResults); + process.exit(totalFailed > 0 ? 1 : 0); + } } } catch (error) { if (error instanceof ZodError) { @@ -292,6 +389,8 @@ program }); console.error('\nAvailable server scenarios:'); listClientScenarios().forEach((s) => console.error(` - ${s}`)); + console.error('\nAvailable server auth scenarios:'); + listServerAuthScenarios().forEach((s) => console.error(` - ${s}`)); process.exit(1); } console.error('Server test error:', error); @@ -305,15 +404,27 @@ program .description('List available test scenarios') .option('--client', 'List client scenarios') .option('--server', 'List server scenarios') + .option('--server-auth', 'List server auth scenarios') .action((options) => { - if (options.server || (!options.client && !options.server)) { + const showAll = !options.client && !options.server && !options.serverAuth; + + if (options.server || showAll) { console.log('Server scenarios (test against a server):'); const serverScenarios = listClientScenarios(); serverScenarios.forEach((s) => console.log(` - ${s}`)); } - if (options.client || (!options.client && !options.server)) { - if (options.server || (!options.client && !options.server)) { + if (options.serverAuth || showAll) { + if (options.server || showAll) { + console.log(''); + } + console.log('Server auth scenarios (test server auth implementation):'); + const authScenarios = listServerAuthScenarios(); + authScenarios.forEach((s) => console.log(` - ${s}`)); + } + + if (options.client || showAll) { + if (options.server || options.serverAuth || showAll) { console.log(''); } console.log('Client scenarios (test against a client):'); @@ -322,4 +433,43 @@ program } }); +// Fake auth server command - starts a standalone fake authorization server +program + .command('fake-auth-server') + .description( + 'Start a standalone fake authorization server for manual testing' + ) + .option('--port ', 'Port to listen on (default: random)') + .action(async (options) => { + const port = options.port ? parseInt(options.port, 10) : undefined; + + console.log('Starting fake authorization server...'); + const { url, stop } = await startFakeAuthServer(port); + console.log(`\nFake authorization server running at: ${url}`); + console.log('\nEndpoints:'); + console.log( + ` Metadata: ${url}/.well-known/oauth-authorization-server` + ); + console.log(` Authorization: ${url}/authorize`); + console.log(` Token: ${url}/token`); + console.log(` Registration: ${url}/register`); + console.log('\nPress Ctrl+C to stop.'); + + // Handle graceful shutdown + process.on('SIGINT', async () => { + console.log('\nShutting down...'); + await stop(); + process.exit(0); + }); + + process.on('SIGTERM', async () => { + console.log('\nShutting down...'); + await stop(); + process.exit(0); + }); + + // Keep the process running + await new Promise(() => {}); + }); + program.parse(); diff --git a/src/runner/index.ts b/src/runner/index.ts index d48a291..7f642cb 100644 --- a/src/runner/index.ts +++ b/src/runner/index.ts @@ -9,6 +9,8 @@ export { // Export server functions export { runServerConformanceTest, + runServerAuthConformanceTest, + startFakeAuthServer, printServerResults, printServerSummary } from './server'; diff --git a/src/runner/server.ts b/src/runner/server.ts index 18c8254..21fa139 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -1,8 +1,11 @@ import { promises as fs } from 'fs'; import path from 'path'; +import { spawn, ChildProcess } from 'child_process'; import { ConformanceCheck } from '../types'; -import { getClientScenario } from '../scenarios'; +import { getClientScenario, getServerAuthScenario } from '../scenarios'; import { ensureResultsDir, createResultDir, formatPrettyChecks } from './utils'; +import { createAuthServer } from '../scenarios/client/auth/helpers/createAuthServer'; +import { ServerLifecycle } from '../scenarios/client/auth/helpers/serverLifecycle'; /** * Format markdown-style text for terminal output using ANSI codes @@ -119,3 +122,222 @@ export function printServerSummary( return { totalPassed, totalFailed }; } + +/** + * Wait for a URL to become available by polling + */ +async function waitForServerReady( + url: string, + timeoutMs: number = 30000, + intervalMs: number = 500 +): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + try { + const response = await fetch(url, { method: 'HEAD' }); + if (response.ok || response.status === 401 || response.status === 404) { + // Server is up (401/404 are acceptable - means server is responding) + return; + } + } catch { + // Server not ready yet, keep polling + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error( + `Server at ${url} did not become ready within ${timeoutMs}ms` + ); +} + +/** + * Run server auth conformance test + * + * For --command mode: Spawns the fake AS, then spawns the server with + * MCP_CONFORMANCE_AUTH_SERVER_URL env var pointing to the fake AS. + * + * For --url mode: Just runs the auth scenario against the provided URL. + */ +export async function runServerAuthConformanceTest(options: { + url?: string; + command?: string; + scenarioName: string; + timeout?: number; + interactive?: boolean; +}): Promise<{ + checks: ConformanceCheck[]; + resultDir: string; + scenarioDescription: string; +}> { + const { + url, + command, + scenarioName, + timeout = 30000, + interactive = false + } = options; + + await ensureResultsDir(); + const resultDir = createResultDir(scenarioName, 'server-auth'); + await fs.mkdir(resultDir, { recursive: true }); + + // Get the scenario + const scenario = getServerAuthScenario(scenarioName); + if (!scenario) { + throw new Error(`Unknown server auth scenario: ${scenarioName}`); + } + + let checks: ConformanceCheck[] = []; + let serverProcess: ChildProcess | null = null; + let authServerLifecycle: ServerLifecycle | null = null; + + try { + if (command) { + // --command mode: Start fake AS, then spawn server with env var + console.log(`Starting fake authorization server...`); + + authServerLifecycle = new ServerLifecycle(); + const authApp = createAuthServer(checks, authServerLifecycle.getUrl); + const authServerUrl = await authServerLifecycle.start(authApp); + console.log(`Fake AS running at ${authServerUrl}`); + + // Spawn the server command with the auth server URL env var + console.log(`Starting server with command: ${command}`); + serverProcess = spawn(command, { + shell: true, + env: { + ...process.env, + MCP_CONFORMANCE_AUTH_SERVER_URL: authServerUrl + }, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + // Collect server output for debugging + let serverOutput = ''; + serverProcess.stdout?.on('data', (data) => { + serverOutput += data.toString(); + }); + serverProcess.stderr?.on('data', (data) => { + serverOutput += data.toString(); + }); + + // Wait for server to be ready + // The server should output its URL or we need a way to determine it + // For now, we'll assume the server outputs its URL to stdout + const serverUrl = await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject( + new Error( + `Server did not start within ${timeout}ms. Output: ${serverOutput}` + ) + ); + }, timeout); + + // Look for URL in server output + const checkOutput = () => { + const urlMatch = serverOutput.match(/https?:\/\/localhost:\d+\/mcp/); + if (urlMatch) { + clearTimeout(timeoutId); + resolve(urlMatch[0]); + } + }; + + serverProcess!.stdout?.on('data', checkOutput); + serverProcess!.stderr?.on('data', checkOutput); + + serverProcess!.on('error', (err) => { + clearTimeout(timeoutId); + reject(err); + }); + + serverProcess!.on('exit', (code) => { + if (code !== null && code !== 0) { + clearTimeout(timeoutId); + reject( + new Error( + `Server process exited with code ${code}. Output: ${serverOutput}` + ) + ); + } + }); + }); + + console.log(`Server running at ${serverUrl}`); + await waitForServerReady(serverUrl); + + // Run the scenario + console.log( + `Running server auth scenario '${scenarioName}' against server: ${serverUrl}` + ); + const scenarioChecks = await scenario.run(serverUrl, { interactive }); + checks.push(...scenarioChecks); + } else if (url) { + // --url mode: Just run the scenario against the provided URL + console.log( + `Running server auth scenario '${scenarioName}' against: ${url}` + ); + checks = await scenario.run(url, { interactive }); + } else { + throw new Error( + 'Either --url or --command must be provided for auth scenarios' + ); + } + + await fs.writeFile( + path.join(resultDir, 'checks.json'), + JSON.stringify(checks, null, 2) + ); + + console.log(`Results saved to ${resultDir}`); + + return { + checks, + resultDir, + scenarioDescription: scenario.description + }; + } finally { + // Cleanup + if (serverProcess) { + console.log('Stopping server process...'); + serverProcess.kill(); + } + if (authServerLifecycle) { + console.log('Stopping fake authorization server...'); + await authServerLifecycle.stop(); + } + } +} + +/** + * Start a standalone fake authorization server for manual testing + */ +export async function startFakeAuthServer(port?: number): Promise<{ + url: string; + stop: () => Promise; +}> { + const checks: ConformanceCheck[] = []; + const lifecycle = new ServerLifecycle(); + const app = createAuthServer(checks, lifecycle.getUrl, { + loggingEnabled: true + }); + + if (port) { + // If a specific port is requested, we need to handle it differently + const httpServer = app.listen(port); + const url = `http://localhost:${port}`; + return { + url, + stop: async () => { + await new Promise((resolve) => { + httpServer.closeAllConnections?.(); + httpServer.close(() => resolve()); + }); + } + }; + } + + const url = await lifecycle.start(app); + return { + url, + stop: () => lifecycle.stop() + }; +} diff --git a/src/scenarios/client/auth/discovery-metadata.ts b/src/scenarios/client/auth/discovery-metadata.ts index 6fc09a8..db7e81b 100644 --- a/src/scenarios/client/auth/discovery-metadata.ts +++ b/src/scenarios/client/auth/discovery-metadata.ts @@ -176,10 +176,14 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario { }, getChecks(): ConformanceCheck[] { + // Check if CIMD was used (URL-based client ID) + const usedCimd = checks.some((c) => c.id === 'cimd-client-id'); + const expectedSlugs = [ ...(isPathBasedPrm ? ['prm-pathbased-requested'] : []), 'authorization-server-metadata', - 'client-registration', + // Either CIMD or DCR should be used, not both + ...(usedCimd ? [] : ['client-registration']), 'authorization-request', 'token-request' ]; diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 1071828..c9b37ae 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -24,6 +24,10 @@ export interface AuthServerOptions { grantTypesSupported?: string[]; tokenEndpointAuthMethodsSupported?: string[]; tokenEndpointAuthSigningAlgValuesSupported?: string[]; + /** + * Whether to advertise support for Client ID Metadata Documents (CIMD/SEP-991). + * Defaults to true - CIMD is preferred over DCR when available. + */ clientIdMetadataDocumentSupported?: boolean; tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { @@ -64,7 +68,8 @@ export function createAuthServer( grantTypesSupported = ['authorization_code', 'refresh_token'], tokenEndpointAuthMethodsSupported = ['none'], tokenEndpointAuthSigningAlgValuesSupported, - clientIdMetadataDocumentSupported, + // Default to true - CIMD is preferred over DCR + clientIdMetadataDocumentSupported = true, tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -77,7 +82,8 @@ export function createAuthServer( const authRoutes = { authorization_endpoint: `${routePrefix}/authorize`, token_endpoint: `${routePrefix}/token`, - registration_endpoint: `${routePrefix}/register` + registration_endpoint: `${routePrefix}/register`, + introspection_endpoint: `${routePrefix}/introspect` }; const app = express(); @@ -115,6 +121,7 @@ export function createAuthServer( authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`, token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`, + introspection_endpoint: `${getAuthBaseUrl()}${authRoutes.introspection_endpoint}`, response_types_supported: ['code'], grant_types_supported: grantTypesSupported, code_challenge_methods_supported: ['S256'], @@ -148,6 +155,28 @@ export function createAuthServer( app.get(authRoutes.authorization_endpoint, (req: Request, res: Response) => { const timestamp = new Date().toISOString(); + const clientId = req.query.client_id as string | undefined; + + // Check if client is using CIMD (URL-based client ID) + const isUrlBasedClientId = + clientId && + (clientId.startsWith('https://') || clientId.startsWith('http://')); + + if (isUrlBasedClientId) { + checks.push({ + id: 'cimd-client-id', + name: 'CIMDClientId', + description: + 'Client used URL-based client ID (CIMD/SEP-991) instead of DCR', + status: 'SUCCESS', + timestamp, + specReferences: [SpecReferences.MCP_DCR], + details: { + clientId + } + }); + } + checks.push({ id: 'authorization-request', name: 'AuthorizationRequest', @@ -166,7 +195,7 @@ export function createAuthServer( if (onAuthorizationRequest) { onAuthorizationRequest({ - clientId: req.query.client_id as string | undefined, + clientId, scope: scopeParam, timestamp }); @@ -278,5 +307,48 @@ export function createAuthServer( }); }); + // Token introspection endpoint (RFC 7662) + app.post( + authRoutes.introspection_endpoint, + async (req: Request, res: Response) => { + const { token } = req.body; + + if (!token) { + res.status(400).json({ error: 'Token is required' }); + return; + } + + // If we have a tokenVerifier, use it to validate + if (tokenVerifier) { + try { + const tokenInfo = await tokenVerifier.verifyAccessToken(token); + res.json({ + active: true, + client_id: tokenInfo.clientId, + scope: tokenInfo.scopes.join(' '), + exp: tokenInfo.expiresAt + }); + return; + } catch { + res.json({ active: false }); + return; + } + } + + // Fallback: accept tokens with known prefixes + if (token.startsWith('test-token') || token.startsWith('cc-token')) { + res.json({ + active: true, + client_id: 'test-client', + scope: '', + exp: Math.floor(Date.now() / 1000) + 3600 + }); + return; + } + + res.json({ active: false }); + } + ); + return app; } diff --git a/src/scenarios/client/auth/helpers/serverLifecycle.ts b/src/scenarios/client/auth/helpers/serverLifecycle.ts index 7fb3886..7491531 100644 --- a/src/scenarios/client/auth/helpers/serverLifecycle.ts +++ b/src/scenarios/client/auth/helpers/serverLifecycle.ts @@ -10,11 +10,11 @@ export class ServerLifecycle { return this.baseUrl; }; - async start(app: express.Application): Promise { + async start(app: express.Application, port?: number): Promise { this.app = app; - this.httpServer = this.app.listen(0); - const port = this.httpServer.address().port; - this.baseUrl = `http://localhost:${port}`; + this.httpServer = this.app.listen(port ?? 0); + const actualPort = this.httpServer.address().port; + this.baseUrl = `http://localhost:${actualPort}`; return this.baseUrl; } diff --git a/src/scenarios/client/auth/march-spec-backcompat.ts b/src/scenarios/client/auth/march-spec-backcompat.ts index 4f0a5ae..e9f8184 100644 --- a/src/scenarios/client/auth/march-spec-backcompat.ts +++ b/src/scenarios/client/auth/march-spec-backcompat.ts @@ -42,9 +42,13 @@ export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario { } getChecks(): ConformanceCheck[] { + // Check if CIMD was used (URL-based client ID) + const usedCimd = this.checks.some((c) => c.id === 'cimd-client-id'); + const expectedSlugs = [ 'authorization-server-metadata', - 'client-registration', + // Either CIMD or DCR should be used, not both + ...(usedCimd ? [] : ['client-registration']), 'authorization-request', 'token-request' ]; @@ -169,8 +173,12 @@ export class Auth20250326OEndpointFallbackScenario implements Scenario { } getChecks(): ConformanceCheck[] { + // Check if CIMD was used (URL-based client ID) + const usedCimd = this.checks.some((c) => c.id === 'cimd-client-id'); + const expectedSlugs = [ - 'client-registration', + // Either CIMD or DCR should be used, not both + ...(usedCimd ? [] : ['client-registration']), 'authorization-request', 'token-request' ]; diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts index 4203789..979987e 100644 --- a/src/scenarios/client/auth/token-endpoint-auth.ts +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -64,6 +64,8 @@ class TokenEndpointAuthScenario implements Scenario { const authApp = createAuthServer(this.checks, this.authServer.getUrl, { tokenVerifier, tokenEndpointAuthMethodsSupported: [this.expectedAuthMethod], + // Disable CIMD to force DCR - we need client_secret for auth method testing + clientIdMetadataDocumentSupported: false, onTokenRequest: ({ authorizationHeader, body, timestamp }) => { const bodyClientSecret = body.client_secret; const actualMethod = detectAuthMethod( diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 470ffed..d4ac2a3 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -53,6 +53,10 @@ import { import { authScenariosList } from './client/auth/index'; import { listMetadataScenarios } from './client/auth/discovery-metadata'; +import { + serverAuthScenarios as serverAuthScenariosList, + getServerAuthScenario as getServerAuthScenarioFromModule +} from './server-auth'; // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ @@ -187,3 +191,19 @@ export function listAuthScenarios(): string[] { } export { listMetadataScenarios }; + +// Server auth scenario helpers +// Map for server auth scenarios (for consistency with other scenario maps) +export const serverAuthScenarios = new Map( + serverAuthScenariosList.map((scenario) => [scenario.name, scenario]) +); + +export function getServerAuthScenario( + name: string +): ClientScenario | undefined { + return getServerAuthScenarioFromModule(name); +} + +export function listServerAuthScenarios(): string[] { + return serverAuthScenariosList.map((s) => s.name); +} diff --git a/src/scenarios/server-auth/basic-dcr-flow.ts b/src/scenarios/server-auth/basic-dcr-flow.ts new file mode 100644 index 0000000..994edd6 --- /dev/null +++ b/src/scenarios/server-auth/basic-dcr-flow.ts @@ -0,0 +1,583 @@ +/** + * Basic DCR Flow Scenario + * + * Tests the complete OAuth authentication flow using Dynamic Client Registration: + * 1. Unauthenticated MCP request triggers 401 + WWW-Authenticate header + * 2. Protected Resource Metadata (PRM) discovery + * 3. Authorization Server (AS) metadata discovery + * 4. Dynamic Client Registration (DCR) + * 5. Token acquisition via authorization_code flow + * 6. Authenticated MCP tool call with Bearer token + * + * This scenario uses the MCP SDK's real client with observation middleware + * to verify server conformance. + */ + +import type { + ClientScenario, + ClientScenarioOptions, + ConformanceCheck +} from '../../types'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { applyMiddlewares } from '@modelcontextprotocol/sdk/client/middleware.js'; +import { + auth, + extractWWWAuthenticateParams, + UnauthorizedError +} from '@modelcontextprotocol/sdk/client/auth.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { + ConformanceOAuthProvider, + createObservationMiddleware, + type ObservedRequest +} from './helpers/oauth-client'; +import { ServerAuthSpecReferences } from './spec-references'; + +/** + * Basic DCR Flow - Tests complete OAuth flow with Dynamic Client Registration. + */ +export class BasicDcrFlowScenario implements ClientScenario { + name = 'server-auth/basic-dcr-flow'; + description = `Tests the complete OAuth authentication flow using Dynamic Client Registration. + +**Flow tested:** +1. Unauthenticated MCP request -> 401 + WWW-Authenticate +2. PRM Discovery -> authorization_servers +3. AS Metadata Discovery -> registration_endpoint, token_endpoint +4. DCR Registration -> client_id, client_secret +5. Token Acquisition -> access_token +6. Authenticated MCP Call -> success + +**Spec References:** +- RFC 9728 (Protected Resource Metadata) +- RFC 8414 (Authorization Server Metadata) +- RFC 7591 (Dynamic Client Registration) +- RFC 6750 (Bearer Token Usage) +- MCP Authorization Specification`; + + async run( + serverUrl: string, + options?: ClientScenarioOptions + ): Promise { + const checks: ConformanceCheck[] = []; + const observedRequests: ObservedRequest[] = []; + const timestamp = () => new Date().toISOString(); + const interactive = options?.interactive ?? false; + + // Create observation middleware to record all requests + const observationMiddleware = createObservationMiddleware((req) => { + observedRequests.push(req); + }); + + // Create OAuth provider for conformance testing + const provider = new ConformanceOAuthProvider( + { + client_name: 'MCP Conformance Test Client', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post' + }, + { interactive } + ); + + // Handle 401 with OAuth flow + const handle401 = async ( + response: Response, + next: FetchLike, + url: string + ): Promise => { + const { resourceMetadataUrl, scope } = + extractWWWAuthenticateParams(response); + + let result = await auth(provider, { + serverUrl: url, + resourceMetadataUrl, + scope, + fetchFn: next + }); + + if (result === 'REDIRECT') { + // Get auth code from the redirect (auto-login) + const authorizationCode = await provider.getAuthCode(); + + result = await auth(provider, { + serverUrl: url, + resourceMetadataUrl, + scope, + authorizationCode, + fetchFn: next + }); + + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError( + `Authentication failed with result: ${result}` + ); + } + } + }; + + // Create middleware that handles OAuth with observation + const oauthMiddleware = (next: FetchLike): FetchLike => { + return async (input, init) => { + const headers = new Headers(init?.headers); + const tokens = await provider.tokens(); + if (tokens) { + headers.set('Authorization', `Bearer ${tokens.access_token}`); + } + + const response = await next(input, { ...init, headers }); + + if (response.status === 401) { + const url = typeof input === 'string' ? input : input.toString(); + await handle401(response.clone(), next, url); + // Retry with fresh tokens + const newTokens = await provider.tokens(); + if (newTokens) { + headers.set('Authorization', `Bearer ${newTokens.access_token}`); + } + return await next(input, { ...init, headers }); + } + + return response; + }; + }; + + // Compose middlewares: observation wraps oauth handling + const enhancedFetch = applyMiddlewares( + observationMiddleware, + oauthMiddleware + )(fetch); + + try { + // Create MCP client + const client = new Client( + { name: 'conformance-test-client', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: enhancedFetch + }); + + // Connect triggers the OAuth flow + await client.connect(transport); + + // Make an authenticated call + try { + await client.listTools(); + } catch { + // Tool listing may fail if server doesn't have tools, but that's ok + } + + await transport.close(); + + // Analyze observed requests to generate conformance checks + this.analyzeRequests(observedRequests, checks, timestamp); + } catch (error) { + // Still analyze what we observed before the error + this.analyzeRequests(observedRequests, checks, timestamp); + + checks.push({ + id: 'auth-flow-completion', + name: 'OAuth Flow Completion', + description: 'Complete OAuth authentication flow', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: error instanceof Error ? error.message : String(error), + specReferences: [ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN] + }); + } + + return checks; + } + + /** + * Analyze observed requests and generate conformance checks. + */ + private analyzeRequests( + requests: ObservedRequest[], + checks: ConformanceCheck[], + timestamp: () => string + ): void { + // Phase 1: Check for 401 response with WWW-Authenticate + const unauthorizedRequest = requests.find( + (r) => r.responseStatus === 401 && r.requestType === 'mcp-request' + ); + + if (unauthorizedRequest) { + checks.push({ + id: 'auth-401-response', + name: 'Unauthenticated Request Returns 401', + description: + 'Server returns 401 Unauthorized for unauthenticated MCP requests', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_7235_401_RESPONSE, + ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN + ], + details: { + url: unauthorizedRequest.url, + status: unauthorizedRequest.responseStatus + } + }); + + // Check WWW-Authenticate header + if (unauthorizedRequest.wwwAuthenticate) { + const wwwAuth = unauthorizedRequest.wwwAuthenticate; + + checks.push({ + id: 'auth-www-authenticate-header', + name: 'WWW-Authenticate Header Present', + description: + 'Server includes WWW-Authenticate header in 401 response', + status: + wwwAuth.scheme.toLowerCase() === 'bearer' ? 'SUCCESS' : 'WARNING', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE, + ServerAuthSpecReferences.RFC_7235_WWW_AUTHENTICATE + ], + details: { + scheme: wwwAuth.scheme, + params: wwwAuth.params + } + }); + + // Check for resource_metadata parameter + if (wwwAuth.params.resource_metadata) { + checks.push({ + id: 'auth-resource-metadata-param', + name: 'Resource Metadata URL in WWW-Authenticate', + description: + 'WWW-Authenticate header includes resource_metadata parameter', + status: 'INFO', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_9728_WWW_AUTHENTICATE + ], + details: { + resourceMetadata: wwwAuth.params.resource_metadata + } + }); + } + } else { + checks.push({ + id: 'auth-www-authenticate-header', + name: 'WWW-Authenticate Header Present', + description: + 'Server should include WWW-Authenticate header in 401 response', + status: 'INFO', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE] + }); + } + } else { + checks.push({ + id: 'auth-401-response', + name: 'Unauthenticated Request Returns 401', + description: + 'No 401 response observed - server may not require authentication', + status: 'FAILURE', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_7235_401_RESPONSE] + }); + } + + // Phase 2: PRM Discovery + const prmRequest = requests.find((r) => r.requestType === 'prm-discovery'); + if (prmRequest) { + checks.push({ + id: 'auth-prm-discovery', + name: 'Protected Resource Metadata Discovery', + description: 'Client discovered Protected Resource Metadata endpoint', + status: prmRequest.responseStatus === 200 ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_9728_PRM_DISCOVERY, + ServerAuthSpecReferences.MCP_AUTH_PRM_DISCOVERY + ], + details: { + url: prmRequest.url, + status: prmRequest.responseStatus, + body: prmRequest.responseBody + } + }); + + // Check PRM response content + if ( + prmRequest.responseStatus === 200 && + typeof prmRequest.responseBody === 'object' + ) { + const prm = prmRequest.responseBody as Record; + + if ( + prm.authorization_servers && + Array.isArray(prm.authorization_servers) + ) { + checks.push({ + id: 'auth-prm-authorization-servers', + name: 'PRM Contains Authorization Servers', + description: + 'Protected Resource Metadata includes authorization_servers array', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE], + details: { + authorizationServers: prm.authorization_servers + } + }); + } else { + checks.push({ + id: 'auth-prm-authorization-servers', + name: 'PRM Contains Authorization Servers', + description: + 'Protected Resource Metadata must include authorization_servers array', + status: 'FAILURE', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE] + }); + } + } + } else { + checks.push({ + id: 'auth-prm-discovery', + name: 'Protected Resource Metadata Discovery', + description: + 'No PRM discovery request observed - required for OAuth flow', + status: 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_9728_PRM_DISCOVERY, + ServerAuthSpecReferences.MCP_AUTH_PRM_DISCOVERY + ] + }); + } + + // Phase 3: AS Metadata Discovery + const asMetadataRequest = requests.find( + (r) => r.requestType === 'as-metadata' + ); + if (asMetadataRequest) { + checks.push({ + id: 'auth-as-metadata-discovery', + name: 'Authorization Server Metadata Discovery', + description: 'Client discovered Authorization Server metadata', + status: + asMetadataRequest.responseStatus === 200 ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY, + ServerAuthSpecReferences.MCP_AUTH_SERVER_METADATA + ], + details: { + url: asMetadataRequest.url, + status: asMetadataRequest.responseStatus + } + }); + + // Check AS metadata required fields + if ( + asMetadataRequest.responseStatus === 200 && + typeof asMetadataRequest.responseBody === 'object' + ) { + const metadata = asMetadataRequest.responseBody as Record< + string, + unknown + >; + + // Required fields per RFC 8414 and MCP auth spec + const hasIssuer = !!metadata.issuer; + const hasAuthorizationEndpoint = !!metadata.authorization_endpoint; + const hasTokenEndpoint = !!metadata.token_endpoint; + const codeChallengeMethodsSupported = + metadata.code_challenge_methods_supported; + const supportsPkceS256 = + Array.isArray(codeChallengeMethodsSupported) && + codeChallengeMethodsSupported.includes('S256'); + + // Build list of missing/invalid fields + const issues = []; + if (!hasIssuer) issues.push('missing issuer'); + if (!hasAuthorizationEndpoint) + issues.push('missing authorization_endpoint'); + if (!hasTokenEndpoint) issues.push('missing token_endpoint'); + if (!supportsPkceS256) + issues.push('code_challenge_methods_supported must include S256'); + + const allValid = issues.length === 0; + + checks.push({ + id: 'auth-as-metadata-fields', + name: 'AS Metadata Required Fields', + description: allValid + ? 'Authorization Server metadata includes all required fields' + : `Authorization Server metadata issues: ${issues.join(', ')}`, + status: allValid ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_8414_AS_FIELDS, + ServerAuthSpecReferences.MCP_AUTH_SERVER_METADATA + ], + details: { + issuer: metadata.issuer, + authorizationEndpoint: metadata.authorization_endpoint, + tokenEndpoint: metadata.token_endpoint, + codeChallengeMethodsSupported, + registrationEndpoint: metadata.registration_endpoint + } + }); + } + } else { + checks.push({ + id: 'auth-as-metadata-discovery', + name: 'Authorization Server Metadata Discovery', + description: + 'No AS metadata discovery request observed - required for OAuth flow', + status: 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY, + ServerAuthSpecReferences.MCP_AUTH_SERVER_METADATA + ] + }); + } + + // Phase 4: DCR Registration + const dcrRequest = requests.find( + (r) => r.requestType === 'dcr-registration' + ); + if (dcrRequest) { + checks.push({ + id: 'auth-dcr-registration', + name: 'Dynamic Client Registration', + description: 'Client registered via Dynamic Client Registration', + status: dcrRequest.responseStatus === 201 ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_7591_DCR_ENDPOINT, + ServerAuthSpecReferences.MCP_AUTH_DCR + ], + details: { + url: dcrRequest.url, + status: dcrRequest.responseStatus + } + }); + + // Check DCR response + if ( + dcrRequest.responseStatus === 201 && + typeof dcrRequest.responseBody === 'object' + ) { + const client = dcrRequest.responseBody as Record; + + checks.push({ + id: 'auth-dcr-response', + name: 'DCR Response Contains Client Credentials', + description: 'DCR response includes client_id', + status: client.client_id ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_7591_DCR_RESPONSE], + details: { + hasClientId: !!client.client_id, + hasClientSecret: !!client.client_secret + } + }); + } + } + + // Phase 5: Token Request + const tokenRequest = requests.find( + (r) => r.requestType === 'token-request' + ); + if (tokenRequest) { + checks.push({ + id: 'auth-token-request', + name: 'Token Acquisition', + description: 'Client obtained access token from token endpoint', + status: tokenRequest.responseStatus === 200 ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.OAUTH_2_1_TOKEN_REQUEST, + ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN + ], + details: { + url: tokenRequest.url, + status: tokenRequest.responseStatus + } + }); + + // Check token response + if ( + tokenRequest.responseStatus === 200 && + typeof tokenRequest.responseBody === 'object' + ) { + const tokens = tokenRequest.responseBody as Record; + + checks.push({ + id: 'auth-token-response', + name: 'Token Response Contains Access Token', + description: 'Token response includes access_token', + status: tokens.access_token ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.OAUTH_2_1_TOKEN_REQUEST], + details: { + hasAccessToken: !!tokens.access_token, + hasRefreshToken: !!tokens.refresh_token, + tokenType: tokens.token_type + } + }); + } + } else { + checks.push({ + id: 'auth-token-request', + name: 'Token Acquisition', + description: + 'No token request observed - required to complete OAuth flow', + status: 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.OAUTH_2_1_TOKEN_REQUEST, + ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN + ] + }); + } + + // Phase 6: Authenticated MCP Request + const authenticatedRequest = requests.find( + (r) => + r.requestType === 'mcp-request' && + r.requestHeaders['authorization']?.startsWith('Bearer ') && + r.responseStatus === 200 + ); + + if (authenticatedRequest) { + checks.push({ + id: 'auth-authenticated-request', + name: 'Authenticated MCP Request Succeeds', + description: 'MCP request with Bearer token succeeds', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_6750_BEARER_TOKEN, + ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN + ], + details: { + url: authenticatedRequest.url, + status: authenticatedRequest.responseStatus + } + }); + + // Overall flow success + checks.push({ + id: 'auth-flow-completion', + name: 'OAuth Flow Completion', + description: 'Complete OAuth authentication flow succeeded', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN] + }); + } + } +} diff --git a/src/scenarios/server-auth/helpers/oauth-client.ts b/src/scenarios/server-auth/helpers/oauth-client.ts new file mode 100644 index 0000000..1155392 --- /dev/null +++ b/src/scenarios/server-auth/helpers/oauth-client.ts @@ -0,0 +1,410 @@ +/** + * OAuth client provider and observation middleware for conformance testing. + * + * This module provides: + * 1. A conformance-aware OAuthClientProvider that handles auto-login for testing + * 2. An observation middleware that records all HTTP requests for conformance checks + * 3. Interactive mode support for servers that require browser-based login + */ + +import http from 'http'; +import type { + OAuthClientMetadata, + OAuthClientInformationFull, + OAuthTokens +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { Middleware } from '@modelcontextprotocol/sdk/client/middleware.js'; +import { createMiddleware } from '@modelcontextprotocol/sdk/client/middleware.js'; + +/** + * Observed HTTP request/response for conformance checking. + */ +export interface ObservedRequest { + timestamp: string; + method: string; + url: string; + requestHeaders: Record; + responseStatus: number; + responseHeaders: Record; + responseBody?: unknown; + /** Parsed WWW-Authenticate header if present */ + wwwAuthenticate?: { + scheme: string; + params: Record; + }; + /** Classification of the request type */ + requestType?: + | 'mcp-request' + | 'prm-discovery' + | 'as-metadata' + | 'dcr-registration' + | 'token-request' + | 'authorization' + | 'unknown'; +} + +/** + * Observer callback for recording requests. + */ +export type RequestObserver = (request: ObservedRequest) => void; + +/** + * Parse WWW-Authenticate header value. + */ +function parseWWWAuthenticate(headerValue: string): { + scheme: string; + params: Record; +} { + const params: Record = {}; + const spaceIndex = headerValue.indexOf(' '); + + if (spaceIndex === -1) { + return { scheme: headerValue.trim(), params }; + } + + const scheme = headerValue.substring(0, spaceIndex).trim(); + let rest = headerValue.substring(spaceIndex + 1).trim(); + + while (rest.length > 0) { + rest = rest.replace(/^[\s,]+/, ''); + if (rest.length === 0) break; + + const eqMatch = rest.match(/^([^=\s]+)\s*=/); + if (!eqMatch) break; + + const key = eqMatch[1].toLowerCase(); + rest = rest.substring(eqMatch[0].length).trim(); + + let value: string; + if (rest.startsWith('"')) { + let endQuote = 1; + while (endQuote < rest.length) { + if (rest[endQuote] === '"' && rest[endQuote - 1] !== '\\') break; + endQuote++; + } + value = rest.substring(1, endQuote).replace(/\\"/g, '"'); + rest = rest.substring(endQuote + 1); + } else { + const tokenMatch = rest.match(/^([^,\s]+)/); + value = tokenMatch ? tokenMatch[1] : ''; + rest = rest.substring(value.length); + } + params[key] = value; + } + + return { scheme, params }; +} + +/** + * Classify request type based on URL patterns. + */ +function classifyRequest( + url: string, + method: string +): ObservedRequest['requestType'] { + if (url.includes('/.well-known/oauth-protected-resource')) { + return 'prm-discovery'; + } + if ( + url.includes('/.well-known/oauth-authorization-server') || + url.includes('/.well-known/openid-configuration') + ) { + return 'as-metadata'; + } + if (url.includes('/register') && method === 'POST') { + return 'dcr-registration'; + } + if (url.includes('/token') && method === 'POST') { + return 'token-request'; + } + if (url.includes('/authorize')) { + return 'authorization'; + } + if (url.includes('/mcp') && method === 'POST') { + return 'mcp-request'; + } + return 'unknown'; +} + +/** + * Creates an observation middleware that records HTTP requests. + * + * @param observer - Callback function to receive observed requests + * @returns Middleware function + */ +export function createObservationMiddleware( + observer: RequestObserver +): Middleware { + return createMiddleware(async (next, input, init) => { + const url = typeof input === 'string' ? input : input.toString(); + const method = init?.method || 'GET'; + const requestHeaders: Record = {}; + + if (init?.headers) { + const headers = new Headers(init.headers); + headers.forEach((value, key) => { + requestHeaders[key] = value; + }); + } + + const response = await next(input, init); + + // Clone response to read body without consuming it + const clonedResponse = response.clone(); + let responseBody: unknown; + try { + const text = await clonedResponse.text(); + try { + responseBody = JSON.parse(text); + } catch { + responseBody = text; + } + } catch { + // Body not readable + } + + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + const observed: ObservedRequest = { + timestamp: new Date().toISOString(), + method, + url, + requestHeaders, + responseStatus: response.status, + responseHeaders, + responseBody, + requestType: classifyRequest(url, method) + }; + + // Parse WWW-Authenticate if present + const wwwAuthHeader = response.headers.get('WWW-Authenticate'); + if (wwwAuthHeader) { + observed.wwwAuthenticate = parseWWWAuthenticate(wwwAuthHeader); + } + + observer(observed); + return response; + }); +} + +/** + * Fixed client metadata URL for CIMD conformance tests. + * When server supports client_id_metadata_document_supported, this URL + * will be used as the client_id instead of doing dynamic registration. + */ +const DEFAULT_CIMD_CLIENT_METADATA_URL = + 'https://conformance-test.local/client-metadata.json'; + +/** Callback URL for OAuth redirects */ +const CALLBACK_URL = 'http://localhost:3333/callback'; + +/** + * Conformance OAuth client provider for testing. + * + * This provider: + * - Stores client information and tokens in memory + * - Handles auto-login by fetching the authorization URL and extracting the code from redirect + * - Uses CIMD (URL-based client IDs) by default when server supports it + * - Supports interactive mode for servers requiring browser-based login + */ +export class ConformanceOAuthProvider implements OAuthClientProvider { + private _clientInformation?: OAuthClientInformationFull; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + private _authCode?: string; + private _interactive: boolean; + private _callbackServer?: http.Server; + + /** + * URL for Client ID Metadata Document (CIMD/SEP-991). + * When provided and server advertises client_id_metadata_document_supported, + * this URL will be used as the client_id instead of DCR. + */ + readonly clientMetadataUrl?: string; + + constructor( + private readonly _clientMetadata: Omit< + OAuthClientMetadata, + 'redirect_uris' + >, + options?: { clientMetadataUrl?: string; interactive?: boolean } + ) { + this.clientMetadataUrl = + options?.clientMetadataUrl ?? DEFAULT_CIMD_CLIENT_METADATA_URL; + this._interactive = options?.interactive ?? false; + } + + get redirectUrl(): string { + return CALLBACK_URL; + } + + get clientMetadata(): OAuthClientMetadata { + return { + ...this._clientMetadata, + redirect_uris: [CALLBACK_URL] + }; + } + + clientInformation(): OAuthClientInformationFull | undefined { + return this._clientInformation; + } + + saveClientInformation(clientInformation: OAuthClientInformationFull): void { + this._clientInformation = clientInformation; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + /** + * Handle authorization redirect by fetching the URL and extracting auth code. + * In auto mode: fetches URL and expects immediate redirect with code. + * In interactive mode: starts callback server and waits for user to complete login in browser. + */ + async redirectToAuthorization(authorizationUrl: URL): Promise { + if (this._interactive) { + return this._interactiveAuthorization(authorizationUrl); + } + return this._autoAuthorization(authorizationUrl); + } + + /** + * Auto-login mode: fetch URL and extract code from redirect. + */ + private async _autoAuthorization(authorizationUrl: URL): Promise { + try { + const response = await fetch(authorizationUrl.toString(), { + redirect: 'manual' // Don't follow redirects automatically + }); + + // Get the Location header which contains the redirect with auth code + const location = response.headers.get('location'); + if (location) { + const redirectUrl = new URL(location, authorizationUrl); + const code = redirectUrl.searchParams.get('code'); + if (code) { + this._authCode = code; + return; + } + throw new Error('No auth code in redirect URL'); + } + throw new Error( + `No redirect location received from ${authorizationUrl.toString()}` + ); + } catch (error) { + console.error('Failed to fetch authorization URL:', error); + throw error; + } + } + + /** + * Interactive mode: start callback server and wait for user to complete login. + */ + private async _interactiveAuthorization( + authorizationUrl: URL + ): Promise { + const callbackUrl = new URL(CALLBACK_URL); + const port = parseInt(callbackUrl.port, 10); + + return new Promise((resolve, reject) => { + // Start callback server + this._callbackServer = http.createServer((req, res) => { + const url = new URL(req.url || '/', callbackUrl.origin); + + if (url.pathname === '/callback') { + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end(`Authorization Error: ${error}`); + this._stopCallbackServer(); + reject(new Error(`Authorization error: ${error}`)); + return; + } + + if (code) { + this._authCode = code; + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end( + 'Authorization successful! You can close this window and return to the terminal.' + ); + this._stopCallbackServer(); + resolve(); + return; + } + + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Missing authorization code'); + } else { + res.writeHead(404); + res.end('Not found'); + } + }); + + this._callbackServer.listen(port, () => { + console.log(`\n${'='.repeat(70)}`); + console.log('INTERACTIVE AUTHORIZATION REQUIRED'); + console.log('='.repeat(70)); + console.log('\nOpen this URL in your browser to complete login:\n'); + console.log(` ${authorizationUrl.toString()}\n`); + console.log(`Waiting for callback on ${CALLBACK_URL} ...`); + console.log('='.repeat(70) + '\n'); + }); + + this._callbackServer.on('error', (err) => { + reject(new Error(`Callback server error: ${err.message}`)); + }); + + // Timeout after 5 minutes + setTimeout( + () => { + this._stopCallbackServer(); + reject( + new Error( + 'Authorization timeout - no callback received within 5 minutes' + ) + ); + }, + 5 * 60 * 1000 + ); + }); + } + + /** + * Stop the callback server if running. + */ + private _stopCallbackServer(): void { + if (this._callbackServer) { + this._callbackServer.close(); + this._callbackServer = undefined; + } + } + + async getAuthCode(): Promise { + if (this._authCode) { + return this._authCode; + } + throw new Error('No authorization code'); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + if (!this._codeVerifier) { + throw new Error('No code verifier saved'); + } + return this._codeVerifier; + } +} diff --git a/src/scenarios/server-auth/index.ts b/src/scenarios/server-auth/index.ts new file mode 100644 index 0000000..6e3fcb9 --- /dev/null +++ b/src/scenarios/server-auth/index.ts @@ -0,0 +1,44 @@ +/** + * Server Authentication Conformance Scenarios + * + * This module exports scenarios for testing MCP servers' OAuth implementation. + * These are client scenarios that connect to a server and verify its conformance + * with OAuth-related RFCs and the MCP authorization specification. + */ + +import type { ClientScenario } from '../../types'; +import { BasicDcrFlowScenario } from './basic-dcr-flow'; + +// Re-export helpers and spec references +export * from './helpers/oauth-client'; +export * from './spec-references'; +export { BasicDcrFlowScenario } from './basic-dcr-flow'; + +/** + * All server authentication scenarios. + */ +export const serverAuthScenarios: ClientScenario[] = [ + new BasicDcrFlowScenario() +]; + +/** + * List all available server auth scenarios. + */ +export function listServerAuthScenarios(): { + name: string; + description: string; +}[] { + return serverAuthScenarios.map((s) => ({ + name: s.name, + description: s.description + })); +} + +/** + * Get a server auth scenario by name. + */ +export function getServerAuthScenario( + name: string +): ClientScenario | undefined { + return serverAuthScenarios.find((s) => s.name === name); +} diff --git a/src/scenarios/server-auth/spec-references.ts b/src/scenarios/server-auth/spec-references.ts new file mode 100644 index 0000000..e9029b8 --- /dev/null +++ b/src/scenarios/server-auth/spec-references.ts @@ -0,0 +1,127 @@ +/** + * Specification references for server OAuth conformance tests. + * + * Links test checks to relevant specifications: + * - RFC 9728 (Protected Resource Metadata) + * - RFC 8414 (Authorization Server Metadata) + * - RFC 7591 (Dynamic Client Registration) + * - RFC 6750 (Bearer Token Usage) + * - OAuth 2.1 Draft (Client Credentials, Token Endpoint Auth) + * - MCP Authorization Specification (2025-11-25) + */ + +import { SpecReference } from '../../types'; + +export const ServerAuthSpecReferences: { [key: string]: SpecReference } = { + // ───────────────────────────────────────────────────────────────────────── + // RFC 9728: Protected Resource Metadata + // ───────────────────────────────────────────────────────────────────────── + RFC_9728_PRM_DISCOVERY: { + id: 'RFC-9728-discovery', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-3' + }, + RFC_9728_PRM_RESPONSE: { + id: 'RFC-9728-response', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-3.2' + }, + RFC_9728_WWW_AUTHENTICATE: { + id: 'RFC-9728-www-authenticate', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-5' + }, + + // ───────────────────────────────────────────────────────────────────────── + // RFC 8414: Authorization Server Metadata + // ───────────────────────────────────────────────────────────────────────── + RFC_8414_AS_DISCOVERY: { + id: 'RFC-8414-discovery', + url: 'https://www.rfc-editor.org/rfc/rfc8414.html#section-3' + }, + RFC_8414_AS_FIELDS: { + id: 'RFC-8414-fields', + url: 'https://www.rfc-editor.org/rfc/rfc8414.html#section-2' + }, + + // ───────────────────────────────────────────────────────────────────────── + // RFC 7591: Dynamic Client Registration (DCR) + // ───────────────────────────────────────────────────────────────────────── + RFC_7591_DCR_ENDPOINT: { + id: 'RFC-7591-endpoint', + url: 'https://www.rfc-editor.org/rfc/rfc7591.html#section-3' + }, + RFC_7591_DCR_REQUEST: { + id: 'RFC-7591-request', + url: 'https://www.rfc-editor.org/rfc/rfc7591.html#section-3.1' + }, + RFC_7591_DCR_RESPONSE: { + id: 'RFC-7591-response', + url: 'https://www.rfc-editor.org/rfc/rfc7591.html#section-3.2' + }, + + // ───────────────────────────────────────────────────────────────────────── + // RFC 6750: Bearer Token Usage + // ───────────────────────────────────────────────────────────────────────── + RFC_6750_BEARER_TOKEN: { + id: 'RFC-6750-bearer', + url: 'https://www.rfc-editor.org/rfc/rfc6750.html#section-2.1' + }, + RFC_6750_WWW_AUTHENTICATE: { + id: 'RFC-6750-www-authenticate', + url: 'https://www.rfc-editor.org/rfc/rfc6750.html#section-3' + }, + + // ───────────────────────────────────────────────────────────────────────── + // RFC 7235: HTTP Authentication + // ───────────────────────────────────────────────────────────────────────── + RFC_7235_401_RESPONSE: { + id: 'RFC-7235-401', + url: 'https://www.rfc-editor.org/rfc/rfc7235.html#section-3.1' + }, + RFC_7235_WWW_AUTHENTICATE: { + id: 'RFC-7235-www-authenticate', + url: 'https://www.rfc-editor.org/rfc/rfc7235.html#section-4.1' + }, + + // ───────────────────────────────────────────────────────────────────────── + // OAuth 2.1 Draft + // ───────────────────────────────────────────────────────────────────────── + OAUTH_2_1_CLIENT_CREDENTIALS: { + id: 'OAuth-2.1-client-credentials', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-4.2' + }, + OAUTH_2_1_TOKEN_REQUEST: { + id: 'OAuth-2.1-token-request', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#name-token-request' + }, + + // ───────────────────────────────────────────────────────────────────────── + // MCP Authorization Specification (2025-11-25) + // ───────────────────────────────────────────────────────────────────────── + MCP_AUTH_SERVER_LOCATION: { + id: 'MCP-2025-11-25-server-location', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-location' + }, + MCP_AUTH_PRM_DISCOVERY: { + id: 'MCP-2025-11-25-prm-discovery', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements' + }, + MCP_AUTH_SERVER_METADATA: { + id: 'MCP-2025-11-25-server-metadata', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery' + }, + MCP_AUTH_DCR: { + id: 'MCP-2025-11-25-dcr', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration' + }, + MCP_AUTH_ACCESS_TOKEN: { + id: 'MCP-2025-11-25-access-token', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#access-token-usage' + }, + + // ───────────────────────────────────────────────────────────────────────── + // MCP Extension: Client Credentials (SEP-1046) + // ───────────────────────────────────────────────────────────────────────── + SEP_1046_CLIENT_CREDENTIALS: { + id: 'SEP-1046-client-credentials', + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx' + } +}; diff --git a/src/types.ts b/src/types.ts index d5192b7..1084e7e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,8 +41,15 @@ export interface Scenario { getChecks(): ConformanceCheck[]; } +export interface ClientScenarioOptions { + interactive?: boolean; +} + export interface ClientScenario { name: string; description: string; - run(serverUrl: string): Promise; + run( + serverUrl: string, + options?: ClientScenarioOptions + ): Promise; }