From f4526ff2f166f84979c8fad27fdd2694a8ddc160 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 25 Nov 2025 17:30:49 +0000 Subject: [PATCH 1/5] Add client credentials conformance scenarios with dynamic keypair generation Implements SEP-1046 client credentials conformance tests: - auth/client-credentials-jwt: Tests private_key_jwt authentication - auth/client-credentials-basic: Tests client_secret_basic authentication Key changes: - Generate EC P-256 keypair dynamically at test start (no hardcoded keys) - Pass credentials to client via MCP_CONFORMANCE_CONTEXT environment variable - Add context field to ScenarioUrls interface for scenario-specific data - Update client runner to pass context as env var to spawned client process The MCP_CONFORMANCE_CONTEXT env var contains a JSON object with: - client_id: The expected client identifier - private_key_pem: PEM-encoded private key (for JWT scenarios) - client_secret: Client secret (for basic auth scenarios) - signing_algorithm: JWT signing algorithm (defaults to ES256) --- package-lock.json | 10 + package.json | 1 + src/runner/client.ts | 34 +- .../client/auth/client-credentials.ts | 354 ++++++++++++++++++ .../client/auth/helpers/createAuthServer.ts | 59 ++- .../client/auth/helpers/mockTokenVerifier.ts | 4 +- src/scenarios/client/auth/index.test.ts | 7 +- src/scenarios/client/auth/index.ts | 8 +- src/scenarios/client/auth/spec-references.ts | 8 + src/types.ts | 5 + 10 files changed, 467 insertions(+), 23 deletions(-) create mode 100644 src/scenarios/client/auth/client-credentials.ts 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..2febdf6 --- /dev/null +++ b/src/scenarios/client/auth/client-credentials.ts @@ -0,0 +1,354 @@ +import * as jose 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: jose.KeyLike; + privateKeyPem: string; +}> { + const { publicKey, privateKey } = await jose.generateKeyPair('ES256'); + 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.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 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.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.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, headers }) => { + 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.SEP_1046_CLIENT_CREDENTIALS] + }); + return { + error: 'unsupported_grant_type', + errorDescription: 'Only client_credentials grant is supported' + }; + } + + // Verify Basic auth header + const authHeader = headers.authorization; + 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.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.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..f297b6f 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -60,5 +60,13 @@ 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' + }, + 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 { From 3016d5d4fa82c27605cbbd2f6ca347ec4e709885 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 25 Nov 2025 17:58:02 +0000 Subject: [PATCH 2/5] Fix client-credentials scenarios for conformance testing - Generate EC keypair with extractable: true so private key can be exported and passed to clients via MCP_CONFORMANCE_CONTEXT - Fix client_secret_basic scenario to use authorizationHeader param instead of non-existent headers object --- src/scenarios/client/auth/client-credentials.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/scenarios/client/auth/client-credentials.ts b/src/scenarios/client/auth/client-credentials.ts index 2febdf6..a47ce31 100644 --- a/src/scenarios/client/auth/client-credentials.ts +++ b/src/scenarios/client/auth/client-credentials.ts @@ -16,7 +16,9 @@ async function generateTestKeypair(): Promise<{ publicKey: jose.KeyLike; privateKeyPem: string; }> { - const { publicKey, privateKey } = await jose.generateKeyPair('ES256'); + const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { + extractable: true + }); const privateKeyPem = await jose.exportPKCS8(privateKey); return { publicKey, privateKeyPem }; } @@ -228,7 +230,12 @@ export class ClientCredentialsBasicScenario implements Scenario { const authApp = createAuthServer(this.checks, this.authServer.getUrl, { grantTypesSupported: ['client_credentials'], tokenEndpointAuthMethodsSupported: ['client_secret_basic'], - onTokenRequest: async ({ grantType, body, timestamp, headers }) => { + onTokenRequest: async ({ + grantType, + body, + timestamp, + authorizationHeader + }) => { if (grantType !== 'client_credentials') { this.checks.push({ id: 'client-credentials-grant-type', @@ -245,7 +252,7 @@ export class ClientCredentialsBasicScenario implements Scenario { } // Verify Basic auth header - const authHeader = headers.authorization; + const authHeader = authorizationHeader; if (!authHeader || !authHeader.startsWith('Basic ')) { this.checks.push({ id: 'client-credentials-basic-auth', From 9622e90f1bb5ff8ef0f3d0f1a089bd18b4825865 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 25 Nov 2025 18:03:12 +0000 Subject: [PATCH 3/5] Fix jose type import - use CryptoKey instead of KeyLike --- src/scenarios/client/auth/client-credentials.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/scenarios/client/auth/client-credentials.ts b/src/scenarios/client/auth/client-credentials.ts index a47ce31..909f183 100644 --- a/src/scenarios/client/auth/client-credentials.ts +++ b/src/scenarios/client/auth/client-credentials.ts @@ -1,4 +1,5 @@ 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'; @@ -13,7 +14,7 @@ const CONFORMANCE_TEST_CLIENT_SECRET = 'conformance-test-secret'; * Returns both public key (for server verification) and private key PEM (for client signing). */ async function generateTestKeypair(): Promise<{ - publicKey: jose.KeyLike; + publicKey: CryptoKey; privateKeyPem: string; }> { const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { From d883e9d727a1d3938b6aa6a8a381510e5354f64c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 25 Nov 2025 18:59:54 +0000 Subject: [PATCH 4/5] Add OAuth 2.1 section 4.2 spec reference to client credentials checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OAUTH_2_1_CLIENT_CREDENTIALS spec reference pointing to OAuth 2.1 draft section 4.2 (Client Credentials Grant) and include it in all client_credentials conformance checks for both JWT and basic auth flows. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../client/auth/client-credentials.ts | 26 +++++++++++++++---- src/scenarios/client/auth/spec-references.ts | 4 +++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/scenarios/client/auth/client-credentials.ts b/src/scenarios/client/auth/client-credentials.ts index 909f183..4ac599e 100644 --- a/src/scenarios/client/auth/client-credentials.ts +++ b/src/scenarios/client/auth/client-credentials.ts @@ -61,7 +61,10 @@ export class ClientCredentialsJwtScenario implements Scenario { description: `Expected grant_type=client_credentials, got ${grantType}`, status: 'FAILURE', timestamp, - specReferences: [SpecReferences.SEP_1046_CLIENT_CREDENTIALS] + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ] }); return { error: 'unsupported_grant_type', @@ -131,6 +134,7 @@ export class ClientCredentialsJwtScenario implements Scenario { status: 'SUCCESS', timestamp, specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, SpecReferences.SEP_1046_CLIENT_CREDENTIALS, SpecReferences.RFC_JWT_CLIENT_AUTH ], @@ -203,7 +207,10 @@ export class ClientCredentialsJwtScenario implements Scenario { description: 'Client did not make a client_credentials token request', status: 'FAILURE', timestamp: new Date().toISOString(), - specReferences: [SpecReferences.SEP_1046_CLIENT_CREDENTIALS] + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ] }); } @@ -244,7 +251,10 @@ export class ClientCredentialsBasicScenario implements Scenario { description: `Expected grant_type=client_credentials, got ${grantType}`, status: 'FAILURE', timestamp, - specReferences: [SpecReferences.SEP_1046_CLIENT_CREDENTIALS] + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ] }); return { error: 'unsupported_grant_type', @@ -305,7 +315,10 @@ export class ClientCredentialsBasicScenario implements Scenario { 'Client successfully authenticated with client_secret_basic', status: 'SUCCESS', timestamp, - specReferences: [SpecReferences.SEP_1046_CLIENT_CREDENTIALS], + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ], details: { clientId } }); @@ -353,7 +366,10 @@ export class ClientCredentialsBasicScenario implements Scenario { description: 'Client did not make a client_credentials token request', status: 'FAILURE', timestamp: new Date().toISOString(), - specReferences: [SpecReferences.SEP_1046_CLIENT_CREDENTIALS] + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ] }); } diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index f297b6f..52a08ca 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -65,6 +65,10 @@ export const SpecReferences: { [key: string]: SpecReference } = { 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' From 9da6b1201773ab8d8679deed182ebe537f23f2aa Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 25 Nov 2025 20:02:24 +0000 Subject: [PATCH 5/5] Add JWT iss claim verification for client credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 7523, verify that the JWT issuer (iss) claim matches the expected client_id, in addition to the existing sub claim check. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../client/auth/client-credentials.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/scenarios/client/auth/client-credentials.ts b/src/scenarios/client/auth/client-credentials.ts index 4ac599e..df13384 100644 --- a/src/scenarios/client/auth/client-credentials.ts +++ b/src/scenarios/client/auth/client-credentials.ts @@ -103,6 +103,28 @@ export class ClientCredentialsJwtScenario implements Scenario { 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({