Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lefthook.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# lefthook.yml
# Configuration reference: https://lefthook.dev/configuration/

assert_lefthook_installed: true
assert_lefthook_installed: false

output:
- meta # Print lefthook version
Expand Down
8 changes: 5 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
listScenarios,
listClientScenarios,
listActiveClientScenarios,
listAuthScenarios
listAuthScenarios,
listMetadataScenarios
} from './scenarios';
import { ConformanceCheck } from './types';
import { ClientOptionsSchema, ServerOptionsSchema } from './schemas';
Expand Down Expand Up @@ -51,7 +52,8 @@ program
}

const suites: Record<string, () => string[]> = {
auth: listAuthScenarios
auth: listAuthScenarios,
metadata: listMetadataScenarios
};

const suiteName = options.suite.toLowerCase();
Expand Down Expand Up @@ -147,7 +149,7 @@ program
console.error('Either --scenario or --suite is required');
console.error('\nAvailable client scenarios:');
listScenarios().forEach((s) => console.error(` - ${s}`));
console.error('\nAvailable suites: auth');
console.error('\nAvailable suites: auth, metadata');
process.exit(1);
}

Expand Down
5 changes: 5 additions & 0 deletions src/scenarios/client/auth/discovery-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,8 @@ export const AuthMetadataVar3Scenario = createMetadataScenario(

// Export all scenarios as an array for convenience
export const metadataScenarios = SCENARIO_CONFIGS.map(createMetadataScenario);

// Export function to list metadata scenario names (for suite support)
export function listMetadataScenarios(): string[] {
return metadataScenarios.map((s) => s.name);
}
57 changes: 38 additions & 19 deletions src/scenarios/client/auth/helpers/createAuthServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,21 @@ export interface AuthServerOptions {
loggingEnabled?: boolean;
routePrefix?: string;
scopesSupported?: string[];
tokenEndpointAuthMethodsSupported?: string[];
tokenVerifier?: MockTokenVerifier;
onTokenRequest?: (requestData: {
scope?: string;
grantType: string;
timestamp: string;
}) => { token: string; scopes: string[] };
onTokenRequest?: (
req: Request,
timestamp: string
) => { token: string; scopes: string[] } | void;
onAuthorizationRequest?: (requestData: {
scope?: string;
timestamp: string;
}) => void;
onRegistrationRequest?: (req: Request) => {
clientId: string;
clientSecret?: string;
tokenEndpointAuthMethod?: string;
};
}

export function createAuthServer(
Expand All @@ -33,9 +38,11 @@ export function createAuthServer(
loggingEnabled = true,
routePrefix = '',
scopesSupported,
tokenEndpointAuthMethodsSupported = ['none'],
tokenVerifier,
onTokenRequest,
onAuthorizationRequest
onAuthorizationRequest,
onRegistrationRequest
} = options;

// Track scopes from the most recent authorization request
Expand Down Expand Up @@ -85,7 +92,7 @@ export function createAuthServer(
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['none']
token_endpoint_auth_methods_supported: tokenEndpointAuthMethodsSupported
};

// Add scopes_supported if provided
Expand Down Expand Up @@ -141,7 +148,6 @@ export function createAuthServer(

app.post(authRoutes.token_endpoint, (req: Request, res: Response) => {
const timestamp = new Date().toISOString();
const requestedScope = req.body.scope;

checks.push({
id: 'token-request',
Expand All @@ -160,13 +166,11 @@ export function createAuthServer(
let scopes: string[] = lastAuthorizationScopes;

if (onTokenRequest) {
const result = onTokenRequest({
scope: requestedScope,
grantType: req.body.grant_type,
timestamp
});
token = result.token;
scopes = result.scopes;
const result = onTokenRequest(req, timestamp);
if (result) {
token = result.token;
scopes = result.scopes;
}
}

// Register token with verifier if provided
Expand All @@ -183,6 +187,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',
Expand All @@ -192,15 +207,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
})
});
});

Expand Down
10 changes: 9 additions & 1 deletion src/scenarios/client/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import {
ScopeOmittedWhenUndefinedScenario,
ScopeStepUpAuthScenario
} from './scope-handling.js';
import {
ClientSecretBasicAuthScenario,
ClientSecretPostAuthScenario,
PublicClientAuthScenario
} from './token-endpoint-auth.js';

export const authScenariosList: Scenario[] = [
...metadataScenarios,
Expand All @@ -18,5 +23,8 @@ export const authScenariosList: Scenario[] = [
new ScopeFromWwwAuthenticateScenario(),
new ScopeFromScopesSupportedScenario(),
new ScopeOmittedWhenUndefinedScenario(),
new ScopeStepUpAuthScenario()
new ScopeStepUpAuthScenario(),
new ClientSecretBasicAuthScenario(),
new ClientSecretPostAuthScenario(),
new PublicClientAuthScenario()
];
176 changes: 176 additions & 0 deletions src/scenarios/client/auth/token-endpoint-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
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: (req, timestamp) => {
const authorizationHeader = req.headers.authorization as
| string
| undefined;
const bodyClientSecret = req.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 })
}
});
},
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');
}
}
3 changes: 3 additions & 0 deletions src/scenarios/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
} from './server/prompts.js';

import { authScenariosList } from './client/auth/index.js';
import { listMetadataScenarios } from './client/auth/discovery-metadata.js';

// Pending client scenarios (not yet fully tested/implemented)
const pendingClientScenariosList: ClientScenario[] = [
Expand Down Expand Up @@ -151,3 +152,5 @@ export function listActiveClientScenarios(): string[] {
export function listAuthScenarios(): string[] {
return authScenariosList.map((scenario) => scenario.name);
}

export { listMetadataScenarios };
Loading