[auth] Scenarios for scope selection (#36)
* 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
This commit is contained in:
parent
253ede03ad
commit
5c151133bb
8 changed files with 1074 additions and 186 deletions
|
|
@ -2,12 +2,24 @@ import express, { Request, Response } from 'express';
|
|||
import type { ConformanceCheck } from '../../../../types.js';
|
||||
import { createRequestLogger } from '../../../request-logger.js';
|
||||
import { SpecReferences } from '../spec-references.js';
|
||||
import { MockTokenVerifier } from './mockTokenVerifier.js';
|
||||
|
||||
export interface AuthServerOptions {
|
||||
metadataPath?: string;
|
||||
isOpenIdConfiguration?: boolean;
|
||||
loggingEnabled?: boolean;
|
||||
routePrefix?: string;
|
||||
scopesSupported?: string[];
|
||||
tokenVerifier?: MockTokenVerifier;
|
||||
onTokenRequest?: (requestData: {
|
||||
scope?: string;
|
||||
grantType: string;
|
||||
timestamp: string;
|
||||
}) => { token: string; scopes: string[] };
|
||||
onAuthorizationRequest?: (requestData: {
|
||||
scope?: string;
|
||||
timestamp: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function createAuthServer(
|
||||
|
|
@ -19,9 +31,16 @@ export function createAuthServer(
|
|||
metadataPath = '/.well-known/oauth-authorization-server',
|
||||
isOpenIdConfiguration = false,
|
||||
loggingEnabled = true,
|
||||
routePrefix = ''
|
||||
routePrefix = '',
|
||||
scopesSupported,
|
||||
tokenVerifier,
|
||||
onTokenRequest,
|
||||
onAuthorizationRequest
|
||||
} = options;
|
||||
|
||||
// Track scopes from the most recent authorization request
|
||||
let lastAuthorizationScopes: string[] = [];
|
||||
|
||||
const authRoutes = {
|
||||
authorization_endpoint: `${routePrefix}/authorize`,
|
||||
token_endpoint: `${routePrefix}/token`,
|
||||
|
|
@ -69,6 +88,11 @@ export function createAuthServer(
|
|||
token_endpoint_auth_methods_supported: ['none']
|
||||
};
|
||||
|
||||
// Add scopes_supported if provided
|
||||
if (scopesSupported !== undefined) {
|
||||
metadata.scopes_supported = scopesSupported;
|
||||
}
|
||||
|
||||
// Add OpenID Configuration specific fields
|
||||
if (isOpenIdConfiguration) {
|
||||
metadata.jwks_uri = `${getAuthBaseUrl()}/.well-known/jwks.json`;
|
||||
|
|
@ -80,23 +104,30 @@ export function createAuthServer(
|
|||
});
|
||||
|
||||
app.get(authRoutes.authorization_endpoint, (req: Request, res: Response) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
checks.push({
|
||||
id: 'authorization-request',
|
||||
name: 'AuthorizationRequest',
|
||||
description: 'Client made authorization request',
|
||||
status: 'SUCCESS',
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp,
|
||||
specReferences: [SpecReferences.OAUTH_2_1_AUTHORIZATION_ENDPOINT],
|
||||
details: {
|
||||
response_type: req.query.response_type,
|
||||
client_id: req.query.client_id,
|
||||
redirect_uri: req.query.redirect_uri,
|
||||
state: req.query.state,
|
||||
code_challenge: req.query.code_challenge ? 'present' : 'missing',
|
||||
code_challenge_method: req.query.code_challenge_method
|
||||
query: req.query
|
||||
}
|
||||
});
|
||||
|
||||
// Track scopes from authorization request for token issuance
|
||||
const scopeParam = req.query.scope as string | undefined;
|
||||
lastAuthorizationScopes = scopeParam ? scopeParam.split(' ') : [];
|
||||
|
||||
if (onAuthorizationRequest) {
|
||||
onAuthorizationRequest({
|
||||
scope: scopeParam,
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
|
||||
const redirectUri = req.query.redirect_uri as string;
|
||||
const state = req.query.state as string;
|
||||
const redirectUrl = new URL(redirectUri);
|
||||
|
|
@ -109,12 +140,15 @@ 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',
|
||||
name: 'TokenRequest',
|
||||
description: 'Client requested access token',
|
||||
status: 'SUCCESS',
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp,
|
||||
specReferences: [SpecReferences.OAUTH_2_1_TOKEN],
|
||||
details: {
|
||||
endpoint: '/token',
|
||||
|
|
@ -122,10 +156,29 @@ export function createAuthServer(
|
|||
}
|
||||
});
|
||||
|
||||
let token = `test-token-${Date.now()}`;
|
||||
let scopes: string[] = lastAuthorizationScopes;
|
||||
|
||||
if (onTokenRequest) {
|
||||
const result = onTokenRequest({
|
||||
scope: requestedScope,
|
||||
grantType: req.body.grant_type,
|
||||
timestamp
|
||||
});
|
||||
token = result.token;
|
||||
scopes = result.scopes;
|
||||
}
|
||||
|
||||
// Register token with verifier if provided
|
||||
if (tokenVerifier) {
|
||||
tokenVerifier.registerToken(token, scopes);
|
||||
}
|
||||
|
||||
res.json({
|
||||
access_token: 'test-token',
|
||||
access_token: token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600
|
||||
expires_in: 3600,
|
||||
...(scopes.length > 0 && { scope: scopes.join(' ') })
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
CallToolResult,
|
||||
ErrorCode,
|
||||
ListToolsRequestSchema,
|
||||
McpError
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import type { ConformanceCheck } from '../../../../types.js';
|
||||
|
|
@ -10,6 +16,11 @@ import { SpecReferences } from '../spec-references.js';
|
|||
|
||||
export interface ServerOptions {
|
||||
prmPath?: string | null;
|
||||
requiredScopes?: string[];
|
||||
scopesSupported?: string[];
|
||||
includeScopeInWwwAuth?: boolean;
|
||||
authMiddleware?: express.RequestHandler;
|
||||
tokenVerifier?: MockTokenVerifier;
|
||||
}
|
||||
|
||||
export function createServer(
|
||||
|
|
@ -18,7 +29,13 @@ export function createServer(
|
|||
getAuthServerUrl: () => string,
|
||||
options: ServerOptions = {}
|
||||
): express.Application {
|
||||
const { prmPath = '/.well-known/oauth-protected-resource/mcp' } = options;
|
||||
const {
|
||||
prmPath = '/.well-known/oauth-protected-resource/mcp',
|
||||
requiredScopes = [],
|
||||
scopesSupported,
|
||||
includeScopeInWwwAuth = false,
|
||||
tokenVerifier
|
||||
} = options;
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'auth-prm-pathbased-server',
|
||||
|
|
@ -33,10 +50,30 @@ export function createServer(
|
|||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: []
|
||||
tools: [
|
||||
{
|
||||
name: 'test-tool',
|
||||
inputSchema: { type: 'object' }
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
server.setRequestHandler(
|
||||
CallToolRequestSchema,
|
||||
async (request): Promise<CallToolResult> => {
|
||||
if (request.params.name === 'test-tool') {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'test' }]
|
||||
};
|
||||
}
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Tool ${request.params.name} not found`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
|
|
@ -73,10 +110,16 @@ export function createServer(
|
|||
? getBaseUrl()
|
||||
: `${getBaseUrl()}/mcp`;
|
||||
|
||||
res.json({
|
||||
const prmResponse: any = {
|
||||
resource,
|
||||
authorization_servers: [getAuthServerUrl()]
|
||||
});
|
||||
};
|
||||
|
||||
if (scopesSupported !== undefined) {
|
||||
prmResponse.scopes_supported = scopesSupported;
|
||||
}
|
||||
|
||||
res.json(prmResponse);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -84,13 +127,19 @@ export function createServer(
|
|||
// Apply bearer token auth per-request in order to delay setting PRM URL
|
||||
// until after the server has started
|
||||
// TODO: Find a way to do this w/ pre-applying middleware.
|
||||
const authMiddleware = requireBearerAuth({
|
||||
verifier: new MockTokenVerifier(checks),
|
||||
requiredScopes: [],
|
||||
...(prmPath !== null && {
|
||||
resourceMetadataUrl: `${getBaseUrl()}${prmPath}`
|
||||
})
|
||||
});
|
||||
const verifier =
|
||||
tokenVerifier || new MockTokenVerifier(checks, requiredScopes);
|
||||
|
||||
const authMiddleware =
|
||||
options.authMiddleware ??
|
||||
requireBearerAuth({
|
||||
verifier,
|
||||
// Only pass requiredScopes if we want them in the WWW-Authenticate header
|
||||
requiredScopes: includeScopeInWwwAuth ? requiredScopes : [],
|
||||
...(prmPath !== null && {
|
||||
resourceMetadataUrl: `${getBaseUrl()}${prmPath}`
|
||||
})
|
||||
});
|
||||
|
||||
authMiddleware(req, res, async (err?: any) => {
|
||||
if (err) return next(err);
|
||||
|
|
|
|||
|
|
@ -4,10 +4,23 @@ import type { ConformanceCheck } from '../../../../types.js';
|
|||
import { SpecReferences } from '../spec-references.js';
|
||||
|
||||
export class MockTokenVerifier implements OAuthTokenVerifier {
|
||||
constructor(private checks: ConformanceCheck[]) {}
|
||||
private tokenScopes: Map<string, string[]> = new Map();
|
||||
|
||||
constructor(
|
||||
private checks: ConformanceCheck[],
|
||||
private expectedScopes: string[] = []
|
||||
) {}
|
||||
|
||||
registerToken(token: string, scopes: string[]) {
|
||||
this.tokenScopes.set(token, scopes);
|
||||
}
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
if (token === 'test-token') {
|
||||
// Accept tokens that start with 'test-token'
|
||||
if (token.startsWith('test-token')) {
|
||||
// Get scopes for this token, or use empty array
|
||||
const scopes = this.tokenScopes.get(token) || [];
|
||||
|
||||
this.checks.push({
|
||||
id: 'valid-bearer-token',
|
||||
name: 'ValidBearerToken',
|
||||
|
|
@ -16,13 +29,14 @@ export class MockTokenVerifier implements OAuthTokenVerifier {
|
|||
timestamp: new Date().toISOString(),
|
||||
specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE],
|
||||
details: {
|
||||
token: token.substring(0, 10) + '...'
|
||||
token: token.substring(0, 15) + '...',
|
||||
scopes
|
||||
}
|
||||
});
|
||||
return {
|
||||
token,
|
||||
clientId: 'test-client',
|
||||
scopes: [],
|
||||
scopes,
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 3600
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,39 @@
|
|||
import { authScenariosList } from './index.js';
|
||||
import {
|
||||
runClientAgainstScenario,
|
||||
SpawnedClientRunner
|
||||
InlineClientRunner
|
||||
} from './test_helpers/testClient.js';
|
||||
import path from 'path';
|
||||
import { runClient as goodClient } from '../../../../examples/clients/typescript/auth-test.js';
|
||||
import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm.js';
|
||||
import { runClient as ignoreScopeClient } from '../../../../examples/clients/typescript/auth-test-ignore-scope.js';
|
||||
import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes.js';
|
||||
import { runClient as ignore403Client } from '../../../../examples/clients/typescript/auth-test-ignore-403.js';
|
||||
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger.js';
|
||||
|
||||
beforeAll(() => {
|
||||
setLogLevel('error');
|
||||
});
|
||||
|
||||
const skipScenarios = new Set<string>([
|
||||
// Waiting on typescript-sdk support in bearerAuth middleware to include
|
||||
// scope in WWW-Authenticate header
|
||||
// https://github.com/modelcontextprotocol/typescript-sdk/pull/1133
|
||||
'auth/scope-from-www-authenticate',
|
||||
// Waiting on typescript-sdk support for using scopes_supported from PRM
|
||||
// to request scopes.
|
||||
// https://github.com/modelcontextprotocol/typescript-sdk/pull/1133
|
||||
'auth/scope-from-scopes-supported'
|
||||
]);
|
||||
|
||||
describe('Client Auth Scenarios', () => {
|
||||
const clientPath = path.join(
|
||||
process.cwd(),
|
||||
'examples/clients/typescript/auth-test.ts'
|
||||
);
|
||||
|
||||
// Generate individual test for each auth scenario
|
||||
for (const scenario of authScenariosList) {
|
||||
test(`${scenario.name} passes`, async () => {
|
||||
const runner = new SpawnedClientRunner(clientPath);
|
||||
if (skipScenarios.has(scenario.name)) {
|
||||
// TODO: skip in a native way?
|
||||
return;
|
||||
}
|
||||
const runner = new InlineClientRunner(goodClient);
|
||||
await runClientAgainstScenario(runner, scenario.name);
|
||||
});
|
||||
}
|
||||
|
|
@ -22,16 +41,39 @@ describe('Client Auth Scenarios', () => {
|
|||
|
||||
describe('Negative tests', () => {
|
||||
test('bad client requests root PRM location', async () => {
|
||||
const clientPath = path.join(
|
||||
process.cwd(),
|
||||
'examples/clients/typescript/auth-test-broken1.ts'
|
||||
);
|
||||
const runner = new SpawnedClientRunner(clientPath);
|
||||
const runner = new InlineClientRunner(badPrmClient);
|
||||
await runClientAgainstScenario(runner, 'auth/basic-dcr', [
|
||||
// There will be other failures, but this is the one that matters
|
||||
'prm-priority-order'
|
||||
]);
|
||||
});
|
||||
|
||||
// TODO: Add more negative tests here
|
||||
test('client ignores scope from WWW-Authenticate header', async () => {
|
||||
const runner = new InlineClientRunner(ignoreScopeClient);
|
||||
await runClientAgainstScenario(runner, 'auth/scope-from-www-authenticate', [
|
||||
'scope-from-www-authenticate'
|
||||
]);
|
||||
});
|
||||
|
||||
test('client only requests subset of scopes_supported', async () => {
|
||||
const runner = new InlineClientRunner(partialScopesClient);
|
||||
await runClientAgainstScenario(runner, 'auth/scope-from-scopes-supported', [
|
||||
'scope-from-scopes-supported'
|
||||
]);
|
||||
});
|
||||
|
||||
test('client requests scope even if scopes_supported is empty', async () => {
|
||||
const runner = new InlineClientRunner(partialScopesClient);
|
||||
await runClientAgainstScenario(
|
||||
runner,
|
||||
'auth/scope-omitted-when-undefined',
|
||||
['scope-omitted-when-undefined']
|
||||
);
|
||||
});
|
||||
|
||||
test('client only responds to 401, not 403', async () => {
|
||||
const runner = new InlineClientRunner(ignore403Client);
|
||||
await runClientAgainstScenario(runner, 'auth/scope-step-up', [
|
||||
'scope-step-up-escalation'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ import {
|
|||
Auth20250326OAuthMetadataBackcompatScenario,
|
||||
Auth20250326OEndpointFallbackScenario
|
||||
} from './march-spec-backcompat.js';
|
||||
import {
|
||||
ScopeFromWwwAuthenticateScenario,
|
||||
ScopeFromScopesSupportedScenario,
|
||||
ScopeOmittedWhenUndefinedScenario,
|
||||
ScopeStepUpAuthScenario
|
||||
} from './scope-handling.js';
|
||||
|
||||
export const authScenariosList: Scenario[] = [
|
||||
new AuthBasicDCRScenario(),
|
||||
|
|
@ -16,5 +22,9 @@ export const authScenariosList: Scenario[] = [
|
|||
new AuthBasicMetadataVar2Scenario(),
|
||||
new AuthBasicMetadataVar3Scenario(),
|
||||
new Auth20250326OAuthMetadataBackcompatScenario(),
|
||||
new Auth20250326OEndpointFallbackScenario()
|
||||
new Auth20250326OEndpointFallbackScenario(),
|
||||
new ScopeFromWwwAuthenticateScenario(),
|
||||
new ScopeFromScopesSupportedScenario(),
|
||||
new ScopeOmittedWhenUndefinedScenario(),
|
||||
new ScopeStepUpAuthScenario()
|
||||
];
|
||||
|
|
|
|||
423
src/scenarios/client/auth/scope-handling.ts
Normal file
423
src/scenarios/client/auth/scope-handling.ts
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -40,5 +40,17 @@ export const SpecReferences: { [key: string]: SpecReference } = {
|
|||
MCP_ACCESS_TOKEN_USAGE: {
|
||||
id: 'MCP-Access-token-usage',
|
||||
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#access-token-usage'
|
||||
},
|
||||
MCP_SCOPE_SELECTION_STRATEGY: {
|
||||
id: 'MCP-Scope-selection-strategy',
|
||||
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-selection-strategy'
|
||||
},
|
||||
MCP_SCOPE_CHALLENGE_HANDLING: {
|
||||
id: 'MCP-Scope-challenge-handling',
|
||||
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling'
|
||||
},
|
||||
MCP_AUTH_ERROR_HANDLING: {
|
||||
id: 'MCP-Auth-error-handling',
|
||||
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#error-handling'
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -136,7 +136,9 @@ export async function runClientAgainstScenario(
|
|||
}
|
||||
|
||||
// Verify that only the expected checks failed
|
||||
const failures = nonInfoChecks.filter((c) => c.status === 'FAILURE');
|
||||
const failures = nonInfoChecks.filter(
|
||||
(c) => c.status === 'FAILURE' || c.status === 'WARNING'
|
||||
);
|
||||
const failureSlugs = failures.map((c) => c.id);
|
||||
// Check that failureSlugs contains all expectedFailureSlugs
|
||||
expect(failureSlugs).toEqual(
|
||||
|
|
|
|||
Loading…
Reference in a new issue