[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
22 changed files with 1074 additions and 186 deletions
79
examples/clients/typescript/auth-test-bad-prm.ts
Normal file
79
examples/clients/typescript/auth-test-bad-prm.ts
Normal 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>');
|
||||
|
|
@ -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();
|
||||
87
examples/clients/typescript/auth-test-ignore-403.ts
Normal file
87
examples/clients/typescript/auth-test-ignore-403.ts
Normal 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>');
|
||||
77
examples/clients/typescript/auth-test-ignore-scope.ts
Normal file
77
examples/clients/typescript/auth-test-ignore-scope.ts
Normal 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>');
|
||||
78
examples/clients/typescript/auth-test-partial-scopes.ts
Normal file
78
examples/clients/typescript/auth-test-partial-scopes.ts
Normal 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>');
|
||||
|
|
@ -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>');
|
||||
|
|
|
|||
28
examples/clients/typescript/helpers/cliRunner.ts
Normal file
28
examples/clients/typescript/helpers/cliRunner.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
27
examples/clients/typescript/helpers/logger.ts
Normal file
27
examples/clients/typescript/helpers/logger.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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
1
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue