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:
parent
f0e948187d
commit
b96a29a962
10 changed files with 517 additions and 23 deletions
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"eventsource-parser": "^3.0.6",
|
"eventsource-parser": "^3.0.6",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"jose": "^6.1.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -3579,6 +3580,15 @@
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"eventsource-parser": "^3.0.6",
|
"eventsource-parser": "^3.0.6",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"jose": "^6.1.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ export interface ClientExecutionResult {
|
||||||
async function executeClient(
|
async function executeClient(
|
||||||
command: string,
|
command: string,
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
timeout: number = 30000
|
timeout: number = 30000,
|
||||||
|
context?: Record<string, unknown>
|
||||||
): Promise<ClientExecutionResult> {
|
): Promise<ClientExecutionResult> {
|
||||||
const commandParts = command.split(' ');
|
const commandParts = command.split(' ');
|
||||||
const executable = commandParts[0];
|
const executable = commandParts[0];
|
||||||
|
|
@ -25,30 +26,37 @@ async function executeClient(
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
let timedOut = false;
|
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) => {
|
return new Promise((resolve) => {
|
||||||
const process = spawn(executable, args, {
|
const childProcess = spawn(executable, args, {
|
||||||
shell: true,
|
shell: true,
|
||||||
stdio: 'pipe'
|
stdio: 'pipe',
|
||||||
|
env
|
||||||
});
|
});
|
||||||
|
|
||||||
const timeoutHandle = setTimeout(() => {
|
const timeoutHandle = setTimeout(() => {
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
process.kill();
|
childProcess.kill();
|
||||||
}, timeout);
|
}, timeout);
|
||||||
|
|
||||||
if (process.stdout) {
|
if (childProcess.stdout) {
|
||||||
process.stdout.on('data', (data) => {
|
childProcess.stdout.on('data', (data) => {
|
||||||
stdout += data.toString();
|
stdout += data.toString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.stderr) {
|
if (childProcess.stderr) {
|
||||||
process.stderr.on('data', (data) => {
|
childProcess.stderr.on('data', (data) => {
|
||||||
stderr += data.toString();
|
stderr += data.toString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('close', (code) => {
|
childProcess.on('close', (code) => {
|
||||||
clearTimeout(timeoutHandle);
|
clearTimeout(timeoutHandle);
|
||||||
resolve({
|
resolve({
|
||||||
exitCode: code || 0,
|
exitCode: code || 0,
|
||||||
|
|
@ -58,7 +66,7 @@ async function executeClient(
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('error', (error) => {
|
childProcess.on('error', (error) => {
|
||||||
clearTimeout(timeoutHandle);
|
clearTimeout(timeoutHandle);
|
||||||
resolve({
|
resolve({
|
||||||
exitCode: -1,
|
exitCode: -1,
|
||||||
|
|
@ -90,12 +98,16 @@ export async function runConformanceTest(
|
||||||
const urls = await scenario.start();
|
const urls = await scenario.start();
|
||||||
|
|
||||||
console.error(`Executing client: ${clientCommand} ${urls.serverUrl}`);
|
console.error(`Executing client: ${clientCommand} ${urls.serverUrl}`);
|
||||||
|
if (urls.context) {
|
||||||
|
console.error(`With context: ${JSON.stringify(urls.context)}`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const clientOutput = await executeClient(
|
const clientOutput = await executeClient(
|
||||||
clientCommand,
|
clientCommand,
|
||||||
urls.serverUrl,
|
urls.serverUrl,
|
||||||
timeout
|
timeout,
|
||||||
|
urls.context
|
||||||
);
|
);
|
||||||
|
|
||||||
// Print stdout/stderr if client exited with nonzero code
|
// Print stdout/stderr if client exited with nonzero code
|
||||||
|
|
|
||||||
400
src/scenarios/client/auth/client-credentials.ts
Normal file
400
src/scenarios/client/auth/client-credentials.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,19 +4,40 @@ import { createRequestLogger } from '../../../request-logger';
|
||||||
import { SpecReferences } from '../spec-references';
|
import { SpecReferences } from '../spec-references';
|
||||||
import { MockTokenVerifier } from './mockTokenVerifier';
|
import { MockTokenVerifier } from './mockTokenVerifier';
|
||||||
|
|
||||||
|
export interface TokenRequestResult {
|
||||||
|
token: string;
|
||||||
|
scopes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenRequestError {
|
||||||
|
error: string;
|
||||||
|
errorDescription?: string;
|
||||||
|
statusCode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthServerOptions {
|
export interface AuthServerOptions {
|
||||||
metadataPath?: string;
|
metadataPath?: string;
|
||||||
isOpenIdConfiguration?: boolean;
|
isOpenIdConfiguration?: boolean;
|
||||||
loggingEnabled?: boolean;
|
loggingEnabled?: boolean;
|
||||||
routePrefix?: string;
|
routePrefix?: string;
|
||||||
scopesSupported?: string[];
|
scopesSupported?: string[];
|
||||||
|
grantTypesSupported?: string[];
|
||||||
|
tokenEndpointAuthMethodsSupported?: string[];
|
||||||
|
tokenEndpointAuthSigningAlgValuesSupported?: string[];
|
||||||
clientIdMetadataDocumentSupported?: boolean;
|
clientIdMetadataDocumentSupported?: boolean;
|
||||||
tokenVerifier?: MockTokenVerifier;
|
tokenVerifier?: MockTokenVerifier;
|
||||||
onTokenRequest?: (requestData: {
|
onTokenRequest?: (requestData: {
|
||||||
scope?: string;
|
scope?: string;
|
||||||
grantType: string;
|
grantType: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}) => { token: string; scopes: string[] };
|
body: Record<string, string>;
|
||||||
|
authBaseUrl: string;
|
||||||
|
tokenEndpoint: string;
|
||||||
|
authorizationHeader?: string;
|
||||||
|
}) =>
|
||||||
|
| TokenRequestResult
|
||||||
|
| TokenRequestError
|
||||||
|
| Promise<TokenRequestResult | TokenRequestError>;
|
||||||
onAuthorizationRequest?: (requestData: {
|
onAuthorizationRequest?: (requestData: {
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
|
|
@ -35,6 +56,9 @@ export function createAuthServer(
|
||||||
loggingEnabled = true,
|
loggingEnabled = true,
|
||||||
routePrefix = '',
|
routePrefix = '',
|
||||||
scopesSupported,
|
scopesSupported,
|
||||||
|
grantTypesSupported = ['authorization_code', 'refresh_token'],
|
||||||
|
tokenEndpointAuthMethodsSupported = ['none'],
|
||||||
|
tokenEndpointAuthSigningAlgValuesSupported,
|
||||||
clientIdMetadataDocumentSupported,
|
clientIdMetadataDocumentSupported,
|
||||||
tokenVerifier,
|
tokenVerifier,
|
||||||
onTokenRequest,
|
onTokenRequest,
|
||||||
|
|
@ -86,9 +110,13 @@ export function createAuthServer(
|
||||||
token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`,
|
token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`,
|
||||||
registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`,
|
registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`,
|
||||||
response_types_supported: ['code'],
|
response_types_supported: ['code'],
|
||||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
grant_types_supported: grantTypesSupported,
|
||||||
code_challenge_methods_supported: ['S256'],
|
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
|
// Add scopes_supported if provided
|
||||||
|
|
@ -149,9 +177,10 @@ export function createAuthServer(
|
||||||
res.redirect(redirectUrl.toString());
|
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 timestamp = new Date().toISOString();
|
||||||
const requestedScope = req.body.scope;
|
const requestedScope = req.body.scope;
|
||||||
|
const grantType = req.body.grant_type;
|
||||||
|
|
||||||
checks.push({
|
checks.push({
|
||||||
id: 'token-request',
|
id: 'token-request',
|
||||||
|
|
@ -162,7 +191,7 @@ export function createAuthServer(
|
||||||
specReferences: [SpecReferences.OAUTH_2_1_TOKEN],
|
specReferences: [SpecReferences.OAUTH_2_1_TOKEN],
|
||||||
details: {
|
details: {
|
||||||
endpoint: '/token',
|
endpoint: '/token',
|
||||||
grantType: req.body.grant_type
|
grantType
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -170,11 +199,25 @@ export function createAuthServer(
|
||||||
let scopes: string[] = lastAuthorizationScopes;
|
let scopes: string[] = lastAuthorizationScopes;
|
||||||
|
|
||||||
if (onTokenRequest) {
|
if (onTokenRequest) {
|
||||||
const result = onTokenRequest({
|
const result = await onTokenRequest({
|
||||||
scope: requestedScope,
|
scope: requestedScope,
|
||||||
grantType: req.body.grant_type,
|
grantType,
|
||||||
timestamp
|
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;
|
token = result.token;
|
||||||
scopes = result.scopes;
|
scopes = result.scopes;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ export class MockTokenVerifier implements OAuthTokenVerifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||||
// Accept tokens that start with 'test-token'
|
// Accept tokens that start with known prefixes
|
||||||
if (token.startsWith('test-token')) {
|
if (token.startsWith('test-token') || token.startsWith('cc-token')) {
|
||||||
// Get scopes for this token, or use empty array
|
// Get scopes for this token, or use empty array
|
||||||
const scopes = this.tokenScopes.get(token) || [];
|
const scopes = this.tokenScopes.get(token) || [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,12 @@ beforeAll(() => {
|
||||||
setLogLevel('error');
|
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>([
|
const allowClientErrorScenarios = new Set<string>([
|
||||||
// Client is expected to give up (error) after limited retries, but check should pass
|
// Client is expected to give up (error) after limited retries, but check should pass
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ import {
|
||||||
ScopeStepUpAuthScenario,
|
ScopeStepUpAuthScenario,
|
||||||
ScopeRetryLimitScenario
|
ScopeRetryLimitScenario
|
||||||
} from './scope-handling';
|
} from './scope-handling';
|
||||||
|
import {
|
||||||
|
ClientCredentialsJwtScenario,
|
||||||
|
ClientCredentialsBasicScenario
|
||||||
|
} from './client-credentials';
|
||||||
|
|
||||||
export const authScenariosList: Scenario[] = [
|
export const authScenariosList: Scenario[] = [
|
||||||
...metadataScenarios,
|
...metadataScenarios,
|
||||||
|
|
@ -22,5 +26,7 @@ export const authScenariosList: Scenario[] = [
|
||||||
new ScopeFromScopesSupportedScenario(),
|
new ScopeFromScopesSupportedScenario(),
|
||||||
new ScopeOmittedWhenUndefinedScenario(),
|
new ScopeOmittedWhenUndefinedScenario(),
|
||||||
new ScopeStepUpAuthScenario(),
|
new ScopeStepUpAuthScenario(),
|
||||||
new ScopeRetryLimitScenario()
|
new ScopeRetryLimitScenario(),
|
||||||
|
new ClientCredentialsJwtScenario(),
|
||||||
|
new ClientCredentialsBasicScenario()
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -60,5 +60,17 @@ export const SpecReferences: { [key: string]: SpecReference } = {
|
||||||
IETF_CIMD: {
|
IETF_CIMD: {
|
||||||
id: 'IETF-OAuth-Client-ID-Metadata-Document',
|
id: 'IETF-OAuth-Client-ID-Metadata-Document',
|
||||||
url: 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00'
|
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'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ export interface ConformanceCheck {
|
||||||
export interface ScenarioUrls {
|
export interface ScenarioUrls {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
authUrl?: 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 {
|
export interface Scenario {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue