Add token endpoint auth method conformance tests (#48)
* [auth] Add metadata suite for running metadata discovery tests Adds a new 'metadata' suite that runs just the auth/metadata-* scenarios for faster iteration when testing metadata discovery specifically. Usage: node dist/index.mjs client --suite metadata --command "..." 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Don't require lefthook to be installed Set assert_lefthook_installed to false so the hooks gracefully skip if lefthook is not installed on the system. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Add token endpoint auth method conformance tests Add three new scenarios to test that clients correctly use the appropriate authentication method based on server metadata: - auth/token-endpoint-auth-basic: Tests client_secret_basic (HTTP Basic) - auth/token-endpoint-auth-post: Tests client_secret_post - auth/token-endpoint-auth-none: Tests public client (no auth) Each scenario configures the server to only support one auth method and verifies the client uses the correct method in token requests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Refactor token endpoint auth tests to use shared createAuthServer Extend createAuthServer helper with: - tokenEndpointAuthMethodsSupported option for metadata - onTokenRequest callback now receives full Request object - onRegistrationRequest callback for custom client credentials This eliminates the duplicate auth server implementation in token-endpoint-auth.ts and reduces code by ~140 lines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix onTokenRequest callback to use new interface The createAuthServer onTokenRequest callback now passes an object with authorizationHeader, body, timestamp etc. instead of (req, timestamp). Also added missing return value for TokenRequestResult. * Update @modelcontextprotocol/sdk to 1.25.1 --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Olivier Chafik <ochafik@anthropic.com>
This commit is contained in:
parent
e4cb55ead6
commit
a90896b7d3
5 changed files with 254 additions and 16 deletions
51
package-lock.json
generated
51
package-lock.json
generated
|
|
@ -9,7 +9,7 @@
|
|||
"version": "0.1.8",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0-beta.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"commander": "^14.0.2",
|
||||
"eventsource-parser": "^3.0.6",
|
||||
"express": "^5.1.0",
|
||||
|
|
@ -749,6 +749,18 @@
|
|||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
|
||||
"integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
|
|
@ -841,11 +853,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.23.0-beta.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.23.0-beta.0.tgz",
|
||||
"integrity": "sha512-NvoyrhhNcNiyf0nI8J1O+wheNiyOzK3kMTkMuwGb/TGHpSHXCcubcg0IxC/p9Aym+K4QZFxq9Wn67clOAegFKQ==",
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
|
||||
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
|
|
@ -855,6 +868,8 @@
|
|||
"eventsource-parser": "^3.0.0",
|
||||
"express": "^5.0.1",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"jose": "^6.1.1",
|
||||
"json-schema-typed": "^8.0.2",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
"raw-body": "^3.0.0",
|
||||
"zod": "^3.25 || ^4.0",
|
||||
|
|
@ -3388,6 +3403,16 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.11.3",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz",
|
||||
"integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hookable": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||
|
|
@ -3582,6 +3607,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-typed": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||
|
|
@ -4095,9 +4126,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
|
||||
"integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
|
|
@ -5647,9 +5678,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.0",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
|
||||
"integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
|
||||
"version": "3.25.1",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25 || ^4"
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
"vitest": "^4.0.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0-beta.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"commander": "^14.0.2",
|
||||
"eventsource-parser": "^3.0.6",
|
||||
"express": "^5.1.0",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ export interface AuthServerOptions {
|
|||
scope?: string;
|
||||
timestamp: string;
|
||||
}) => void;
|
||||
onRegistrationRequest?: (req: Request) => {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
tokenEndpointAuthMethod?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function createAuthServer(
|
||||
|
|
@ -62,7 +67,8 @@ export function createAuthServer(
|
|||
clientIdMetadataDocumentSupported,
|
||||
tokenVerifier,
|
||||
onTokenRequest,
|
||||
onAuthorizationRequest
|
||||
onAuthorizationRequest,
|
||||
onRegistrationRequest
|
||||
} = options;
|
||||
|
||||
// Track scopes from the most recent authorization request
|
||||
|
|
@ -236,6 +242,17 @@ export function createAuthServer(
|
|||
});
|
||||
|
||||
app.post(authRoutes.registration_endpoint, (req: Request, res: Response) => {
|
||||
let clientId = 'test-client-id';
|
||||
let clientSecret: string | undefined = 'test-client-secret';
|
||||
let tokenEndpointAuthMethod: string | undefined;
|
||||
|
||||
if (onRegistrationRequest) {
|
||||
const result = onRegistrationRequest(req);
|
||||
clientId = result.clientId;
|
||||
clientSecret = result.clientSecret;
|
||||
tokenEndpointAuthMethod = result.tokenEndpointAuthMethod;
|
||||
}
|
||||
|
||||
checks.push({
|
||||
id: 'client-registration',
|
||||
name: 'ClientRegistration',
|
||||
|
|
@ -245,15 +262,19 @@ export function createAuthServer(
|
|||
specReferences: [SpecReferences.MCP_DCR],
|
||||
details: {
|
||||
endpoint: '/register',
|
||||
clientName: req.body.client_name
|
||||
clientName: req.body.client_name,
|
||||
...(tokenEndpointAuthMethod && { tokenEndpointAuthMethod })
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
client_id: clientId,
|
||||
...(clientSecret && { client_secret: clientSecret }),
|
||||
client_name: req.body.client_name || 'test-client',
|
||||
redirect_uris: req.body.redirect_uris || []
|
||||
redirect_uris: req.body.redirect_uris || [],
|
||||
...(tokenEndpointAuthMethod && {
|
||||
token_endpoint_auth_method: tokenEndpointAuthMethod
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ import {
|
|||
ScopeStepUpAuthScenario,
|
||||
ScopeRetryLimitScenario
|
||||
} from './scope-handling';
|
||||
import {
|
||||
ClientSecretBasicAuthScenario,
|
||||
ClientSecretPostAuthScenario,
|
||||
PublicClientAuthScenario
|
||||
} from './token-endpoint-auth';
|
||||
import {
|
||||
ClientCredentialsJwtScenario,
|
||||
ClientCredentialsBasicScenario
|
||||
|
|
@ -27,6 +32,9 @@ export const authScenariosList: Scenario[] = [
|
|||
new ScopeOmittedWhenUndefinedScenario(),
|
||||
new ScopeStepUpAuthScenario(),
|
||||
new ScopeRetryLimitScenario(),
|
||||
new ClientSecretBasicAuthScenario(),
|
||||
new ClientSecretPostAuthScenario(),
|
||||
new PublicClientAuthScenario(),
|
||||
new ClientCredentialsJwtScenario(),
|
||||
new ClientCredentialsBasicScenario()
|
||||
];
|
||||
|
|
|
|||
178
src/scenarios/client/auth/token-endpoint-auth.ts
Normal file
178
src/scenarios/client/auth/token-endpoint-auth.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import type { Scenario, ConformanceCheck } from '../../../types.js';
|
||||
import { ScenarioUrls } from '../../../types.js';
|
||||
import { createAuthServer } from './helpers/createAuthServer.js';
|
||||
import { createServer } from './helpers/createServer.js';
|
||||
import { ServerLifecycle } from './helpers/serverLifecycle.js';
|
||||
import { SpecReferences } from './spec-references.js';
|
||||
import { MockTokenVerifier } from './helpers/mockTokenVerifier.js';
|
||||
|
||||
type AuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none';
|
||||
|
||||
function detectAuthMethod(
|
||||
authorizationHeader?: string,
|
||||
bodyClientSecret?: string
|
||||
): AuthMethod {
|
||||
if (authorizationHeader?.startsWith('Basic ')) {
|
||||
return 'client_secret_basic';
|
||||
}
|
||||
if (bodyClientSecret) {
|
||||
return 'client_secret_post';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function validateBasicAuthFormat(authorizationHeader: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
const encoded = authorizationHeader.substring('Basic '.length);
|
||||
try {
|
||||
const decoded = Buffer.from(encoded, 'base64').toString('utf-8');
|
||||
if (!decoded.includes(':')) {
|
||||
return { valid: false, error: 'missing colon separator' };
|
||||
}
|
||||
return { valid: true };
|
||||
} catch {
|
||||
return { valid: false, error: 'base64 decoding failed' };
|
||||
}
|
||||
}
|
||||
|
||||
const AUTH_METHOD_NAMES: Record<AuthMethod, string> = {
|
||||
client_secret_basic: 'HTTP Basic authentication (client_secret_basic)',
|
||||
client_secret_post: 'client_secret_post',
|
||||
none: 'no authentication (public client)'
|
||||
};
|
||||
|
||||
class TokenEndpointAuthScenario implements Scenario {
|
||||
name: string;
|
||||
description: string;
|
||||
private expectedAuthMethod: AuthMethod;
|
||||
private authServer = new ServerLifecycle();
|
||||
private server = new ServerLifecycle();
|
||||
private checks: ConformanceCheck[] = [];
|
||||
|
||||
constructor(expectedAuthMethod: AuthMethod) {
|
||||
this.expectedAuthMethod = expectedAuthMethod;
|
||||
this.name = `auth/token-endpoint-auth-${expectedAuthMethod === 'client_secret_basic' ? 'basic' : expectedAuthMethod === 'client_secret_post' ? 'post' : 'none'}`;
|
||||
this.description = `Tests that client uses ${AUTH_METHOD_NAMES[expectedAuthMethod]} when server only supports ${expectedAuthMethod}`;
|
||||
}
|
||||
|
||||
async start(): Promise<ScenarioUrls> {
|
||||
this.checks = [];
|
||||
const tokenVerifier = new MockTokenVerifier(this.checks, []);
|
||||
|
||||
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
|
||||
tokenVerifier,
|
||||
tokenEndpointAuthMethodsSupported: [this.expectedAuthMethod],
|
||||
onTokenRequest: ({ authorizationHeader, body, timestamp }) => {
|
||||
const bodyClientSecret = body.client_secret;
|
||||
const actualMethod = detectAuthMethod(
|
||||
authorizationHeader,
|
||||
bodyClientSecret
|
||||
);
|
||||
const isCorrect = actualMethod === this.expectedAuthMethod;
|
||||
|
||||
// For basic auth, also validate the format
|
||||
let formatError: string | undefined;
|
||||
if (actualMethod === 'client_secret_basic' && authorizationHeader) {
|
||||
const validation = validateBasicAuthFormat(authorizationHeader);
|
||||
if (!validation.valid) {
|
||||
formatError = validation.error;
|
||||
}
|
||||
}
|
||||
|
||||
const status = isCorrect && !formatError ? 'SUCCESS' : 'FAILURE';
|
||||
let description: string;
|
||||
|
||||
if (formatError) {
|
||||
description = `Client sent Basic auth header but ${formatError}`;
|
||||
} else if (isCorrect) {
|
||||
description = `Client correctly used ${AUTH_METHOD_NAMES[this.expectedAuthMethod]} for token endpoint`;
|
||||
} else {
|
||||
description = `Client used ${actualMethod} but server only supports ${this.expectedAuthMethod}`;
|
||||
}
|
||||
|
||||
this.checks.push({
|
||||
id: 'token-endpoint-auth-method',
|
||||
name: 'Token endpoint authentication method',
|
||||
description,
|
||||
status,
|
||||
timestamp,
|
||||
specReferences: [SpecReferences.OAUTH_2_1_TOKEN],
|
||||
details: {
|
||||
expectedAuthMethod: this.expectedAuthMethod,
|
||||
actualAuthMethod: actualMethod,
|
||||
hasAuthorizationHeader: !!authorizationHeader,
|
||||
hasBodyClientSecret: !!bodyClientSecret,
|
||||
...(formatError && { formatError })
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
token: `test-token-${Date.now()}`,
|
||||
scopes: []
|
||||
};
|
||||
},
|
||||
onRegistrationRequest: () => ({
|
||||
clientId: `test-client-${Date.now()}`,
|
||||
clientSecret:
|
||||
this.expectedAuthMethod === 'none'
|
||||
? undefined
|
||||
: `test-secret-${Date.now()}`,
|
||||
tokenEndpointAuthMethod: this.expectedAuthMethod
|
||||
})
|
||||
});
|
||||
await this.authServer.start(authApp);
|
||||
|
||||
const app = createServer(
|
||||
this.checks,
|
||||
this.server.getUrl,
|
||||
this.authServer.getUrl,
|
||||
{
|
||||
prmPath: '/.well-known/oauth-protected-resource/mcp',
|
||||
requiredScopes: [],
|
||||
tokenVerifier
|
||||
}
|
||||
);
|
||||
await this.server.start(app);
|
||||
|
||||
return { serverUrl: `${this.server.getUrl()}/mcp` };
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.authServer.stop();
|
||||
await this.server.stop();
|
||||
}
|
||||
|
||||
getChecks(): ConformanceCheck[] {
|
||||
if (!this.checks.some((c) => c.id === 'token-endpoint-auth-method')) {
|
||||
this.checks.push({
|
||||
id: 'token-endpoint-auth-method',
|
||||
name: 'Token endpoint authentication method',
|
||||
description: 'Client did not make a token request',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
specReferences: [SpecReferences.OAUTH_2_1_TOKEN]
|
||||
});
|
||||
}
|
||||
return this.checks;
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientSecretBasicAuthScenario extends TokenEndpointAuthScenario {
|
||||
constructor() {
|
||||
super('client_secret_basic');
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientSecretPostAuthScenario extends TokenEndpointAuthScenario {
|
||||
constructor() {
|
||||
super('client_secret_post');
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicClientAuthScenario extends TokenEndpointAuthScenario {
|
||||
constructor() {
|
||||
super('none');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue