[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:
Paul Carleton 2025-11-18 17:16:23 +00:00 • committed by GitHub
commit 5c151133bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1074 additions and 186 deletions

View file

@ -0,0 +1,79 @@
#!/usr/bin/env node
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import {
auth,
UnauthorizedError
} from '@modelcontextprotocol/sdk/client/auth.js';
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
import { runAsCli } from './helpers/cliRunner.js';
import { logger } from './helpers/logger.js';
/**
* Broken client that always uses root-based PRM discovery.
* BUG: Ignores the resource_metadata URL from WWW-Authenticate header.
*/
export async function runClient(serverUrl: string): Promise<void> {
const handle401Broken = async (
response: Response,
provider: ConformanceOAuthProvider,
next: FetchLike,
serverUrl: string | URL
): Promise<void> => {
// BUG: Use root-based PRM discovery exclusively
const resourceMetadataUrl = new URL(
'/.well-known/oauth-protected-resource',
typeof serverUrl === 'string' ? serverUrl : serverUrl.origin
);
let result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
fetchFn: next
});
if (result === 'REDIRECT') {
const authorizationCode = await provider.getAuthCode();
result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
authorizationCode,
fetchFn: next
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError(
`Authentication failed with result: ${result}`
);
}
}
};
const client = new Client(
{ name: 'test-auth-client-broken', version: '1.0.0' },
{ capabilities: {} }
);
const oauthFetch = withOAuthRetry(
'test-auth-client-broken',
new URL(serverUrl),
handle401Broken
)(fetch);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});
await client.connect(transport);
logger.debug('✅ Successfully connected to MCP server');
await client.listTools();
logger.debug('✅ Successfully listed tools');
await transport.close();
logger.debug('✅ Connection closed successfully');
}
runAsCli(runClient, import.meta.url, 'auth-test-bad-prm <server-url>');

View file

@ -1,99 +0,0 @@
#!/usr/bin/env node
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
import { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
import {
auth,
UnauthorizedError
} from '@modelcontextprotocol/sdk/client/auth.js';
export const handle401Broken = async (
response: Response,
provider: ConformanceOAuthProvider,
next: FetchLike,
serverUrl: string | URL
): Promise<void> => {
// BROKEN: Use root-based PRM discovery exclusively, regardless of input.
const resourceMetadataUrl = new URL(
'/.well-known/oauth-protected-resource',
typeof serverUrl === 'string' ? serverUrl : serverUrl.origin
);
let result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
fetchFn: next
});
if (result === 'REDIRECT') {
// Ordinarily, we'd wait for the callback to be handled here,
// but in our conformance provider, we get the authorization code
// during the redirect handling, so we can go straight to
// retrying the auth step.
// await provider.waitForCallback();
const authorizationCode = await provider.getAuthCode();
// TODO: this retry logic should be incorporated into the typescript SDK
result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
authorizationCode,
fetchFn: next
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError(
`Authentication failed with result: ${result}`
);
}
}
};
async function main(): Promise<void> {
const serverUrl = process.argv[2];
if (!serverUrl) {
console.error('Usage: auth-test <server-url>');
process.exit(1);
}
console.log(`Connecting to MCP server at: ${serverUrl}`);
const client = new Client(
{
name: 'test-auth-client',
version: '1.0.0'
},
{
capabilities: {}
}
);
// Create a custom fetch that uses the OAuth middleware with retry logic
const oauthFetch = withOAuthRetry(
'test-auth-client',
new URL(serverUrl),
handle401Broken
)(fetch);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});
// Connect to the server - OAuth is handled automatically by the middleware
await client.connect(transport);
console.log('✅ Successfully connected to MCP server');
await client.listTools();
console.log('✅ Successfully listed tools');
await transport.close();
console.log('✅ Connection closed successfully');
process.exit(0);
}
main();

View file

@ -0,0 +1,87 @@
#!/usr/bin/env node
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import {
auth,
extractWWWAuthenticateParams,
UnauthorizedError
} from '@modelcontextprotocol/sdk/client/auth.js';
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
import { runAsCli } from './helpers/cliRunner.js';
import { logger } from './helpers/logger.js';
/**
* Broken client that only responds to 401, not 403.
* BUG: Ignores 403 responses which are used for step-up auth.
*/
export async function runClient(serverUrl: string): Promise<void> {
const handle401Broken = async (
response: Response,
provider: ConformanceOAuthProvider,
next: FetchLike,
serverUrl: string | URL
): Promise<void> => {
// BUG: Only respond to 401, not 403
if (response.status !== 401) {
return;
}
const { resourceMetadataUrl, scope } =
extractWWWAuthenticateParams(response);
let result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
scope,
fetchFn: next
});
if (result === 'REDIRECT') {
const authorizationCode = await provider.getAuthCode();
result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
scope,
authorizationCode,
fetchFn: next
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError(
`Authentication failed with result: ${result}`
);
}
}
};
const client = new Client(
{ name: 'test-auth-client-broken', version: '1.0.0' },
{ capabilities: {} }
);
const oauthFetch = withOAuthRetry(
'test-auth-client-broken',
new URL(serverUrl),
handle401Broken
)(fetch);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});
await client.connect(transport);
logger.debug('✅ Successfully connected to MCP server');
await client.listTools();
logger.debug('✅ Successfully listed tools');
// Call tool to trigger step-up auth
await client.callTool({ name: 'test-tool', arguments: {} });
logger.debug('✅ Successfully called tool');
await transport.close();
logger.debug('✅ Connection closed successfully');
}
runAsCli(runClient, import.meta.url, 'auth-test-ignore-403 <server-url>');

View file

@ -0,0 +1,77 @@
#!/usr/bin/env node
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import {
auth,
extractWWWAuthenticateParams,
UnauthorizedError
} from '@modelcontextprotocol/sdk/client/auth.js';
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
import { runAsCli } from './helpers/cliRunner.js';
import { logger } from './helpers/logger.js';
/**
* Broken client that ignores the scope from WWW-Authenticate header.
* BUG: Doesn't pass the scope parameter from the 401 response.
*/
export async function runClient(serverUrl: string): Promise<void> {
const handle401Broken = async (
response: Response,
provider: ConformanceOAuthProvider,
next: FetchLike,
serverUrl: string | URL
): Promise<void> => {
// BUG: Don't read the scope from the header
const { resourceMetadataUrl } = extractWWWAuthenticateParams(response);
let result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
// scope deliberately omitted
fetchFn: next
});
if (result === 'REDIRECT') {
const authorizationCode = await provider.getAuthCode();
result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
authorizationCode,
fetchFn: next
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError(
`Authentication failed with result: ${result}`
);
}
}
};
const client = new Client(
{ name: 'test-auth-client-broken', version: '1.0.0' },
{ capabilities: {} }
);
const oauthFetch = withOAuthRetry(
'test-auth-client-broken',
new URL(serverUrl),
handle401Broken
)(fetch);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});
await client.connect(transport);
logger.debug('✅ Successfully connected to MCP server');
await client.listTools();
logger.debug('✅ Successfully listed tools');
await transport.close();
logger.debug('✅ Connection closed successfully');
}
runAsCli(runClient, import.meta.url, 'auth-test-ignore-scope <server-url>');

View file

@ -0,0 +1,78 @@
#!/usr/bin/env node
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import {
auth,
extractWWWAuthenticateParams,
UnauthorizedError
} from '@modelcontextprotocol/sdk/client/auth.js';
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
import { runAsCli } from './helpers/cliRunner.js';
import { logger } from './helpers/logger.js';
/**
* Broken client that only requests a subset of scopes.
* BUG: Hardcodes a single scope instead of using all from scopes_supported.
*/
export async function runClient(serverUrl: string): Promise<void> {
const handle401Broken = async (
response: Response,
provider: ConformanceOAuthProvider,
next: FetchLike,
serverUrl: string | URL
): Promise<void> => {
const { resourceMetadataUrl } = extractWWWAuthenticateParams(response);
// BUG: Only request one scope instead of all from scopes_supported
let result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
scope: 'mcp:basic',
fetchFn: next
});
if (result === 'REDIRECT') {
const authorizationCode = await provider.getAuthCode();
result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
scope: 'mcp:basic',
authorizationCode,
fetchFn: next
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError(
`Authentication failed with result: ${result}`
);
}
}
};
const client = new Client(
{ name: 'test-auth-client-broken', version: '1.0.0' },
{ capabilities: {} }
);
const oauthFetch = withOAuthRetry(
'test-auth-client-broken',
new URL(serverUrl),
handle401Broken
)(fetch);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});
await client.connect(transport);
logger.debug('✅ Successfully connected to MCP server');
await client.listTools();
logger.debug('✅ Successfully listed tools');
await transport.close();
logger.debug('✅ Connection closed successfully');
}
runAsCli(runClient, import.meta.url, 'auth-test-partial-scopes <server-url>');

View file

@ -3,28 +3,18 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
import { runAsCli } from './helpers/cliRunner.js';
import { logger } from './helpers/logger.js';
async function main(): Promise<void> {
const serverUrl = process.argv[2];
if (!serverUrl) {
console.error('Usage: auth-test <server-url>');
process.exit(1);
}
console.log(`Connecting to MCP server at: ${serverUrl}`);
/**
* Well-behaved auth client that follows all OAuth protocols correctly.
*/
export async function runClient(serverUrl: string): Promise<void> {
const client = new Client(
{
name: 'test-auth-client',
version: '1.0.0'
},
{
capabilities: {}
}
{ name: 'test-auth-client', version: '1.0.0' },
{ capabilities: {} }
);
// Create a custom fetch that uses the OAuth middleware with retry logic
const oauthFetch = withOAuthRetry(
'test-auth-client',
new URL(serverUrl)
@ -34,17 +24,17 @@ async function main(): Promise<void> {
fetch: oauthFetch
});
// Connect to the server - OAuth is handled automatically by the middleware
await client.connect(transport);
console.log('✅ Successfully connected to MCP server');
logger.debug('✅ Successfully connected to MCP server');
await client.listTools();
console.log('✅ Successfully listed tools');
logger.debug('✅ Successfully listed tools');
await client.callTool({ name: 'test-tool', arguments: {} });
logger.debug('✅ Successfully called tool');
await transport.close();
console.log('✅ Connection closed successfully');
process.exit(0);
logger.debug('✅ Connection closed successfully');
}
main();
runAsCli(runClient, import.meta.url, 'auth-test <server-url>');

View file

@ -0,0 +1,28 @@
import { fileURLToPath } from 'url';
/**
* Helper to run a client function as a CLI command.
* Only runs if the file is executed directly (not imported).
* Handles argv parsing and exit codes.
*/
export function runAsCli(
clientFn: (serverUrl: string) => Promise<void>,
importMetaUrl: string,
usage: string = 'client <server-url>'
): void {
// Check if this file is being run directly
const isMain = process.argv[1] === fileURLToPath(importMetaUrl);
if (!isMain) return;
const serverUrl = process.argv[2];
if (!serverUrl) {
console.error(`Usage: ${usage}`);
process.exit(1);
}
clientFn(serverUrl)
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});
}

View file

@ -0,0 +1,27 @@
/**
* Simple logger with configurable log levels.
* Set to 'error' in tests to suppress debug output.
*/
export type LogLevel = 'debug' | 'error';
let currentLogLevel: LogLevel = 'debug';
export function setLogLevel(level: LogLevel): void {
currentLogLevel = level;
}
export function getLogLevel(): LogLevel {
return currentLogLevel;
}
export const logger = {
debug: (...args: unknown[]): void => {
if (currentLogLevel === 'debug') {
console.log(...args);
}
},
error: (...args: unknown[]): void => {
console.error(...args);
}
};

View file

