* wip scope change * rm useless bool * fix-me: package changes for linking * negative test * negative test for multiple scopes * fix displays of warnings * wip step-up * negative test for step up auth * move warning into main request flow * clean up more checks * cleanup step-up scenario * cleanup and simplify * cleanup middleware * inline negative tests * inline the other test * refactor logging and inlining for standalone execution too * skip pending tests
423 lines
14 KiB
TypeScript
423 lines
14 KiB
TypeScript
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';
|
|
import type { Request, Response, NextFunction } from 'express';
|
|
|
|
/**
|
|
* Scenario 1: Client uses scope from WWW-Authenticate header
|
|
*
|
|
* Tests that clients SHOULD follow the scope parameter from the initial
|
|
* WWW-Authenticate header in the 401 response, per the scope selection strategy.
|
|
*/
|
|
export class ScopeFromWwwAuthenticateScenario implements Scenario {
|
|
name = 'auth/scope-from-www-authenticate';
|
|
description =
|
|
'Tests that client uses scope parameter from WWW-Authenticate header when provided';
|
|
private authServer = new ServerLifecycle();
|
|
private server = new ServerLifecycle();
|
|
private checks: ConformanceCheck[] = [];
|
|
|
|
async start(): Promise<ScenarioUrls> {
|
|
this.checks = [];
|
|
|
|
const expectedScope = 'mcp:basic';
|
|
const tokenVerifier = new MockTokenVerifier(this.checks, [expectedScope]);
|
|
|
|
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
|
|
tokenVerifier,
|
|
onAuthorizationRequest: (data) => {
|
|
// Check if client used the scope from WWW-Authenticate header
|
|
const requestedScopes = data.scope ? data.scope.split(' ') : [];
|
|
const usedCorrectScope = requestedScopes.includes(expectedScope);
|
|
this.checks.push({
|
|
id: 'scope-from-www-authenticate',
|
|
name: 'Client scope selection from WWW-Authenticate header',
|
|
description: usedCorrectScope
|
|
? 'Client correctly used the scope parameter from the WWW-Authenticate header'
|
|
: 'Client SHOULD use the scope parameter from the WWW-Authenticate header when provided',
|
|
status: usedCorrectScope ? 'SUCCESS' : 'WARNING',
|
|
timestamp: data.timestamp,
|
|
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY],
|
|
details: {
|
|
expectedScope,
|
|
requestedScope: data.scope || 'none'
|
|
}
|
|
});
|
|
}
|
|
});
|
|
await this.authServer.start(authApp);
|
|
|
|
const app = createServer(
|
|
this.checks,
|
|
this.server.getUrl,
|
|
this.authServer.getUrl,
|
|
{
|
|
prmPath: '/.well-known/oauth-protected-resource/mcp',
|
|
requiredScopes: [expectedScope],
|
|
includeScopeInWwwAuth: true,
|
|
tokenVerifier
|
|
}
|
|
);
|
|
await this.server.start(app);
|
|
|
|
return { serverUrl: `${this.server.getUrl()}/mcp` };
|
|
}
|
|
|
|
async stop() {
|
|
await this.authServer.stop();
|
|
await this.server.stop();
|
|
}
|
|
|
|
getChecks(): ConformanceCheck[] {
|
|
return this.checks;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scenario 2: Client falls back to scopes_supported when scope not in WWW-Authenticate
|
|
*
|
|
* Tests that clients SHOULD use all scopes from scopes_supported in the PRM
|
|
* when the scope parameter is not available in the WWW-Authenticate header.
|
|
*/
|
|
export class ScopeFromScopesSupportedScenario implements Scenario {
|
|
name = 'auth/scope-from-scopes-supported';
|
|
description =
|
|
'Tests that client uses all scopes from scopes_supported when scope not in WWW-Authenticate header';
|
|
private authServer = new ServerLifecycle();
|
|
private server = new ServerLifecycle();
|
|
private checks: ConformanceCheck[] = [];
|
|
|
|
async start(): Promise<ScenarioUrls> {
|
|
this.checks = [];
|
|
|
|
const scopesSupported = ['mcp:basic', 'mcp:read', 'mcp:write'];
|
|
const tokenVerifier = new MockTokenVerifier(this.checks, scopesSupported);
|
|
|
|
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
|
|
tokenVerifier,
|
|
onAuthorizationRequest: (data) => {
|
|
// Check if client requested all scopes from scopes_supported
|
|
const requestedScopes = data.scope ? data.scope.split(' ') : [];
|
|
const hasAllScopes = scopesSupported.every((scope) =>
|
|
requestedScopes.includes(scope)
|
|
);
|
|
this.checks.push({
|
|
id: 'scope-from-scopes-supported',
|
|
name: 'Client scope selection from scopes_supported',
|
|
description: hasAllScopes
|
|
? 'Client correctly used all scopes from scopes_supported in PRM when scope not in WWW-Authenticate'
|
|
: 'Client SHOULD use all scopes from scopes_supported when scope not available in WWW-Authenticate header',
|
|
status: hasAllScopes ? 'SUCCESS' : 'WARNING',
|
|
timestamp: data.timestamp,
|
|
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY],
|
|
details: {
|
|
scopesSupported: scopesSupported.join(' '),
|
|
requestedScope: data.scope || 'none',
|
|
...(hasAllScopes
|
|
? {}
|
|
: {
|
|
missingScopes: scopesSupported
|
|
.filter((s) => !requestedScopes.includes(s))
|
|
.join(' ')
|
|
})
|
|
}
|
|
});
|
|
}
|
|
});
|
|
await this.authServer.start(authApp);
|
|
|
|
const app = createServer(
|
|
this.checks,
|
|
this.server.getUrl,
|
|
this.authServer.getUrl,
|
|
{
|
|
prmPath: '/.well-known/oauth-protected-resource/mcp',
|
|
requiredScopes: scopesSupported,
|
|
scopesSupported: scopesSupported,
|
|
includeScopeInWwwAuth: false,
|
|
tokenVerifier
|
|
}
|
|
);
|
|
await this.server.start(app);
|
|
|
|
return { serverUrl: `${this.server.getUrl()}/mcp` };
|
|
}
|
|
|
|
async stop() {
|
|
await this.authServer.stop();
|
|
await this.server.stop();
|
|
}
|
|
|
|
getChecks(): ConformanceCheck[] {
|
|
return this.checks;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scenario 3: Client omits scope when scopes_supported is undefined
|
|
*
|
|
* Tests that clients SHOULD omit the scope parameter when scopes_supported
|
|
* is not available in the PRM and scope is not in WWW-Authenticate header.
|
|
*/
|
|
export class ScopeOmittedWhenUndefinedScenario implements Scenario {
|
|
name = 'auth/scope-omitted-when-undefined';
|
|
description =
|
|
'Tests that client omits scope parameter when scopes_supported is undefined';
|
|
private authServer = new ServerLifecycle();
|
|
private server = new ServerLifecycle();
|
|
private checks: ConformanceCheck[] = [];
|
|
|
|
async start(): Promise<ScenarioUrls> {
|
|
this.checks = [];
|
|
|
|
const tokenVerifier = new MockTokenVerifier(this.checks, []);
|
|
|
|
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
|
|
tokenVerifier,
|
|
onAuthorizationRequest: (data) => {
|
|
// Check if client omitted scope parameter
|
|
const scopeOmitted = !data.scope || data.scope.trim() === '';
|
|
this.checks.push({
|
|
id: 'scope-omitted-when-undefined',
|
|
name: 'Client scope omission when scopes_supported undefined',
|
|
description: scopeOmitted
|
|
? 'Client correctly omitted scope parameter when scopes_supported is undefined'
|
|
: 'Client SHOULD omit scope parameter when scopes_supported is undefined and scope not in WWW-Authenticate',
|
|
status: scopeOmitted ? 'SUCCESS' : 'WARNING',
|
|
timestamp: data.timestamp,
|
|
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY],
|
|
details: {
|
|
scopeParameter: scopeOmitted ? 'omitted' : data.scope
|
|
}
|
|
});
|
|
}
|
|
});
|
|
await this.authServer.start(authApp);
|
|
|
|
const app = createServer(
|
|
this.checks,
|
|
this.server.getUrl,
|
|
this.authServer.getUrl,
|
|
{
|
|
prmPath: '/.well-known/oauth-protected-resource/mcp',
|
|
requiredScopes: [],
|
|
scopesSupported: undefined,
|
|
includeScopeInWwwAuth: false,
|
|
tokenVerifier
|
|
}
|
|
);
|
|
await this.server.start(app);
|
|
|
|
return { serverUrl: `${this.server.getUrl()}/mcp` };
|
|
}
|
|
|
|
async stop() {
|
|
await this.authServer.stop();
|
|
await this.server.stop();
|
|
}
|
|
|
|
getChecks(): ConformanceCheck[] {
|
|
return this.checks;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scenario 4: Client performs step-up authentication
|
|
*
|
|
* Tests that clients handle step-up authentication where:
|
|
* - initialize/notifications do not require auth
|
|
* - listTools requires mcp:basic scope (401 if missing)
|
|
* - tools/call requires mcp:basic + mcp:write scopes (403 if insufficient)
|
|
* Client must handle both 401 and 403 responses with different scope requirements
|
|
*/
|
|
export class ScopeStepUpAuthScenario implements Scenario {
|
|
name = 'auth/scope-step-up';
|
|
description =
|
|
'Tests that client handles step-up authentication with different scope requirements per operation';
|
|
private authServer = new ServerLifecycle();
|
|
private server = new ServerLifecycle();
|
|
private checks: ConformanceCheck[] = [];
|
|
|
|
async start(): Promise<ScenarioUrls> {
|
|
this.checks = [];
|
|
|
|
const initialScope = 'mcp:basic';
|
|
const escalatedScopes = ['mcp:basic', 'mcp:write'];
|
|
const tokenVerifier = new MockTokenVerifier(this.checks, escalatedScopes);
|
|
let authRequestCount = 0;
|
|
|
|
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
|
|
tokenVerifier,
|
|
onAuthorizationRequest: (data) => {
|
|
authRequestCount++;
|
|
const requestedScopes = data.scope ? data.scope.split(' ') : [];
|
|
|
|
if (authRequestCount === 1) {
|
|
// First auth request - should request mcp:basic from WWW-Authenticate
|
|
const usedCorrectScope = requestedScopes.includes(initialScope);
|
|
this.checks.push({
|
|
id: 'scope-step-up-initial',
|
|
name: 'Client initial scope selection for step-up auth',
|
|
description: usedCorrectScope
|
|
? 'Client correctly used scope from WWW-Authenticate header for initial auth'
|
|
: 'Client SHOULD use the scope parameter from the WWW-Authenticate header',
|
|
status: usedCorrectScope ? 'SUCCESS' : 'WARNING',
|
|
timestamp: data.timestamp,
|
|
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY],
|
|
details: {
|
|
expectedScope: initialScope,
|
|
requestedScope: data.scope || 'none'
|
|
}
|
|
});
|
|
} else if (authRequestCount === 2) {
|
|
// Second auth request - should escalate to mcp:basic + mcp:write
|
|
const hasAllScopes = escalatedScopes.every((s) =>
|
|
requestedScopes.includes(s)
|
|
);
|
|
this.checks.push({
|
|
id: 'scope-step-up-escalation',
|
|
name: 'Client scope escalation for step-up auth',
|
|
description: hasAllScopes
|
|
? 'Client correctly escalated scopes for step-up authentication'
|
|
: 'Client SHOULD request additional scopes when receiving 403 with new scope requirements',
|
|
status: hasAllScopes ? 'SUCCESS' : 'WARNING',
|
|
timestamp: data.timestamp,
|
|
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY],
|
|
details: {
|
|
expectedScopes: escalatedScopes.join(' '),
|
|
requestedScope: data.scope || 'none'
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
await this.authServer.start(authApp);
|
|
|
|
// Inline step-up auth middleware
|
|
const resourceMetadataUrl = () =>
|
|
`${this.server.getUrl()}/.well-known/oauth-protected-resource/mcp`;
|
|
|
|
const stepUpMiddleware = async (
|
|
req: Request,
|
|
res: Response,
|
|
next: NextFunction
|
|
) => {
|
|
// Parse body to check method
|
|
let body = req.body;
|
|
if (typeof body === 'string') {
|
|
body = JSON.parse(body);
|
|
}
|
|
const method = body?.method;
|
|
|
|
// Allow initialize and notifications without auth
|
|
if (method === 'initialize' || method?.startsWith('notifications/')) {
|
|
return next();
|
|
}
|
|
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
// No auth - return 401 with initial scope
|
|
return res
|
|
.status(401)
|
|
.set(
|
|
'WWW-Authenticate',
|
|
`Bearer scope="${initialScope}", resource_metadata="${resourceMetadataUrl()}"`
|
|
)
|
|
.json({
|
|
error: 'invalid_token',
|
|
error_description: 'Missing Authorization header'
|
|
});
|
|
}
|
|
|
|
const token = authHeader.substring('Bearer '.length);
|
|
const authInfo = await tokenVerifier.verifyAccessToken(token);
|
|
const tokenScopes = authInfo.scopes || [];
|
|
|
|
// Determine required scopes based on method
|
|
const isToolCall = method === 'tools/call';
|
|
const requiredScopes = isToolCall ? escalatedScopes : [initialScope];
|
|
|
|
const hasRequiredScopes = requiredScopes.every((s) =>
|
|
tokenScopes.includes(s)
|
|
);
|
|
|
|
if (!hasRequiredScopes) {
|
|
// Has token but insufficient scopes - return 403
|
|
return res
|
|
.status(403)
|
|
.set(
|
|
'WWW-Authenticate',
|
|
`Bearer scope="${requiredScopes.join(' ')}", resource_metadata="${resourceMetadataUrl()}"`
|
|
)
|
|
.json({
|
|
error: 'insufficient_scope',
|
|
error_description: 'Token has insufficient scope'
|
|
});
|
|
}
|
|
|
|
next();
|
|
};
|
|
|
|
const baseApp = createServer(
|
|
this.checks,
|
|
this.server.getUrl,
|
|
this.authServer.getUrl,
|
|
{
|
|
prmPath: '/.well-known/oauth-protected-resource/mcp',
|
|
requiredScopes: escalatedScopes,
|
|
scopesSupported: escalatedScopes,
|
|
includeScopeInWwwAuth: true,
|
|
authMiddleware: stepUpMiddleware,
|
|
tokenVerifier
|
|
}
|
|
);
|
|
|
|
await this.server.start(baseApp);
|
|
|
|
return { serverUrl: `${this.server.getUrl()}/mcp` };
|
|
}
|
|
|
|
async stop() {
|
|
await this.authServer.stop();
|
|
await this.server.stop();
|
|
}
|
|
|
|
getChecks(): ConformanceCheck[] {
|
|
// Emit failure checks if expected auth requests didn't happen
|
|
const hasInitialCheck = this.checks.some(
|
|
(c) => c.id === 'scope-step-up-initial'
|
|
);
|
|
const hasEscalationCheck = this.checks.some(
|
|
(c) => c.id === 'scope-step-up-escalation'
|
|
);
|
|
|
|
if (!hasInitialCheck) {
|
|
this.checks.push({
|
|
id: 'scope-step-up-initial',
|
|
name: 'Client initial scope selection for step-up auth',
|
|
description: 'Client did not make an initial authorization request',
|
|
status: 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY]
|
|
});
|
|
}
|
|
|
|
if (!hasEscalationCheck) {
|
|
this.checks.push({
|
|
id: 'scope-step-up-escalation',
|
|
name: 'Client scope escalation for step-up auth',
|
|
description:
|
|
'Client did not make a second authorization request for scope escalation',
|
|
status: 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY]
|
|
});
|
|
}
|
|
|
|
return this.checks;
|
|
}
|
|
}
|