Skip to content

Commit a90896b

Browse files
pcarletonclaudeochafik
authored
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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> Co-authored-by: Olivier Chafik <[email protected]>
1 parent e4cb55e commit a90896b

File tree

5 files changed

+254
-16
lines changed

5 files changed

+254
-16
lines changed

package-lock.json

Lines changed: 41 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"vitest": "^4.0.16"
4646
},
4747
"dependencies": {
48-
"@modelcontextprotocol/sdk": "^1.23.0-beta.0",
48+
"@modelcontextprotocol/sdk": "^1.25.1",
4949
"commander": "^14.0.2",
5050
"eventsource-parser": "^3.0.6",
5151
"express": "^5.1.0",

src/scenarios/client/auth/helpers/createAuthServer.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ export interface AuthServerOptions {
4343
scope?: string;
4444
timestamp: string;
4545
}) => void;
46+
onRegistrationRequest?: (req: Request) => {
47+
clientId: string;
48+
clientSecret?: string;
49+
tokenEndpointAuthMethod?: string;
50+
};
4651
}
4752

4853
export function createAuthServer(
@@ -62,7 +67,8 @@ export function createAuthServer(
6267
clientIdMetadataDocumentSupported,
6368
tokenVerifier,
6469
onTokenRequest,
65-
onAuthorizationRequest
70+
onAuthorizationRequest,
71+
onRegistrationRequest
6672
} = options;
6773

6874
// Track scopes from the most recent authorization request
@@ -236,6 +242,17 @@ export function createAuthServer(
236242
});
237243

