Add client credentials conformance tests (JWT and Basic auth) (#55)

* 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)

* 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

* Fix jose type import - use CryptoKey instead of KeyLike

* Add OAuth 2.1 section 4.2 spec reference to client credentials checks

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 <noreply@anthropic.com>

* Add JWT iss claim verification for client credentials

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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Carleton 2025-11-25 20:07:05 +00:00 • committed by GitHub
commit b96a29a962
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 517 additions and 23 deletions

10
package-lock.json generated
View file

@ -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",

View file

@ -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"
}
}

View file

@ -15,7 +15,8 @@ export interface ClientExecutionResult {
async function executeClient(
command: string,
serverUrl: string,
timeout: number = 30000
timeout: number = 30000,
context?: Record<string, unknown>
): Promise<ClientExecutionResult> {
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

View file

@ -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<ScenarioUrls> {
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<ScenarioUrls> {
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;
}
}

View file

@ -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<string, string>;
authBaseUrl: string;
tokenEndpoint: string;
authorizationHeader?: string;
}) =>
| TokenRequestResult
| TokenRequestError
| Promise<TokenRequestResult | TokenRequestError>;
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;
}

View file

@ -16,8 +16,8 @@ export class MockTokenVerifier implements OAuthTokenVerifier {
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
// 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) || [];

View file

@ -16,7 +16,12 @@ beforeAll(() => {
setLogLevel('error');
});
const skipScenarios = new Set<string>([]);
const skipScenarios = new Set<string>([
// 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<string>([
// Client is expected to give up (error) after limited retries, but check should pass

View file

@ -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()
];

View file

@ -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'
}
};

View file

@ -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<string, unknown>;
}
export interface Scenario {