@ -1,6 +1,6 @@
import {
auth,
extractResourceMetadataUrl,
extractWWWAuthenticateParams,
UnauthorizedError
} from '@modelcontextprotocol/sdk/client/auth.js';
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
@ -13,10 +13,11 @@ export const handle401 = async (
next: FetchLike,
serverUrl: string | URL
): Promise<void> => {
const resourceMetadataUrl = extractResourceMetadataUrl(response);
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
let result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
scope,
fetchFn: next
});
@ -33,6 +34,7 @@ export const handle401 = async (
result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
scope,
authorizationCode,
fetchFn: next
});
@ -87,7 +89,7 @@ export const withOAuthRetry = (
let response = await makeRequest();
// Handle 401 responses by attempting re-authentication
if (response.status === 401) {
if (response.status === 401 || response.status === 403) {
const serverUrl =
baseUrl ||
(typeof input === 'string' ? new URL(input).origin : input.origin);
@ -97,7 +99,7 @@ export const withOAuthRetry = (
}
// If we still have a 401 after re-auth attempt, throw an error
if (response.status === 401) {
if (response.status === 401 || response.status === 403) {
const url = typeof input === 'string' ? input : input.toString();
throw new UnauthorizedError(`Authentication failed for ${url}`);
}

1
package-lock.json generated
View file

@ -23,6 +23,7 @@
"@types/express": "^5.0.3",
"@types/node": "^22.10.2",
"@typescript/native-preview": "^7.0.0-dev.20251030.1",
"cors": "^2.8.5",
"eslint": "^9.8.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "3.6.2",

View file

@ -33,6 +33,7 @@
"@types/express": "^5.0.3",
"@types/node": "^22.10.2",
"@typescript/native-preview": "^7.0.0-dev.20251030.1",
"cors": "^2.8.5",
"eslint": "^9.8.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "3.6.2",

View file

@ -86,10 +86,10 @@ export async function runConformanceTest(
// Scenario is guaranteed to exist by CLI validation
const scenario = getScenario(scenarioName)!;
console.log(`Starting scenario: ${scenarioName}`);
console.error(`Starting scenario: ${scenarioName}`);
const urls = await scenario.start();
console.log(`Executing client: ${clientCommand} ${urls.serverUrl}`);
console.error(`Executing client: ${clientCommand} ${urls.serverUrl}`);
try {
const clientOutput = await executeClient(
@ -124,7 +124,7 @@ export async function runConformanceTest(
await fs.writeFile(path.join(resultDir, 'stderr.txt'), clientOutput.stderr);
console.log(`Results saved to ${resultDir}`);
console.error(`Results saved to ${resultDir}`);
return {
checks,
@ -139,35 +139,41 @@ export async function runConformanceTest(
export function printClientResults(
checks: ConformanceCheck[],
verbose: boolean = false
): { passed: number; failed: number; denominator: number } {
): { passed: number; failed: number; denominator: number; warnings: number } {
const denominator = checks.filter(
(c) => c.status === 'SUCCESS' || c.status === 'FAILURE'
).length;
const passed = checks.filter((c) => c.status === 'SUCCESS').length;
const failed = checks.filter((c) => c.status === 'FAILURE').length;
const warnings = checks.filter((c) => c.status === 'WARNING').length;
if (verbose) {
console.log(`Checks:\n${JSON.stringify(checks, null, 2)}`);
// Verbose mode: JSON goes to stdout for piping to jq/jless
console.log(JSON.stringify(checks, null, 2));
} else {
console.log(`Checks:\n${formatPrettyChecks(checks)}`);
// Non-verbose: Pretty checks go to stderr
console.error(`Checks:\n${formatPrettyChecks(checks)}`);
}
console.log(`\nTest Results:`);
console.log(`Passed: ${passed}/${denominator}, ${failed} failed`);
// Test results summary goes to stderr
console.error(`\nTest Results:`);
console.error(
`Passed: ${passed}/${denominator}, ${failed} failed, ${warnings} warnings`
);
if (failed > 0) {
console.log('\nFailed Checks:');
console.error('\nFailed Checks:');
checks
.filter((c) => c.status === 'FAILURE')
.forEach((c) => {
console.log(` - ${c.name}: ${c.description}`);
console.error(` - ${c.name}: ${c.description}`);
if (c.errorMessage) {
console.log(` Error: ${c.errorMessage}`);
console.error(` Error: ${c.errorMessage}`);
}
});
}
return { passed, failed, denominator };
return { passed, failed, denominator, warnings };
}
export async function runInteractiveMode(

View file

@ -59,17 +59,21 @@ export function printServerResults(
passed: number;
failed: number;
denominator: number;
warnings: number;
} {
const denominator = checks.filter(
(c) => c.status === 'SUCCESS' || c.status === 'FAILURE'
).length;
const passed = checks.filter((c) => c.status === 'SUCCESS').length;
const failed = checks.filter((c) => c.status === 'FAILURE').length;
const warnings = checks.filter((c) => c.status === 'WARNING').length;
console.log(`Checks:\n${JSON.stringify(checks, null, 2)}`);
console.log(`\nTest Results:`);
console.log(`Passed: ${passed}/${denominator}, ${failed} failed`);
console.log(
`Passed: ${passed}/${denominator}, ${failed} failed, ${warnings} warnings`
);
if (failed > 0) {
console.log('\n=== Failed Checks ===');
@ -84,7 +88,7 @@ export function printServerResults(
});
}
return { passed, failed, denominator };
return { passed, failed, denominator, warnings };
}
export function printServerSummary(

View file

@ -18,6 +18,8 @@ export function getStatusColor(status: string): string {
return COLORS.GREEN;
case 'FAILURE':
return COLORS.RED;
case 'WARNING':
return COLORS.YELLOW;
case 'INFO':
return COLORS.BLUE;
default:

View file

@ -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(' ') })
});
});

View file

@ -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);

View file

@ -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
};
}

View file

@ -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'
]);
});
});

View file

@ -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()
];

View 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;
}
}

Some files were not shown because too many files have changed in this diff Show more