diff --git a/README.md b/README.md index 741b0b7..7046e94 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,20 @@ npx @modelcontextprotocol/conformance server --url [--scenario ] **Options:** - `--url` - URL of the server to test -- `--scenario ` - Test scenario to run (e.g., "server-initialize". Runs all available scenarios by default +- `--scenario ` - Test scenario to run (e.g., "server-initialize"). Runs all available scenarios by default +- `--suite ` - Suite to run: "active" (default), "all", "pending", or "auth" +- `--auth` - Include OAuth conformance tests when running active suite + +### Authorization Server OAuth Conformity Testing + +To test the OAuth implementation protecting your server: + +```bash +# Run only OAuth conformance tests +npx @modelcontextprotocol/conformance server --url http://localhost:3000/mcp --suite auth + +# Run a specific OAuth scenario +npx @modelcontextprotocol/conformance server --url http://localhost:3000/mcp --scenario server/auth-prm-discovery ## Test Results diff --git a/src/index.ts b/src/index.ts index 6128a16..e562214 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,8 @@ import { listActiveClientScenarios, listPendingClientScenarios, listAuthScenarios, - listMetadataScenarios + listMetadataScenarios, + listServerAuthScenarios } from './scenarios'; import { ConformanceCheck } from './types'; import { ClientOptionsSchema, ServerOptionsSchema } from './schemas'; @@ -201,9 +202,10 @@ program ) .option( '--suite ', - 'Suite to run: "active" (default, excludes pending), "all", or "pending"', + 'Suite to run: "active" (default), "all", "pending", or "auth"', 'active' ) + .option('--auth', 'Include OAuth conformance tests (server/auth-* scenarios)') .option('--verbose', 'Show verbose output (JSON instead of pretty print)') .action(async (options) => { try { @@ -228,22 +230,31 @@ program } else { // Run scenarios based on suite const suite = options.suite?.toLowerCase() || 'active'; + const includeAuth = options.auth ?? false; let scenarios: string[]; if (suite === 'all') { scenarios = listClientScenarios(); } else if (suite === 'active') { scenarios = listActiveClientScenarios(); + // Add auth scenarios if --auth flag is set + if (includeAuth) { + scenarios = [...scenarios, ...listServerAuthScenarios()]; + } } else if (suite === 'pending') { scenarios = listPendingClientScenarios(); + } else if (suite === 'auth') { + // Run only auth scenarios + scenarios = listServerAuthScenarios(); } else { console.error(`Unknown suite: ${suite}`); - console.error('Available suites: active, all, pending'); + console.error('Available suites: active, all, pending, auth'); process.exit(1); } + const authNote = includeAuth && suite !== 'auth' ? ' (with auth)' : ''; console.log( - `Running ${suite} suite (${scenarios.length} scenarios) against ${validated.url}\n` + `Running ${suite}${authNote} suite (${scenarios.length} scenarios) against ${validated.url}\n` ); const allResults: { scenario: string; checks: ConformanceCheck[] }[] = @@ -300,15 +311,27 @@ program .description('List available test scenarios') .option('--client', 'List client scenarios') .option('--server', 'List server scenarios') + .option('--auth', 'List server OAuth auth scenarios') .action((options) => { - if (options.server || (!options.client && !options.server)) { + const showAll = !options.client && !options.server && !options.auth; + + 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.auth || showAll) { + if (options.server || showAll) { + console.log(''); + } + console.log('Server OAuth scenarios (use --auth or --suite auth):'); + const authScenarios = listServerAuthScenarios(); + authScenarios.forEach((s) => console.log(` - ${s}`)); + } + + if (options.client || showAll) { + if (options.server || options.auth || showAll) { console.log(''); } console.log('Client scenarios (test against a client):'); diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index de76cd6..25bdf01 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -53,6 +53,13 @@ import { import { authScenariosList } from './client/auth/index'; import { listMetadataScenarios } from './client/auth/discovery-metadata'; +// Server auth scenarios (OAuth conformance testing) +import { + serverAuthScenarios, + listServerAuthScenarios, + getServerAuthScenario +} from './server/auth/index'; + // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ // Elicitation scenarios (SEP-1330) @@ -135,7 +142,10 @@ const activeClientScenariosList: ClientScenario[] = // Client scenarios map - built from list export const clientScenarios = new Map( - allClientScenariosList.map((scenario) => [scenario.name, scenario]) + [...allClientScenariosList, ...serverAuthScenarios].map((scenario) => [ + scenario.name, + scenario + ]) ); // Scenario scenarios @@ -185,3 +195,6 @@ export function listAuthScenarios(): string[] { } export { listMetadataScenarios }; + +// Server auth scenario exports +export { listServerAuthScenarios, getServerAuthScenario, serverAuthScenarios }; diff --git a/src/scenarios/server/auth/helpers/as-metadata.ts b/src/scenarios/server/auth/helpers/as-metadata.ts new file mode 100644 index 0000000..c2673c6 --- /dev/null +++ b/src/scenarios/server/auth/helpers/as-metadata.ts @@ -0,0 +1,193 @@ +/** + * Authorization Server Metadata helpers. + * + * Provides utilities for fetching and validating AS metadata + * per RFC 8414 and OIDC Discovery. + */ + +import { authFetch, buildPrmUrl, AuthTestResult } from './auth-fetch'; + +/** + * Result of fetching AS metadata. + */ +export interface AsMetadataResult { + /** Whether metadata was successfully fetched */ + success: boolean; + /** The AS metadata document if successful */ + metadata?: Record; + /** The URL that was used to fetch metadata */ + url?: string; + /** Whether OIDC discovery was used (vs RFC 8414) */ + isOidc?: boolean; + /** Error message if fetch failed */ + error?: string; + /** The AS URL from PRM */ + asUrl?: string; + /** Raw response for debugging */ + response?: AuthTestResult; +} + +/** + * Result of fetching PRM. + */ +export interface PrmResult { + /** Whether PRM was successfully fetched */ + success: boolean; + /** The PRM document if successful */ + prm?: Record; + /** The URL that was used to fetch PRM */ + url?: string; + /** Error message if fetch failed */ + error?: string; + /** Raw response for debugging */ + response?: AuthTestResult; +} + +/** + * Build AS metadata discovery URL. + */ +export function buildAsMetadataUrl(asUrl: string, useOidc: boolean): string { + const parsed = new URL(asUrl); + const base = `${parsed.protocol}//${parsed.host}`; + + if (useOidc) { + return `${base}/.well-known/openid-configuration`; + } + return `${base}/.well-known/oauth-authorization-server`; +} + +/** + * Fetch Protected Resource Metadata from a server. + */ +export async function fetchPrm(serverUrl: string): Promise { + const pathBasedUrl = buildPrmUrl(serverUrl, true); + const rootUrl = buildPrmUrl(serverUrl, false); + + // Try path-based first + try { + const response = await authFetch(pathBasedUrl); + if ( + response.status === 200 && + typeof response.body === 'object' && + response.body !== null + ) { + return { + success: true, + prm: response.body as Record, + url: pathBasedUrl, + response + }; + } + } catch { + // Will try root + } + + // Try root + if (pathBasedUrl !== rootUrl) { + try { + const response = await authFetch(rootUrl); + if ( + response.status === 200 && + typeof response.body === 'object' && + response.body !== null + ) { + return { + success: true, + prm: response.body as Record, + url: rootUrl, + response + }; + } + } catch { + // Both failed + } + } + + return { + success: false, + error: `No valid PRM found at ${pathBasedUrl} or ${rootUrl}` + }; +} + +/** + * Fetch Authorization Server metadata from the AS referenced in PRM. + * + * @param serverUrl - The MCP server URL + * @returns AS metadata result + */ +export async function fetchAsMetadata( + serverUrl: string +): Promise { + // First fetch PRM + const prmResult = await fetchPrm(serverUrl); + + if (!prmResult.success || !prmResult.prm) { + return { + success: false, + error: prmResult.error || 'Failed to fetch PRM' + }; + } + + const authServers = prmResult.prm.authorization_servers as + | string[] + | undefined; + + if (!Array.isArray(authServers) || authServers.length === 0) { + return { + success: false, + error: 'PRM missing authorization_servers array' + }; + } + + const asUrl = authServers[0]; + + // Try RFC 8414 first + const rfc8414Url = buildAsMetadataUrl(asUrl, false); + try { + const response = await authFetch(rfc8414Url); + if ( + response.status === 200 && + typeof response.body === 'object' && + response.body !== null + ) { + return { + success: true, + metadata: response.body as Record, + url: rfc8414Url, + isOidc: false, + asUrl, + response + }; + } + } catch { + // Will try OIDC + } + + // Try OIDC Discovery + const oidcUrl = buildAsMetadataUrl(asUrl, true); + try { + const response = await authFetch(oidcUrl); + if ( + response.status === 200 && + typeof response.body === 'object' && + response.body !== null + ) { + return { + success: true, + metadata: response.body as Record, + url: oidcUrl, + isOidc: true, + asUrl, + response + }; + } + } catch { + // Both failed + } + + return { + success: false, + error: `No AS metadata found at ${rfc8414Url} or ${oidcUrl}`, + asUrl + }; +} diff --git a/src/scenarios/server/auth/helpers/auth-fetch.ts b/src/scenarios/server/auth/helpers/auth-fetch.ts new file mode 100644 index 0000000..7d35403 --- /dev/null +++ b/src/scenarios/server/auth/helpers/auth-fetch.ts @@ -0,0 +1,245 @@ +/** + * Low-level HTTP utilities for OAuth conformance testing. + * + * This module provides raw HTTP fetch capabilities without using the MCP SDK, + * allowing us to test edge cases and validate HTTP-level OAuth behavior. + */ + +/** + * Parsed WWW-Authenticate header components. + * + * @see RFC 7235 Section 4.1 + * @see RFC 6750 Section 3 (Bearer Token Usage) + */ +export interface ParsedWWWAuthenticate { + /** Authentication scheme (e.g., "Bearer") */ + scheme: string; + /** Key-value parameters from the challenge */ + params: Record; + /** Raw header value for debugging */ + raw: string; +} + +/** + * Result of an auth-aware HTTP request. + */ +export interface AuthTestResult { + /** HTTP status code */ + status: number; + /** Response headers */ + headers: Headers; + /** Parsed response body (JSON if applicable) */ + body: unknown; + /** Raw response body string */ + rawBody: string; + /** Parsed WWW-Authenticate header if present */ + wwwAuthenticate?: ParsedWWWAuthenticate; +} + +/** + * Options for auth-aware fetch requests. + */ +export interface AuthFetchOptions { + /** Bearer token to include in Authorization header */ + token?: string; + /** HTTP method (defaults to GET) */ + method?: string; + /** Request body (will be JSON-serialized) */ + body?: unknown; + /** Additional headers */ + headers?: Record; + /** Request timeout in milliseconds */ + timeout?: number; +} + +/** + * Parse a WWW-Authenticate header value per RFC 7235. + * + * Handles the Bearer challenge format: + * Bearer realm="example", scope="read write", error="invalid_token" + * + * @param headerValue - The raw WWW-Authenticate header value + * @returns Parsed challenge with scheme and parameters + */ +export function parseWWWAuthenticate( + headerValue: string +): ParsedWWWAuthenticate { + const raw = headerValue; + const params: Record = {}; + + // Extract scheme (first token before space) + const spaceIndex = headerValue.indexOf(' '); + let scheme: string; + let rest: string; + + if (spaceIndex === -1) { + // No parameters, just scheme + scheme = headerValue.trim(); + rest = ''; + } else { + scheme = headerValue.substring(0, spaceIndex).trim(); + rest = headerValue.substring(spaceIndex + 1).trim(); + } + + // Parse parameters: key="value" or key=value, comma-separated + // RFC 7235 allows both quoted and unquoted values + if (rest) { + // State machine for parsing auth-param list + let current = rest; + + while (current.length > 0) { + // Skip whitespace and commas + current = current.replace(/^[\s,]+/, ''); + if (current.length === 0) break; + + // Extract key (token before =) + const eqMatch = current.match(/^([^=\s]+)\s*=/); + if (!eqMatch) break; + + const key = eqMatch[1].toLowerCase(); + current = current.substring(eqMatch[0].length).trim(); + + // Extract value (quoted or unquoted) + let value: string; + + if (current.startsWith('"')) { + // Quoted string - find closing quote (handling escaped quotes) + let endQuote = 1; + while (endQuote < current.length) { + if (current[endQuote] === '"' && current[endQuote - 1] !== '\\') { + break; + } + endQuote++; + } + value = current.substring(1, endQuote).replace(/\\"/g, '"'); + current = current.substring(endQuote + 1); + } else { + // Unquoted token - read until comma or whitespace + const tokenMatch = current.match(/^([^,\s]+)/); + value = tokenMatch ? tokenMatch[1] : ''; + current = current.substring(value.length); + } + + params[key] = value; + } + } + + return { scheme, params, raw }; +} + +/** + * Perform an HTTP request with optional Bearer token authentication. + * + * @param url - URL to fetch + * @param options - Request options including optional token + * @returns AuthTestResult with status, headers, body, and parsed WWW-Authenticate + */ +export async function authFetch( + url: string, + options: AuthFetchOptions = {} +): Promise { + const { + token, + method = 'GET', + body, + headers = {}, + timeout = 30000 + } = options; + + // Build headers + // MCP Streamable HTTP transport requires both application/json and text/event-stream + const requestHeaders: Record = { + Accept: 'application/json, text/event-stream', + ...headers + }; + + if (token) { + requestHeaders['Authorization'] = `Bearer ${token}`; + } + + if (body !== undefined) { + requestHeaders['Content-Type'] = 'application/json'; + } + + // Create abort controller for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + method, + headers: requestHeaders, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + // Read response body + const rawBody = await response.text(); + let parsedBody: unknown; + + try { + parsedBody = JSON.parse(rawBody); + } catch { + parsedBody = rawBody; + } + + // Parse WWW-Authenticate if present + let wwwAuthenticate: ParsedWWWAuthenticate | undefined; + const wwwAuthHeader = response.headers.get('WWW-Authenticate'); + if (wwwAuthHeader) { + wwwAuthenticate = parseWWWAuthenticate(wwwAuthHeader); + } + + return { + status: response.status, + headers: response.headers, + body: parsedBody, + rawBody, + wwwAuthenticate + }; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Request timed out after ${timeout}ms`); + } + throw error; + } +} + +/** + * Extract the base URL from a full URL (removes path). + * + * @param url - Full URL + * @returns Base URL (scheme + host + port) + */ +export function getBaseUrl(url: string): string { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}`; +} + +/** + * Build a well-known URL for Protected Resource Metadata. + * + * Per RFC 9728, PRM can be at: + * - /.well-known/oauth-protected-resource (root) + * - /.well-known/oauth-protected-resource/ (path-based) + * + * @param serverUrl - The MCP server URL + * @param pathBased - Whether to use path-based PRM URL + * @returns Well-known URL for PRM + */ +export function buildPrmUrl(serverUrl: string, pathBased: boolean): string { + const parsed = new URL(serverUrl); + const base = `${parsed.protocol}//${parsed.host}`; + + if (pathBased && parsed.pathname !== '/') { + // Path-based: /.well-known/oauth-protected-resource/ + return `${base}/.well-known/oauth-protected-resource${parsed.pathname}`; + } else { + // Root: /.well-known/oauth-protected-resource + return `${base}/.well-known/oauth-protected-resource`; + } +} diff --git a/src/scenarios/server/auth/index.ts b/src/scenarios/server/auth/index.ts new file mode 100644 index 0000000..9948acb --- /dev/null +++ b/src/scenarios/server/auth/index.ts @@ -0,0 +1,126 @@ +/** + * Server OAuth Conformance Test Scenarios + * + * This module exports all OAuth-related conformance tests for MCP servers. + * These tests validate that servers correctly implement OAuth 2.1 authorization + * as specified in the MCP Authorization specification. + * + * @see MCP Authorization Specification (2025-06-18) + * @see RFC 9728 (Protected Resource Metadata) + * @see RFC 8414 (Authorization Server Metadata) + * @see RFC 7636 (PKCE) + * @see RFC 7523 (JWT Client Authentication) + * @see RFC 6750 (Bearer Token Usage) + * @see OAuth 2.1 Draft (Client Credentials, Token Endpoint Auth) + * @see MCP Extension SEP-1046 (Client Credentials) + * @see IETF CIMD Draft (Client ID Metadata Documents) + */ + +import { ClientScenario } from '../../../types'; +import { AuthPrmDiscoveryScenario } from './scenarios/prm-discovery'; +import { AuthUnauthorizedResponseScenario } from './scenarios/unauthorized-response'; +import { AuthWWWAuthenticateHeaderScenario } from './scenarios/www-authenticate-header'; +import { AuthAsMetadataDiscoveryScenario } from './scenarios/as-metadata-discovery'; +import { AuthAsCimdSupportedScenario } from './scenarios/as-cimd-supported'; +import { AuthAsPkceSupportScenario } from './scenarios/as-pkce-support'; +import { AuthPrmResourceValidationScenario } from './scenarios/prm-resource-validation'; +import { AuthDiscoveryMechanismScenario } from './scenarios/discovery-mechanism'; +import { AuthAsTokenAuthMethodsScenario } from './scenarios/as-token-auth-methods'; +import { AuthAsGrantTypesScenario } from './scenarios/as-grant-types'; + +/** + * All server OAuth conformance scenarios. + * + * These scenarios test OAuth behavior without requiring a valid token, + * making them suitable for basic conformance testing. + * + * Organized by dependency order: + * 1. PRM Discovery (foundation) + * 2. AS Metadata Discovery (depends on PRM) + * 3. Discovery Mechanism Validation + * 4. CIMD Support (depends on AS metadata) + * 5. PKCE Support (depends on AS metadata) + * 6. Token Auth Methods (depends on AS metadata) - Client credentials readiness + * 7. Grant Types Support (depends on AS metadata) - Client credentials support + * 8. PRM Resource Validation + * 9. 401 Response / WWW-Authenticate (independent) + */ +export const serverAuthScenarios: ClientScenario[] = [ + // Foundation: PRM Discovery + new AuthPrmDiscoveryScenario(), + + // AS Metadata Discovery (requires PRM) + new AuthAsMetadataDiscoveryScenario(), + + // Discovery Mechanism Validation + new AuthDiscoveryMechanismScenario(), + + // CIMD Support (requires AS metadata) + new AuthAsCimdSupportedScenario(), + + // PKCE Support (requires AS metadata) + new AuthAsPkceSupportScenario(), + + // Token Auth Methods (requires AS metadata) - Client credentials readiness + new AuthAsTokenAuthMethodsScenario(), + + // Grant Types Support (requires AS metadata) - Client credentials support + new AuthAsGrantTypesScenario(), + + // PRM Resource Validation (requires PRM) + new AuthPrmResourceValidationScenario(), + + // HTTP-level checks (independent) + new AuthUnauthorizedResponseScenario(), + new AuthWWWAuthenticateHeaderScenario() +]; + +/** + * Get list of server auth scenario names. + */ +export function listServerAuthScenarios(): string[] { + return serverAuthScenarios.map((s) => s.name); +} + +/** + * Get a server auth scenario by name. + */ +export function getServerAuthScenario( + name: string +): ClientScenario | undefined { + return serverAuthScenarios.find((s) => s.name === name); +} + +// Re-export individual scenarios for direct use +export { AuthPrmDiscoveryScenario } from './scenarios/prm-discovery'; +export { AuthUnauthorizedResponseScenario } from './scenarios/unauthorized-response'; +export { AuthWWWAuthenticateHeaderScenario } from './scenarios/www-authenticate-header'; +export { AuthAsMetadataDiscoveryScenario } from './scenarios/as-metadata-discovery'; +export { AuthAsCimdSupportedScenario } from './scenarios/as-cimd-supported'; +export { AuthAsPkceSupportScenario } from './scenarios/as-pkce-support'; +export { AuthPrmResourceValidationScenario } from './scenarios/prm-resource-validation'; +export { AuthDiscoveryMechanismScenario } from './scenarios/discovery-mechanism'; +export { AuthAsTokenAuthMethodsScenario } from './scenarios/as-token-auth-methods'; +export { AuthAsGrantTypesScenario } from './scenarios/as-grant-types'; + +// Re-export spec references +export { ServerAuthSpecReferences } from './spec-references'; + +// Re-export helpers +export { + authFetch, + parseWWWAuthenticate, + buildPrmUrl, + getBaseUrl, + type AuthTestResult, + type AuthFetchOptions, + type ParsedWWWAuthenticate +} from './helpers/auth-fetch'; + +export { + fetchPrm, + fetchAsMetadata, + buildAsMetadataUrl, + type PrmResult, + type AsMetadataResult +} from './helpers/as-metadata'; diff --git a/src/scenarios/server/auth/scenarios/as-cimd-supported.ts b/src/scenarios/server/auth/scenarios/as-cimd-supported.ts new file mode 100644 index 0000000..6746f56 --- /dev/null +++ b/src/scenarios/server/auth/scenarios/as-cimd-supported.ts @@ -0,0 +1,216 @@ +/** + * CIMD Support Advertisement Scenario + * + * Tests that the Authorization Server properly advertises Client ID + * Metadata Document support per the IETF CIMD draft. + * + * @see IETF Draft: draft-ietf-oauth-client-id-metadata-document-00 + */ + +import { ClientScenario, ConformanceCheck } from '../../../../types'; +import { fetchAsMetadata } from '../helpers/as-metadata'; +import { ServerAuthSpecReferences } from '../spec-references'; + +/** + * Validates CIMD support advertisement in AS metadata. + * + * Per IETF CIMD draft Section 4, authorization servers that support + * Client ID Metadata Documents MUST advertise this via: + * "client_id_metadata_document_supported": true + */ +export class AuthAsCimdSupportedScenario implements ClientScenario { + name = 'server/auth-as-cimd-supported'; + description = `Test CIMD (Client ID Metadata Document) support advertisement. + +**Prerequisites**: Server must have valid AS metadata endpoint. + +**Check**: AS metadata contains \`client_id_metadata_document_supported\` field. + +CIMD is an alternative to DCR that allows clients to use HTTPS URLs as client_id, +pointing to a metadata document hosted by the client. + +**Spec References**: +- IETF CIMD Draft Section 4 (Authorization Server Metadata)`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // Fetch AS metadata + const asResult = await fetchAsMetadata(serverUrl); + + if (!asResult.success || !asResult.metadata) { + checks.push({ + id: 'auth-cimd-as-prerequisite', + name: 'AS Metadata Prerequisite', + description: 'Valid AS metadata required to check CIMD support', + status: 'SKIPPED', + timestamp: timestamp(), + errorMessage: + asResult.error || + 'Cannot fetch AS metadata - run auth-as-metadata-discovery first', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY] + }); + return checks; + } + + checks.push({ + id: 'auth-cimd-as-prerequisite', + name: 'AS Metadata Prerequisite', + description: 'Valid AS metadata found', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY], + details: { asUrl: asResult.asUrl, metadataUrl: asResult.url } + }); + + const metadata = asResult.metadata; + + // Check: client_id_metadata_document_supported field + const cimdSupported = metadata.client_id_metadata_document_supported; + + if (cimdSupported === undefined) { + checks.push({ + id: 'auth-cimd-field-present', + name: 'CIMD Support Field Present', + description: + 'AS metadata contains client_id_metadata_document_supported field', + status: 'INFO', + timestamp: timestamp(), + errorMessage: + 'Field not present - CIMD support unknown (DCR may be available)', + specReferences: [ServerAuthSpecReferences.IETF_CIMD_AS_METADATA], + details: { client_id_metadata_document_supported: undefined } + }); + + checks.push({ + id: 'auth-cimd-supported', + name: 'CIMD Supported', + description: + 'Authorization Server supports Client ID Metadata Documents', + status: 'SKIPPED', + timestamp: timestamp(), + errorMessage: 'Cannot determine - field not present in AS metadata', + specReferences: [ServerAuthSpecReferences.IETF_CIMD_AS_METADATA] + }); + } else if (cimdSupported === true) { + checks.push({ + id: 'auth-cimd-field-present', + name: 'CIMD Support Field Present', + description: + 'AS metadata contains client_id_metadata_document_supported field', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.IETF_CIMD_AS_METADATA], + details: { client_id_metadata_document_supported: true } + }); + + checks.push({ + id: 'auth-cimd-supported', + name: 'CIMD Supported', + description: + 'Authorization Server supports Client ID Metadata Documents', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.IETF_CIMD, + ServerAuthSpecReferences.IETF_CIMD_AS_METADATA + ], + details: { client_id_metadata_document_supported: true } + }); + } else if (cimdSupported === false) { + checks.push({ + id: 'auth-cimd-field-present', + name: 'CIMD Support Field Present', + description: + 'AS metadata contains client_id_metadata_document_supported field', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.IETF_CIMD_AS_METADATA], + details: { client_id_metadata_document_supported: false } + }); + + checks.push({ + id: 'auth-cimd-supported', + name: 'CIMD Supported', + description: + 'Authorization Server supports Client ID Metadata Documents', + status: 'INFO', + timestamp: timestamp(), + errorMessage: + 'CIMD explicitly not supported - DCR or pre-registration required', + specReferences: [ServerAuthSpecReferences.IETF_CIMD_AS_METADATA], + details: { client_id_metadata_document_supported: false } + }); + } else { + // Invalid value type + checks.push({ + id: 'auth-cimd-field-present', + name: 'CIMD Support Field Present', + description: + 'AS metadata contains client_id_metadata_document_supported field', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: `Invalid value type: expected boolean, got ${typeof cimdSupported}`, + specReferences: [ServerAuthSpecReferences.IETF_CIMD_AS_METADATA], + details: { client_id_metadata_document_supported: cimdSupported } + }); + + checks.push({ + id: 'auth-cimd-supported', + name: 'CIMD Supported', + description: + 'Authorization Server supports Client ID Metadata Documents', + status: 'SKIPPED', + timestamp: timestamp(), + errorMessage: 'Invalid field value type', + specReferences: [ServerAuthSpecReferences.IETF_CIMD_AS_METADATA] + }); + } + + // Check registration options summary + const hasRegistrationEndpoint = + typeof metadata.registration_endpoint === 'string'; + const hasCimd = cimdSupported === true; + + if (!hasRegistrationEndpoint && !hasCimd) { + checks.push({ + id: 'auth-cimd-registration-options', + name: 'Registration Options Available', + description: 'At least one client registration mechanism available', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: + 'Neither DCR (registration_endpoint) nor CIMD available - pre-registration may be required', + specReferences: [ + ServerAuthSpecReferences.MCP_AUTH_DCR, + ServerAuthSpecReferences.IETF_CIMD + ], + details: { + dcr_available: hasRegistrationEndpoint, + cimd_available: hasCimd, + registration_endpoint: metadata.registration_endpoint + } + }); + } else { + checks.push({ + id: 'auth-cimd-registration-options', + name: 'Registration Options Available', + description: 'At least one client registration mechanism available', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.MCP_AUTH_DCR, + ServerAuthSpecReferences.IETF_CIMD + ], + details: { + dcr_available: hasRegistrationEndpoint, + cimd_available: hasCimd, + registration_endpoint: metadata.registration_endpoint + } + }); + } + + return checks; + } +} diff --git a/src/scenarios/server/auth/scenarios/as-grant-types.ts b/src/scenarios/server/auth/scenarios/as-grant-types.ts new file mode 100644 index 0000000..c7a7026 --- /dev/null +++ b/src/scenarios/server/auth/scenarios/as-grant-types.ts @@ -0,0 +1,363 @@ +/** + * Grant Types Support Validation Scenario + * + * Tests that the Authorization Server properly advertises supported OAuth grant types. + * + * @see RFC 8414 Section 2 (grant_types_supported) + * @see OAuth 2.1 Section 4 (Grant Types) + * @see OAuth 2.1 Section 4.2 (Client Credentials Grant) + * @see MCP Extension SEP-1046 (Client Credentials) + */ + +import { ClientScenario, ConformanceCheck } from '../../../../types'; +import { fetchAsMetadata } from '../helpers/as-metadata'; +import { ServerAuthSpecReferences } from '../spec-references'; + +/** + * Standard OAuth 2.1 grant types. + */ +const STANDARD_GRANT_TYPES = [ + 'authorization_code', // Standard OAuth code flow + 'refresh_token', // Token refresh + 'client_credentials', // Machine-to-machine (M2M) + 'urn:ietf:params:oauth:grant-type:device_code', // Device flow + 'urn:ietf:params:oauth:grant-type:jwt-bearer', // JWT bearer assertion + 'urn:ietf:params:oauth:grant-type:token-exchange' // Token exchange +]; + +/** + * Validates grant types advertisement in AS metadata. + * + * Per RFC 8414: + * - AS MAY advertise grant_types_supported + * - Defaults to ["authorization_code", "implicit"] if not present + * - Important for determining M2M (client_credentials) support + */ +export class AuthAsGrantTypesScenario implements ClientScenario { + name = 'server/auth-as-grant-types'; + description = `Test OAuth grant types advertisement. + +**Prerequisites**: Server must have valid AS metadata endpoint. + +**Check**: AS metadata contains \`grant_types_supported\` field +advertising available OAuth grant types. + +Grant types determine the OAuth flows a server supports: +- authorization_code: Standard OAuth flow for user authorization +- refresh_token: Support for token refresh +- client_credentials: Machine-to-machine (M2M) authentication + +**Spec References**: +- RFC 8414 Section 2 (AS Metadata Fields) +- OAuth 2.1 Section 4 (Grant Types) +- OAuth 2.1 Section 4.2 (Client Credentials Grant) +- MCP Extension SEP-1046 (Client Credentials)`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // Fetch AS metadata + const asResult = await fetchAsMetadata(serverUrl); + + if (!asResult.success || !asResult.metadata) { + checks.push({ + id: 'auth-grant-types-prerequisite', + name: 'AS Metadata Prerequisite', + description: 'Valid AS metadata required to check grant types', + status: 'SKIPPED', + timestamp: timestamp(), + errorMessage: + asResult.error || + 'Cannot fetch AS metadata - run auth-as-metadata-discovery first', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY] + }); + return checks; + } + + checks.push({ + id: 'auth-grant-types-prerequisite', + name: 'AS Metadata Prerequisite', + description: 'Valid AS metadata found', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY], + details: { asUrl: asResult.asUrl, metadataUrl: asResult.url } + }); + + const metadata = asResult.metadata; + const grantTypes = metadata.grant_types_supported; + + // Check: grant_types_supported field present + if (grantTypes === undefined) { + checks.push({ + id: 'auth-grant-types-present', + name: 'Grant Types Field Present', + description: 'AS metadata contains grant_types_supported field', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: + 'Field not present - defaults to ["authorization_code", "implicit"] per RFC 8414', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { + grant_types_supported: undefined, + default_value: ['authorization_code', 'implicit'], + note: 'implicit is deprecated in OAuth 2.1' + } + }); + + // Assume defaults per RFC 8414 + checks.push({ + id: 'auth-grant-types-authorization-code', + name: 'Authorization Code Grant', + description: 'Check if authorization_code grant is supported', + status: 'INFO', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.OAUTH_2_1_GRANT_TYPES], + details: { + authorization_code: 'assumed (default)', + note: 'Field not advertised - assuming default per RFC 8414' + } + }); + + checks.push({ + id: 'auth-grant-types-client-credentials', + name: 'Client Credentials Grant', + description: 'Check if client_credentials grant is supported', + status: 'INFO', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + ServerAuthSpecReferences.SEP_1046_CLIENT_CREDENTIALS + ], + details: { + client_credentials: 'unknown', + note: 'Grant types not advertised - client_credentials support unknown' + } + }); + + return checks; + } + + // Check: field is an array + if (!Array.isArray(grantTypes)) { + checks.push({ + id: 'auth-grant-types-present', + name: 'Grant Types Field Present', + description: 'AS metadata contains grant_types_supported field', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: `Invalid type: expected array, got ${typeof grantTypes}`, + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { grant_types_supported: grantTypes } + }); + return checks; + } + + // Check: array is not empty + if (grantTypes.length === 0) { + checks.push({ + id: 'auth-grant-types-present', + name: 'Grant Types Field Present', + description: 'AS metadata contains grant_types_supported field', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: 'Empty array - at least one grant type must be supported', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { grant_types_supported: grantTypes } + }); + return checks; + } + + checks.push({ + id: 'auth-grant-types-present', + name: 'Grant Types Field Present', + description: 'AS metadata contains grant_types_supported field', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { grant_types_supported: grantTypes } + }); + + // Check: authorization_code support (required for standard OAuth) + const hasAuthorizationCode = grantTypes.includes('authorization_code'); + + if (hasAuthorizationCode) { + checks.push({ + id: 'auth-grant-types-authorization-code', + name: 'Authorization Code Grant', + description: 'Check if authorization_code grant is supported', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.OAUTH_2_1_GRANT_TYPES], + details: { + authorization_code: true, + note: 'Standard OAuth flow for user authorization supported' + } + }); + } else { + checks.push({ + id: 'auth-grant-types-authorization-code', + name: 'Authorization Code Grant', + description: 'Check if authorization_code grant is supported', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: 'authorization_code not in grant_types_supported', + specReferences: [ServerAuthSpecReferences.OAUTH_2_1_GRANT_TYPES], + details: { + authorization_code: false, + grant_types_supported: grantTypes, + note: 'Standard OAuth flow may not be available' + } + }); + } + + // Check: refresh_token support + const hasRefreshToken = grantTypes.includes('refresh_token'); + + checks.push({ + id: 'auth-grant-types-refresh-token', + name: 'Refresh Token Grant', + description: 'Check if refresh_token grant is supported', + status: hasRefreshToken ? 'SUCCESS' : 'INFO', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.OAUTH_2_1_GRANT_TYPES], + details: { + refresh_token: hasRefreshToken, + note: hasRefreshToken + ? 'Token refresh supported - long-lived sessions possible' + : 'Token refresh not advertised - clients must re-authorize when tokens expire' + } + }); + + // Check: client_credentials support (M2M authentication) + const hasClientCredentials = grantTypes.includes('client_credentials'); + + if (hasClientCredentials) { + checks.push({ + id: 'auth-grant-types-client-credentials', + name: 'Client Credentials Grant', + description: 'Check if client_credentials grant is supported', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + ServerAuthSpecReferences.SEP_1046_CLIENT_CREDENTIALS + ], + details: { + client_credentials: true, + note: 'Machine-to-machine (M2M) authentication supported per SEP-1046' + } + }); + } else { + checks.push({ + id: 'auth-grant-types-client-credentials', + name: 'Client Credentials Grant', + description: 'Check if client_credentials grant is supported', + status: 'INFO', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + ServerAuthSpecReferences.SEP_1046_CLIENT_CREDENTIALS + ], + details: { + client_credentials: false, + note: 'M2M authentication not supported - user authorization required' + } + }); + } + + // Check: deprecated implicit grant (OAuth 2.1 removes this) + const hasImplicit = grantTypes.includes('implicit'); + + if (hasImplicit) { + checks.push({ + id: 'auth-grant-types-implicit-deprecated', + name: 'Implicit Grant Deprecated', + description: 'Check for deprecated implicit grant type', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: + 'implicit grant is deprecated in OAuth 2.1 - use authorization_code with PKCE instead', + specReferences: [ServerAuthSpecReferences.OAUTH_2_1_GRANT_TYPES], + details: { + implicit: true, + deprecated: true, + recommendation: 'Use authorization_code with PKCE for public clients' + } + }); + } + + // Check: deprecated password grant (OAuth 2.1 removes this) + const hasPassword = grantTypes.includes('password'); + + if (hasPassword) { + checks.push({ + id: 'auth-grant-types-password-deprecated', + name: 'Password Grant Deprecated', + description: 'Check for deprecated password grant type', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: + 'password grant (Resource Owner Password Credentials) is removed in OAuth 2.1', + specReferences: [ServerAuthSpecReferences.OAUTH_2_1_GRANT_TYPES], + details: { + password: true, + deprecated: true, + note: 'ROPC grant should not be used in new implementations' + } + }); + } + + // Check: unknown/non-standard grant types + const unknownGrants = grantTypes.filter( + (g: unknown) => + typeof g !== 'string' || !STANDARD_GRANT_TYPES.includes(g as string) + ); + const deprecatedGrants = ['implicit', 'password']; + const customGrants = unknownGrants.filter( + (g: unknown) => + typeof g !== 'string' || !deprecatedGrants.includes(g as string) + ); + + if (customGrants.length > 0) { + checks.push({ + id: 'auth-grant-types-custom', + name: 'Custom Grant Types', + description: 'Check for non-standard grant types', + status: 'INFO', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.OAUTH_2_1_GRANT_TYPES], + details: { + custom_grant_types: customGrants, + note: 'Non-standard grant types detected - may be extension grants' + } + }); + } + + // Summary check: SEP-1046 readiness + const sep1046Ready = hasClientCredentials; + + checks.push({ + id: 'auth-grant-types-sep1046-ready', + name: 'SEP-1046 Client Credentials Ready', + description: + 'Authorization Server is ready for MCP client_credentials flow', + status: sep1046Ready ? 'SUCCESS' : 'INFO', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + ServerAuthSpecReferences.SEP_1046_CLIENT_CREDENTIALS + ], + details: { + sep1046_ready: sep1046Ready, + grant_types_supported: grantTypes, + note: sep1046Ready + ? 'Server supports client_credentials grant per MCP SEP-1046' + : 'Server does not advertise client_credentials - M2M flow not available' + } + }); + + return checks; + } +} diff --git a/src/scenarios/server/auth/scenarios/as-metadata-discovery.ts b/src/scenarios/server/auth/scenarios/as-metadata-discovery.ts new file mode 100644 index 0000000..5843bca --- /dev/null +++ b/src/scenarios/server/auth/scenarios/as-metadata-discovery.ts @@ -0,0 +1,429 @@ +/** + * Authorization Server Metadata Discovery Scenario + * + * Tests that the Authorization Server referenced in the PRM document + * exposes valid metadata per RFC 8414. + * + * @see RFC 8414 - OAuth 2.0 Authorization Server Metadata + * @see MCP Authorization Specification (2025-06-18) + */ + +import { ClientScenario, ConformanceCheck } from '../../../../types'; +import { authFetch, buildPrmUrl } from '../helpers/auth-fetch'; +import { ServerAuthSpecReferences } from '../spec-references'; + +/** + * Build AS metadata discovery URL. + * + * Per RFC 8414, AS metadata is at: + * - /.well-known/oauth-authorization-server (RFC 8414) + * - /.well-known/openid-configuration (OIDC Discovery) + */ +function buildAsMetadataUrl(asUrl: string, useOidc: boolean): string { + const parsed = new URL(asUrl); + const base = `${parsed.protocol}//${parsed.host}`; + + if (useOidc) { + return `${base}/.well-known/openid-configuration`; + } + return `${base}/.well-known/oauth-authorization-server`; +} + +/** + * Validates Authorization Server Metadata endpoint. + * + * Per RFC 8414, the AS metadata document: + * - MUST be served at /.well-known/oauth-authorization-server + * - MUST be valid JSON with Content-Type: application/json + * - MUST contain "issuer" field matching the AS URL + * - MUST contain "authorization_endpoint" + * - MUST contain "token_endpoint" + * - MUST contain "response_types_supported" including "code" + * - SHOULD contain "registration_endpoint" for DCR + * - SHOULD contain "code_challenge_methods_supported" for PKCE + */ +export class AuthAsMetadataDiscoveryScenario implements ClientScenario { + name = 'server/auth-as-metadata-discovery'; + description = `Test Authorization Server Metadata discovery endpoint. + +**Prerequisites**: Server must have valid PRM with authorization_servers array. + +**Endpoint**: \`/.well-known/oauth-authorization-server\` or \`/.well-known/openid-configuration\` + +**Requirements**: +- Return HTTP 200 with Content-Type: application/json +- Required: \`issuer\` (MUST match AS URL) +- Required: \`authorization_endpoint\` +- Required: \`token_endpoint\` +- Required: \`response_types_supported\` (MUST include "code") +- Recommended: \`registration_endpoint\` (for DCR) +- Recommended: \`code_challenge_methods_supported\` (for PKCE) + +**Spec References**: +- RFC 8414 Section 3 (Discovery) +- RFC 8414 Section 2 (Metadata Fields) +- MCP 2025-06-18 - Server Metadata Discovery`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // First, we need to get the PRM to find the authorization server + const pathBasedUrl = buildPrmUrl(serverUrl, true); + const rootUrl = buildPrmUrl(serverUrl, false); + + let prmResponse: Awaited> | null = null; + + try { + const response = await authFetch(pathBasedUrl); + if (response.status === 200) { + prmResponse = response; + } + } catch { + // Try root + } + + if (!prmResponse) { + try { + const response = await authFetch(rootUrl); + if (response.status === 200) { + prmResponse = response; + } + } catch { + // Both failed + } + } + + // Check: Can fetch PRM (prerequisite) + if ( + !prmResponse || + typeof prmResponse.body !== 'object' || + prmResponse.body === null + ) { + checks.push({ + id: 'auth-as-prm-prerequisite', + name: 'PRM Prerequisite', + description: 'Valid PRM required to discover Authorization Server', + status: 'SKIPPED', + timestamp: timestamp(), + errorMessage: 'Cannot fetch valid PRM - run auth-prm-discovery first', + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_DISCOVERY] + }); + return checks; + } + + const prm = prmResponse.body as Record; + const authServers = prm.authorization_servers as string[] | undefined; + + if (!Array.isArray(authServers) || authServers.length === 0) { + checks.push({ + id: 'auth-as-prm-prerequisite', + name: 'PRM Prerequisite', + description: 'PRM must contain authorization_servers array', + status: 'SKIPPED', + timestamp: timestamp(), + errorMessage: 'PRM missing authorization_servers array', + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE] + }); + return checks; + } + + checks.push({ + id: 'auth-as-prm-prerequisite', + name: 'PRM Prerequisite', + description: 'Valid PRM with authorization_servers found', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE], + details: { authorizationServers: authServers } + }); + + // Use the first authorization server + const asUrl = authServers[0]; + + // Try RFC 8414 endpoint first, then OIDC + const rfc8414Url = buildAsMetadataUrl(asUrl, false); + const oidcUrl = buildAsMetadataUrl(asUrl, true); + + let asResponse: Awaited> | null = null; + let usedUrl = ''; + let isOidc = false; + + // Try RFC 8414 first + try { + const response = await authFetch(rfc8414Url); + if (response.status === 200) { + asResponse = response; + usedUrl = rfc8414Url; + } + } catch { + // Will try OIDC + } + + // Try OIDC if RFC 8414 didn't work + if (!asResponse) { + try { + const response = await authFetch(oidcUrl); + if (response.status === 200) { + asResponse = response; + usedUrl = oidcUrl; + isOidc = true; + } + } catch { + // Both failed + } + } + + // Check: AS metadata endpoint exists + if (!asResponse) { + checks.push({ + id: 'auth-as-endpoint-exists', + name: 'AS Metadata Endpoint Exists', + description: + 'Authorization Server exposes metadata at well-known endpoint', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: `No AS metadata found at ${rfc8414Url} or ${oidcUrl}`, + specReferences: [ + ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY, + ServerAuthSpecReferences.OIDC_DISCOVERY + ], + details: { asUrl, triedUrls: [rfc8414Url, oidcUrl] } + }); + return checks; + } + + checks.push({ + id: 'auth-as-endpoint-exists', + name: 'AS Metadata Endpoint Exists', + description: + 'Authorization Server exposes metadata at well-known endpoint', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + isOidc + ? ServerAuthSpecReferences.OIDC_DISCOVERY + : ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY + ], + details: { url: usedUrl, discoveryType: isOidc ? 'OIDC' : 'RFC8414' } + }); + + // Check: Response is valid JSON object + const body = asResponse.body; + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + checks.push({ + id: 'auth-as-valid-json', + name: 'AS Metadata Valid JSON', + description: 'AS metadata response is a valid JSON object', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: 'Response is not a JSON object', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_RESPONSE], + details: { rawBody: asResponse.rawBody.substring(0, 500) } + }); + return checks; + } + + checks.push({ + id: 'auth-as-valid-json', + name: 'AS Metadata Valid JSON', + description: 'AS metadata response is a valid JSON object', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_RESPONSE] + }); + + const asMeta = body as Record; + + // Check: Required "issuer" field + if (typeof asMeta.issuer !== 'string' || asMeta.issuer.length === 0) { + checks.push({ + id: 'auth-as-has-issuer', + name: 'AS Has Issuer', + description: 'AS metadata contains required "issuer" field', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: + 'Missing or invalid "issuer" field (must be non-empty string)', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { issuer: asMeta.issuer } + }); + } else { + // Validate issuer matches AS URL (per RFC 8414) + const issuerMatches = + asMeta.issuer === asUrl || + asMeta.issuer === asUrl.replace(/\/$/, '') || + asUrl.startsWith(asMeta.issuer as string); + + checks.push({ + id: 'auth-as-has-issuer', + name: 'AS Has Issuer', + description: 'AS metadata contains required "issuer" field', + status: issuerMatches ? 'SUCCESS' : 'WARNING', + timestamp: timestamp(), + errorMessage: issuerMatches + ? undefined + : `Issuer "${asMeta.issuer}" may not match AS URL "${asUrl}"`, + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { issuer: asMeta.issuer, asUrl, matches: issuerMatches } + }); + } + + // Check: Required "authorization_endpoint" field + if ( + typeof asMeta.authorization_endpoint !== 'string' || + asMeta.authorization_endpoint.length === 0 + ) { + checks.push({ + id: 'auth-as-has-authorization-endpoint', + name: 'AS Has Authorization Endpoint', + description: + 'AS metadata contains required "authorization_endpoint" field', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: 'Missing or invalid "authorization_endpoint" field', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { authorization_endpoint: asMeta.authorization_endpoint } + }); + } else { + // Validate it's a valid URL + let isValidUrl = false; + try { + new URL(asMeta.authorization_endpoint as string); + isValidUrl = true; + } catch { + isValidUrl = false; + } + + checks.push({ + id: 'auth-as-has-authorization-endpoint', + name: 'AS Has Authorization Endpoint', + description: + 'AS metadata contains required "authorization_endpoint" field', + status: isValidUrl ? 'SUCCESS' : 'WARNING', + timestamp: timestamp(), + errorMessage: isValidUrl + ? undefined + : 'authorization_endpoint is not a valid URL', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { authorization_endpoint: asMeta.authorization_endpoint } + }); + } + + // Check: Required "token_endpoint" field + if ( + typeof asMeta.token_endpoint !== 'string' || + asMeta.token_endpoint.length === 0 + ) { + checks.push({ + id: 'auth-as-has-token-endpoint', + name: 'AS Has Token Endpoint', + description: 'AS metadata contains required "token_endpoint" field', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: 'Missing or invalid "token_endpoint" field', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { token_endpoint: asMeta.token_endpoint } + }); + } else { + let isValidUrl = false; + try { + new URL(asMeta.token_endpoint as string); + isValidUrl = true; + } catch { + isValidUrl = false; + } + + checks.push({ + id: 'auth-as-has-token-endpoint', + name: 'AS Has Token Endpoint', + description: 'AS metadata contains required "token_endpoint" field', + status: isValidUrl ? 'SUCCESS' : 'WARNING', + timestamp: timestamp(), + errorMessage: isValidUrl + ? undefined + : 'token_endpoint is not a valid URL', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { token_endpoint: asMeta.token_endpoint } + }); + } + + // Check: Required "response_types_supported" field with "code" + const responseTypes = asMeta.response_types_supported; + if (!Array.isArray(responseTypes)) { + checks.push({ + id: 'auth-as-response-types-supported', + name: 'AS Response Types Supported', + description: + 'AS metadata contains "response_types_supported" with "code"', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: 'Missing or invalid "response_types_supported" array', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { response_types_supported: responseTypes } + }); + } else { + const hasCode = responseTypes.includes('code'); + checks.push({ + id: 'auth-as-response-types-supported', + name: 'AS Response Types Supported', + description: + 'AS metadata contains "response_types_supported" with "code"', + status: hasCode ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: hasCode + ? undefined + : '"response_types_supported" must include "code" for authorization code flow', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { response_types_supported: responseTypes, hasCode } + }); + } + + // Check: Recommended "registration_endpoint" for DCR + if (asMeta.registration_endpoint !== undefined) { + const regEndpoint = asMeta.registration_endpoint; + let isValidUrl = false; + if (typeof regEndpoint === 'string') { + try { + new URL(regEndpoint); + isValidUrl = true; + } catch { + isValidUrl = false; + } + } + + checks.push({ + id: 'auth-as-registration-endpoint', + name: 'AS Registration Endpoint', + description: 'AS metadata contains "registration_endpoint" for DCR', + status: isValidUrl ? 'SUCCESS' : 'WARNING', + timestamp: timestamp(), + errorMessage: isValidUrl + ? undefined + : 'registration_endpoint is not a valid URL', + specReferences: [ + ServerAuthSpecReferences.RFC_8414_AS_FIELDS, + ServerAuthSpecReferences.MCP_AUTH_DCR + ], + details: { registration_endpoint: regEndpoint } + }); + } else { + checks.push({ + id: 'auth-as-registration-endpoint', + name: 'AS Registration Endpoint', + description: 'AS metadata contains "registration_endpoint" for DCR', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: + 'No registration_endpoint - DCR not supported (CIMD may be alternative)', + specReferences: [ + ServerAuthSpecReferences.RFC_8414_AS_FIELDS, + ServerAuthSpecReferences.MCP_AUTH_DCR + ], + details: { registration_endpoint: undefined } + }); + } + + return checks; + } +} diff --git a/src/scenarios/server/auth/scenarios/as-pkce-support.ts b/src/scenarios/server/auth/scenarios/as-pkce-support.ts new file mode 100644 index 0000000..a01a6cd --- /dev/null +++ b/src/scenarios/server/auth/scenarios/as-pkce-support.ts @@ -0,0 +1,232 @@ +/** + * PKCE Support Validation Scenario + * + * Tests that the Authorization Server supports PKCE (Proof Key for Code Exchange) + * with the S256 code challenge method. + * + * @see RFC 7636 - Proof Key for Code Exchange + * @see OAuth 2.1 Draft Section 7.5.2 + */ + +import { ClientScenario, ConformanceCheck } from '../../../../types'; +import { fetchAsMetadata } from '../helpers/as-metadata'; +import { ServerAuthSpecReferences } from '../spec-references'; + +/** + * Validates PKCE support in AS metadata. + * + * Per RFC 7636 and OAuth 2.1: + * - AS SHOULD advertise code_challenge_methods_supported + * - SHOULD include "S256" (SHA-256 based) + * - MAY include "plain" (not recommended for security) + */ +export class AuthAsPkceSupportScenario implements ClientScenario { + name = 'server/auth-as-pkce-support'; + description = `Test PKCE (Proof Key for Code Exchange) support. + +**Prerequisites**: Server must have valid AS metadata endpoint. + +**Check**: AS metadata contains \`code_challenge_methods_supported\` with "S256". + +PKCE is a security extension for OAuth that prevents authorization code interception attacks. +S256 is the recommended method (SHA-256 hash of code verifier). + +**Spec References**: +- RFC 7636 Section 4.2 (Code Challenge Methods) +- OAuth 2.1 Section 7.5.2`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // Fetch AS metadata + const asResult = await fetchAsMetadata(serverUrl); + + if (!asResult.success || !asResult.metadata) { + checks.push({ + id: 'auth-pkce-as-prerequisite', + name: 'AS Metadata Prerequisite', + description: 'Valid AS metadata required to check PKCE support', + status: 'SKIPPED', + timestamp: timestamp(), + errorMessage: + asResult.error || + 'Cannot fetch AS metadata - run auth-as-metadata-discovery first', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY] + }); + return checks; + } + + checks.push({ + id: 'auth-pkce-as-prerequisite', + name: 'AS Metadata Prerequisite', + description: 'Valid AS metadata found', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY], + details: { asUrl: asResult.asUrl, metadataUrl: asResult.url } + }); + + const metadata = asResult.metadata; + const challengeMethods = metadata.code_challenge_methods_supported; + + // Check: code_challenge_methods_supported field present + if (challengeMethods === undefined) { + checks.push({ + id: 'auth-pkce-field-present', + name: 'PKCE Methods Field Present', + description: + 'AS metadata contains code_challenge_methods_supported field', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: + 'Field not present - PKCE support unknown (may still be supported)', + specReferences: [ + ServerAuthSpecReferences.RFC_7636_CODE_CHALLENGE_METHODS, + ServerAuthSpecReferences.RFC_8414_AS_FIELDS + ], + details: { code_challenge_methods_supported: undefined } + }); + + // Can't determine S256 support + checks.push({ + id: 'auth-pkce-s256-supported', + name: 'PKCE S256 Supported', + description: 'Authorization Server supports S256 code challenge method', + status: 'SKIPPED', + timestamp: timestamp(), + errorMessage: + 'Cannot determine - code_challenge_methods_supported not advertised', + specReferences: [ServerAuthSpecReferences.RFC_7636_CODE_CHALLENGE] + }); + + return checks; + } + + // Check: field is an array + if (!Array.isArray(challengeMethods)) { + checks.push({ + id: 'auth-pkce-field-present', + name: 'PKCE Methods Field Present', + description: + 'AS metadata contains code_challenge_methods_supported field', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: `Invalid type: expected array, got ${typeof challengeMethods}`, + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { code_challenge_methods_supported: challengeMethods } + }); + return checks; + } + + checks.push({ + id: 'auth-pkce-field-present', + name: 'PKCE Methods Field Present', + description: + 'AS metadata contains code_challenge_methods_supported field', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_7636_CODE_CHALLENGE_METHODS, + ServerAuthSpecReferences.RFC_8414_AS_FIELDS + ], + details: { code_challenge_methods_supported: challengeMethods } + }); + + // Check: S256 supported + const hasS256 = challengeMethods.includes('S256'); + const hasPlain = challengeMethods.includes('plain'); + + if (hasS256) { + checks.push({ + id: 'auth-pkce-s256-supported', + name: 'PKCE S256 Supported', + description: 'Authorization Server supports S256 code challenge method', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_7636_CODE_CHALLENGE, + ServerAuthSpecReferences.OAUTH_2_1_PKCE + ], + details: { + code_challenge_methods_supported: challengeMethods, + s256_supported: true + } + }); + } else { + checks.push({ + id: 'auth-pkce-s256-supported', + name: 'PKCE S256 Supported', + description: 'Authorization Server supports S256 code challenge method', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: + 'S256 not in code_challenge_methods_supported - required for secure PKCE', + specReferences: [ + ServerAuthSpecReferences.RFC_7636_CODE_CHALLENGE, + ServerAuthSpecReferences.OAUTH_2_1_PKCE + ], + details: { + code_challenge_methods_supported: challengeMethods, + s256_supported: false + } + }); + } + + // Check: plain method (warning if present alone, or only method) + if (hasPlain && !hasS256) { + checks.push({ + id: 'auth-pkce-plain-only', + name: 'PKCE Plain Method Only', + description: + 'Check if only "plain" method is supported (security risk)', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: + 'Only "plain" PKCE method supported - S256 is recommended for security', + specReferences: [ServerAuthSpecReferences.RFC_7636_CODE_CHALLENGE], + details: { + code_challenge_methods_supported: challengeMethods, + plain_only: true + } + }); + } else if (hasPlain && hasS256) { + checks.push({ + id: 'auth-pkce-plain-only', + name: 'PKCE Plain Method Only', + description: + 'Check if only "plain" method is supported (security risk)', + status: 'INFO', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_7636_CODE_CHALLENGE], + details: { + code_challenge_methods_supported: challengeMethods, + plain_available: true, + s256_available: true, + note: 'Both plain and S256 available - clients should use S256' + } + }); + } + + // Summary check + if (hasS256) { + checks.push({ + id: 'auth-pkce-ready', + name: 'PKCE Ready', + description: 'Authorization Server is ready for PKCE-protected flows', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_7636_CODE_CHALLENGE, + ServerAuthSpecReferences.OAUTH_2_1_PKCE + ], + details: { + recommended_method: 'S256', + available_methods: challengeMethods + } + }); + } + + return checks; + } +} diff --git a/src/scenarios/server/auth/scenarios/as-token-auth-methods.ts b/src/scenarios/server/auth/scenarios/as-token-auth-methods.ts new file mode 100644 index 0000000..a8aca75 --- /dev/null +++ b/src/scenarios/server/auth/scenarios/as-token-auth-methods.ts @@ -0,0 +1,368 @@ +/** + * Token Endpoint Authentication Methods Validation Scenario + * + * Tests that the Authorization Server properly advertises supported + * token endpoint authentication methods. + * + * @see RFC 8414 Section 2 (token_endpoint_auth_methods_supported) + * @see OAuth 2.1 Section 2.4 (Client Authentication) + * @see RFC 7523 (JWT Client Authentication) + */ + +import { ClientScenario, ConformanceCheck } from '../../../../types'; +import { fetchAsMetadata } from '../helpers/as-metadata'; +import { ServerAuthSpecReferences } from '../spec-references'; + +/** + * Valid token endpoint authentication methods per OAuth 2.1. + */ +const VALID_AUTH_METHODS = [ + 'none', // Public clients (no authentication) + 'client_secret_basic', // HTTP Basic authentication + 'client_secret_post', // Client credentials in POST body + 'client_secret_jwt', // Client secret used to sign JWT assertion + 'private_key_jwt', // Private key used to sign JWT assertion + 'tls_client_auth', // Mutual TLS client authentication + 'self_signed_tls_client_auth' // Self-signed certificate mutual TLS +]; + +/** + * Secure JWT signing algorithms. + */ +const SECURE_SIGNING_ALGORITHMS = [ + 'ES256', + 'ES384', + 'ES512', + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512' +]; + +/** + * Validates token endpoint authentication methods in AS metadata. + * + * Per RFC 8414 and OAuth 2.1: + * - AS MAY advertise token_endpoint_auth_methods_supported + * - If private_key_jwt is supported, SHOULD include signing algorithms + * - Important for client_credentials grant type support + */ +export class AuthAsTokenAuthMethodsScenario implements ClientScenario { + name = 'server/auth-as-token-auth-methods'; + description = `Test token endpoint authentication methods advertisement. + +**Prerequisites**: Server must have valid AS metadata endpoint. + +**Check**: AS metadata contains \`token_endpoint_auth_methods_supported\` field +with valid authentication methods. + +Token endpoint authentication methods determine how clients authenticate +when making token requests. Important for client_credentials grant. + +**Spec References**: +- RFC 8414 Section 2 (AS Metadata Fields) +- OAuth 2.1 Section 2.4 (Client Authentication) +- RFC 7523 (JWT Client Authentication)`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // Fetch AS metadata + const asResult = await fetchAsMetadata(serverUrl); + + if (!asResult.success || !asResult.metadata) { + checks.push({ + id: 'auth-token-auth-methods-prerequisite', + name: 'AS Metadata Prerequisite', + description: 'Valid AS metadata required to check token auth methods', + status: 'SKIPPED', + timestamp: timestamp(), + errorMessage: + asResult.error || + 'Cannot fetch AS metadata - run auth-as-metadata-discovery first', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY] + }); + return checks; + } + + checks.push({ + id: 'auth-token-auth-methods-prerequisite', + name: 'AS Metadata Prerequisite', + description: 'Valid AS metadata found', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY], + details: { asUrl: asResult.asUrl, metadataUrl: asResult.url } + }); + + const metadata = asResult.metadata; + const authMethods = metadata.token_endpoint_auth_methods_supported; + const signingAlgorithms = + metadata.token_endpoint_auth_signing_alg_values_supported; + + // Check: token_endpoint_auth_methods_supported field present + if (authMethods === undefined) { + checks.push({ + id: 'auth-token-auth-methods-present', + name: 'Token Auth Methods Field Present', + description: + 'AS metadata contains token_endpoint_auth_methods_supported field', + status: 'INFO', + timestamp: timestamp(), + errorMessage: + 'Field not present - defaults to ["client_secret_basic"] per RFC 8414', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { + token_endpoint_auth_methods_supported: undefined, + default_value: ['client_secret_basic'] + } + }); + + // Cannot determine further details + return checks; + } + + // Check: field is an array + if (!Array.isArray(authMethods)) { + checks.push({ + id: 'auth-token-auth-methods-present', + name: 'Token Auth Methods Field Present', + description: + 'AS metadata contains token_endpoint_auth_methods_supported field', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: `Invalid type: expected array, got ${typeof authMethods}`, + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { token_endpoint_auth_methods_supported: authMethods } + }); + return checks; + } + + // Check: array is not empty + if (authMethods.length === 0) { + checks.push({ + id: 'auth-token-auth-methods-present', + name: 'Token Auth Methods Field Present', + description: + 'AS metadata contains token_endpoint_auth_methods_supported field', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: + 'Empty array - at least one auth method must be supported', + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { token_endpoint_auth_methods_supported: authMethods } + }); + return checks; + } + + checks.push({ + id: 'auth-token-auth-methods-present', + name: 'Token Auth Methods Field Present', + description: + 'AS metadata contains token_endpoint_auth_methods_supported field', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { token_endpoint_auth_methods_supported: authMethods } + }); + + // Check: all methods are valid + const invalidMethods = authMethods.filter( + (m: unknown) => + typeof m !== 'string' || !VALID_AUTH_METHODS.includes(m as string) + ); + + if (invalidMethods.length > 0) { + checks.push({ + id: 'auth-token-auth-methods-valid', + name: 'Token Auth Methods Valid', + description: 'All advertised auth methods are valid OAuth methods', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: `Unknown auth method(s): ${invalidMethods.join(', ')}`, + specReferences: [ + ServerAuthSpecReferences.OAUTH_2_1_TOKEN_ENDPOINT_AUTH + ], + details: { + token_endpoint_auth_methods_supported: authMethods, + invalid_methods: invalidMethods, + valid_methods: VALID_AUTH_METHODS + } + }); + } else { + checks.push({ + id: 'auth-token-auth-methods-valid', + name: 'Token Auth Methods Valid', + description: 'All advertised auth methods are valid OAuth methods', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.OAUTH_2_1_TOKEN_ENDPOINT_AUTH + ], + details: { token_endpoint_auth_methods_supported: authMethods } + }); + } + + // Check: public clients only (none as only method) + if (authMethods.length === 1 && authMethods[0] === 'none') { + checks.push({ + id: 'auth-token-auth-methods-public-only', + name: 'Public Clients Only', + description: + 'Check if only public clients (no authentication) supported', + status: 'INFO', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.OAUTH_2_1_TOKEN_ENDPOINT_AUTH + ], + details: { + note: 'Only "none" auth method supported - public clients only', + client_credentials_support: 'Not available for confidential clients' + } + }); + } + + // Check: private_key_jwt support + const hasPrivateKeyJwt = authMethods.includes('private_key_jwt'); + const hasClientSecretJwt = authMethods.includes('client_secret_jwt'); + + if (hasPrivateKeyJwt || hasClientSecretJwt) { + // Check: signing algorithms advertised + if (signingAlgorithms === undefined) { + checks.push({ + id: 'auth-token-auth-jwt-signing-algs', + name: 'JWT Signing Algorithms', + description: + 'Check token_endpoint_auth_signing_alg_values_supported for JWT auth', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: + 'JWT auth supported but token_endpoint_auth_signing_alg_values_supported not advertised', + specReferences: [ + ServerAuthSpecReferences.RFC_7523_JWT_CLIENT_AUTH, + ServerAuthSpecReferences.RFC_8414_AS_FIELDS + ], + details: { + jwt_auth_methods: authMethods.filter( + (m: unknown) => + m === 'private_key_jwt' || m === 'client_secret_jwt' + ), + signing_algorithms: undefined + } + }); + } else if (!Array.isArray(signingAlgorithms)) { + checks.push({ + id: 'auth-token-auth-jwt-signing-algs', + name: 'JWT Signing Algorithms', + description: + 'Check token_endpoint_auth_signing_alg_values_supported for JWT auth', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: `Invalid type: expected array, got ${typeof signingAlgorithms}`, + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { + token_endpoint_auth_signing_alg_values_supported: signingAlgorithms + } + }); + } else { + // Check for secure algorithms + const secureAlgs = signingAlgorithms.filter( + (alg: unknown) => + typeof alg === 'string' && + SECURE_SIGNING_ALGORITHMS.includes(alg as string) + ); + + if (secureAlgs.length > 0) { + checks.push({ + id: 'auth-token-auth-jwt-signing-algs', + name: 'JWT Signing Algorithms', + description: + 'Check token_endpoint_auth_signing_alg_values_supported for JWT auth', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_7523_JWT_CLIENT_AUTH, + ServerAuthSpecReferences.RFC_8414_AS_FIELDS + ], + details: { + token_endpoint_auth_signing_alg_values_supported: + signingAlgorithms, + secure_algorithms: secureAlgs + } + }); + } else { + checks.push({ + id: 'auth-token-auth-jwt-signing-algs', + name: 'JWT Signing Algorithms', + description: + 'Check token_endpoint_auth_signing_alg_values_supported for JWT auth', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: + 'No secure signing algorithms found (ES256, RS256, etc. recommended)', + specReferences: [ServerAuthSpecReferences.RFC_7523_JWT_CLIENT_AUTH], + details: { + token_endpoint_auth_signing_alg_values_supported: + signingAlgorithms, + recommended: SECURE_SIGNING_ALGORITHMS.slice(0, 4) + } + }); + } + } + } + + // Check: client_secret_basic support (most common for confidential clients) + const hasClientSecretBasic = authMethods.includes('client_secret_basic'); + + if (hasClientSecretBasic) { + checks.push({ + id: 'auth-token-auth-basic-supported', + name: 'Client Secret Basic Supported', + description: + 'Authorization Server supports client_secret_basic authentication', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.OAUTH_2_1_TOKEN_ENDPOINT_AUTH + ], + details: { + client_secret_basic: true, + note: 'Standard HTTP Basic authentication for confidential clients' + } + }); + } + + // Summary check: client credentials readiness + const supportsConfidentialClients = + hasClientSecretBasic || + authMethods.includes('client_secret_post') || + hasPrivateKeyJwt || + hasClientSecretJwt; + + checks.push({ + id: 'auth-token-auth-confidential-client-ready', + name: 'Confidential Client Ready', + description: + 'Authorization Server supports authentication for confidential clients', + status: supportsConfidentialClients ? 'SUCCESS' : 'INFO', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.OAUTH_2_1_TOKEN_ENDPOINT_AUTH, + ServerAuthSpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS + ], + details: { + supports_confidential_clients: supportsConfidentialClients, + token_endpoint_auth_methods_supported: authMethods, + client_credentials_ready: supportsConfidentialClients, + note: supportsConfidentialClients + ? 'Server can authenticate confidential clients for client_credentials grant' + : 'Only public clients supported - client_credentials grant not available' + } + }); + + return checks; + } +} diff --git a/src/scenarios/server/auth/scenarios/discovery-mechanism.ts b/src/scenarios/server/auth/scenarios/discovery-mechanism.ts new file mode 100644 index 0000000..4d442f3 --- /dev/null +++ b/src/scenarios/server/auth/scenarios/discovery-mechanism.ts @@ -0,0 +1,315 @@ +/** + * Discovery Mechanism Validation Scenario + * + * Tests that at least one discovery mechanism is available + * and validates consistency if multiple mechanisms are present. + * + * @see RFC 8414 - OAuth 2.0 Authorization Server Metadata + * @see OpenID Connect Discovery 1.0 + * @see MCP Authorization Specification (2025-06-18) + */ + +import { ClientScenario, ConformanceCheck } from '../../../../types'; +import { authFetch } from '../helpers/auth-fetch'; +import { fetchPrm, buildAsMetadataUrl } from '../helpers/as-metadata'; +import { ServerAuthSpecReferences } from '../spec-references'; + +/** + * Validates discovery mechanisms are available. + * + * Per MCP spec and OAuth best practices: + * - Server MUST provide at least one discovery mechanism + * - RFC 8414: /.well-known/oauth-authorization-server + * - OIDC: /.well-known/openid-configuration + * - If both present, key fields should be consistent + */ +export class AuthDiscoveryMechanismScenario implements ClientScenario { + name = 'server/auth-discovery-mechanism'; + description = `Test discovery mechanism availability. + +**Prerequisites**: Server must have valid PRM with authorization_servers. + +**Checks**: +- At least one discovery endpoint available: + - RFC 8414: \`/.well-known/oauth-authorization-server\` + - OIDC: \`/.well-known/openid-configuration\` +- If both present, validates consistency of common fields + +**Spec References**: +- RFC 8414 Section 3 (Discovery) +- OIDC Discovery 1.0 +- MCP 2025-06-18 - Server Metadata Discovery`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // Fetch PRM to get AS URL + const prmResult = await fetchPrm(serverUrl); + + if (!prmResult.success || !prmResult.prm) { + checks.push({ + id: 'auth-discovery-prm-prerequisite', + name: 'PRM Prerequisite', + description: 'Valid PRM required to discover Authorization Server', + status: 'SKIPPED', + timestamp: timestamp(), + errorMessage: + prmResult.error || 'Cannot fetch PRM - run auth-prm-discovery first', + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_DISCOVERY] + }); + return checks; + } + + const authServers = prmResult.prm.authorization_servers as + | string[] + | undefined; + + if (!Array.isArray(authServers) || authServers.length === 0) { + checks.push({ + id: 'auth-discovery-prm-prerequisite', + name: 'PRM Prerequisite', + description: 'PRM must contain authorization_servers array', + status: 'SKIPPED', + timestamp: timestamp(), + errorMessage: 'PRM missing authorization_servers array', + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE] + }); + return checks; + } + + checks.push({ + id: 'auth-discovery-prm-prerequisite', + name: 'PRM Prerequisite', + description: 'Valid PRM with authorization_servers found', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE], + details: { authorizationServers: authServers } + }); + + const asUrl = authServers[0]; + + // Try RFC 8414 endpoint + const rfc8414Url = buildAsMetadataUrl(asUrl, false); + let rfc8414Response: Awaited> | null = null; + let rfc8414Metadata: Record | null = null; + + try { + const response = await authFetch(rfc8414Url); + if ( + response.status === 200 && + typeof response.body === 'object' && + response.body !== null + ) { + rfc8414Response = response; + rfc8414Metadata = response.body as Record; + } + } catch { + // Will check OIDC + } + + // Try OIDC endpoint + const oidcUrl = buildAsMetadataUrl(asUrl, true); + let oidcResponse: Awaited> | null = null; + let oidcMetadata: Record | null = null; + + try { + const response = await authFetch(oidcUrl); + if ( + response.status === 200 && + typeof response.body === 'object' && + response.body !== null + ) { + oidcResponse = response; + oidcMetadata = response.body as Record; + } + } catch { + // May only have RFC 8414 + } + + const hasRfc8414 = rfc8414Metadata !== null; + const hasOidc = oidcMetadata !== null; + + // Check: RFC 8414 endpoint + if (hasRfc8414) { + checks.push({ + id: 'auth-discovery-rfc8414', + name: 'RFC 8414 Discovery', + description: 'OAuth 2.0 AS Metadata endpoint available', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY], + details: { url: rfc8414Url, status: rfc8414Response?.status } + }); + } else { + checks.push({ + id: 'auth-discovery-rfc8414', + name: 'RFC 8414 Discovery', + description: 'OAuth 2.0 AS Metadata endpoint available', + status: 'INFO', + timestamp: timestamp(), + errorMessage: `No response from ${rfc8414Url}`, + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY], + details: { url: rfc8414Url } + }); + } + + // Check: OIDC endpoint + if (hasOidc) { + checks.push({ + id: 'auth-discovery-oidc', + name: 'OIDC Discovery', + description: 'OpenID Connect Discovery endpoint available', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.OIDC_DISCOVERY], + details: { url: oidcUrl, status: oidcResponse?.status } + }); + } else { + checks.push({ + id: 'auth-discovery-oidc', + name: 'OIDC Discovery', + description: 'OpenID Connect Discovery endpoint available', + status: 'INFO', + timestamp: timestamp(), + errorMessage: `No response from ${oidcUrl}`, + specReferences: [ServerAuthSpecReferences.OIDC_DISCOVERY], + details: { url: oidcUrl } + }); + } + + // Check: At least one mechanism available + if (!hasRfc8414 && !hasOidc) { + checks.push({ + id: 'auth-discovery-any-available', + name: 'Discovery Available', + description: 'At least one discovery mechanism is available', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: + 'No discovery endpoint found - AS metadata not discoverable', + specReferences: [ + ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY, + ServerAuthSpecReferences.OIDC_DISCOVERY, + ServerAuthSpecReferences.MCP_AUTH_SERVER_METADATA + ], + details: { + rfc8414_url: rfc8414Url, + oidc_url: oidcUrl, + rfc8414_available: false, + oidc_available: false + } + }); + return checks; + } + + checks.push({ + id: 'auth-discovery-any-available', + name: 'Discovery Available', + description: 'At least one discovery mechanism is available', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY, + ServerAuthSpecReferences.MCP_AUTH_SERVER_METADATA + ], + details: { + rfc8414_available: hasRfc8414, + oidc_available: hasOidc, + mechanisms: [ + ...(hasRfc8414 ? ['RFC8414'] : []), + ...(hasOidc ? ['OIDC'] : []) + ] + } + }); + + // Check: If both available, validate consistency + if (hasRfc8414 && hasOidc && rfc8414Metadata && oidcMetadata) { + const consistencyIssues: string[] = []; + + // Check issuer consistency + if (rfc8414Metadata.issuer !== oidcMetadata.issuer) { + consistencyIssues.push( + `issuer mismatch: RFC8414="${rfc8414Metadata.issuer}" vs OIDC="${oidcMetadata.issuer}"` + ); + } + + // Check authorization_endpoint consistency + if ( + rfc8414Metadata.authorization_endpoint !== + oidcMetadata.authorization_endpoint + ) { + consistencyIssues.push( + 'authorization_endpoint differs between endpoints' + ); + } + + // Check token_endpoint consistency + if (rfc8414Metadata.token_endpoint !== oidcMetadata.token_endpoint) { + consistencyIssues.push('token_endpoint differs between endpoints'); + } + + if (consistencyIssues.length === 0) { + checks.push({ + id: 'auth-discovery-consistency', + name: 'Discovery Consistency', + description: 'RFC 8414 and OIDC Discovery return consistent metadata', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_8414_AS_FIELDS, + ServerAuthSpecReferences.OIDC_DISCOVERY + ], + details: { + rfc8414_issuer: rfc8414Metadata.issuer, + oidc_issuer: oidcMetadata.issuer, + consistent: true + } + }); + } else { + checks.push({ + id: 'auth-discovery-consistency', + name: 'Discovery Consistency', + description: 'RFC 8414 and OIDC Discovery return consistent metadata', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: `Inconsistencies found: ${consistencyIssues.join('; ')}`, + specReferences: [ + ServerAuthSpecReferences.RFC_8414_AS_FIELDS, + ServerAuthSpecReferences.OIDC_DISCOVERY + ], + details: { + issues: consistencyIssues, + rfc8414_issuer: rfc8414Metadata.issuer, + oidc_issuer: oidcMetadata.issuer + } + }); + } + } + + // Summary + checks.push({ + id: 'auth-discovery-summary', + name: 'Discovery Summary', + description: 'Summary of available discovery mechanisms', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.MCP_AUTH_SERVER_METADATA], + details: { + as_url: asUrl, + rfc8414: { + available: hasRfc8414, + url: rfc8414Url + }, + oidc: { + available: hasOidc, + url: oidcUrl + }, + recommended: hasRfc8414 ? 'RFC8414' : 'OIDC' + } + }); + + return checks; + } +} diff --git a/src/scenarios/server/auth/scenarios/prm-discovery.ts b/src/scenarios/server/auth/scenarios/prm-discovery.ts new file mode 100644 index 0000000..f10dd01 --- /dev/null +++ b/src/scenarios/server/auth/scenarios/prm-discovery.ts @@ -0,0 +1,301 @@ +/** + * Protected Resource Metadata (PRM) Discovery Scenario + * + * Tests that an OAuth-protected MCP server correctly exposes its + * Protected Resource Metadata at the well-known endpoint. + * + * @see RFC 9728 - OAuth 2.0 Protected Resource Metadata + * @see MCP Authorization Specification + */ + +import { ClientScenario, ConformanceCheck } from '../../../../types'; +import { authFetch, buildPrmUrl } from '../helpers/auth-fetch'; +import { ServerAuthSpecReferences } from '../spec-references'; + +/** + * Validates Protected Resource Metadata endpoint. + * + * Per RFC 9728, the PRM document: + * - MUST be served at /.well-known/oauth-protected-resource or path-based variant + * - MUST be valid JSON with Content-Type: application/json + * - MUST contain "resource" field (the protected resource identifier) + * - MUST contain "authorization_servers" array (list of AS URLs) + * - MAY contain "scopes_supported" array + */ +export class AuthPrmDiscoveryScenario implements ClientScenario { + name = 'server/auth-prm-discovery'; + description = `Test Protected Resource Metadata (PRM) discovery endpoint. + +**Server Implementation Requirements:** + +**Endpoint**: \`/.well-known/oauth-protected-resource\` or path-based variant + +**Requirements**: +- Return HTTP 200 with Content-Type: application/json +- Include required \`resource\` field (protected resource identifier) +- Include required \`authorization_servers\` array (authorization server URLs) +- Optional \`scopes_supported\` array if scopes are defined + +**Example Response**: +\`\`\`json +{ + "resource": "https://server.example.com/mcp", + "authorization_servers": ["https://auth.example.com"], + "scopes_supported": ["mcp:read", "mcp:write"] +} +\`\`\` + +**Spec References**: +- RFC 9728 Section 3.1 (Discovery) +- RFC 9728 Section 3.2 (Response) +- MCP Authorization - Protected Resource Metadata`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // Try path-based PRM first (more specific), then fall back to root + const pathBasedUrl = buildPrmUrl(serverUrl, true); + const rootUrl = buildPrmUrl(serverUrl, false); + + let prmResponse: Awaited> | null = null; + let usedUrl = ''; + + // Try path-based first + try { + const response = await authFetch(pathBasedUrl); + if (response.status === 200) { + prmResponse = response; + usedUrl = pathBasedUrl; + } + } catch { + // Path-based failed, will try root + } + + // Try root if path-based didn't work + if (!prmResponse && pathBasedUrl !== rootUrl) { + try { + const response = await authFetch(rootUrl); + if (response.status === 200) { + prmResponse = response; + usedUrl = rootUrl; + } + } catch { + // Root also failed + } + } + + // Check 1: PRM endpoint exists + if (!prmResponse) { + checks.push({ + id: 'auth-prm-endpoint-exists', + name: 'PRM Endpoint Exists', + description: + 'Server exposes Protected Resource Metadata at well-known endpoint', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: `No PRM found at ${pathBasedUrl} or ${rootUrl}`, + specReferences: [ + ServerAuthSpecReferences.RFC_9728_PRM_DISCOVERY, + ServerAuthSpecReferences.MCP_AUTH_SERVER_LOCATION + ], + details: { + triedUrls: [pathBasedUrl, rootUrl] + } + }); + return checks; + } + + checks.push({ + id: 'auth-prm-endpoint-exists', + name: 'PRM Endpoint Exists', + description: + 'Server exposes Protected Resource Metadata at well-known endpoint', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_9728_PRM_DISCOVERY, + ServerAuthSpecReferences.MCP_AUTH_PRM_DISCOVERY + ], + details: { + url: usedUrl, + status: prmResponse.status + } + }); + + // Check 2: Response is valid JSON + const body = prmResponse.body; + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + checks.push({ + id: 'auth-prm-valid-json', + name: 'PRM Valid JSON', + description: 'PRM response is a valid JSON object', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: 'Response is not a JSON object', + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE], + details: { + rawBody: prmResponse.rawBody.substring(0, 500) + } + }); + return checks; + } + + checks.push({ + id: 'auth-prm-valid-json', + name: 'PRM Valid JSON', + description: 'PRM response is a valid JSON object', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE] + }); + + const prm = body as Record; + + // Check 3: Required "resource" field + if (typeof prm.resource !== 'string' || prm.resource.length === 0) { + checks.push({ + id: 'auth-prm-has-resource', + name: 'PRM Has Resource Field', + description: 'PRM contains required "resource" identifier', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: + 'Missing or invalid "resource" field (must be non-empty string)', + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE], + details: { + resource: prm.resource + } + }); + } else { + checks.push({ + id: 'auth-prm-has-resource', + name: 'PRM Has Resource Field', + description: 'PRM contains required "resource" identifier', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE], + details: { + resource: prm.resource + } + }); + } + + // Check 4: Required "authorization_servers" field + if ( + !Array.isArray(prm.authorization_servers) || + prm.authorization_servers.length === 0 + ) { + checks.push({ + id: 'auth-prm-has-authorization-servers', + name: 'PRM Has Authorization Servers', + description: 'PRM contains required "authorization_servers" array', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: + 'Missing or invalid "authorization_servers" field (must be non-empty array)', + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE], + details: { + authorization_servers: prm.authorization_servers + } + }); + } else { + // Validate each URL in the array + const invalidUrls: string[] = []; + for (const server of prm.authorization_servers) { + if (typeof server !== 'string') { + invalidUrls.push(String(server)); + continue; + } + try { + new URL(server); + } catch { + invalidUrls.push(server); + } + } + + if (invalidUrls.length > 0) { + checks.push({ + id: 'auth-prm-has-authorization-servers', + name: 'PRM Has Authorization Servers', + description: + 'PRM contains required "authorization_servers" array with valid URLs', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: `Invalid URLs in authorization_servers: ${invalidUrls.join(', ')}`, + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE], + details: { + authorization_servers: prm.authorization_servers, + invalidUrls + } + }); + } else { + checks.push({ + id: 'auth-prm-has-authorization-servers', + name: 'PRM Has Authorization Servers', + description: + 'PRM contains required "authorization_servers" array with valid URLs', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE], + details: { + authorization_servers: prm.authorization_servers + } + }); + } + } + + // Check 5: Optional "scopes_supported" validation (if present) + if (prm.scopes_supported !== undefined) { + if (!Array.isArray(prm.scopes_supported)) { + checks.push({ + id: 'auth-prm-scopes-supported-valid', + name: 'PRM Scopes Supported Valid', + description: + 'PRM "scopes_supported" field is a valid array (if present)', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: '"scopes_supported" must be an array when present', + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE], + details: { + scopes_supported: prm.scopes_supported + } + }); + } else { + // Check all scopes are strings + const nonStringScopes = prm.scopes_supported.filter( + (s) => typeof s !== 'string' + ); + if (nonStringScopes.length > 0) { + checks.push({ + id: 'auth-prm-scopes-supported-valid', + name: 'PRM Scopes Supported Valid', + description: 'PRM "scopes_supported" contains only string values', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: 'Some scopes are not strings', + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE], + details: { + scopes_supported: prm.scopes_supported, + nonStringScopes + } + }); + } else { + checks.push({ + id: 'auth-prm-scopes-supported-valid', + name: 'PRM Scopes Supported Valid', + description: 'PRM "scopes_supported" field is valid', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE], + details: { + scopes_supported: prm.scopes_supported + } + }); + } + } + } + + return checks; + } +} diff --git a/src/scenarios/server/auth/scenarios/prm-resource-validation.ts b/src/scenarios/server/auth/scenarios/prm-resource-validation.ts new file mode 100644 index 0000000..e9ecff6 --- /dev/null +++ b/src/scenarios/server/auth/scenarios/prm-resource-validation.ts @@ -0,0 +1,276 @@ +/** + * PRM Resource Identifier Validation Scenario + * + * Tests that the "resource" field in the Protected Resource Metadata + * is properly formatted and relates to the server URL. + * + * @see RFC 8707 - Resource Indicators for OAuth 2.0 + * @see MCP Authorization Specification (2025-06-18) + */ + +import { ClientScenario, ConformanceCheck } from '../../../../types'; +import { fetchPrm } from '../helpers/as-metadata'; +import { ServerAuthSpecReferences } from '../spec-references'; + +/** + * Validates the "resource" field in PRM. + * + * Per RFC 8707 and MCP spec: + * - "resource" MUST be a valid absolute URI + * - "resource" SHOULD use HTTPS scheme + * - "resource" SHOULD match or be related to the server URL + * - "resource" MUST NOT contain a fragment component + */ +export class AuthPrmResourceValidationScenario implements ClientScenario { + name = 'server/auth-prm-resource-validation'; + description = `Test PRM "resource" field validation. + +**Prerequisites**: Server must have valid PRM endpoint. + +**Checks**: +- "resource" is a valid absolute URI +- "resource" uses HTTPS scheme (recommended) +- "resource" relates to the server URL +- "resource" has no fragment component + +The resource identifier is used for token audience binding. + +**Spec References**: +- RFC 8707 Section 2 (Resource Parameter) +- MCP 2025-06-18 - Canonical Server URI`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // Fetch PRM + const prmResult = await fetchPrm(serverUrl); + + if (!prmResult.success || !prmResult.prm) { + checks.push({ + id: 'auth-prm-resource-prerequisite', + name: 'PRM Prerequisite', + description: 'Valid PRM required to validate resource field', + status: 'SKIPPED', + timestamp: timestamp(), + errorMessage: + prmResult.error || 'Cannot fetch PRM - run auth-prm-discovery first', + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_DISCOVERY] + }); + return checks; + } + + checks.push({ + id: 'auth-prm-resource-prerequisite', + name: 'PRM Prerequisite', + description: 'Valid PRM found', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_DISCOVERY], + details: { prmUrl: prmResult.url } + }); + + const resource = prmResult.prm.resource; + + // Check: resource field exists and is string + if (typeof resource !== 'string' || resource.length === 0) { + checks.push({ + id: 'auth-prm-resource-exists', + name: 'Resource Field Exists', + description: 'PRM contains "resource" field', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: + 'Missing or invalid "resource" field (must be non-empty string)', + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_FIELDS], + details: { resource } + }); + return checks; + } + + checks.push({ + id: 'auth-prm-resource-exists', + name: 'Resource Field Exists', + description: 'PRM contains "resource" field', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_FIELDS], + details: { resource } + }); + + // Check: resource is a valid absolute URI + let resourceUrl: URL | null = null; + try { + resourceUrl = new URL(resource); + } catch { + checks.push({ + id: 'auth-prm-resource-valid-uri', + name: 'Resource Valid URI', + description: 'Resource field is a valid absolute URI', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: `"${resource}" is not a valid absolute URI`, + specReferences: [ServerAuthSpecReferences.RFC_8707_RESOURCE_PARAMETER], + details: { resource, valid: false } + }); + return checks; + } + + checks.push({ + id: 'auth-prm-resource-valid-uri', + name: 'Resource Valid URI', + description: 'Resource field is a valid absolute URI', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_8707_RESOURCE_PARAMETER], + details: { resource, valid: true } + }); + + // Check: resource uses HTTPS + if (resourceUrl.protocol !== 'https:') { + checks.push({ + id: 'auth-prm-resource-https', + name: 'Resource Uses HTTPS', + description: 'Resource URI uses HTTPS scheme (recommended)', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: `Resource uses ${resourceUrl.protocol} - HTTPS is recommended for security`, + specReferences: [ServerAuthSpecReferences.RFC_8707_SECURITY], + details: { resource, protocol: resourceUrl.protocol } + }); + } else { + checks.push({ + id: 'auth-prm-resource-https', + name: 'Resource Uses HTTPS', + description: 'Resource URI uses HTTPS scheme (recommended)', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_8707_SECURITY], + details: { resource, protocol: resourceUrl.protocol } + }); + } + + // Check: resource has no fragment + if (resourceUrl.hash && resourceUrl.hash.length > 0) { + checks.push({ + id: 'auth-prm-resource-no-fragment', + name: 'Resource No Fragment', + description: 'Resource URI does not contain fragment component', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: + 'Resource URI contains fragment - not allowed per RFC 8707', + specReferences: [ServerAuthSpecReferences.RFC_8707_RESOURCE_PARAMETER], + details: { resource, fragment: resourceUrl.hash } + }); + } else { + checks.push({ + id: 'auth-prm-resource-no-fragment', + name: 'Resource No Fragment', + description: 'Resource URI does not contain fragment component', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_8707_RESOURCE_PARAMETER], + details: { resource } + }); + } + + // Check: resource relates to server URL + let serverUrlParsed: URL; + try { + serverUrlParsed = new URL(serverUrl); + } catch { + // Can't compare, skip this check + checks.push({ + id: 'auth-prm-resource-matches-server', + name: 'Resource Matches Server', + description: 'Resource URI relates to the server URL', + status: 'SKIPPED', + timestamp: timestamp(), + errorMessage: 'Cannot parse server URL for comparison', + specReferences: [ServerAuthSpecReferences.MCP_AUTH_CANONICAL_URI], + details: { resource, serverUrl } + }); + return checks; + } + + // Check if resource matches server URL in various ways: + // 1. Exact match + // 2. Same host with resource being prefix of server + // 3. Same host with server being prefix of resource + const resourceBase = `${resourceUrl.protocol}//${resourceUrl.host}`; + const serverBase = `${serverUrlParsed.protocol}//${serverUrlParsed.host}`; + + const exactMatch = + resource === serverUrl || + resource === serverUrl.replace(/\/$/, '') || + resource.replace(/\/$/, '') === serverUrl; + + const sameHost = resourceUrl.host === serverUrlParsed.host; + + const resourceIsPrefix = serverUrl.startsWith(resource.replace(/\/$/, '')); + const serverIsPrefix = resource.startsWith(serverUrl.replace(/\/$/, '')); + + const relatesTo = + exactMatch || (sameHost && (resourceIsPrefix || serverIsPrefix)); + + if (relatesTo) { + checks.push({ + id: 'auth-prm-resource-matches-server', + name: 'Resource Matches Server', + description: 'Resource URI relates to the server URL', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.MCP_AUTH_CANONICAL_URI], + details: { + resource, + serverUrl, + exactMatch, + sameHost, + relationship: exactMatch + ? 'exact' + : resourceIsPrefix + ? 'resource_is_prefix' + : 'server_is_prefix' + } + }); + } else if (sameHost) { + checks.push({ + id: 'auth-prm-resource-matches-server', + name: 'Resource Matches Server', + description: 'Resource URI relates to the server URL', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: + 'Same host but paths differ significantly - verify this is intentional', + specReferences: [ServerAuthSpecReferences.MCP_AUTH_CANONICAL_URI], + details: { + resource, + serverUrl, + resourceBase, + serverBase, + sameHost: true + } + }); + } else { + checks.push({ + id: 'auth-prm-resource-matches-server', + name: 'Resource Matches Server', + description: 'Resource URI relates to the server URL', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: `Resource host (${resourceUrl.host}) differs from server host (${serverUrlParsed.host})`, + specReferences: [ServerAuthSpecReferences.MCP_AUTH_CANONICAL_URI], + details: { + resource, + serverUrl, + resourceHost: resourceUrl.host, + serverHost: serverUrlParsed.host, + sameHost: false + } + }); + } + + return checks; + } +} diff --git a/src/scenarios/server/auth/scenarios/unauthorized-response.ts b/src/scenarios/server/auth/scenarios/unauthorized-response.ts new file mode 100644 index 0000000..b7fe80e --- /dev/null +++ b/src/scenarios/server/auth/scenarios/unauthorized-response.ts @@ -0,0 +1,258 @@ +/** + * Unauthorized Response (401) Scenario + * + * Tests that an OAuth-protected MCP server correctly returns 401 Unauthorized + * when requests are made without a Bearer token. + * + * @see RFC 6750 - Bearer Token Usage + * @see RFC 7235 - HTTP Authentication + * @see MCP Authorization Specification + */ + +import { ClientScenario, ConformanceCheck } from '../../../../types'; +import { authFetch } from '../helpers/auth-fetch'; +import { ServerAuthSpecReferences } from '../spec-references'; + +/** + * Validates server returns 401 for unauthenticated requests. + * + * Per RFC 6750 and MCP Authorization spec: + * - Protected endpoints MUST return 401 when no Authorization header is provided + * - Response MUST include WWW-Authenticate header with Bearer scheme + * - Response body SHOULD be valid JSON (either JSON-RPC error or OAuth error) + */ +export class AuthUnauthorizedResponseScenario implements ClientScenario { + name = 'server/auth-401-unauthorized'; + description = `Test that server returns 401 Unauthorized for unauthenticated requests. + +**Server Implementation Requirements:** + +**Behavior**: When a request is made without an Authorization header: +- Return HTTP 401 Unauthorized status code +- Include WWW-Authenticate header with Bearer scheme +- Return valid JSON response body + +**Expected Response**: +- Status: 401 +- Headers: WWW-Authenticate: Bearer ... +- Body: JSON (OAuth error or JSON-RPC error format) + +**Example WWW-Authenticate**: +\`\`\` +WWW-Authenticate: Bearer realm="mcp", scope="mcp:read mcp:write" +\`\`\` + +**Spec References**: +- RFC 6750 Section 3 (WWW-Authenticate Response Header) +- RFC 7235 Section 3.1 (401 Unauthorized) +- MCP Authorization - Access Token Usage`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // Make a request to the MCP endpoint without Authorization header + // We'll send a minimal JSON-RPC request to trigger auth check + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { + name: 'conformance-auth-test', + version: '1.0.0' + } + }, + id: 1 + }; + + let response: Awaited>; + + try { + response = await authFetch(serverUrl, { + method: 'POST', + body: jsonRpcRequest + }); + } catch (error) { + checks.push({ + id: 'auth-401-request-completes', + name: 'Auth Request Completes', + description: 'Server responds to unauthenticated request', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [ServerAuthSpecReferences.RFC_7235_401_RESPONSE] + }); + return checks; + } + + // Check 1: Server returns 401 status + if (response.status === 401) { + checks.push({ + id: 'auth-401-status-code', + name: '401 Status Code', + description: + 'Server returns 401 Unauthorized for unauthenticated requests', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_7235_401_RESPONSE, + ServerAuthSpecReferences.RFC_6750_BEARER_TOKEN + ], + details: { + status: response.status + } + }); + } else if (response.status === 200) { + // Server might allow unauthenticated initialize but protect other methods + // This is acceptable per MCP spec (step-up auth) + checks.push({ + id: 'auth-401-status-code', + name: '401 Status Code', + description: + 'Server returns 401 Unauthorized for unauthenticated requests', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: + 'Server allowed unauthenticated request (may use step-up auth)', + specReferences: [ + ServerAuthSpecReferences.RFC_7235_401_RESPONSE, + ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN + ], + details: { + status: response.status, + note: 'Server may allow initialize without auth and protect other methods' + } + }); + + // If server allows initialize, try tools/list which typically requires auth + try { + const toolsRequest = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 2 + }; + + const toolsResponse = await authFetch(serverUrl, { + method: 'POST', + body: toolsRequest + }); + + if (toolsResponse.status === 401) { + checks.push({ + id: 'auth-401-protected-method', + name: '401 For Protected Method', + description: + 'Server returns 401 for protected methods (step-up auth)', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN], + details: { + method: 'tools/list', + status: toolsResponse.status + } + }); + // Use this response for further checks + response = toolsResponse; + } else { + checks.push({ + id: 'auth-401-protected-method', + name: '401 For Protected Method', + description: 'Server returns 401 for protected methods', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: `tools/list returned ${toolsResponse.status}, not 401`, + specReferences: [ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN], + details: { + method: 'tools/list', + status: toolsResponse.status + } + }); + } + } catch { + // tools/list request failed, continue with original response + } + } else { + checks.push({ + id: 'auth-401-status-code', + name: '401 Status Code', + description: + 'Server returns 401 Unauthorized for unauthenticated requests', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: `Expected 401, got ${response.status}`, + specReferences: [ + ServerAuthSpecReferences.RFC_7235_401_RESPONSE, + ServerAuthSpecReferences.RFC_6750_BEARER_TOKEN + ], + details: { + status: response.status, + body: response.body + } + }); + } + + // Check 2: WWW-Authenticate header is present + if (response.wwwAuthenticate) { + checks.push({ + id: 'auth-401-www-authenticate-present', + name: 'WWW-Authenticate Header Present', + description: '401 response includes WWW-Authenticate header', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_7235_WWW_AUTHENTICATE, + ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE + ], + details: { + wwwAuthenticate: response.wwwAuthenticate.raw + } + }); + } else if (response.status === 401) { + checks.push({ + id: 'auth-401-www-authenticate-present', + name: 'WWW-Authenticate Header Present', + description: '401 response includes WWW-Authenticate header', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: '401 response missing required WWW-Authenticate header', + specReferences: [ + ServerAuthSpecReferences.RFC_7235_WWW_AUTHENTICATE, + ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE + ] + }); + } + + // Check 3: Response body is valid JSON + if (typeof response.body === 'object' && response.body !== null) { + checks.push({ + id: 'auth-401-response-json', + name: 'Response Is JSON', + description: '401 response body is valid JSON', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.MCP_AUTH_ERROR_HANDLING], + details: { + bodyType: Array.isArray(response.body) ? 'array' : 'object' + } + }); + } else if (response.status === 401) { + checks.push({ + id: 'auth-401-response-json', + name: 'Response Is JSON', + description: '401 response body is valid JSON', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: 'Response body is not valid JSON', + specReferences: [ServerAuthSpecReferences.MCP_AUTH_ERROR_HANDLING], + details: { + rawBody: response.rawBody.substring(0, 200) + } + }); + } + + return checks; + } +} diff --git a/src/scenarios/server/auth/scenarios/www-authenticate-header.ts b/src/scenarios/server/auth/scenarios/www-authenticate-header.ts new file mode 100644 index 0000000..43a7c79 --- /dev/null +++ b/src/scenarios/server/auth/scenarios/www-authenticate-header.ts @@ -0,0 +1,347 @@ +/** + * WWW-Authenticate Header Validation Scenario + * + * Tests that an OAuth-protected MCP server returns properly formatted + * WWW-Authenticate headers in 401 responses. + * + * @see RFC 6750 - Bearer Token Usage + * @see RFC 7235 - HTTP Authentication + * @see MCP Authorization Specification + */ + +import { ClientScenario, ConformanceCheck } from '../../../../types'; +import { authFetch } from '../helpers/auth-fetch'; +import { ServerAuthSpecReferences } from '../spec-references'; + +/** + * Validates WWW-Authenticate header format and content. + * + * Per RFC 6750 Section 3: + * - MUST use "Bearer" authentication scheme + * - MAY include "realm" parameter + * - MAY include "scope" parameter (space-separated scope values) + * - MAY include "error" parameter for error responses + * - MAY include "error_description" parameter + * + * MCP extends this with: + * - "resource_metadata" parameter pointing to PRM URL + */ +export class AuthWWWAuthenticateHeaderScenario implements ClientScenario { + name = 'server/auth-www-authenticate-header'; + description = `Test WWW-Authenticate header format in 401 responses. + +**Server Implementation Requirements:** + +**Header Format**: Bearer challenge per RFC 6750 + +**Required**: +- Use "Bearer" authentication scheme + +**Recommended**: +- Include \`resource_metadata\` parameter with PRM URL +- Include \`scope\` parameter if scopes are required + +**Optional**: +- Include \`realm\` parameter +- Include \`error\` parameter for specific error conditions +- Include \`error_description\` for human-readable error details + +**Example Headers**: +\`\`\` +WWW-Authenticate: Bearer realm="mcp" +WWW-Authenticate: Bearer scope="mcp:read mcp:write", resource_metadata="https://server.example.com/.well-known/oauth-protected-resource" +WWW-Authenticate: Bearer error="invalid_token", error_description="Token expired" +\`\`\` + +**Spec References**: +- RFC 6750 Section 3 (WWW-Authenticate Response Header) +- RFC 7235 Section 4.1 (WWW-Authenticate) +- MCP Authorization - Scope Selection Strategy`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // Make unauthenticated request to trigger 401 + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 1 + }; + + let response: Awaited>; + + try { + response = await authFetch(serverUrl, { + method: 'POST', + body: jsonRpcRequest + }); + } catch (error) { + checks.push({ + id: 'auth-www-auth-request-completes', + name: 'Request Completes', + description: 'Server responds to request', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE] + }); + return checks; + } + + // If not 401, we can't test WWW-Authenticate + if (response.status !== 401) { + // Try initialize first to establish session, then tools/list + const initRequest = { + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'conformance-test', version: '1.0.0' } + }, + id: 0 + }; + + try { + await authFetch(serverUrl, { method: 'POST', body: initRequest }); + response = await authFetch(serverUrl, { + method: 'POST', + body: jsonRpcRequest + }); + } catch { + // Continue with original response + } + } + + if (response.status !== 401) { + checks.push({ + id: 'auth-www-auth-401-received', + name: '401 Response Received', + description: 'Server returned 401 to test WWW-Authenticate header', + status: 'SKIPPED', + timestamp: timestamp(), + errorMessage: `Server returned ${response.status}, not 401 - cannot test WWW-Authenticate`, + specReferences: [ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE], + details: { + status: response.status, + note: 'Server may not require authentication or may allow this method without auth' + } + }); + return checks; + } + + // Check 1: WWW-Authenticate header exists + if (!response.wwwAuthenticate) { + checks.push({ + id: 'auth-www-auth-header-exists', + name: 'WWW-Authenticate Header Exists', + description: '401 response includes WWW-Authenticate header', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: 'Missing WWW-Authenticate header in 401 response', + specReferences: [ + ServerAuthSpecReferences.RFC_7235_WWW_AUTHENTICATE, + ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE + ] + }); + return checks; + } + + checks.push({ + id: 'auth-www-auth-header-exists', + name: 'WWW-Authenticate Header Exists', + description: '401 response includes WWW-Authenticate header', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_7235_WWW_AUTHENTICATE, + ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE + ], + details: { + raw: response.wwwAuthenticate.raw + } + }); + + const wwwAuth = response.wwwAuthenticate; + + // Check 2: Uses Bearer scheme + if (wwwAuth.scheme.toLowerCase() === 'bearer') { + checks.push({ + id: 'auth-www-auth-bearer-scheme', + name: 'Bearer Scheme Used', + description: 'WWW-Authenticate uses Bearer authentication scheme', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE], + details: { + scheme: wwwAuth.scheme + } + }); + } else { + checks.push({ + id: 'auth-www-auth-bearer-scheme', + name: 'Bearer Scheme Used', + description: 'WWW-Authenticate uses Bearer authentication scheme', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: `Expected "Bearer" scheme, got "${wwwAuth.scheme}"`, + specReferences: [ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE], + details: { + scheme: wwwAuth.scheme, + expected: 'Bearer' + } + }); + } + + // Check 3: resource_metadata parameter (recommended for MCP) + if (wwwAuth.params.resource_metadata) { + // Validate it's a valid URL + try { + new URL(wwwAuth.params.resource_metadata); + checks.push({ + id: 'auth-www-auth-resource-metadata', + name: 'Resource Metadata URL', + description: 'WWW-Authenticate includes valid resource_metadata URL', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.MCP_AUTH_SCOPE_SELECTION], + details: { + resource_metadata: wwwAuth.params.resource_metadata + } + }); + } catch { + checks.push({ + id: 'auth-www-auth-resource-metadata', + name: 'Resource Metadata URL', + description: 'WWW-Authenticate includes valid resource_metadata URL', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: `Invalid resource_metadata URL: ${wwwAuth.params.resource_metadata}`, + specReferences: [ServerAuthSpecReferences.MCP_AUTH_SCOPE_SELECTION], + details: { + resource_metadata: wwwAuth.params.resource_metadata + } + }); + } + } else { + checks.push({ + id: 'auth-www-auth-resource-metadata', + name: 'Resource Metadata URL', + description: + 'WWW-Authenticate includes resource_metadata URL (recommended)', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: + 'Missing resource_metadata parameter (recommended for MCP)', + specReferences: [ServerAuthSpecReferences.MCP_AUTH_SCOPE_SELECTION], + details: { + params: wwwAuth.params + } + }); + } + + // Check 4: scope parameter format (if present) + if (wwwAuth.params.scope !== undefined) { + const scope = wwwAuth.params.scope; + + // Scope should be space-separated tokens per RFC 6749 + // Each scope token should be printable ASCII without certain characters + const scopeTokenRegex = /^[\x21\x23-\x5B\x5D-\x7E]+$/; + const scopes = scope.split(' ').filter((s) => s.length > 0); + + if (scopes.length === 0 && scope.length > 0) { + checks.push({ + id: 'auth-www-auth-scope-format', + name: 'Scope Parameter Format', + description: 'Scope parameter is properly formatted', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: 'Scope parameter is present but empty or malformed', + specReferences: [ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE], + details: { + scope, + parsed: scopes + } + }); + } else { + const invalidScopes = scopes.filter((s) => !scopeTokenRegex.test(s)); + if (invalidScopes.length > 0) { + checks.push({ + id: 'auth-www-auth-scope-format', + name: 'Scope Parameter Format', + description: 'Scope parameter contains valid scope tokens', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: `Some scope tokens may contain invalid characters: ${invalidScopes.join(', ')}`, + specReferences: [ + ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE + ], + details: { + scope, + scopes, + invalidScopes + } + }); + } else { + checks.push({ + id: 'auth-www-auth-scope-format', + name: 'Scope Parameter Format', + description: 'Scope parameter is properly formatted', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE + ], + details: { + scope, + scopes + } + }); + } + } + } + + // Check 5: error parameter (if present, validate it's a known value) + if (wwwAuth.params.error !== undefined) { + const validErrors = [ + 'invalid_request', + 'invalid_token', + 'insufficient_scope' + ]; + + if (validErrors.includes(wwwAuth.params.error)) { + checks.push({ + id: 'auth-www-auth-error-code', + name: 'Error Code Valid', + description: 'Error parameter uses RFC 6750 defined value', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_6750_ERROR_CODES], + details: { + error: wwwAuth.params.error, + error_description: wwwAuth.params.error_description + } + }); + } else { + checks.push({ + id: 'auth-www-auth-error-code', + name: 'Error Code Valid', + description: 'Error parameter uses RFC 6750 defined value', + status: 'WARNING', + timestamp: timestamp(), + errorMessage: `Unknown error code: ${wwwAuth.params.error}`, + specReferences: [ServerAuthSpecReferences.RFC_6750_ERROR_CODES], + details: { + error: wwwAuth.params.error, + validErrors, + note: 'Custom error codes are allowed but not standard' + } + }); + } + } + + return checks; + } +} diff --git a/src/scenarios/server/auth/spec-references.ts b/src/scenarios/server/auth/spec-references.ts new file mode 100644 index 0000000..7e5f96b --- /dev/null +++ b/src/scenarios/server/auth/spec-references.ts @@ -0,0 +1,249 @@ +/** + * 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 7636 (PKCE) + * - RFC 7523 (JWT Client Authentication) + * - RFC 6750 (Bearer Token Usage) + * - RFC 7235 (HTTP Authentication) + * - RFC 8707 (Resource Indicators) + * - OAuth 2.1 Draft (Client Credentials, Token Endpoint Auth) + * - MCP Authorization Specification (2025-06-18) + * - MCP Extension SEP-1046 (Client Credentials) + * - IETF CIMD Draft (Client ID Metadata Documents) + */ + +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_PRM_FIELDS: { + id: 'RFC-9728-fields', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-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_RESPONSE: { + id: 'RFC-8414-response', + url: 'https://www.rfc-editor.org/rfc/rfc8414.html#section-3.2' + }, + 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 7636: PKCE (Proof Key for Code Exchange) + // ───────────────────────────────────────────────────────────────────────── + RFC_7636_CODE_CHALLENGE: { + id: 'RFC-7636-code-challenge', + url: 'https://www.rfc-editor.org/rfc/rfc7636.html#section-4.2' + }, + RFC_7636_CODE_CHALLENGE_METHODS: { + id: 'RFC-7636-methods', + url: 'https://www.rfc-editor.org/rfc/rfc7636.html#section-4.2' + }, + + // ───────────────────────────────────────────────────────────────────────── + // RFC 8707: Resource Indicators + // ───────────────────────────────────────────────────────────────────────── + RFC_8707_RESOURCE_PARAMETER: { + id: 'RFC-8707-resource', + url: 'https://www.rfc-editor.org/rfc/rfc8707.html#section-2' + }, + RFC_8707_SECURITY: { + id: 'RFC-8707-security', + url: 'https://www.rfc-editor.org/rfc/rfc8707.html#section-3' + }, + + // ───────────────────────────────────────────────────────────────────────── + // 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_6750_ERROR_CODES: { + id: 'RFC-6750-errors', + url: 'https://www.rfc-editor.org/rfc/rfc6750.html#section-3.1' + }, + + // ───────────────────────────────────────────────────────────────────────── + // 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_TOKEN_VALIDATION: { + id: 'OAuth-2.1-token-validation', + url: 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-5.2' + }, + OAUTH_2_1_ERROR_RESPONSE: { + id: 'OAuth-2.1-error-response', + url: 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-5.3' + }, + OAUTH_2_1_PKCE: { + id: 'OAuth-2.1-pkce', + url: 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-7.5.2' + }, + + // ───────────────────────────────────────────────────────────────────────── + // MCP Authorization Specification (2025-06-18) + // https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization + // ───────────────────────────────────────────────────────────────────────── + MCP_AUTH_SERVER_LOCATION: { + id: 'MCP-2025-06-18-server-location', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location' + }, + MCP_AUTH_PRM_DISCOVERY: { + id: 'MCP-2025-06-18-prm-discovery', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location' + }, + MCP_AUTH_SERVER_METADATA: { + id: 'MCP-2025-06-18-server-metadata', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#server-metadata-discovery' + }, + MCP_AUTH_DCR: { + id: 'MCP-2025-06-18-dcr', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#dynamic-client-registration' + }, + MCP_AUTH_ACCESS_TOKEN: { + id: 'MCP-2025-06-18-access-token', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#access-token-usage' + }, + MCP_AUTH_ERROR_HANDLING: { + id: 'MCP-2025-06-18-error-handling', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#error-handling' + }, + MCP_AUTH_AUDIENCE_VALIDATION: { + id: 'MCP-2025-06-18-audience-validation', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#token-audience-binding-and-validation' + }, + MCP_AUTH_CANONICAL_URI: { + id: 'MCP-2025-06-18-canonical-uri', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#canonical-server-uri' + }, + MCP_AUTH_SCOPE_SELECTION: { + id: 'MCP-scope-selection', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#scope-selection-strategy' + }, + + // ───────────────────────────────────────────────────────────────────────── + // IETF CIMD: Client ID Metadata Documents + // https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ + // Note: CIMD is defined in the IETF draft, not in MCP spec directly. + // It provides an alternative to DCR for client registration. + // ───────────────────────────────────────────────────────────────────────── + IETF_CIMD: { + id: 'IETF-CIMD', + url: 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00' + }, + IETF_CIMD_CLIENT_ID_SYNTAX: { + id: 'IETF-CIMD-client-id-syntax', + url: 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-3.1' + }, + IETF_CIMD_CLIENT_METADATA: { + id: 'IETF-CIMD-client-metadata', + url: 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-3.2' + }, + IETF_CIMD_AS_METADATA: { + id: 'IETF-CIMD-as-metadata', + url: 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-4' + }, + IETF_CIMD_SECURITY: { + id: 'IETF-CIMD-security', + url: 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-6' + }, + + // ───────────────────────────────────────────────────────────────────────── + // OpenID Connect Discovery 1.0 + // ───────────────────────────────────────────────────────────────────────── + OIDC_DISCOVERY: { + id: 'OIDC-discovery', + url: 'https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig' + }, + + // ───────────────────────────────────────────────────────────────────────── + // RFC 7523: JWT Client Authentication + // ───────────────────────────────────────────────────────────────────────── + RFC_7523_JWT_CLIENT_AUTH: { + id: 'RFC-7523-jwt-client-auth', + url: 'https://datatracker.ietf.org/doc/html/rfc7523#section-2.2' + }, + + // ───────────────────────────────────────────────────────────────────────── + // OAuth 2.1: Client Credentials Grant + // ───────────────────────────────────────────────────────────────────────── + 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_GRANT_TYPES: { + id: 'OAuth-2.1-grant-types', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-4' + }, + OAUTH_2_1_TOKEN_ENDPOINT_AUTH: { + id: 'OAuth-2.1-token-endpoint-auth', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-2.4' + }, + + // ───────────────────────────────────────────────────────────────────────── + // 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' + } +};