238244
app.post(authRoutes.registration_endpoint, (req: Request, res: Response) => {
245+
let clientId = 'test-client-id';
246+
let clientSecret: string | undefined = 'test-client-secret';
247+
let tokenEndpointAuthMethod: string | undefined;
248+
249+
if (onRegistrationRequest) {
250+
const result = onRegistrationRequest(req);
251+
clientId = result.clientId;
252+
clientSecret = result.clientSecret;
253+
tokenEndpointAuthMethod = result.tokenEndpointAuthMethod;
254+
}
255+
239256
checks.push({
240257
id: 'client-registration',
241258
name: 'ClientRegistration',
@@ -245,15 +262,19 @@ export function createAuthServer(
245262
specReferences: [SpecReferences.MCP_DCR],
246263
details: {
247264
endpoint: '/register',
248-
clientName: req.body.client_name
265+
clientName: req.body.client_name,
266+
...(tokenEndpointAuthMethod && { tokenEndpointAuthMethod })
249267
}
250268
});
251269

252270
res.status(201).json({
253-
client_id: 'test-client-id',
254-
client_secret: 'test-client-secret',
271+
client_id: clientId,
272+
...(clientSecret && { client_secret: clientSecret }),
255273
client_name: req.body.client_name || 'test-client',
256-
redirect_uris: req.body.redirect_uris || []
274+
redirect_uris: req.body.redirect_uris || [],
275+
...(tokenEndpointAuthMethod && {
276+
token_endpoint_auth_method: tokenEndpointAuthMethod
277+
})
257278
});
258279
});
259280

src/scenarios/client/auth/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import {
1212
ScopeStepUpAuthScenario,
1313
ScopeRetryLimitScenario
1414
} from './scope-handling';
15+
import {
16+
ClientSecretBasicAuthScenario,
17+
ClientSecretPostAuthScenario,
18+
PublicClientAuthScenario
19+
} from './token-endpoint-auth';
1520
import {
1621
ClientCredentialsJwtScenario,
1722
ClientCredentialsBasicScenario
@@ -27,6 +32,9 @@ export const authScenariosList: Scenario[] = [
2732
new ScopeOmittedWhenUndefinedScenario(),
2833
new ScopeStepUpAuthScenario(),
2934
new ScopeRetryLimitScenario(),
35+
new ClientSecretBasicAuthScenario(),
36+
new ClientSecretPostAuthScenario(),
37+
new PublicClientAuthScenario(),
3038
new ClientCredentialsJwtScenario(),
3139
new ClientCredentialsBasicScenario()
3240
];
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import type { Scenario, ConformanceCheck } from '../../../types.js';
2+
import { ScenarioUrls } from '../../../types.js';
3+
import { createAuthServer } from './helpers/createAuthServer.js';
4+
import { createServer } from './helpers/createServer.js';
5+
import { ServerLifecycle } from './helpers/serverLifecycle.js';
6+
import { SpecReferences } from './spec-references.js';
7+
import { MockTokenVerifier } from './helpers/mockTokenVerifier.js';
8+
9+
type AuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none';
10+
11+
function detectAuthMethod(
12+
authorizationHeader?: string,
13+
bodyClientSecret?: string
14+
): AuthMethod {
15+
if (authorizationHeader?.startsWith('Basic ')) {
16+
return 'client_secret_basic';
17+
}
18+
if (bodyClientSecret) {
19+
return 'client_secret_post';
20+
}
21+
return 'none';
22+
}
23+
24+
function validateBasicAuthFormat(authorizationHeader: string): {
25+
valid: boolean;
26+
error?: string;
27+
} {
28+
const encoded = authorizationHeader.substring('Basic '.length);
29+
try {
30+
const decoded = Buffer.from(encoded, 'base64').toString('utf-8');
31+
if (!decoded.includes(':')) {
32+
return { valid: false, error: 'missing colon separator' };
33+
}
34+
return { valid: true };
35+
} catch {
36+
return { valid: false, error: 'base64 decoding failed' };
37+
}
38+
}
39+
40+
const AUTH_METHOD_NAMES: Record<AuthMethod, string> = {
41+
client_secret_basic: 'HTTP Basic authentication (client_secret_basic)',
42+
client_secret_post: 'client_secret_post',
43+
none: 'no authentication (public client)'
44+
};
45+
46+
class TokenEndpointAuthScenario implements Scenario {
47+
name: string;
48+
description: string;
49+
private expectedAuthMethod: AuthMethod;
50+
private authServer = new ServerLifecycle();
51+
private server = new ServerLifecycle();
52+
private checks: ConformanceCheck[] = [];
53+
54+
constructor(expectedAuthMethod: AuthMethod) {
55+
this.expectedAuthMethod = expectedAuthMethod;
56+
this.name = `auth/token-endpoint-auth-${expectedAuthMethod === 'client_secret_basic' ? 'basic' : expectedAuthMethod === 'client_secret_post' ? 'post' : 'none'}`;
57+
this.description = `Tests that client uses ${AUTH_METHOD_NAMES[expectedAuthMethod]} when server only supports ${expectedAuthMethod}`;
58+
}
59+
60+
async start(): Promise<ScenarioUrls> {
61+
this.checks = [];
62+
const tokenVerifier = new MockTokenVerifier(this.checks, []);
63+
64+
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
65+
tokenVerifier,
66+
tokenEndpointAuthMethodsSupported: [this.expectedAuthMethod],
67+
onTokenRequest: ({ authorizationHeader, body, timestamp }) => {
68+
const bodyClientSecret = body.client_secret;
69+
const actualMethod = detectAuthMethod(
70+
authorizationHeader,
71+
bodyClientSecret
72+
);
73+
const isCorrect = actualMethod === this.expectedAuthMethod;
74+
75+
// For basic auth, also validate the format
76+
let formatError: string | undefined;
77+
if (actualMethod === 'client_secret_basic' && authorizationHeader) {
78+
const validation = validateBasicAuthFormat(authorizationHeader);
79+
if (!validation.valid) {
80+
formatError = validation.error;
81+
}
82+
}
83+
84+
const status = isCorrect && !formatError ? 'SUCCESS' : 'FAILURE';
85+
let description: string;
86+
87+
if (formatError) {
88+
description = `Client sent Basic auth header but ${formatError}`;
89+
} else if (isCorrect) {
90+
description = `Client correctly used ${AUTH_METHOD_NAMES[this.expectedAuthMethod]} for token endpoint`;
91+
} else {
92+
description = `Client used ${actualMethod} but server only supports ${this.expectedAuthMethod}`;
93+
}
94+
95+
this.checks.push({
96+
id: 'token-endpoint-auth-method',
97+
name: 'Token endpoint authentication method',
98+
description,
99+
status,
100+
timestamp,
101+
specReferences: [SpecReferences.OAUTH_2_1_TOKEN],
102+
details: {
103+
expectedAuthMethod: this.expectedAuthMethod,
104+
actualAuthMethod: actualMethod,
105+
hasAuthorizationHeader: !!authorizationHeader,
106+
hasBodyClientSecret: !!bodyClientSecret,
107+
...(formatError && { formatError })
108+
}
109+
});
110+
111+
return {
112+
token: `test-token-${Date.now()}`,
113+
scopes: []
114+
};
115+
},
116+
onRegistrationRequest: () => ({
117+
clientId: `test-client-${Date.now()}`,
118+
clientSecret:
119+
this.expectedAuthMethod === 'none'
120+
? undefined
121+
: `test-secret-${Date.now()}`,
122+
tokenEndpointAuthMethod: this.expectedAuthMethod
123+
})
124+
});
125+
await this.authServer.start(authApp);
126+
127+
const app = createServer(
128+
this.checks,
129+
this.server.getUrl,
130+
this.authServer.getUrl,
131+
{
132+
prmPath: '/.well-known/oauth-protected-resource/mcp',
133+
requiredScopes: [],
134+
tokenVerifier
135+
}
136+
);
137+
await this.server.start(app);
138+
139+
return { serverUrl: `${this.server.getUrl()}/mcp` };
140+
}
141+
142+
async stop() {
143+
await this.authServer.stop();
144+
await this.server.stop();
145+
}
146+
147+
getChecks(): ConformanceCheck[] {
148+
if (!this.checks.some((c) => c.id === 'token-endpoint-auth-method')) {
149+
this.checks.push({
150+
id: 'token-endpoint-auth-method',
151+
name: 'Token endpoint authentication method',
152+
description: 'Client did not make a token request',
153+
status: 'FAILURE',
154+
timestamp: new Date().toISOString(),
155+
specReferences: [SpecReferences.OAUTH_2_1_TOKEN]
156+
});
157+
}
158+
return this.checks;
159+
}
160+
}
161+
162+
export class ClientSecretBasicAuthScenario extends TokenEndpointAuthScenario {
163+
constructor() {
164+
super('client_secret_basic');
165+
}
166+
}
167+
168+
export class ClientSecretPostAuthScenario extends TokenEndpointAuthScenario {
169+
constructor() {
170+
super('client_secret_post');
171+
}
172+
}
173+
174+
export class PublicClientAuthScenario extends TokenEndpointAuthScenario {
175+
constructor() {
176+
super('none');
177+
}
178+
}

0 commit comments

Comments
 (0)