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
31 changes: 26 additions & 5 deletions src/scenarios/client/auth/helpers/createAuthServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export interface AuthServerOptions {
scope?: string;
timestamp: string;
}) => void;
onRegistrationRequest?: (req: Request) => {
clientId: string;
clientSecret?: string;
tokenEndpointAuthMethod?: string;
};
}

export function createAuthServer(
Expand All @@ -62,7 +67,8 @@ export function createAuthServer(
clientIdMetadataDocumentSupported,
tokenVerifier,
onTokenRequest,
onAuthorizationRequest
onAuthorizationRequest,
onRegistrationRequest
} = options;

// Track scopes from the most recent authorization request
Expand Down Expand Up @@ -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',
Expand All @@ -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
})
});
});

Expand Down
8 changes: 8 additions & 0 deletions src/scenarios/client/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import {
ScopeStepUpAuthScenario,
ScopeRetryLimitScenario
} from './scope-handling';
import {
ClientSecretBasicAuthScenario,
ClientSecretPostAuthScenario,
PublicClientAuthScenario
} from './token-endpoint-auth';
import {
ClientCredentialsJwtScenario,
ClientCredentialsBasicScenario
Expand All @@ -27,6 +32,9 @@ export const authScenariosList: Scenario[] = [
new ScopeOmittedWhenUndefinedScenario(),
new ScopeStepUpAuthScenario(),
new ScopeRetryLimitScenario(),
new ClientSecretBasicAuthScenario(),
new ClientSecretPostAuthScenario(),
new PublicClientAuthScenario(),
new ClientCredentialsJwtScenario(),
new ClientCredentialsBasicScenario()
];
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) => {

Check failure on line 67 in src/scenarios/client/auth/token-endpoint-auth.ts

View workflow job for this annotation

GitHub Actions / test

Parameter 'timestamp' implicitly has an 'any' type.

Check failure on line 67 in src/scenarios/client/auth/token-endpoint-auth.ts

View workflow job for this annotation

GitHub Actions / test

Parameter 'req' implicitly has an 'any' type.

Check failure on line 67 in src/scenarios/client/auth/token-endpoint-auth.ts

View workflow job for this annotation

GitHub Actions / test

Type '(req: any, timestamp: any) => void' is not assignable to type '(requestData: { scope?: string | undefined; grantType: string; timestamp: string; body: Record<string, string>; authBaseUrl: string; tokenEndpoint: string; authorizationHeader?: string | undefined; }) => Promise<...> | ... 1 more ... | TokenRequestResult'.
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');
}
}
Loading