diff --git a/package-lock.json b/package-lock.json index 8833116..5335f3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 1c38bc9..5a3e166 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index c251597..1071828 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -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 + }) }); }); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 170c1cd..6a3ad1c 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -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() ]; diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts new file mode 100644 index 0000000..4203789 --- /dev/null +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -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 = { + 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 { + 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'); + } +}