diff --git a/package-lock.json b/package-lock.json index fcfc981..c8fe2e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", + "jose": "^6.1.2", "zod": "^3.25.76" }, "bin": { @@ -3579,6 +3580,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.2.tgz", + "integrity": "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", diff --git a/package.json b/package.json index 2dc81c3..fc781d9 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", + "jose": "^6.1.2", "zod": "^3.25.76" } } diff --git a/src/runner/client.ts b/src/runner/client.ts index 07454ff..cafb7c3 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -15,7 +15,8 @@ export interface ClientExecutionResult { async function executeClient( command: string, serverUrl: string, - timeout: number = 30000 + timeout: number = 30000, + context?: Record ): Promise { const commandParts = command.split(' '); const executable = commandParts[0]; @@ -25,30 +26,37 @@ async function executeClient( let stderr = ''; let timedOut = false; + // Build environment with optional context + const env = { ...process.env }; + if (context) { + env.MCP_CONFORMANCE_CONTEXT = JSON.stringify(context); + } + return new Promise((resolve) => { - const process = spawn(executable, args, { + const childProcess = spawn(executable, args, { shell: true, - stdio: 'pipe' + stdio: 'pipe', + env }); const timeoutHandle = setTimeout(() => { timedOut = true; - process.kill(); + childProcess.kill(); }, timeout); - if (process.stdout) { - process.stdout.on('data', (data) => { + if (childProcess.stdout) { + childProcess.stdout.on('data', (data) => { stdout += data.toString(); }); } - if (process.stderr) { - process.stderr.on('data', (data) => { + if (childProcess.stderr) { + childProcess.stderr.on('data', (data) => { stderr += data.toString(); }); } - process.on('close', (code) => { + childProcess.on('close', (code) => { clearTimeout(timeoutHandle); resolve({ exitCode: code || 0, @@ -58,7 +66,7 @@ async function executeClient( }); }); - process.on('error', (error) => { + childProcess.on('error', (error) => { clearTimeout(timeoutHandle); resolve({ exitCode: -1, @@ -90,12 +98,16 @@ export async function runConformanceTest( const urls = await scenario.start(); console.error(`Executing client: ${clientCommand} ${urls.serverUrl}`); + if (urls.context) { + console.error(`With context: ${JSON.stringify(urls.context)}`); + } try { const clientOutput = await executeClient( clientCommand, urls.serverUrl, - timeout + timeout, + urls.context ); // Print stdout/stderr if client exited with nonzero code diff --git a/src/scenarios/client/auth/client-credentials.ts b/src/scenarios/client/auth/client-credentials.ts new file mode 100644 index 0000000..df13384 --- /dev/null +++ b/src/scenarios/client/auth/client-credentials.ts @@ -0,0 +1,400 @@ +import * as jose from 'jose'; +import type { CryptoKey } from 'jose'; +import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; +import { createAuthServer } from './helpers/createAuthServer'; +import { createServer } from './helpers/createServer'; +import { ServerLifecycle } from './helpers/serverLifecycle'; +import { SpecReferences } from './spec-references'; + +const CONFORMANCE_TEST_CLIENT_ID = 'conformance-test-client'; +const CONFORMANCE_TEST_CLIENT_SECRET = 'conformance-test-secret'; + +/** + * Generate an EC P-256 keypair for JWT signing. + * Returns both public key (for server verification) and private key PEM (for client signing). + */ +async function generateTestKeypair(): Promise<{ + publicKey: CryptoKey; + privateKeyPem: string; +}> { + const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { + extractable: true + }); + const privateKeyPem = await jose.exportPKCS8(privateKey); + return { publicKey, privateKeyPem }; +} + +/** + * Scenario: Client Credentials with JWT Authentication (SEP-1046) + * + * Tests OAuth client_credentials flow with private_key_jwt authentication. + * Client authenticates using a JWT assertion signed with a dynamically generated keypair. + */ +export class ClientCredentialsJwtScenario implements Scenario { + name = 'auth/client-credentials-jwt'; + description = + 'Tests OAuth client_credentials flow with private_key_jwt authentication (SEP-1046)'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + // Generate a fresh keypair for this test run + const { publicKey, privateKeyPem } = await generateTestKeypair(); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + grantTypesSupported: ['client_credentials'], + tokenEndpointAuthMethodsSupported: ['private_key_jwt'], + tokenEndpointAuthSigningAlgValuesSupported: ['ES256'], + onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => { + // Per RFC 7523bis, the audience MUST be the issuer identifier + const issuerUrl = authBaseUrl.endsWith('/') + ? authBaseUrl + : `${authBaseUrl}/`; + if (grantType !== 'client_credentials') { + this.checks.push({ + id: 'client-credentials-grant-type', + name: 'ClientCredentialsGrantType', + description: `Expected grant_type=client_credentials, got ${grantType}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ] + }); + return { + error: 'unsupported_grant_type', + errorDescription: 'Only client_credentials grant is supported' + }; + } + + const clientAssertion = body.client_assertion; + const clientAssertionType = body.client_assertion_type; + + // Verify assertion type + if ( + clientAssertionType !== + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + ) { + this.checks.push({ + id: 'client-credentials-assertion-type', + name: 'ClientCredentialsAssertionType', + description: `Invalid client_assertion_type: ${clientAssertionType}`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_JWT_CLIENT_AUTH] + }); + return { + error: 'invalid_client', + errorDescription: 'Invalid client_assertion_type', + statusCode: 401 + }; + } + + // Verify JWT signature and claims using the generated public key + try { + // Per RFC 7523bis, audience MUST be the issuer identifier + const { payload } = await jose.jwtVerify(clientAssertion, publicKey, { + audience: issuerUrl, + clockTolerance: 30 + }); + + // Verify iss claim matches expected client_id + if (payload.iss !== CONFORMANCE_TEST_CLIENT_ID) { + this.checks.push({ + id: 'client-credentials-jwt-iss', + name: 'ClientCredentialsJwtIss', + description: `JWT iss claim '${payload.iss}' does not match expected client_id '${CONFORMANCE_TEST_CLIENT_ID}'`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_JWT_CLIENT_AUTH], + details: { + expected: CONFORMANCE_TEST_CLIENT_ID, + actual: payload.iss + } + }); + return { + error: 'invalid_client', + errorDescription: + 'JWT iss claim does not match expected client_id', + statusCode: 401 + }; + } + + // Verify sub claim matches expected client_id + if (payload.sub !== CONFORMANCE_TEST_CLIENT_ID) { + this.checks.push({ + id: 'client-credentials-jwt-sub', + name: 'ClientCredentialsJwtSub', + description: `JWT sub claim '${payload.sub}' does not match expected client_id '${CONFORMANCE_TEST_CLIENT_ID}'`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_JWT_CLIENT_AUTH], + details: { + expected: CONFORMANCE_TEST_CLIENT_ID, + actual: payload.sub + } + }); + return { + error: 'invalid_client', + errorDescription: + 'JWT sub claim does not match expected client_id', + statusCode: 401 + }; + } + + // Success! + this.checks.push({ + id: 'client-credentials-jwt-verified', + name: 'ClientCredentialsJwtVerified', + description: + 'Client successfully authenticated with signed JWT assertion', + status: 'SUCCESS', + timestamp, + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS, + SpecReferences.RFC_JWT_CLIENT_AUTH + ], + details: { + iss: payload.iss, + sub: payload.sub, + aud: payload.aud + } + }); + + const scopes = body.scope ? body.scope.split(' ') : []; + return { + token: `cc-token-${Date.now()}`, + scopes + }; + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + this.checks.push({ + id: 'client-credentials-jwt-verified', + name: 'ClientCredentialsJwtVerified', + description: `JWT verification failed: ${errorMessage}`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_JWT_CLIENT_AUTH], + details: { error: errorMessage } + }); + return { + error: 'invalid_client', + errorDescription: `JWT verification failed: ${errorMessage}`, + statusCode: 401 + }; + } + } + }); + + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl + ); + + await this.server.start(app); + + return { + serverUrl: `${this.server.getUrl()}/mcp`, + context: { + client_id: CONFORMANCE_TEST_CLIENT_ID, + private_key_pem: privateKeyPem, + signing_algorithm: 'ES256' + } + }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + // Ensure we have the JWT verification check + const hasJwtCheck = this.checks.some( + (c) => c.id === 'client-credentials-jwt-verified' + ); + if (!hasJwtCheck) { + this.checks.push({ + id: 'client-credentials-jwt-verified', + name: 'ClientCredentialsJwtVerified', + description: 'Client did not make a client_credentials token request', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ] + }); + } + + return this.checks; + } +} + +/** + * Scenario: Client Credentials with client_secret_basic Authentication + * + * Tests OAuth client_credentials flow with HTTP Basic authentication. + */ +export class ClientCredentialsBasicScenario implements Scenario { + name = 'auth/client-credentials-basic'; + description = + 'Tests OAuth client_credentials flow with client_secret_basic authentication'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + grantTypesSupported: ['client_credentials'], + tokenEndpointAuthMethodsSupported: ['client_secret_basic'], + onTokenRequest: async ({ + grantType, + body, + timestamp, + authorizationHeader + }) => { + if (grantType !== 'client_credentials') { + this.checks.push({ + id: 'client-credentials-grant-type', + name: 'ClientCredentialsGrantType', + description: `Expected grant_type=client_credentials, got ${grantType}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ] + }); + return { + error: 'unsupported_grant_type', + errorDescription: 'Only client_credentials grant is supported' + }; + } + + // Verify Basic auth header + const authHeader = authorizationHeader; + if (!authHeader || !authHeader.startsWith('Basic ')) { + this.checks.push({ + id: 'client-credentials-basic-auth', + name: 'ClientCredentialsBasicAuth', + description: + 'Missing or invalid Authorization header for Basic auth', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.SEP_1046_CLIENT_CREDENTIALS] + }); + return { + error: 'invalid_client', + errorDescription: 'Missing or invalid Authorization header', + statusCode: 401 + }; + } + + const base64Credentials = authHeader.slice(6); + const credentials = Buffer.from(base64Credentials, 'base64').toString( + 'utf-8' + ); + const [clientId, clientSecret] = credentials.split(':'); + + if ( + clientId !== CONFORMANCE_TEST_CLIENT_ID || + clientSecret !== CONFORMANCE_TEST_CLIENT_SECRET + ) { + this.checks.push({ + id: 'client-credentials-basic-auth', + name: 'ClientCredentialsBasicAuth', + description: 'Invalid client credentials', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.SEP_1046_CLIENT_CREDENTIALS], + details: { clientId } + }); + return { + error: 'invalid_client', + errorDescription: 'Invalid client credentials', + statusCode: 401 + }; + } + + // Success! + this.checks.push({ + id: 'client-credentials-basic-auth', + name: 'ClientCredentialsBasicAuth', + description: + 'Client successfully authenticated with client_secret_basic', + status: 'SUCCESS', + timestamp, + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ], + details: { clientId } + }); + + const scopes = body.scope ? body.scope.split(' ') : []; + return { + token: `cc-token-${Date.now()}`, + scopes + }; + } + }); + + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl + ); + + await this.server.start(app); + + return { + serverUrl: `${this.server.getUrl()}/mcp`, + context: { + client_id: CONFORMANCE_TEST_CLIENT_ID, + client_secret: CONFORMANCE_TEST_CLIENT_SECRET + } + }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + // Ensure we have the basic auth check + const hasBasicAuthCheck = this.checks.some( + (c) => c.id === 'client-credentials-basic-auth' + ); + if (!hasBasicAuthCheck) { + this.checks.push({ + id: 'client-credentials-basic-auth', + name: 'ClientCredentialsBasicAuth', + description: 'Client did not make a client_credentials token request', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ] + }); + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index c4a2094..c251597 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -4,19 +4,40 @@ import { createRequestLogger } from '../../../request-logger'; import { SpecReferences } from '../spec-references'; import { MockTokenVerifier } from './mockTokenVerifier'; +export interface TokenRequestResult { + token: string; + scopes: string[]; +} + +export interface TokenRequestError { + error: string; + errorDescription?: string; + statusCode?: number; +} + export interface AuthServerOptions { metadataPath?: string; isOpenIdConfiguration?: boolean; loggingEnabled?: boolean; routePrefix?: string; scopesSupported?: string[]; + grantTypesSupported?: string[]; + tokenEndpointAuthMethodsSupported?: string[]; + tokenEndpointAuthSigningAlgValuesSupported?: string[]; clientIdMetadataDocumentSupported?: boolean; tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { scope?: string; grantType: string; timestamp: string; - }) => { token: string; scopes: string[] }; + body: Record; + authBaseUrl: string; + tokenEndpoint: string; + authorizationHeader?: string; + }) => + | TokenRequestResult + | TokenRequestError + | Promise; onAuthorizationRequest?: (requestData: { clientId?: string; scope?: string; @@ -35,6 +56,9 @@ export function createAuthServer( loggingEnabled = true, routePrefix = '', scopesSupported, + grantTypesSupported = ['authorization_code', 'refresh_token'], + tokenEndpointAuthMethodsSupported = ['none'], + tokenEndpointAuthSigningAlgValuesSupported, clientIdMetadataDocumentSupported, tokenVerifier, onTokenRequest, @@ -86,9 +110,13 @@ export function createAuthServer( token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`, response_types_supported: ['code'], - grant_types_supported: ['authorization_code', 'refresh_token'], + grant_types_supported: grantTypesSupported, code_challenge_methods_supported: ['S256'], - token_endpoint_auth_methods_supported: ['none'] + token_endpoint_auth_methods_supported: tokenEndpointAuthMethodsSupported, + ...(tokenEndpointAuthSigningAlgValuesSupported && { + token_endpoint_auth_signing_alg_values_supported: + tokenEndpointAuthSigningAlgValuesSupported + }) }; // Add scopes_supported if provided @@ -149,9 +177,10 @@ export function createAuthServer( res.redirect(redirectUrl.toString()); }); - app.post(authRoutes.token_endpoint, (req: Request, res: Response) => { + app.post(authRoutes.token_endpoint, async (req: Request, res: Response) => { const timestamp = new Date().toISOString(); const requestedScope = req.body.scope; + const grantType = req.body.grant_type; checks.push({ id: 'token-request', @@ -162,7 +191,7 @@ export function createAuthServer( specReferences: [SpecReferences.OAUTH_2_1_TOKEN], details: { endpoint: '/token', - grantType: req.body.grant_type + grantType } }); @@ -170,11 +199,25 @@ export function createAuthServer( let scopes: string[] = lastAuthorizationScopes; if (onTokenRequest) { - const result = onTokenRequest({ + const result = await onTokenRequest({ scope: requestedScope, - grantType: req.body.grant_type, - timestamp + grantType, + timestamp, + body: req.body, + authBaseUrl: getAuthBaseUrl(), + tokenEndpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, + authorizationHeader: req.headers.authorization }); + + // Check if result is an error + if ('error' in result) { + res.status(result.statusCode || 400).json({ + error: result.error, + error_description: result.errorDescription + }); + return; + } + token = result.token; scopes = result.scopes; } diff --git a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts index f49f101..8cbfae1 100644 --- a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts +++ b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts @@ -16,8 +16,8 @@ export class MockTokenVerifier implements OAuthTokenVerifier { } async verifyAccessToken(token: string): Promise { - // Accept tokens that start with 'test-token' - if (token.startsWith('test-token')) { + // Accept tokens that start with known prefixes + if (token.startsWith('test-token') || token.startsWith('cc-token')) { // Get scopes for this token, or use empty array const scopes = this.tokenScopes.get(token) || []; diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 285280f..803ecf7 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -16,7 +16,12 @@ beforeAll(() => { setLogLevel('error'); }); -const skipScenarios = new Set([]); +const skipScenarios = new Set([ + // Client credentials scenarios require SDK support for client_credentials grant + // Pending typescript-sdk implementation + 'auth/client-credentials-jwt', + 'auth/client-credentials-basic' +]); const allowClientErrorScenarios = new Set([ // Client is expected to give up (error) after limited retries, but check should pass diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index eed1213..170c1cd 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -12,6 +12,10 @@ import { ScopeStepUpAuthScenario, ScopeRetryLimitScenario } from './scope-handling'; +import { + ClientCredentialsJwtScenario, + ClientCredentialsBasicScenario +} from './client-credentials'; export const authScenariosList: Scenario[] = [ ...metadataScenarios, @@ -22,5 +26,7 @@ export const authScenariosList: Scenario[] = [ new ScopeFromScopesSupportedScenario(), new ScopeOmittedWhenUndefinedScenario(), new ScopeStepUpAuthScenario(), - new ScopeRetryLimitScenario() + new ScopeRetryLimitScenario(), + new ClientCredentialsJwtScenario(), + new ClientCredentialsBasicScenario() ]; diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 6f6b824..52a08ca 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -60,5 +60,17 @@ export const SpecReferences: { [key: string]: SpecReference } = { IETF_CIMD: { id: 'IETF-OAuth-Client-ID-Metadata-Document', url: 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00' + }, + RFC_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: { + id: 'OAUTH-2.1-client-credentials-grant', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-4.2' + }, + SEP_1046_CLIENT_CREDENTIALS: { + id: 'SEP-1046-Client-Credentials', + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx' } }; diff --git a/src/types.ts b/src/types.ts index 07e2b3d..d5192b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,11 @@ export interface ConformanceCheck { export interface ScenarioUrls { serverUrl: string; authUrl?: string; + /** + * Optional context to pass to the client via MCP_CONFORMANCE_CONTEXT env var. + * This is a JSON-serializable object containing scenario-specific data like credentials. + */ + context?: Record; } export interface Scenario {