diff --git a/examples/clients/typescript/ema-broken-clients.ts b/examples/clients/typescript/ema-broken-clients.ts new file mode 100644 index 00000000..bf5bee9a --- /dev/null +++ b/examples/clients/typescript/ema-broken-clients.ts @@ -0,0 +1,142 @@ +/** Broken requests for the optional EMA refresh-token exchange profile. */ +import { ClientConformanceContextSchema } from '../../../src/schemas/context.js'; + +export type EmaRefreshDefect = + | 'ordinary-refresh' + | 'wrong-subject-type' + | 'unknown-refresh-token' + | 'missing-idp-auth' + | 'wrong-idp-auth' + | 'wrong-audience' + | 'wrong-resource' + | 'scope-escalation' + | 'unissued-mcp-token' + | 'invalid-mcp-request' + | 'stops-after-token'; + +export async function runEmaRefreshBrokenClient( + serverUrl: string, + defect: EmaRefreshDefect +): Promise<{ status: number; body: Record }> { + const ctx = ClientConformanceContextSchema.parse( + JSON.parse(process.env.MCP_CONFORMANCE_CONTEXT ?? '{}') + ); + if (ctx.name !== 'auth/enterprise-managed-authorization-refresh-token') { + throw new Error(`Expected EMA refresh-token context, got ${ctx.name}`); + } + + const prm = await ( + await fetch(new URL('/.well-known/oauth-protected-resource/mcp', serverUrl)) + ).json(); + const metadata = await ( + await fetch( + new URL( + '/.well-known/oauth-authorization-server', + prm.authorization_servers[0] + ) + ) + ).json(); + const params = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + subject_token_type: 'urn:ietf:params:oauth:token-type:refresh_token', + subject_token: ctx.idp_refresh_token, + audience: metadata.issuer, + resource: prm.resource, + scope: 'test:read test:write' + }); + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: basicAuth(ctx.idp_client_id, ctx.idp_client_secret) + }; + + switch (defect) { + case 'ordinary-refresh': + params.set('grant_type', 'refresh_token'); + params.set('refresh_token', ctx.idp_refresh_token); + params.delete('subject_token'); + break; + case 'wrong-subject-type': + params.set( + 'subject_token_type', + 'urn:ietf:params:oauth:token-type:id_token' + ); + break; + case 'unknown-refresh-token': + params.set('subject_token', 'unknown-refresh-token'); + break; + case 'missing-idp-auth': + delete headers.Authorization; + break; + case 'wrong-idp-auth': + headers.Authorization = basicAuth(ctx.idp_client_id, 'wrong-secret'); + break; + case 'wrong-audience': + params.set('audience', 'https://other.example'); + break; + case 'wrong-resource': + params.set('resource', 'https://other.example/mcp'); + break; + } + + let response = await fetch(ctx.idp_token_endpoint, { + method: 'POST', + headers, + body: params + }); + let body: Record = await response.json(); + if ( + defect === 'scope-escalation' || + defect === 'unissued-mcp-token' || + defect === 'invalid-mcp-request' || + defect === 'stops-after-token' + ) { + if (!response.ok || typeof body.access_token !== 'string') { + throw new Error('Expected a valid ID-JAG before the AS exchange'); + } + const grantParams = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: body.access_token + }); + if (defect === 'scope-escalation') { + grantParams.set('scope', 'test:read test:write'); + } + response = await fetch(metadata.token_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: basicAuth(ctx.client_id, ctx.client_secret) + }, + body: grantParams + }); + body = await response.json(); + } + if (defect === 'unissued-mcp-token' || defect === 'invalid-mcp-request') { + if (!response.ok || typeof body.access_token !== 'string') { + throw new Error( + 'Expected an issued access token before testing MCP access' + ); + } + response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Authorization: `Bearer ${defect === 'unissued-mcp-token' ? 'test-token-not-issued' : body.access_token}` + }, + body: JSON.stringify( + defect === 'invalid-mcp-request' + ? {} + : { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } + ) + }); + body = await response.json(); + } + return { status: response.status, body }; +} + +function basicAuth(clientId: string, clientSecret: string): string { + const encode = (value: string) => + new URLSearchParams([['', value]]).toString().slice(1); + return `Basic ${Buffer.from(`${encode(clientId)}:${encode(clientSecret)}`).toString('base64')}`; +} diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 111f97e5..c8e9e372 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -770,13 +770,17 @@ registerScenario('auth/pre-registration', runPreRegistration); /** * Enterprise-Managed Authorization (SEP-990) - * Tests the complete flow: IDP ID token -> authorization grant -> access token -> MCP access. + * Tests IDP ID-token or refresh-token exchange -> ID-JAG -> access token -> MCP access. + * The refresh-token scenario uses separate IdP credentials and preserves narrowed scope. */ export async function runEnterpriseManagedAuthorization( serverUrl: string ): Promise { const ctx = parseContext(); - if (ctx.name !== 'auth/enterprise-managed-authorization') { + if ( + ctx.name !== 'auth/enterprise-managed-authorization' && + ctx.name !== 'auth/enterprise-managed-authorization-refresh-token' + ) { throw new Error( `Expected enterprise-managed-authorization context, got ${ctx.name}` ); @@ -828,21 +832,42 @@ export async function runEnterpriseManagedAuthorization( } logger.debug('Auth server supports jwt-bearer grant type'); - // Step 1: Token Exchange at IdP (IDP ID token -> ID-JAG) - logger.debug('Step 1: Exchanging IDP ID token for ID-JAG at IdP...'); + // Step 1: Token Exchange at IdP (ID token or refresh token -> ID-JAG) + logger.debug('Step 1: Exchanging the IDP credential for ID-JAG...'); const tokenExchangeParams = new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', requested_token_type: 'urn:ietf:params:oauth:token-type:id-jag', audience: asIssuer, - resource: resource, - subject_token: ctx.idp_id_token, - subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', - client_id: ctx.idp_client_id + resource: resource }); + const tokenExchangeHeaders: Record = { + 'Content-Type': 'application/x-www-form-urlencoded' + }; + if (ctx.name === 'auth/enterprise-managed-authorization-refresh-token') { + tokenExchangeParams.set('subject_token', ctx.idp_refresh_token); + tokenExchangeParams.set( + 'subject_token_type', + 'urn:ietf:params:oauth:token-type:refresh_token' + ); + const idpBasicAuth = Buffer.from( + `${encodeURIComponent(ctx.idp_client_id)}:${encodeURIComponent(ctx.idp_client_secret)}` + ).toString('base64'); + tokenExchangeHeaders.Authorization = `Basic ${idpBasicAuth}`; + if (prm.scopes_supported?.length) { + tokenExchangeParams.set('scope', prm.scopes_supported.join(' ')); + } + } else { + tokenExchangeParams.set('subject_token', ctx.idp_id_token); + tokenExchangeParams.set( + 'subject_token_type', + 'urn:ietf:params:oauth:token-type:id_token' + ); + tokenExchangeParams.set('client_id', ctx.idp_client_id); + } const tokenExchangeResponse = await fetch(ctx.idp_token_endpoint, { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: tokenExchangeHeaders, body: tokenExchangeParams }); @@ -863,6 +888,12 @@ export async function runEnterpriseManagedAuthorization( grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: idJag }); + if ( + ctx.name === 'auth/enterprise-managed-authorization-refresh-token' && + typeof tokenExchangeResult.scope === 'string' + ) { + jwtBearerParams.set('scope', tokenExchangeResult.scope); + } const basicAuth = Buffer.from( `${encodeURIComponent(ctx.client_id)}:${encodeURIComponent(ctx.client_secret)}` @@ -913,8 +944,11 @@ export async function runEnterpriseManagedAuthorization( logger.debug('Enterprise-managed authorization flow completed successfully'); } -registerScenario( - 'auth/enterprise-managed-authorization', +registerScenarios( + [ + 'auth/enterprise-managed-authorization', + 'auth/enterprise-managed-authorization-refresh-token' + ], runEnterpriseManagedAuthorization ); diff --git a/src/scenarios/client/auth/enterprise-managed-authorization.ts b/src/scenarios/client/auth/enterprise-managed-authorization.ts index eff5e255..daea978e 100644 --- a/src/scenarios/client/auth/enterprise-managed-authorization.ts +++ b/src/scenarios/client/auth/enterprise-managed-authorization.ts @@ -2,6 +2,7 @@ import type { ScenarioContext } from '../../../mock-server'; import * as jose from 'jose'; import type { CryptoKey } from 'jose'; import express, { type Request, type Response } from 'express'; +import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { JWT_BEARER_GRANT_TYPE } from './helpers/createWorkloadJwt.js'; @@ -13,7 +14,10 @@ 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 IDP_CLIENT_SECRET = 'conformance-test-idp-secret'; const DEMO_USER_ID = 'demo-user@example.com'; +const REFRESH_TOKEN_SCOPES = ['test:read', 'test:write']; +const GRANTED_SCOPE = 'test:read'; /** * Generate an EC P-256 keypair for IDP ID token signing. @@ -52,16 +56,28 @@ async function createIdpIdToken( /** * Scenario: Enterprise-Managed Authorization (SEP-990) * - * Tests the complete SEP-990 flow: IDP ID token -> authorization grant -> access token + * Tests the complete SEP-990 flow: IdP subject token -> ID-JAG -> 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 name: string; readonly source = { extensionId: 'io.modelcontextprotocol/enterprise-managed-authorization' } as const; - description = - 'Tests complete SEP-990 flow: token exchange + JWT bearer grant (Enterprise-Managed Authorization)'; + readonly description: string; + + constructor( + private readonly subjectTokenType: 'id_token' | 'refresh_token' = 'id_token' + ) { + this.name = + subjectTokenType === 'id_token' + ? 'auth/enterprise-managed-authorization' + : 'auth/enterprise-managed-authorization-refresh-token'; + this.description = + subjectTokenType === 'id_token' + ? 'Tests complete SEP-990 flow: token exchange + JWT bearer grant (Enterprise-Managed Authorization)' + : 'Tests clients supporting optional EMA refresh-token exchange: refresh token -> ID-JAG -> scoped MCP access'; + } private idpServer = new ServerLifecycle(); private authServer = new ServerLifecycle(); @@ -70,9 +86,18 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { private idpPublicKey?: CryptoKey; private idpPrivateKey?: CryptoKey; private grantKeypairs: Map = new Map(); + private refreshToken?: { token: string; expiresAt: number }; + private issuedAccessTokens = new Set(); async start(ctx: ScenarioContext): Promise { this.checks = []; + this.grantKeypairs.clear(); + this.issuedAccessTokens.clear(); + // Seed a previously issued, client-bound IdP refresh token. + this.refreshToken = + this.subjectTokenType === 'refresh_token' + ? { token: crypto.randomUUID(), expiresAt: Date.now() + 3600_000 } + : undefined; // Generate IDP keypair const { publicKey, privateKey } = await generateIdpKeypair(); @@ -82,6 +107,25 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { // Shared token verifier ensures MCP server only accepts tokens // actually issued by the auth server const tokenVerifier = new MockTokenVerifier(this.checks, []); + if (this.subjectTokenType === 'refresh_token') { + const verifyAccessToken = + tokenVerifier.verifyAccessToken.bind(tokenVerifier); + tokenVerifier.verifyAccessToken = async (token) => { + if (!this.issuedAccessTokens.has(token)) { + this.checks.push({ + id: 'complete-flow-mcp-access', + name: 'CompleteFlowMcpAccess', + description: + 'Client used an access token not issued by this scenario', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE] + }); + throw new InvalidTokenError('Token was not issued by this scenario'); + } + return verifyAccessToken(token); + }; + } // Start IDP server await this.startIdpServer(); @@ -126,17 +170,40 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { this.checks, this.mcpServer.getUrl, this.authServer.getUrl, - { tokenVerifier } + { + tokenVerifier, + ...(this.subjectTokenType === 'refresh_token' && { + scopesSupported: REFRESH_TOKEN_SCOPES, + requiredScopes: [GRANTED_SCOPE], + includeScopeInWwwAuth: true, + onMcpOperation: () => + this.checks.push({ + id: 'complete-flow-mcp-access', + name: 'CompleteFlowMcpAccess', + description: + 'Client completed an MCP operation with the issued access token', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE] + }) + }) + } ); await this.mcpServer.start(mcpApp); - // Generate IDP ID token for client - const idpIdToken = await createIdpIdToken( - this.idpPrivateKey!, - this.idpServer.getUrl(), - IDP_CLIENT_ID - ); + const subjectContext = this.refreshToken + ? { + idp_refresh_token: this.refreshToken.token, + idp_client_secret: IDP_CLIENT_SECRET + } + : { + idp_id_token: await createIdpIdToken( + this.idpPrivateKey!, + this.idpServer.getUrl(), + IDP_CLIENT_ID + ) + }; return { serverUrl: `${this.mcpServer.getUrl()}/mcp`, @@ -144,7 +211,7 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { client_id: CONFORMANCE_TEST_CLIENT_ID, client_secret: CONFORMANCE_TEST_CLIENT_SECRET, idp_client_id: IDP_CLIENT_ID, - idp_id_token: idpIdToken, + ...subjectContext, idp_issuer: this.idpServer.getUrl(), idp_token_endpoint: `${this.idpServer.getUrl()}/token` } @@ -167,12 +234,15 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { jwks_uri: `${this.idpServer.getUrl()}/.well-known/jwks.json`, grant_types_supported: [ 'urn:ietf:params:oauth:grant-type:token-exchange' - ] + ], + ...(this.subjectTokenType === 'refresh_token' && { + token_endpoint_auth_methods_supported: ['client_secret_basic'] + }) }); } ); - // IDP token endpoint - handles token exchange (IDP ID token -> ID-JAG) + // IdP token endpoint - exchanges either supported subject token for an ID-JAG. app.post('/token', async (req: Request, res: Response) => { const timestamp = new Date().toISOString(); const grantType = req.body.grant_type; @@ -202,9 +272,10 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { // 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') { + const expectedSubjectType = `urn:ietf:params:oauth:token-type:${this.subjectTokenType}`; + if (subjectTokenType !== expectedSubjectType) { missingParams.push( - `subject_token_type (expected urn:ietf:params:oauth:token-type:id_token, got ${subjectTokenType || 'missing'})` + `subject_token_type (expected ${expectedSubjectType}, got ${subjectTokenType || 'missing'})` ); } if (requestedTokenType !== 'urn:ietf:params:oauth:token-type:id-jag') { @@ -235,32 +306,90 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { } try { - // Verify the IDP ID token - const { payload } = await jose.jwtVerify( - subjectToken, - this.idpPublicKey!, - { - audience: IDP_CLIENT_ID, - issuer: this.idpServer.getUrl() + let userId: string; + let grantedScope: string | undefined; + if (this.subjectTokenType === 'refresh_token') { + const expectedAuth = `Basic ${Buffer.from(`${IDP_CLIENT_ID}:${IDP_CLIENT_SECRET}`).toString('base64')}`; + if ( + req.headers.authorization !== expectedAuth || + (req.body.client_id !== undefined && + req.body.client_id !== IDP_CLIENT_ID) + ) { + this.checks.push({ + id: 'complete-flow-token-exchange', + name: 'CompleteFlowTokenExchange', + description: + 'Refresh-token exchange requires the bound IdP client credentials', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.ID_JAG_REFRESH_TOKEN] + }); + res.status(401).json({ error: 'invalid_client' }); + return; } - ); + if ( + !this.refreshToken || + subjectToken !== this.refreshToken.token || + this.refreshToken.expiresAt <= Date.now() + ) { + throw new Error('Invalid or expired IdP refresh token'); + } + if ( + audience !== this.authServer.getUrl() || + resource !== `${this.mcpServer.getUrl()}/mcp` + ) { + throw new Error( + 'Requested audience or resource is outside the refresh token authorization' + ); + } + const requestedScopes = + req.body.scope === undefined + ? [GRANTED_SCOPE] + : typeof req.body.scope === 'string' + ? req.body.scope.split(' ') + : []; + if ( + requestedScopes.length === 0 || + requestedScopes.some( + (scope: string) => !REFRESH_TOKEN_SCOPES.includes(scope) + ) || + !requestedScopes.includes(GRANTED_SCOPE) + ) { + throw new Error( + 'Requested scope is outside the refresh token authorization' + ); + } + userId = DEMO_USER_ID; + grantedScope = GRANTED_SCOPE; + } else { + const { payload } = await jose.jwtVerify( + subjectToken, + this.idpPublicKey!, + { + audience: IDP_CLIENT_ID, + issuer: this.idpServer.getUrl() + } + ); + userId = payload.sub as string; + } 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', + description: `Successfully exchanged IdP ${this.subjectTokenType} for ID-JAG with all required parameters`, status: 'SUCCESS', timestamp, specReferences: [ SpecReferences.RFC_8693_TOKEN_EXCHANGE, - SpecReferences.SEP_990_ENTERPRISE_OAUTH + SpecReferences.SEP_990_ENTERPRISE_OAUTH, + ...(this.subjectTokenType === 'refresh_token' + ? [SpecReferences.ID_JAG_REFRESH_TOKEN] + : []) ] }); // 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); @@ -271,7 +400,8 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { const idJag = await new jose.SignJWT({ sub: userId, resource: resource, - client_id: CONFORMANCE_TEST_CLIENT_ID + client_id: CONFORMANCE_TEST_CLIENT_ID, + ...(grantedScope && { scope: grantedScope }) }) .setProtectedHeader({ alg: 'ES256', typ: 'oauth-id-jag+jwt' }) .setIssuer(this.idpServer.getUrl()) @@ -284,7 +414,8 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { res.json({ access_token: idJag, issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', - token_type: 'N_A' + token_type: 'N_A', + ...(grantedScope && { scope: grantedScope, expires_in: 300 }) }); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); @@ -294,11 +425,16 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { description: `Token exchange failed: ${errorMessage}`, status: 'FAILURE', timestamp, - specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE] + specReferences: [ + SpecReferences.RFC_8693_TOKEN_EXCHANGE, + ...(this.subjectTokenType === 'refresh_token' + ? [SpecReferences.ID_JAG_REFRESH_TOKEN] + : []) + ] }); res.status(400).json({ error: 'invalid_grant', - error_description: 'Invalid ID token' + error_description: `Invalid ${this.subjectTokenType} exchange` }); } }); @@ -429,6 +565,9 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { await jose.jwtVerify(assertion, publicKey, { audience: [withoutSlash, withSlash], + ...(this.subjectTokenType === 'refresh_token' && { + issuer: this.idpServer.getUrl() + }), clockTolerance: 30 }); @@ -475,6 +614,23 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { }; } + let scopes = body.scope ? body.scope.split(' ') : []; + if (this.subjectTokenType === 'refresh_token') { + // The signed grant bounds the access token, even when scope is omitted. + const grantedScopes = + typeof decoded.scope === 'string' ? decoded.scope.split(' ') : []; + scopes = + body.scope === undefined ? grantedScopes : body.scope.split(' '); + if ( + grantedScopes.length === 0 || + scopes.some((scope) => !grantedScopes.includes(scope)) + ) { + throw new Error( + 'Requested access-token scope exceeds the ID-JAG grant' + ); + } + } + this.checks.push({ id: 'complete-flow-jwt-bearer', name: 'CompleteFlowJwtBearer', @@ -488,9 +644,10 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { ] }); - const scopes = body.scope ? body.scope.split(' ') : []; + const token = `test-token-${crypto.randomUUID()}`; + this.issuedAccessTokens.add(token); return { - token: `test-token-${Date.now()}`, + token, scopes }; } catch (e) { @@ -510,12 +667,28 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { } async stop() { + this.refreshToken = undefined; + this.issuedAccessTokens.clear(); await this.idpServer.stop(); await this.authServer.stop(); await this.mcpServer.stop(); } getChecks(): ConformanceCheck[] { + if ( + this.subjectTokenType === 'refresh_token' && + !this.checks.some((check) => check.id === 'complete-flow-mcp-access') + ) { + this.checks.push({ + id: 'complete-flow-mcp-access', + name: 'CompleteFlowMcpAccess', + description: + 'Client did not complete an MCP operation with the issued access token', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE] + }); + } const hasTokenExchangeCheck = this.checks.some( (c) => c.id === 'complete-flow-token-exchange' ); diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index cd32d21b..2ec4561b 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -28,6 +28,8 @@ export interface ServerOptions { includeScopeInWwwAuth?: boolean; authMiddleware?: express.RequestHandler; tokenVerifier?: MockTokenVerifier; + /** Observe a successful tools/list or test-tool call after bearer validation. */ + onMcpOperation?: () => void; /** Override the resource field in PRM response (for testing resource mismatch) */ prmResourceOverride?: string; } @@ -64,6 +66,7 @@ export function createServer( ); server.setRequestHandler(ListToolsRequestSchema, async () => { + options.onMcpOperation?.(); return { tools: [ { @@ -78,6 +81,7 @@ export function createServer( CallToolRequestSchema, async (request): Promise => { if (request.params.name === 'test-tool') { + options.onMcpOperation?.(); return { content: [{ type: 'text', text: 'test' }] }; @@ -208,6 +212,7 @@ export function createServer( } const { id, method } = v; if (method === 'tools/list') { + options.onMcpOperation?.(); return res.json({ jsonrpc: '2.0', id, @@ -217,6 +222,7 @@ export function createServer( }); } if (method === 'tools/call') { + options.onMcpOperation?.(); return res.json({ jsonrpc: '2.0', id, diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 57ff27ac..cb68e9e8 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -9,6 +9,10 @@ import { InlineClientRunner } from './test_helpers/testClient'; import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm'; +import { + runEmaRefreshBrokenClient, + type EmaRefreshDefect +} from '../../../../examples/clients/typescript/ema-broken-clients'; import { runWifJwtBearerWrongAudience, runWifJwtBearerMissingAssertion, @@ -295,6 +299,93 @@ describe('Client Extension Scenarios', () => { } }); +describe('EMA refresh-token negative tests', () => { + const defects: EmaRefreshDefect[] = [ + 'ordinary-refresh', + 'wrong-subject-type', + 'unknown-refresh-token', + 'missing-idp-auth', + 'wrong-idp-auth', + 'wrong-audience', + 'wrong-resource', + 'scope-escalation' + ]; + + test.each(defects)('rejects %s without issuing a token', async (defect) => { + let rejection: + | Awaited> + | undefined; + const runner = new InlineClientRunner(async (serverUrl) => { + rejection = await runEmaRefreshBrokenClient(serverUrl, defect); + }); + const checkId = + defect === 'scope-escalation' + ? 'complete-flow-jwt-bearer' + : 'complete-flow-token-exchange'; + const checks = await runClientAgainstScenario( + runner, + 'auth/enterprise-managed-authorization-refresh-token', + { + expectedFailureSlugs: [checkId], + expectedSuccessSlugs: + defect === 'scope-escalation' ? ['complete-flow-token-exchange'] : [] + } + ); + + // Assert outside the runner, which tolerates client errors in negative tests. + // Missing progression checks alone must not make these tests pass. + expect(rejection).toBeDefined(); + expect(rejection!.status).toBeGreaterThanOrEqual(400); + expect(rejection!.status).toBeLessThan(500); + expect(rejection!.body.error).toBeTypeOf('string'); + expect(rejection!.body).not.toHaveProperty('access_token'); + expect( + checks.find((check) => check.id === checkId)!.description + ).not.toMatch(/^Client did not/); + }); + + test.each([ + 'unissued-mcp-token', + 'invalid-mcp-request', + 'stops-after-token' + ] as const)('detects %s after successful token exchanges', async (defect) => { + let result: + | Awaited> + | undefined; + const checks = await runClientAgainstScenario( + new InlineClientRunner(async (serverUrl) => { + result = await runEmaRefreshBrokenClient(serverUrl, defect); + }), + 'auth/enterprise-managed-authorization-refresh-token', + { + expectedFailureSlugs: ['complete-flow-mcp-access'], + expectedSuccessSlugs: [ + 'complete-flow-token-exchange', + 'complete-flow-jwt-bearer' + ] + } + ); + expect(result).toBeDefined(); + if (defect === 'unissued-mcp-token') { + expect(result!.status).toBe(401); + expect(result!.body.error).toBe('invalid_token'); + expect(result!.body).not.toHaveProperty('access_token'); + expect( + checks.find((check) => check.id === 'complete-flow-mcp-access')! + .description + ).not.toMatch(/^Client did not/); + } else if (defect === 'invalid-mcp-request') { + expect(result!.status).toBeGreaterThanOrEqual(400); + expect(result!.status).toBeLessThan(500); + expect(result!.body).not.toHaveProperty('access_token'); + } else { + expect(result!.status).toBe(200); + expect(result!.body.access_token).toBeTypeOf('string'); + expect(result!.body.scope).toBe('test:read'); + } + }); +}); + // allowClientError: true because broken clients receive an error response from // the AS and will throw. The AS-side check is the authoritative conformance // signal; client process exit behaviour is not asserted here. diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 7daec39b..572b91c2 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -67,6 +67,7 @@ export const extensionScenariosList: Scenario[] = [ new ClientCredentialsJwtScenario(), new ClientCredentialsBasicScenario(), new EnterpriseManagedAuthorizationScenario(), + new EnterpriseManagedAuthorizationScenario('refresh_token'), new DPoPClientScenario(false), // auth/dpop — nonce-less baseline (common case) new DPoPClientScenario(true), // auth/dpop-nonce — server-required nonce (§8/§9) new WifJwtBearerScenario() diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 972417bd..c400d17b 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -104,7 +104,11 @@ export const SpecReferences: { [key: string]: SpecReference } = { }, SEP_990_ENTERPRISE_OAUTH: { id: 'SEP-990-Enterprise-Managed-OAuth', - url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx' + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx' + }, + ID_JAG_REFRESH_TOKEN: { + id: 'ID-JAG-Refresh-Token', + url: 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-identity-assertion-authz-grant-04#section-4.3.3' }, SEP_2207_REFRESH_TOKEN_GUIDANCE: { id: 'SEP-2207-Refresh-Token-Guidance', diff --git a/src/schemas/context.ts b/src/schemas/context.ts index cea338a7..da09b812 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -32,6 +32,16 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ idp_issuer: z.string(), idp_token_endpoint: z.string() }), + z.object({ + name: z.literal('auth/enterprise-managed-authorization-refresh-token'), + client_id: z.string(), + client_secret: z.string(), + idp_client_id: z.string(), + idp_client_secret: z.string(), + idp_refresh_token: z.string(), + idp_issuer: z.string(), + idp_token_endpoint: z.string() + }), z.object({ name: z.literal('auth/wif-jwt-bearer'), client_id: z.string(),