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:
Paul Carleton 2026-01-05 14:32:35 +00:00 • committed by GitHub
commit a90896b7d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 254 additions and 16 deletions

51
package-lock.json generated
View file

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

View file

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

View file

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

View file

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

View 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');
}
}