refactor: range-based spec version tagging (introducedIn/removedIn) (#265)
* refactor: replace specVersions list with introducedIn/removedIn range tagging Replaces per-scenario `specVersions: SpecVersion[]` arrays with `introducedIn` and optional `removedIn` range fields on all three scenario interfaces. Extension scenarios get an orthogonal `extension?: boolean` flag instead of the former `'extension'` tag in the versions list. matchesSpecVersion() is rewritten as a range comparison (introducedIn <= target && (!removedIn || target < removedIn)), with draft sorting after all dated versions. getScenarioSpecVersions() reconstructs the effective version list from the range for backward-compat with tier-check. Closes #256. * refactor: nest spec-version range under `source: ScenarioSource` union The flat `introducedIn`/`removedIn`/`extension?` shape forced extension scenarios to set a placeholder `introducedIn` that was never read. Nesting the version-range fields inside a `source` property typed as a union lets the compiler enforce introducedIn XOR extensionId — no runtime invariant test needed. type ScenarioSource = | { introducedIn: SpecVersion; removedIn?: SpecVersion } | { extensionId: ExtensionId }; A class can't `implements` a union type directly, so the union lives inside a property and `Scenario` stays a plain interface — `class implements Scenario` keeps working everywhere. `EXTENSION_IDS` is a const array mirroring the `DATED_SPEC_VERSIONS` pattern. Also renames the cross-app-access scenario to enterprise-managed-auth to match the extension's canonical name: file, class, slug, context schema literal, and the everything-client runner. * refactor: use canonical SEP-2133 extension identifiers EXTENSION_IDS now holds the wire identifiers from the spec's extension docs (client-matrix.mdx, docs/extensions/auth/*.mdx) so extensionId values can be cross-referenced against capabilities.extensions keys directly: io.modelcontextprotocol/oauth-client-credentials io.modelcontextprotocol/enterprise-managed-authorization The previous commit used a shortened 'enterprise-managed-auth' which doesn't match the canonical '-authorization'. Re-renames the scenario file/class/slug and downstream references to align. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
04dd6db7ae
commit
acc70de0f6
32 changed files with 193 additions and 208 deletions
|
|
@ -375,20 +375,20 @@ registerScenario('auth/pre-registration', runPreRegistration);
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cross-app access: Complete Flow (SEP-990)
|
* Enterprise-Managed Authorization (SEP-990)
|
||||||
* Tests the complete flow: IDP ID token -> authorization grant -> access token -> MCP access.
|
* Tests the complete flow: IDP ID token -> authorization grant -> access token -> MCP access.
|
||||||
*/
|
*/
|
||||||
export async function runCrossAppAccessCompleteFlow(
|
export async function runEnterpriseManagedAuthorization(
|
||||||
serverUrl: string
|
serverUrl: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ctx = parseContext();
|
const ctx = parseContext();
|
||||||
if (ctx.name !== 'auth/cross-app-access-complete-flow') {
|
if (ctx.name !== 'auth/enterprise-managed-authorization') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected cross-app-access-complete-flow context, got ${ctx.name}`
|
`Expected enterprise-managed-authorization context, got ${ctx.name}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('Starting complete cross-app access flow...');
|
logger.debug('Starting enterprise-managed authorization flow...');
|
||||||
logger.debug('IDP Issuer:', ctx.idp_issuer);
|
logger.debug('IDP Issuer:', ctx.idp_issuer);
|
||||||
logger.debug('IDP Token Endpoint:', ctx.idp_token_endpoint);
|
logger.debug('IDP Token Endpoint:', ctx.idp_token_endpoint);
|
||||||
|
|
||||||
|
|
@ -494,7 +494,7 @@ export async function runCrossAppAccessCompleteFlow(
|
||||||
// Step 3: Use access token to access MCP server
|
// Step 3: Use access token to access MCP server
|
||||||
logger.debug('Step 3: Accessing MCP server with access token...');
|
logger.debug('Step 3: Accessing MCP server with access token...');
|
||||||
const client = new Client(
|
const client = new Client(
|
||||||
{ name: 'conformance-cross-app-access', version: '1.0.0' },
|
{ name: 'conformance-enterprise-managed-authorization', version: '1.0.0' },
|
||||||
{ capabilities: {} }
|
{ capabilities: {} }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -516,12 +516,12 @@ export async function runCrossAppAccessCompleteFlow(
|
||||||
logger.debug('Successfully called tool');
|
logger.debug('Successfully called tool');
|
||||||
|
|
||||||
await transport.close();
|
await transport.close();
|
||||||
logger.debug('Complete cross-app access flow completed successfully');
|
logger.debug('Enterprise-managed authorization flow completed successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
registerScenario(
|
registerScenario(
|
||||||
'auth/cross-app-access-complete-flow',
|
'auth/enterprise-managed-authorization',
|
||||||
runCrossAppAccessCompleteFlow
|
runEnterpriseManagedAuthorization
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
ClientScenarioForAuthorizationServer,
|
ClientScenarioForAuthorizationServer,
|
||||||
ConformanceCheck,
|
ConformanceCheck
|
||||||
SpecVersion
|
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { request } from 'undici';
|
import { request } from 'undici';
|
||||||
|
|
||||||
|
|
@ -12,7 +11,7 @@ type Status = 'SUCCESS' | 'FAILURE';
|
||||||
|
|
||||||
export class AuthorizationServerMetadataEndpointScenario implements ClientScenarioForAuthorizationServer {
|
export class AuthorizationServerMetadataEndpointScenario implements ClientScenarioForAuthorizationServer {
|
||||||
name = 'authorization-server-metadata-endpoint';
|
name = 'authorization-server-metadata-endpoint';
|
||||||
specVersions: SpecVersion[] = ['2025-03-26', '2025-06-18', '2025-11-25'];
|
readonly source = { introducedIn: '2025-03-26' } as const;
|
||||||
description = `Test authorization server metadata endpoint.
|
description = `Test authorization server metadata endpoint.
|
||||||
|
|
||||||
**Authorization Server Implementation Requirements:**
|
**Authorization Server Implementation Requirements:**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Scenario, ConformanceCheck } from '../../../types';
|
import type { Scenario, ConformanceCheck } from '../../../types';
|
||||||
import { ScenarioUrls, SpecVersion } from '../../../types';
|
import { ScenarioUrls } from '../../../types';
|
||||||
import { createAuthServer } from './helpers/createAuthServer';
|
import { createAuthServer } from './helpers/createAuthServer';
|
||||||
import { createServer } from './helpers/createServer';
|
import { createServer } from './helpers/createServer';
|
||||||
import { ServerLifecycle } from './helpers/serverLifecycle';
|
import { ServerLifecycle } from './helpers/serverLifecycle';
|
||||||
|
|
@ -22,7 +22,7 @@ export const CIMD_CLIENT_METADATA_URL =
|
||||||
*/
|
*/
|
||||||
export class AuthBasicCIMDScenario implements Scenario {
|
export class AuthBasicCIMDScenario implements Scenario {
|
||||||
name = 'auth/basic-cimd';
|
name = 'auth/basic-cimd';
|
||||||
specVersions: SpecVersion[] = ['2025-11-25'];
|
readonly source = { introducedIn: '2025-11-25' } as const;
|
||||||
description =
|
description =
|
||||||
'Tests OAuth flow with Client ID Metadata Documents (SEP-991/URL-based client IDs). Server advertises client_id_metadata_document_supported=true and client should use URL as client_id instead of DCR.';
|
'Tests OAuth flow with Client ID Metadata Documents (SEP-991/URL-based client IDs). Server advertises client_id_metadata_document_supported=true and client should use URL as client_id instead of DCR.';
|
||||||
private authServer = new ServerLifecycle();
|
private authServer = new ServerLifecycle();
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import * as jose from 'jose';
|
import * as jose from 'jose';
|
||||||
import type { CryptoKey } from 'jose';
|
import type { CryptoKey } from 'jose';
|
||||||
import type {
|
import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types';
|
||||||
Scenario,
|
|
||||||
ConformanceCheck,
|
|
||||||
ScenarioUrls,
|
|
||||||
ScenarioSpecTag
|
|
||||||
} from '../../../types';
|
|
||||||
import { createAuthServer } from './helpers/createAuthServer';
|
import { createAuthServer } from './helpers/createAuthServer';
|
||||||
import { createServer } from './helpers/createServer';
|
import { createServer } from './helpers/createServer';
|
||||||
import { ServerLifecycle } from './helpers/serverLifecycle';
|
import { ServerLifecycle } from './helpers/serverLifecycle';
|
||||||
|
|
@ -37,7 +32,9 @@ async function generateTestKeypair(): Promise<{
|
||||||
*/
|
*/
|
||||||
export class ClientCredentialsJwtScenario implements Scenario {
|
export class ClientCredentialsJwtScenario implements Scenario {
|
||||||
name = 'auth/client-credentials-jwt';
|
name = 'auth/client-credentials-jwt';
|
||||||
specVersions: ScenarioSpecTag[] = ['extension'];
|
readonly source = {
|
||||||
|
extensionId: 'io.modelcontextprotocol/oauth-client-credentials'
|
||||||
|
} as const;
|
||||||
description =
|
description =
|
||||||
'Tests OAuth client_credentials flow with private_key_jwt authentication (SEP-1046)';
|
'Tests OAuth client_credentials flow with private_key_jwt authentication (SEP-1046)';
|
||||||
|
|
||||||
|
|
@ -256,7 +253,9 @@ export class ClientCredentialsJwtScenario implements Scenario {
|
||||||
*/
|
*/
|
||||||
export class ClientCredentialsBasicScenario implements Scenario {
|
export class ClientCredentialsBasicScenario implements Scenario {
|
||||||
name = 'auth/client-credentials-basic';
|
name = 'auth/client-credentials-basic';
|
||||||
specVersions: ScenarioSpecTag[] = ['extension'];
|
readonly source = {
|
||||||
|
extensionId: 'io.modelcontextprotocol/oauth-client-credentials'
|
||||||
|
} as const;
|
||||||
description =
|
description =
|
||||||
'Tests OAuth client_credentials flow with client_secret_basic authentication';
|
'Tests OAuth client_credentials flow with client_secret_basic authentication';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: `auth/${config.name}`,
|
name: `auth/${config.name}`,
|
||||||
specVersions: ['2025-11-25'],
|
source: { introducedIn: '2025-11-25' },
|
||||||
description: `Tests Basic OAuth metadata discovery flow.
|
description: `Tests Basic OAuth metadata discovery flow.
|
||||||
|
|
||||||
**PRM:** ${config.prmLocation}${config.inWwwAuth ? '' : ' (not in WWW-Authenticate)'}
|
**PRM:** ${config.prmLocation}${config.inWwwAuth ? '' : ' (not in WWW-Authenticate)'}
|
||||||
|
|
|
||||||
553
src/scenarios/client/auth/enterprise-managed-authorization.ts
Normal file
553
src/scenarios/client/auth/enterprise-managed-authorization.ts
Normal file
|
|
@ -0,0 +1,553 @@
|
||||||
|
import * as jose from 'jose';
|
||||||
|
import type { CryptoKey } from 'jose';
|
||||||
|
import express, { type Request, type Response } from 'express';
|
||||||
|
import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types';
|
||||||
|
import { createAuthServer } from './helpers/createAuthServer';
|
||||||
|
import { createServer } from './helpers/createServer';
|
||||||
|
import { MockTokenVerifier } from './helpers/mockTokenVerifier';
|
||||||
|
import { ServerLifecycle } from './helpers/serverLifecycle';
|
||||||
|
import { SpecReferences } from './spec-references';
|
||||||
|
|
||||||
|
const CONFORMANCE_TEST_CLIENT_ID = 'conformance-test-xaa-client';
|
||||||
|
const CONFORMANCE_TEST_CLIENT_SECRET = 'conformance-test-xaa-secret';
|
||||||
|
const IDP_CLIENT_ID = 'conformance-test-idp-client';
|
||||||
|
const DEMO_USER_ID = 'demo-user@example.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an EC P-256 keypair for IDP ID token signing.
|
||||||
|
*/
|
||||||
|
async function generateIdpKeypair(): Promise<{
|
||||||
|
publicKey: CryptoKey;
|
||||||
|
privateKey: CryptoKey;
|
||||||
|
}> {
|
||||||
|
const { publicKey, privateKey } = await jose.generateKeyPair('ES256', {
|
||||||
|
extractable: true
|
||||||
|
});
|
||||||
|
return { publicKey, privateKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a signed ID token from the IDP
|
||||||
|
*/
|
||||||
|
async function createIdpIdToken(
|
||||||
|
privateKey: CryptoKey,
|
||||||
|
idpIssuer: string,
|
||||||
|
audience: string,
|
||||||
|
userId: string = DEMO_USER_ID
|
||||||
|
): Promise<string> {
|
||||||
|
return await new jose.SignJWT({
|
||||||
|
sub: userId,
|
||||||
|
email: userId,
|
||||||
|
aud: audience
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'ES256' })
|
||||||
|
.setIssuer(idpIssuer)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('1h')
|
||||||
|
.sign(privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario: Enterprise-Managed Authorization (SEP-990)
|
||||||
|
*
|
||||||
|
* Tests the complete SEP-990 flow: IDP ID token -> authorization grant -> access token
|
||||||
|
* This scenario combines both RFC 8693 token exchange and RFC 7523 JWT bearer grant.
|
||||||
|
*/
|
||||||
|
export class EnterpriseManagedAuthorizationScenario implements Scenario {
|
||||||
|
name = 'auth/enterprise-managed-authorization';
|
||||||
|
readonly source = {
|
||||||
|
extensionId: 'io.modelcontextprotocol/enterprise-managed-authorization'
|
||||||
|
} as const;
|
||||||
|
description =
|
||||||
|
'Tests complete SEP-990 flow: token exchange + JWT bearer grant (Enterprise-Managed Authorization)';
|
||||||
|
|
||||||
|
private idpServer = new ServerLifecycle();
|
||||||
|
private authServer = new ServerLifecycle();
|
||||||
|
private mcpServer = new ServerLifecycle();
|
||||||
|
private checks: ConformanceCheck[] = [];
|
||||||
|
private idpPublicKey?: CryptoKey;
|
||||||
|
private idpPrivateKey?: CryptoKey;
|
||||||
|
private grantKeypairs: Map<string, CryptoKey> = new Map();
|
||||||
|
|
||||||
|
async start(): Promise<ScenarioUrls> {
|
||||||
|
this.checks = [];
|
||||||
|
|
||||||
|
// Generate IDP keypair
|
||||||
|
const { publicKey, privateKey } = await generateIdpKeypair();
|
||||||
|
this.idpPublicKey = publicKey;
|
||||||
|
this.idpPrivateKey = privateKey;
|
||||||
|
|
||||||
|
// Shared token verifier ensures MCP server only accepts tokens
|
||||||
|
// actually issued by the auth server
|
||||||
|
const tokenVerifier = new MockTokenVerifier(this.checks, []);
|
||||||
|
|
||||||
|
// Start IDP server
|
||||||
|
await this.startIdpServer();
|
||||||
|
|
||||||
|
// Start auth server with JWT bearer grant support only
|
||||||
|
// Token exchange is handled by IdP
|
||||||
|
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
|
||||||
|
grantTypesSupported: ['urn:ietf:params:oauth:grant-type:jwt-bearer'],
|
||||||
|
tokenEndpointAuthMethodsSupported: ['client_secret_basic'],
|
||||||
|
tokenVerifier,
|
||||||
|
onTokenRequest: async ({
|
||||||
|
grantType,
|
||||||
|
body,
|
||||||
|
timestamp,
|
||||||
|
authBaseUrl,
|
||||||
|
authorizationHeader
|
||||||
|
}) => {
|
||||||
|
// Auth server only handles JWT bearer grant (ID-JAG -> access token)
|
||||||
|
if (grantType === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
|
||||||
|
const mcpResourceUrl = `${this.mcpServer.getUrl()}/mcp`;
|
||||||
|
return await this.handleJwtBearerGrant(
|
||||||
|
body,
|
||||||
|
timestamp,
|
||||||
|
authBaseUrl,
|
||||||
|
authorizationHeader,
|
||||||
|
mcpResourceUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: 'unsupported_grant_type',
|
||||||
|
errorDescription: `Auth server only supports jwt-bearer grant, got ${grantType}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.authServer.start(authApp);
|
||||||
|
|
||||||
|
// Start MCP server with shared token verifier
|
||||||
|
const mcpApp = createServer(
|
||||||
|
this.checks,
|
||||||
|
this.mcpServer.getUrl,
|
||||||
|
this.authServer.getUrl,
|
||||||
|
{ tokenVerifier }
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.mcpServer.start(mcpApp);
|
||||||
|
|
||||||
|
// Generate IDP ID token for client
|
||||||
|
const idpIdToken = await createIdpIdToken(
|
||||||
|
this.idpPrivateKey!,
|
||||||
|
this.idpServer.getUrl(),
|
||||||
|
IDP_CLIENT_ID
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverUrl: `${this.mcpServer.getUrl()}/mcp`,
|
||||||
|
context: {
|
||||||
|
client_id: CONFORMANCE_TEST_CLIENT_ID,
|
||||||
|
client_secret: CONFORMANCE_TEST_CLIENT_SECRET,
|
||||||
|
idp_client_id: IDP_CLIENT_ID,
|
||||||
|
idp_id_token: idpIdToken,
|
||||||
|
idp_issuer: this.idpServer.getUrl(),
|
||||||
|
idp_token_endpoint: `${this.idpServer.getUrl()}/token`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startIdpServer(): Promise<void> {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// IDP metadata endpoint
|
||||||
|
app.get(
|
||||||
|
'/.well-known/openid-configuration',
|
||||||
|
(req: Request, res: Response) => {
|
||||||
|
res.json({
|
||||||
|
issuer: this.idpServer.getUrl(),
|
||||||
|
authorization_endpoint: `${this.idpServer.getUrl()}/authorize`,
|
||||||
|
token_endpoint: `${this.idpServer.getUrl()}/token`,
|
||||||
|
jwks_uri: `${this.idpServer.getUrl()}/.well-known/jwks.json`,
|
||||||
|
grant_types_supported: [
|
||||||
|
'urn:ietf:params:oauth:grant-type:token-exchange'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// IDP token endpoint - handles token exchange (IDP ID token -> ID-JAG)
|
||||||
|
app.post('/token', async (req: Request, res: Response) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const grantType = req.body.grant_type;
|
||||||
|
const subjectToken = req.body.subject_token;
|
||||||
|
const subjectTokenType = req.body.subject_token_type;
|
||||||
|
const requestedTokenType = req.body.requested_token_type;
|
||||||
|
const audience = req.body.audience;
|
||||||
|
const resource = req.body.resource;
|
||||||
|
|
||||||
|
// Only handle token exchange at IdP
|
||||||
|
if (grantType !== 'urn:ietf:params:oauth:grant-type:token-exchange') {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-token-exchange',
|
||||||
|
name: 'CompleteFlowTokenExchange',
|
||||||
|
description: `IdP expected token-exchange grant, got ${grantType}`,
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp,
|
||||||
|
specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE]
|
||||||
|
});
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'unsupported_grant_type',
|
||||||
|
error_description: 'IdP only supports token-exchange'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all required token exchange parameters per SEP-990
|
||||||
|
const missingParams: string[] = [];
|
||||||
|
if (!subjectToken) missingParams.push('subject_token');
|
||||||
|
if (subjectTokenType !== 'urn:ietf:params:oauth:token-type:id_token') {
|
||||||
|
missingParams.push(
|
||||||
|
`subject_token_type (expected urn:ietf:params:oauth:token-type:id_token, got ${subjectTokenType || 'missing'})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (requestedTokenType !== 'urn:ietf:params:oauth:token-type:id-jag') {
|
||||||
|
missingParams.push(
|
||||||
|
`requested_token_type (expected urn:ietf:params:oauth:token-type:id-jag, got ${requestedTokenType || 'missing'})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!audience) missingParams.push('audience');
|
||||||
|
if (!resource) missingParams.push('resource');
|
||||||
|
|
||||||
|
if (missingParams.length > 0) {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-token-exchange',
|
||||||
|
name: 'CompleteFlowTokenExchange',
|
||||||
|
description: `Token exchange missing or invalid required parameters: ${missingParams.join(', ')}`,
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp,
|
||||||
|
specReferences: [
|
||||||
|
SpecReferences.RFC_8693_TOKEN_EXCHANGE,
|
||||||
|
SpecReferences.SEP_990_ENTERPRISE_OAUTH
|
||||||
|
]
|
||||||
|
});
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'invalid_request',
|
||||||
|
error_description: `Missing or invalid required parameters: ${missingParams.join(', ')}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify the IDP ID token
|
||||||
|
const { payload } = await jose.jwtVerify(
|
||||||
|
subjectToken,
|
||||||
|
this.idpPublicKey!,
|
||||||
|
{
|
||||||
|
audience: IDP_CLIENT_ID,
|
||||||
|
issuer: this.idpServer.getUrl()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-token-exchange',
|
||||||
|
name: 'CompleteFlowTokenExchange',
|
||||||
|
description:
|
||||||
|
'Successfully exchanged IDP ID token for ID-JAG at IdP with all required parameters',
|
||||||
|
status: 'SUCCESS',
|
||||||
|
timestamp,
|
||||||
|
specReferences: [
|
||||||
|
SpecReferences.RFC_8693_TOKEN_EXCHANGE,
|
||||||
|
SpecReferences.SEP_990_ENTERPRISE_OAUTH
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create ID-JAG (ID-bound JSON Assertion Grant)
|
||||||
|
// Include resource and client_id claims per SEP-990
|
||||||
|
const userId = payload.sub as string;
|
||||||
|
const { publicKey, privateKey } = await jose.generateKeyPair('ES256');
|
||||||
|
this.grantKeypairs.set(userId, publicKey);
|
||||||
|
|
||||||
|
// The IdP uses CONFORMANCE_TEST_CLIENT_ID (the MCP Client's client_id
|
||||||
|
// at the AS), not the IdP client_id from the request body.
|
||||||
|
// Per Section 6.1: "the IdP will need to be aware of the MCP Client's
|
||||||
|
// client_id that it normally uses with the MCP Server."
|
||||||
|
const idJag = await new jose.SignJWT({
|
||||||
|
sub: userId,
|
||||||
|
resource: resource,
|
||||||
|
client_id: CONFORMANCE_TEST_CLIENT_ID
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'ES256', typ: 'oauth-id-jag+jwt' })
|
||||||
|
.setIssuer(this.idpServer.getUrl())
|
||||||
|
.setAudience(audience)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('5m')
|
||||||
|
.setJti(crypto.randomUUID())
|
||||||
|
.sign(privateKey);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
access_token: idJag,
|
||||||
|
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
|
||||||
|
token_type: 'N_A'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-token-exchange',
|
||||||
|
name: 'CompleteFlowTokenExchange',
|
||||||
|
description: `Token exchange failed: ${errorMessage}`,
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp,
|
||||||
|
specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE]
|
||||||
|
});
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: 'Invalid ID token'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.idpServer.start(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleJwtBearerGrant(
|
||||||
|
body: Record<string, string>,
|
||||||
|
timestamp: string,
|
||||||
|
authBaseUrl: string,
|
||||||
|
authorizationHeader?: string,
|
||||||
|
mcpResourceUrl?: string
|
||||||
|
): Promise<any> {
|
||||||
|
// 1. Verify client authentication (client_secret_basic)
|
||||||
|
if (!authorizationHeader || !authorizationHeader.startsWith('Basic ')) {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-jwt-bearer',
|
||||||
|
name: 'CompleteFlowJwtBearer',
|
||||||
|
description:
|
||||||
|
'Missing or invalid Authorization header for client_secret_basic authentication',
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp,
|
||||||
|
specReferences: [SpecReferences.SEP_990_ENTERPRISE_OAUTH],
|
||||||
|
details: {
|
||||||
|
expected: 'Authorization: Basic <base64(client_id:client_secret)>',
|
||||||
|
received: authorizationHeader || 'missing'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
error: 'invalid_client',
|
||||||
|
errorDescription:
|
||||||
|
'Client authentication required (client_secret_basic)',
|
||||||
|
statusCode: 401
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Credentials = authorizationHeader.slice('Basic '.length);
|
||||||
|
const decoded = Buffer.from(base64Credentials, 'base64').toString('utf-8');
|
||||||
|
const separatorIndex = decoded.indexOf(':');
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-jwt-bearer',
|
||||||
|
name: 'CompleteFlowJwtBearer',
|
||||||
|
description: 'Malformed Basic auth header (no colon separator)',
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp,
|
||||||
|
specReferences: [SpecReferences.SEP_990_ENTERPRISE_OAUTH]
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
error: 'invalid_client',
|
||||||
|
errorDescription: 'Malformed Basic auth',
|
||||||
|
statusCode: 401
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const authClientId = decodeURIComponent(decoded.slice(0, separatorIndex));
|
||||||
|
const authClientSecret = decodeURIComponent(
|
||||||
|
decoded.slice(separatorIndex + 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
authClientId !== CONFORMANCE_TEST_CLIENT_ID ||
|
||||||
|
authClientSecret !== CONFORMANCE_TEST_CLIENT_SECRET
|
||||||
|
) {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-jwt-bearer',
|
||||||
|
name: 'CompleteFlowJwtBearer',
|
||||||
|
description: `Client authentication failed: invalid credentials (client_id: ${authClientId})`,
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp,
|
||||||
|
specReferences: [SpecReferences.SEP_990_ENTERPRISE_OAUTH]
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
error: 'invalid_client',
|
||||||
|
errorDescription: 'Invalid client credentials',
|
||||||
|
statusCode: 401
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verify assertion is present
|
||||||
|
const assertion = body.assertion;
|
||||||
|
if (!assertion) {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-jwt-bearer',
|
||||||
|
name: 'CompleteFlowJwtBearer',
|
||||||
|
description: 'Missing assertion in JWT bearer grant',
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp,
|
||||||
|
specReferences: [SpecReferences.RFC_7523_JWT_BEARER]
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
error: 'invalid_request',
|
||||||
|
errorDescription: 'Missing assertion'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 3. Verify the ID-JAG header has the correct typ
|
||||||
|
const header = jose.decodeProtectedHeader(assertion);
|
||||||
|
if (header.typ !== 'oauth-id-jag+jwt') {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-jwt-bearer',
|
||||||
|
name: 'CompleteFlowJwtBearer',
|
||||||
|
description: `ID-JAG has wrong typ header: expected oauth-id-jag+jwt, got ${header.typ}`,
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp,
|
||||||
|
specReferences: [SpecReferences.SEP_990_ENTERPRISE_OAUTH]
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
error: 'invalid_grant',
|
||||||
|
errorDescription: 'Invalid ID-JAG typ header'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Decode and verify the ID-JAG
|
||||||
|
const decoded = jose.decodeJwt(assertion);
|
||||||
|
const userId = decoded.sub as string;
|
||||||
|
const publicKey = this.grantKeypairs.get(userId);
|
||||||
|
|
||||||
|
if (!publicKey) {
|
||||||
|
throw new Error('Unknown authorization grant');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature and audience
|
||||||
|
const withoutSlash = authBaseUrl.replace(/\/+$/, '');
|
||||||
|
const withSlash = `${withoutSlash}/`;
|
||||||
|
|
||||||
|
await jose.jwtVerify(assertion, publicKey, {
|
||||||
|
audience: [withoutSlash, withSlash],
|
||||||
|
clockTolerance: 30
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Verify client_id in ID-JAG matches the authenticating client (Section 5.1)
|
||||||
|
const jagClientId = decoded.client_id as string | undefined;
|
||||||
|
if (jagClientId !== authClientId) {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-jwt-bearer',
|
||||||
|
name: 'CompleteFlowJwtBearer',
|
||||||
|
description: `ID-JAG client_id (${jagClientId}) does not match authenticating client (${authClientId})`,
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp,
|
||||||
|
specReferences: [SpecReferences.SEP_990_ENTERPRISE_OAUTH],
|
||||||
|
details: {
|
||||||
|
jagClientId,
|
||||||
|
authClientId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
error: 'invalid_grant',
|
||||||
|
errorDescription:
|
||||||
|
'ID-JAG client_id does not match authenticating client'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Verify resource claim in ID-JAG matches the MCP server resource
|
||||||
|
const jagResource = decoded.resource as string | undefined;
|
||||||
|
if (mcpResourceUrl && jagResource !== mcpResourceUrl) {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-jwt-bearer',
|
||||||
|
name: 'CompleteFlowJwtBearer',
|
||||||
|
description: `ID-JAG resource (${jagResource}) does not match MCP server resource (${mcpResourceUrl})`,
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp,
|
||||||
|
specReferences: [SpecReferences.SEP_990_ENTERPRISE_OAUTH],
|
||||||
|
details: {
|
||||||
|
jagResource,
|
||||||
|
expectedResource: mcpResourceUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
error: 'invalid_grant',
|
||||||
|
errorDescription: 'ID-JAG resource does not match MCP server resource'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-jwt-bearer',
|
||||||
|
name: 'CompleteFlowJwtBearer',
|
||||||
|
description:
|
||||||
|
'Successfully verified client auth, ID-JAG claims, and exchanged for access token',
|
||||||
|
status: 'SUCCESS',
|
||||||
|
timestamp,
|
||||||
|
specReferences: [
|
||||||
|
SpecReferences.RFC_7523_JWT_BEARER,
|
||||||
|
SpecReferences.SEP_990_ENTERPRISE_OAUTH
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const scopes = body.scope ? body.scope.split(' ') : [];
|
||||||
|
return {
|
||||||
|
token: `test-token-${Date.now()}`,
|
||||||
|
scopes
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-jwt-bearer',
|
||||||
|
name: 'CompleteFlowJwtBearer',
|
||||||
|
description: `JWT bearer grant failed: ${e}`,
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp,
|
||||||
|
specReferences: [SpecReferences.RFC_7523_JWT_BEARER]
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
error: 'invalid_grant',
|
||||||
|
errorDescription: 'Invalid authorization grant'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
await this.idpServer.stop();
|
||||||
|
await this.authServer.stop();
|
||||||
|
await this.mcpServer.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
getChecks(): ConformanceCheck[] {
|
||||||
|
const hasTokenExchangeCheck = this.checks.some(
|
||||||
|
(c) => c.id === 'complete-flow-token-exchange'
|
||||||
|
);
|
||||||
|
const hasJwtBearerCheck = this.checks.some(
|
||||||
|
(c) => c.id === 'complete-flow-jwt-bearer'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasTokenExchangeCheck) {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-token-exchange',
|
||||||
|
name: 'CompleteFlowTokenExchange',
|
||||||
|
description: 'Client did not perform token exchange',
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [
|
||||||
|
SpecReferences.RFC_8693_TOKEN_EXCHANGE,
|
||||||
|
SpecReferences.SEP_990_ENTERPRISE_OAUTH
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasJwtBearerCheck) {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'complete-flow-jwt-bearer',
|
||||||
|
name: 'CompleteFlowJwtBearer',
|
||||||
|
description: 'Client did not perform JWT bearer grant exchange',
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [
|
||||||
|
SpecReferences.RFC_7523_JWT_BEARER,
|
||||||
|
SpecReferences.SEP_990_ENTERPRISE_OAUTH
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.checks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ import {
|
||||||
} from './client-credentials';
|
} from './client-credentials';
|
||||||
import { ResourceMismatchScenario } from './resource-mismatch';
|
import { ResourceMismatchScenario } from './resource-mismatch';
|
||||||
import { PreRegistrationScenario } from './pre-registration';
|
import { PreRegistrationScenario } from './pre-registration';
|
||||||
import { CrossAppAccessCompleteFlowScenario } from './cross-app-access';
|
import { EnterpriseManagedAuthorizationScenario } from './enterprise-managed-authorization';
|
||||||
import {
|
import {
|
||||||
OfflineAccessScopeScenario,
|
OfflineAccessScopeScenario,
|
||||||
OfflineAccessNotSupportedScenario
|
OfflineAccessNotSupportedScenario
|
||||||
|
|
@ -54,7 +54,7 @@ export const backcompatScenariosList: Scenario[] = [
|
||||||
export const extensionScenariosList: Scenario[] = [
|
export const extensionScenariosList: Scenario[] = [
|
||||||
new ClientCredentialsJwtScenario(),
|
new ClientCredentialsJwtScenario(),
|
||||||
new ClientCredentialsBasicScenario(),
|
new ClientCredentialsBasicScenario(),
|
||||||
new CrossAppAccessCompleteFlowScenario()
|
new EnterpriseManagedAuthorizationScenario()
|
||||||
];
|
];
|
||||||
|
|
||||||
// Draft scenarios (informational - not scored for tier assessment)
|
// Draft scenarios (informational - not scored for tier assessment)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Scenario, ConformanceCheck } from '../../../types';
|
import type { Scenario, ConformanceCheck } from '../../../types';
|
||||||
import { ScenarioUrls, SpecVersion } from '../../../types';
|
import { ScenarioUrls } from '../../../types';
|
||||||
import { createAuthServer } from './helpers/createAuthServer';
|
import { createAuthServer } from './helpers/createAuthServer';
|
||||||
import { createServer } from './helpers/createServer';
|
import { createServer } from './helpers/createServer';
|
||||||
import { ServerLifecycle } from './helpers/serverLifecycle';
|
import { ServerLifecycle } from './helpers/serverLifecycle';
|
||||||
|
|
@ -8,7 +8,10 @@ import { SpecReferences } from './spec-references';
|
||||||
|
|
||||||
export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario {
|
export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario {
|
||||||
name = 'auth/2025-03-26-oauth-metadata-backcompat';
|
name = 'auth/2025-03-26-oauth-metadata-backcompat';
|
||||||
specVersions: SpecVersion[] = ['2025-03-26'];
|
readonly source = {
|
||||||
|
introducedIn: '2025-03-26',
|
||||||
|
removedIn: '2025-06-18'
|
||||||
|
} as const;
|
||||||
description =
|
description =
|
||||||
'Tests 2025-03-26 spec OAuth flow: no PRM (Protected Resource Metadata), OAuth metadata at root location';
|
'Tests 2025-03-26 spec OAuth flow: no PRM (Protected Resource Metadata), OAuth metadata at root location';
|
||||||
private server = new ServerLifecycle();
|
private server = new ServerLifecycle();
|
||||||
|
|
@ -69,7 +72,10 @@ export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario {
|
||||||
|
|
||||||
export class Auth20250326OEndpointFallbackScenario implements Scenario {
|
export class Auth20250326OEndpointFallbackScenario implements Scenario {
|
||||||
name = 'auth/2025-03-26-oauth-endpoint-fallback';
|
name = 'auth/2025-03-26-oauth-endpoint-fallback';
|
||||||
specVersions: SpecVersion[] = ['2025-03-26'];
|
readonly source = {
|
||||||
|
introducedIn: '2025-03-26',
|
||||||
|
removedIn: '2025-06-18'
|
||||||
|
} as const;
|
||||||
description =
|
description =
|
||||||
'Tests OAuth flow with no metadata endpoints, relying on fallback to standard OAuth endpoints at server root (2025-03-26 spec behavior)';
|
'Tests OAuth flow with no metadata endpoints, relying on fallback to standard OAuth endpoints at server root (2025-03-26 spec behavior)';
|
||||||
private server = new ServerLifecycle();
|
private server = new ServerLifecycle();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import type { Scenario, ConformanceCheck } from '../../../types';
|
import type { Scenario, ConformanceCheck } from '../../../types';
|
||||||
import {
|
import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types';
|
||||||
ScenarioUrls,
|
|
||||||
SpecVersion,
|
|
||||||
DRAFT_PROTOCOL_VERSION
|
|
||||||
} from '../../../types';
|
|
||||||
import { createAuthServer } from './helpers/createAuthServer';
|
import { createAuthServer } from './helpers/createAuthServer';
|
||||||
import { createServer } from './helpers/createServer';
|
import { createServer } from './helpers/createServer';
|
||||||
import { ServerLifecycle } from './helpers/serverLifecycle';
|
import { ServerLifecycle } from './helpers/serverLifecycle';
|
||||||
|
|
@ -27,7 +23,7 @@ import { MockTokenVerifier } from './helpers/mockTokenVerifier';
|
||||||
*/
|
*/
|
||||||
export class OfflineAccessScopeScenario implements Scenario {
|
export class OfflineAccessScopeScenario implements Scenario {
|
||||||
name = 'auth/offline-access-scope';
|
name = 'auth/offline-access-scope';
|
||||||
specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION];
|
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
|
||||||
description =
|
description =
|
||||||
'Tests that a client that wants a refresh token handles offline_access scope and refresh_token grant type when AS supports them (SEP-2207)';
|
'Tests that a client that wants a refresh token handles offline_access scope and refresh_token grant type when AS supports them (SEP-2207)';
|
||||||
|
|
||||||
|
|
@ -231,7 +227,7 @@ export class OfflineAccessScopeScenario implements Scenario {
|
||||||
*/
|
*/
|
||||||
export class OfflineAccessNotSupportedScenario implements Scenario {
|
export class OfflineAccessNotSupportedScenario implements Scenario {
|
||||||
name = 'auth/offline-access-not-supported';
|
name = 'auth/offline-access-not-supported';
|
||||||
specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION];
|
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
|
||||||
description =
|
description =
|
||||||
'Tests that client does not request offline_access when AS does not list it in scopes_supported (SEP-2207)';
|
'Tests that client does not request offline_access when AS does not list it in scopes_supported (SEP-2207)';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
import type {
|
import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types';
|
||||||
Scenario,
|
|
||||||
ConformanceCheck,
|
|
||||||
ScenarioUrls,
|
|
||||||
SpecVersion
|
|
||||||
} from '../../../types';
|
|
||||||
import { createAuthServer } from './helpers/createAuthServer';
|
import { createAuthServer } from './helpers/createAuthServer';
|
||||||
import { createServer } from './helpers/createServer';
|
import { createServer } from './helpers/createServer';
|
||||||
import { ServerLifecycle } from './helpers/serverLifecycle';
|
import { ServerLifecycle } from './helpers/serverLifecycle';
|
||||||
|
|
@ -24,7 +19,7 @@ const PRE_REGISTERED_CLIENT_SECRET = 'pre-registered-secret';
|
||||||
*/
|
*/
|
||||||
export class PreRegistrationScenario implements Scenario {
|
export class PreRegistrationScenario implements Scenario {
|
||||||
name = 'auth/pre-registration';
|
name = 'auth/pre-registration';
|
||||||
specVersions: SpecVersion[] = ['2025-11-25'];
|
readonly source = { introducedIn: '2025-11-25' } as const;
|
||||||
description =
|
description =
|
||||||
'Tests OAuth flow with pre-registered client credentials. Server does not support DCR.';
|
'Tests OAuth flow with pre-registered client credentials. Server does not support DCR.';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import type { Scenario, ConformanceCheck } from '../../../types.js';
|
import type { Scenario, ConformanceCheck } from '../../../types.js';
|
||||||
import {
|
import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types.js';
|
||||||
ScenarioUrls,
|
|
||||||
SpecVersion,
|
|
||||||
DRAFT_PROTOCOL_VERSION
|
|
||||||
} from '../../../types.js';
|
|
||||||
import { createAuthServer } from './helpers/createAuthServer.js';
|
import { createAuthServer } from './helpers/createAuthServer.js';
|
||||||
import { createServer } from './helpers/createServer.js';
|
import { createServer } from './helpers/createServer.js';
|
||||||
import { ServerLifecycle } from './helpers/serverLifecycle.js';
|
import { ServerLifecycle } from './helpers/serverLifecycle.js';
|
||||||
|
|
@ -31,7 +27,7 @@ import { MockTokenVerifier } from './helpers/mockTokenVerifier.js';
|
||||||
*/
|
*/
|
||||||
export class ResourceMismatchScenario implements Scenario {
|
export class ResourceMismatchScenario implements Scenario {
|
||||||
name = 'auth/resource-mismatch';
|
name = 'auth/resource-mismatch';
|
||||||
specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION];
|
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
|
||||||
description =
|
description =
|
||||||
'Tests that client rejects when PRM resource does not match server URL';
|
'Tests that client rejects when PRM resource does not match server URL';
|
||||||
allowClientError = true;
|
allowClientError = true;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Scenario, ConformanceCheck } from '../../../types';
|
import type { Scenario, ConformanceCheck } from '../../../types';
|
||||||
import { ScenarioUrls, SpecVersion } from '../../../types';
|
import { ScenarioUrls } from '../../../types';
|
||||||
import { createAuthServer } from './helpers/createAuthServer';
|
import { createAuthServer } from './helpers/createAuthServer';
|
||||||
import { createServer } from './helpers/createServer';
|
import { createServer } from './helpers/createServer';
|
||||||
import { ServerLifecycle } from './helpers/serverLifecycle';
|
import { ServerLifecycle } from './helpers/serverLifecycle';
|
||||||
|
|
@ -15,7 +15,7 @@ import type { Request, Response, NextFunction } from 'express';
|
||||||
*/
|
*/
|
||||||
export class ScopeFromWwwAuthenticateScenario implements Scenario {
|
export class ScopeFromWwwAuthenticateScenario implements Scenario {
|
||||||
name = 'auth/scope-from-www-authenticate';
|
name = 'auth/scope-from-www-authenticate';
|
||||||
specVersions: SpecVersion[] = ['2025-11-25'];
|
readonly source = { introducedIn: '2025-11-25' } as const;
|
||||||
description =
|
description =
|
||||||
'Tests that client uses scope parameter from WWW-Authenticate header when provided';
|
'Tests that client uses scope parameter from WWW-Authenticate header when provided';
|
||||||
private authServer = new ServerLifecycle();
|
private authServer = new ServerLifecycle();
|
||||||
|
|
@ -101,7 +101,7 @@ export class ScopeFromWwwAuthenticateScenario implements Scenario {
|
||||||
*/
|
*/
|
||||||
export class ScopeFromScopesSupportedScenario implements Scenario {
|
export class ScopeFromScopesSupportedScenario implements Scenario {
|
||||||
name = 'auth/scope-from-scopes-supported';
|
name = 'auth/scope-from-scopes-supported';
|
||||||
specVersions: SpecVersion[] = ['2025-11-25'];
|
readonly source = { introducedIn: '2025-11-25' } as const;
|
||||||
description =
|
description =
|
||||||
'Tests that client uses all scopes from scopes_supported when scope not in WWW-Authenticate header';
|
'Tests that client uses all scopes from scopes_supported when scope not in WWW-Authenticate header';
|
||||||
private authServer = new ServerLifecycle();
|
private authServer = new ServerLifecycle();
|
||||||
|
|
@ -197,7 +197,7 @@ export class ScopeFromScopesSupportedScenario implements Scenario {
|
||||||
*/
|
*/
|
||||||
export class ScopeOmittedWhenUndefinedScenario implements Scenario {
|
export class ScopeOmittedWhenUndefinedScenario implements Scenario {
|
||||||
name = 'auth/scope-omitted-when-undefined';
|
name = 'auth/scope-omitted-when-undefined';
|
||||||
specVersions: SpecVersion[] = ['2025-11-25'];
|
readonly source = { introducedIn: '2025-11-25' } as const;
|
||||||
description =
|
description =
|
||||||
'Tests that client omits scope parameter when scopes_supported is undefined';
|
'Tests that client omits scope parameter when scopes_supported is undefined';
|
||||||
private authServer = new ServerLifecycle();
|
private authServer = new ServerLifecycle();
|
||||||
|
|
@ -284,7 +284,7 @@ export class ScopeOmittedWhenUndefinedScenario implements Scenario {
|
||||||
*/
|
*/
|
||||||
export class ScopeStepUpAuthScenario implements Scenario {
|
export class ScopeStepUpAuthScenario implements Scenario {
|
||||||
name = 'auth/scope-step-up';
|
name = 'auth/scope-step-up';
|
||||||
specVersions: SpecVersion[] = ['2025-11-25'];
|
readonly source = { introducedIn: '2025-11-25' } as const;
|
||||||
description =
|
description =
|
||||||
'Tests that client handles step-up authentication with different scope requirements per operation';
|
'Tests that client handles step-up authentication with different scope requirements per operation';
|
||||||
private authServer = new ServerLifecycle();
|
private authServer = new ServerLifecycle();
|
||||||
|
|
@ -481,7 +481,7 @@ export class ScopeStepUpAuthScenario implements Scenario {
|
||||||
*/
|
*/
|
||||||
export class ScopeRetryLimitScenario implements Scenario {
|
export class ScopeRetryLimitScenario implements Scenario {
|
||||||
name = 'auth/scope-retry-limit';
|
name = 'auth/scope-retry-limit';
|
||||||
specVersions: SpecVersion[] = ['2025-11-25'];
|
readonly source = { introducedIn: '2025-11-25' } as const;
|
||||||
description =
|
description =
|
||||||
'Tests that client implements retry limits to prevent infinite authorization loops on repeated 403 responses';
|
'Tests that client implements retry limits to prevent infinite authorization loops on repeated 403 responses';
|
||||||
allowClientError = true;
|
allowClientError = true;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Scenario, ConformanceCheck } from '../../../types.js';
|
import type { Scenario, ConformanceCheck } from '../../../types.js';
|
||||||
import { ScenarioUrls, SpecVersion } from '../../../types.js';
|
import { ScenarioUrls } from '../../../types.js';
|
||||||
import { createAuthServer } from './helpers/createAuthServer.js';
|
import { createAuthServer } from './helpers/createAuthServer.js';
|
||||||
import { createServer } from './helpers/createServer.js';
|
import { createServer } from './helpers/createServer.js';
|
||||||
import { ServerLifecycle } from './helpers/serverLifecycle.js';
|
import { ServerLifecycle } from './helpers/serverLifecycle.js';
|
||||||
|
|
@ -45,7 +45,7 @@ const AUTH_METHOD_NAMES: Record<AuthMethod, string> = {
|
||||||
|
|
||||||
class TokenEndpointAuthScenario implements Scenario {
|
class TokenEndpointAuthScenario implements Scenario {
|
||||||
name: string;
|
name: string;
|
||||||
specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25'];
|
readonly source = { introducedIn: '2025-06-18' } as const;
|
||||||
description: string;
|
description: string;
|
||||||
private expectedAuthMethod: AuthMethod;
|
private expectedAuthMethod: AuthMethod;
|
||||||
private authServer = new ServerLifecycle();
|
private authServer = new ServerLifecycle();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
ElicitResultSchema
|
ElicitResultSchema
|
||||||
} from '@modelcontextprotocol/sdk/types.js';
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { Scenario, ConformanceCheck, SpecVersion } from '../../types';
|
import type { Scenario, ConformanceCheck } from '../../types';
|
||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { ScenarioUrls } from '../../types';
|
import { ScenarioUrls } from '../../types';
|
||||||
import { createRequestLogger } from '../request-logger';
|
import { createRequestLogger } from '../request-logger';
|
||||||
|
|
@ -474,7 +474,7 @@ function createServer(checks: ConformanceCheck[]): {
|
||||||
|
|
||||||
export class ElicitationClientDefaultsScenario implements Scenario {
|
export class ElicitationClientDefaultsScenario implements Scenario {
|
||||||
name = 'elicitation-sep1034-client-defaults';
|
name = 'elicitation-sep1034-client-defaults';
|
||||||
specVersions: SpecVersion[] = ['2025-11-25'];
|
readonly source = { introducedIn: '2025-11-25' } as const;
|
||||||
description =
|
description =
|
||||||
'Tests client applies default values for omitted elicitation fields (SEP-1034)';
|
'Tests client applies default values for omitted elicitation fields (SEP-1034)';
|
||||||
private app: express.Application | null = null;
|
private app: express.Application | null = null;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import {
|
||||||
Scenario,
|
Scenario,
|
||||||
ScenarioUrls,
|
ScenarioUrls,
|
||||||
ConformanceCheck,
|
ConformanceCheck,
|
||||||
SpecVersion,
|
|
||||||
LATEST_SPEC_VERSION,
|
LATEST_SPEC_VERSION,
|
||||||
NEGOTIABLE_PROTOCOL_VERSIONS
|
NEGOTIABLE_PROTOCOL_VERSIONS
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
@ -11,7 +10,7 @@ import { clientChecks } from '../../checks/index';
|
||||||
|
|
||||||
export class InitializeScenario implements Scenario {
|
export class InitializeScenario implements Scenario {
|
||||||
name = 'initialize';
|
name = 'initialize';
|
||||||
specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25'];
|
readonly source = { introducedIn: '2025-06-18' } as const;
|
||||||
description = 'Tests MCP client initialization handshake';
|
description = 'Tests MCP client initialization handshake';
|
||||||
|
|
||||||
private server: http.Server | null = null;
|
private server: http.Server | null = null;
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import {
|
import { Scenario, ScenarioUrls, ConformanceCheck } from '../../types.js';
|
||||||
Scenario,
|
|
||||||
ScenarioUrls,
|
|
||||||
ConformanceCheck,
|
|
||||||
SpecVersion
|
|
||||||
} from '../../types.js';
|
|
||||||
|
|
||||||
export class SSERetryScenario implements Scenario {
|
export class SSERetryScenario implements Scenario {
|
||||||
name = 'sse-retry';
|
name = 'sse-retry';
|
||||||
specVersions: SpecVersion[] = ['2025-11-25'];
|
readonly source = { introducedIn: '2025-11-25' } as const;
|
||||||
description =
|
description =
|
||||||
'Tests that client respects SSE retry field timing and reconnects properly (SEP-1699)';
|
'Tests that client respects SSE retry field timing and reconnects properly (SEP-1699)';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import {
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
ListToolsRequestSchema
|
ListToolsRequestSchema
|
||||||
} from '@modelcontextprotocol/sdk/types.js';
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { Scenario, ConformanceCheck, SpecVersion } from '../../types';
|
import type { Scenario, ConformanceCheck } from '../../types';
|
||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { ScenarioUrls } from '../../types';
|
import { ScenarioUrls } from '../../types';
|
||||||
import { createRequestLogger } from '../request-logger';
|
import { createRequestLogger } from '../request-logger';
|
||||||
|
|
@ -115,7 +115,7 @@ function createServerApp(checks: ConformanceCheck[]): express.Application {
|
||||||
|
|
||||||
export class ToolsCallScenario implements Scenario {
|
export class ToolsCallScenario implements Scenario {
|
||||||
name = 'tools_call';
|
name = 'tools_call';
|
||||||
specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25'];
|
readonly source = { introducedIn: '2025-06-18' } as const;
|
||||||
description = 'Tests calling tools with various parameter types';
|
description = 'Tests calling tools with various parameter types';
|
||||||
private app: express.Application | null = null;
|
private app: express.Application | null = null;
|
||||||
private httpServer: any = null;
|
private httpServer: any = null;
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@ import {
|
||||||
Scenario,
|
Scenario,
|
||||||
ClientScenario,
|
ClientScenario,
|
||||||
ClientScenarioForAuthorizationServer,
|
ClientScenarioForAuthorizationServer,
|
||||||
|
ScenarioSource,
|
||||||
SpecVersion,
|
SpecVersion,
|
||||||
|
DatedSpecVersion,
|
||||||
ScenarioSpecTag,
|
ScenarioSpecTag,
|
||||||
DATED_SPEC_VERSIONS,
|
DATED_SPEC_VERSIONS,
|
||||||
DRAFT_PROTOCOL_VERSION,
|
DRAFT_PROTOCOL_VERSION
|
||||||
LATEST_SPEC_VERSION
|
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { InitializeScenario } from './client/initialize';
|
import { InitializeScenario } from './client/initialize';
|
||||||
import { ToolsCallScenario } from './client/tools_call';
|
import { ToolsCallScenario } from './client/tools_call';
|
||||||
|
|
@ -279,31 +280,34 @@ export function resolveSpecVersion(value: string): SpecVersion {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The draft version selects everything in the latest dated release plus
|
function versionIndex(
|
||||||
// scenarios tagged draft-only, so SEP authors can run the full suite against an
|
v: DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION
|
||||||
// SDK tracking the in-progress spec without retagging core scenarios.
|
): number {
|
||||||
|
return ALL_SPEC_VERSIONS.indexOf(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off-timeline sources (extensions etc.) are never selected by --spec-version.
|
||||||
function matchesSpecVersion(
|
function matchesSpecVersion(
|
||||||
scenario: { specVersions: ScenarioSpecTag[] },
|
source: ScenarioSource,
|
||||||
version: SpecVersion
|
version: SpecVersion
|
||||||
): boolean {
|
): boolean {
|
||||||
if (version === DRAFT_PROTOCOL_VERSION) {
|
if ('extensionId' in source) return false;
|
||||||
return (
|
return (
|
||||||
scenario.specVersions.includes(DRAFT_PROTOCOL_VERSION) ||
|
versionIndex(source.introducedIn) <= versionIndex(version) &&
|
||||||
scenario.specVersions.includes(LATEST_SPEC_VERSION)
|
(source.removedIn === undefined ||
|
||||||
);
|
versionIndex(version) < versionIndex(source.removedIn))
|
||||||
}
|
);
|
||||||
return scenario.specVersions.includes(version);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listScenariosForSpec(version: SpecVersion): string[] {
|
export function listScenariosForSpec(version: SpecVersion): string[] {
|
||||||
return scenariosList
|
return scenariosList
|
||||||
.filter((s) => matchesSpecVersion(s, version))
|
.filter((s) => matchesSpecVersion(s.source, version))
|
||||||
.map((s) => s.name);
|
.map((s) => s.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listClientScenariosForSpec(version: SpecVersion): string[] {
|
export function listClientScenariosForSpec(version: SpecVersion): string[] {
|
||||||
return allClientScenariosList
|
return allClientScenariosList
|
||||||
.filter((s) => matchesSpecVersion(s, version))
|
.filter((s) => matchesSpecVersion(s.source, version))
|
||||||
.map((s) => s.name);
|
.map((s) => s.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,18 +315,24 @@ export function listClientScenariosForAuthorizationServerForSpec(
|
||||||
version: SpecVersion
|
version: SpecVersion
|
||||||
): string[] {
|
): string[] {
|
||||||
return allClientScenariosListForAuthorizationServer
|
return allClientScenariosListForAuthorizationServer
|
||||||
.filter((s) => matchesSpecVersion(s, version))
|
.filter((s) => matchesSpecVersion(s.source, version))
|
||||||
.map((s) => s.name);
|
.map((s) => s.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScenarioSpecVersions(
|
export function getScenarioSpecVersions(
|
||||||
name: string
|
name: string
|
||||||
): ScenarioSpecTag[] | undefined {
|
): ScenarioSpecTag[] | undefined {
|
||||||
return (
|
const s =
|
||||||
scenarios.get(name)?.specVersions ??
|
scenarios.get(name) ??
|
||||||
clientScenarios.get(name)?.specVersions ??
|
clientScenarios.get(name) ??
|
||||||
clientScenariosForAuthorizationServer.get(name)?.specVersions
|
clientScenariosForAuthorizationServer.get(name);
|
||||||
);
|
if (!s) return undefined;
|
||||||
|
if ('extensionId' in s.source) return ['extension'];
|
||||||
|
const result: ScenarioSpecTag[] = [];
|
||||||
|
for (const v of ALL_SPEC_VERSIONS) {
|
||||||
|
if (matchesSpecVersion(s.source, v)) result.push(v);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { SpecVersion, ScenarioSpecTag };
|
export type { SpecVersion, ScenarioSpecTag };
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* to prevent DNS rebinding attacks. See GHSA-w48q-cv73-mx4w for details.
|
* to prevent DNS rebinding attacks. See GHSA-w48q-cv73-mx4w for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types';
|
import { ClientScenario, ConformanceCheck } from '../../types';
|
||||||
import { request } from 'undici';
|
import { request } from 'undici';
|
||||||
|
|
||||||
const SPEC_REFERENCES = [
|
const SPEC_REFERENCES = [
|
||||||
|
|
@ -85,7 +85,7 @@ async function sendRequestWithHostAndOrigin(
|
||||||
|
|
||||||
export class DNSRebindingProtectionScenario implements ClientScenario {
|
export class DNSRebindingProtectionScenario implements ClientScenario {
|
||||||
name = 'dns-rebinding-protection';
|
name = 'dns-rebinding-protection';
|
||||||
specVersions: SpecVersion[] = ['2025-11-25'];
|
readonly source = { introducedIn: '2025-11-25' } as const;
|
||||||
description = `Test DNS rebinding protection for localhost servers.
|
description = `Test DNS rebinding protection for localhost servers.
|
||||||
|
|
||||||
**Scope:** This test applies to localhost MCP servers running without HTTPS and without
|
**Scope:** This test applies to localhost MCP servers running without HTTPS and without
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
* SEP-1034: Elicitation default values test scenarios for MCP servers
|
* SEP-1034: Elicitation default values test scenarios for MCP servers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types';
|
import { ClientScenario, ConformanceCheck } from '../../types';
|
||||||
import { connectToServer } from './client-helper';
|
import { connectToServer } from './client-helper';
|
||||||
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
export class ElicitationDefaultsScenario implements ClientScenario {
|
export class ElicitationDefaultsScenario implements ClientScenario {
|
||||||
name = 'elicitation-sep1034-defaults';
|
name = 'elicitation-sep1034-defaults';
|
||||||
specVersions: SpecVersion[] = ['2025-11-25'];
|
readonly source = { introducedIn: '2025-11-25' } as const;
|
||||||
description = `Test elicitation with default values for all primitive types (SEP-1034).
|
description = `Test elicitation with default values for all primitive types (SEP-1034).
|
||||||
|
|
||||||
**Server Implementation Requirements:**
|
**Server Implementation Requirements:**
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue