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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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 { 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) || [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue