From 3348fd73c1c0d3a9d2cefb49017389fae6b196e5 Mon Sep 17 00:00:00 2001 From: Sagar Viswanatha Date: Wed, 21 Jan 2026 11:03:59 +0530 Subject: [PATCH 1/7] feat: add conformance tests for SEP-990 --- .../clients/typescript/everything-client.ts | 218 ++++- src/scenarios/client/auth/cross-app-access.ts | 784 ++++++++++++++++++ src/scenarios/client/auth/index.ts | 10 +- src/scenarios/client/auth/spec-references.ts | 12 + src/schemas/context.ts | 20 + 5 files changed, 1041 insertions(+), 3 deletions(-) create mode 100644 src/scenarios/client/auth/cross-app-access.ts diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 93fd142..d6d07a8 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -361,14 +361,228 @@ export async function runPreRegistration(serverUrl: string): Promise { await client.listTools(); logger.debug('Successfully listed tools'); + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/pre-registration', runPreRegistration); + +// ============================================================================ +// Cross-App Access (SEP-990) scenarios +// ============================================================================ + +/** + * Cross-app access: Token Exchange (RFC 8693) + * Tests the first step of SEP-990 where IDP ID token is exchanged for authorization grant. + */ +export async function runCrossAppAccessTokenExchange( + serverUrl: string +): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/cross-app-access-token-exchange') { + throw new Error( + `Expected cross-app-access-token-exchange context, got ${ctx.name}` + ); + } + + logger.debug('Starting token exchange flow...'); + logger.debug('IDP Issuer:', ctx.idp_issuer); + logger.debug('Auth Server:', ctx.auth_server_url); + + // Step 1: Exchange IDP ID token for authorization grant using RFC 8693 + const tokenExchangeParams = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token: ctx.idp_id_token, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + client_id: ctx.client_id + }); + + logger.debug('Performing token exchange...'); + const tokenExchangeResponse = await fetch(`${ctx.auth_server_url}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenExchangeParams + }); + + if (!tokenExchangeResponse.ok) { + const error = await tokenExchangeResponse.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + const tokenExchangeResult = await tokenExchangeResponse.json(); + logger.debug('Token exchange successful'); + logger.debug('Issued token type:', tokenExchangeResult.issued_token_type); + + // Note: In a real implementation, this authorization grant would be used + // in a subsequent JWT bearer grant flow to get an access token + logger.debug('Token exchange flow completed successfully'); +} + +registerScenario( + 'auth/cross-app-access-token-exchange', + runCrossAppAccessTokenExchange +); + +/** + * Cross-app access: JWT Bearer Grant (RFC 7523) + * Tests the second step of SEP-990 where authorization grant is exchanged for access token. + */ +export async function runCrossAppAccessJwtBearer( + serverUrl: string +): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/cross-app-access-jwt-bearer') { + throw new Error(`Expected cross-app-access-jwt-bearer context, got ${ctx.name}`); + } + + logger.debug('Starting JWT bearer grant flow...'); + logger.debug('Auth Server:', ctx.auth_server_url); + + // Exchange authorization grant for access token using RFC 7523 + const jwtBearerParams = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: ctx.authorization_grant, + client_id: ctx.client_id + }); + + logger.debug('Performing JWT bearer grant...'); + const tokenResponse = await fetch(`${ctx.auth_server_url}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: jwtBearerParams + }); + + if (!tokenResponse.ok) { + const error = await tokenResponse.text(); + throw new Error(`JWT bearer grant failed: ${error}`); + } + + const tokenResult = await tokenResponse.json(); + logger.debug('JWT bearer grant successful'); + logger.debug('Access token obtained'); + + // Use the access token to connect to MCP server + const client = new Client( + { name: 'conformance-cross-app-access', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + requestInit: { + headers: { + Authorization: `Bearer ${tokenResult.access_token}` + } + } + }); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server with access token'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/cross-app-access-jwt-bearer', runCrossAppAccessJwtBearer); + +/** + * Cross-app access: Complete Flow (SEP-990) + * Tests the complete flow: IDP ID token -> authorization grant -> access token -> MCP access. + */ +export async function runCrossAppAccessCompleteFlow( + serverUrl: string +): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/cross-app-access-complete-flow') { + throw new Error( + `Expected cross-app-access-complete-flow context, got ${ctx.name}` + ); + } + + logger.debug('Starting complete cross-app access flow...'); + logger.debug('IDP Issuer:', ctx.idp_issuer); + logger.debug('Auth Server:', ctx.auth_server_url); + + // Step 1: Token Exchange (IDP ID token -> authorization grant) + logger.debug('Step 1: Exchanging IDP ID token for authorization grant...'); + const tokenExchangeParams = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token: ctx.idp_id_token, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + client_id: ctx.client_id + }); + + const tokenExchangeResponse = await fetch(`${ctx.auth_server_url}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenExchangeParams + }); + + if (!tokenExchangeResponse.ok) { + const error = await tokenExchangeResponse.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + const tokenExchangeResult = await tokenExchangeResponse.json(); + const authorizationGrant = tokenExchangeResult.access_token; + logger.debug('Token exchange successful, authorization grant obtained'); + + // Step 2: JWT Bearer Grant (authorization grant -> access token) + logger.debug('Step 2: Exchanging authorization grant for access token...'); + const jwtBearerParams = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: authorizationGrant, + client_id: ctx.client_id + }); + + const tokenResponse = await fetch(`${ctx.auth_server_url}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: jwtBearerParams + }); + + if (!tokenResponse.ok) { + const error = await tokenResponse.text(); + throw new Error(`JWT bearer grant failed: ${error}`); + } + + const tokenResult = await tokenResponse.json(); + logger.debug('JWT bearer grant successful, access token obtained'); + + // Step 3: Use access token to access MCP server + logger.debug('Step 3: Accessing MCP server with access token...'); + const client = new Client( + { name: 'conformance-cross-app-access', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + requestInit: { + headers: { + Authorization: `Bearer ${tokenResult.access_token}` + } + } + }); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + await client.callTool({ name: 'test-tool', arguments: {} }); logger.debug('Successfully called tool'); await transport.close(); - logger.debug('Connection closed successfully'); + logger.debug('Complete cross-app access flow completed successfully'); } -registerScenario('auth/pre-registration', runPreRegistration); +registerScenario( + 'auth/cross-app-access-complete-flow', + runCrossAppAccessCompleteFlow +); // ============================================================================ // Main entry point diff --git a/src/scenarios/client/auth/cross-app-access.ts b/src/scenarios/client/auth/cross-app-access.ts new file mode 100644 index 0000000..4809b3f --- /dev/null +++ b/src/scenarios/client/auth/cross-app-access.ts @@ -0,0 +1,784 @@ +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 { ServerLifecycle } from './helpers/serverLifecycle'; +import { SpecReferences } from './spec-references'; + +const CONFORMANCE_TEST_CLIENT_ID = 'conformance-test-xaa-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 { + return await new jose.SignJWT({ + sub: userId, + email: userId, + aud: audience + }) + .setProtectedHeader({ alg: 'ES256' }) + .setIssuer(idpIssuer) + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey); +} + +/** + * Scenario: Token Exchange Flow (RFC 8693) + * + * Tests that the client can exchange an IDP ID token for an authorization grant + * using RFC 8693 token exchange, and then exchange that grant for an access token + * using RFC 7523 JWT Bearer grant. + */ +export class CrossAppAccessTokenExchangeScenario implements Scenario { + name = 'auth/cross-app-access-token-exchange'; + description = + 'Tests RFC 8693 token exchange flow for converting IDP ID token to authorization grant (SEP-990)'; + + private idpServer = new ServerLifecycle(); + private authServer = new ServerLifecycle(); + private mcpServer = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private idpPublicKey?: CryptoKey; + private idpPrivateKey?: CryptoKey; + + async start(): Promise { + this.checks = []; + + // Generate IDP keypair for signing ID tokens + const { publicKey, privateKey } = await generateIdpKeypair(); + this.idpPublicKey = publicKey; + this.idpPrivateKey = privateKey; + + // Start IDP server (simulates enterprise identity provider) + await this.startIdpServer(); + + // Start MCP authorization server with token exchange support + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + grantTypesSupported: ['urn:ietf:params:oauth:grant-type:token-exchange'], + tokenEndpointAuthMethodsSupported: ['none'], + onTokenRequest: async ({ grantType, body, timestamp }) => { + if (grantType !== 'urn:ietf:params:oauth:grant-type:token-exchange') { + this.checks.push({ + id: 'token-exchange-grant-type', + name: 'TokenExchangeGrantType', + description: `Expected grant_type=urn:ietf:params:oauth:grant-type:token-exchange, got ${grantType}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.RFC_8693_TOKEN_EXCHANGE, + SpecReferences.SEP_990_ENTERPRISE_OAUTH + ] + }); + return { + error: 'unsupported_grant_type', + errorDescription: 'Only token exchange grant is supported' + }; + } + + // Verify subject_token (IDP ID token) + const subjectToken = body.subject_token; + const subjectTokenType = body.subject_token_type; + + if ( + !subjectToken || + subjectTokenType !== 'urn:ietf:params:oauth:token-type:id_token' + ) { + this.checks.push({ + id: 'token-exchange-subject-token', + name: 'TokenExchangeSubjectToken', + description: 'Missing or invalid subject_token or subject_token_type', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE], + details: { + hasSubjectToken: !!subjectToken, + subjectTokenType: subjectTokenType || 'missing' + } + }); + return { + error: 'invalid_request', + errorDescription: 'Invalid subject_token', + statusCode: 400 + }; + } + + // Verify the ID token signature + try { + const { payload } = await jose.jwtVerify( + subjectToken, + this.idpPublicKey!, + { + audience: this.authServer.getUrl(), + issuer: this.idpServer.getUrl() + } + ); + + this.checks.push({ + id: 'token-exchange-id-token-verified', + name: 'TokenExchangeIdTokenVerified', + description: + 'Successfully verified IDP ID token signature and claims', + status: 'SUCCESS', + timestamp, + specReferences: [ + SpecReferences.RFC_8693_TOKEN_EXCHANGE, + SpecReferences.SEP_990_ENTERPRISE_OAUTH + ], + details: { + sub: payload.sub, + iss: payload.iss, + aud: payload.aud + } + }); + + // Return authorization grant token + const authorizationGrant = await this.createAuthorizationGrant( + payload.sub as string + ); + + return { + token: authorizationGrant, + scopes: [], + // RFC 8693 response format + additionalFields: { + issued_token_type: + 'urn:ietf:params:oauth:token-type:authorization_grant', + token_type: 'N_A' + } + }; + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + this.checks.push({ + id: 'token-exchange-id-token-verified', + name: 'TokenExchangeIdTokenVerified', + description: `ID token verification failed: ${errorMessage}`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE], + details: { error: errorMessage } + }); + return { + error: 'invalid_grant', + errorDescription: `ID token verification failed: ${errorMessage}`, + statusCode: 400 + }; + } + } + }); + + await this.authServer.start(authApp); + + // Start MCP resource server + const mcpApp = createServer( + this.checks, + this.mcpServer.getUrl, + this.authServer.getUrl + ); + + await this.mcpServer.start(mcpApp); + + // Generate an ID token for the client to use + const idpIdToken = await createIdpIdToken( + this.idpPrivateKey!, + this.idpServer.getUrl(), + this.authServer.getUrl() + ); + + return { + serverUrl: `${this.mcpServer.getUrl()}/mcp`, + context: { + client_id: CONFORMANCE_TEST_CLIENT_ID, + idp_id_token: idpIdToken, + idp_issuer: this.idpServer.getUrl(), + auth_server_url: this.authServer.getUrl() + } + }; + } + + private async startIdpServer(): Promise { + const app = express(); + app.use(express.json()); + + // IDP metadata endpoint + app.get('/.well-known/openid-configuration', (req: Request, res: Response) => { + this.checks.push({ + id: 'idp-metadata-discovery', + name: 'IdpMetadataDiscovery', + description: 'Client discovered IDP metadata', + status: 'INFO', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.SEP_990_ENTERPRISE_OAUTH] + }); + + 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` + }); + }); + + await this.idpServer.start(app); + } + + private async createAuthorizationGrant(userId: string): Promise { + // Create a simple JWT as authorization grant (in real implementation, this would be opaque) + const { publicKey, privateKey } = await jose.generateKeyPair('ES256'); + return await new jose.SignJWT({ + sub: userId, + grant_type: 'authorization_grant' + }) + .setProtectedHeader({ alg: 'ES256' }) + .setIssuer(this.authServer.getUrl()) + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey); + } + + async stop() { + await this.idpServer.stop(); + await this.authServer.stop(); + await this.mcpServer.stop(); + } + + getChecks(): ConformanceCheck[] { + // Ensure we have the ID token verification check + const hasIdTokenCheck = this.checks.some( + (c) => c.id === 'token-exchange-id-token-verified' + ); + if (!hasIdTokenCheck) { + this.checks.push({ + id: 'token-exchange-id-token-verified', + name: 'TokenExchangeIdTokenVerified', + description: 'Client did not perform token exchange', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.RFC_8693_TOKEN_EXCHANGE, + SpecReferences.SEP_990_ENTERPRISE_OAUTH + ] + }); + } + + return this.checks; + } +} + +/** + * Scenario: JWT Bearer Grant Flow (RFC 7523) + * + * Tests that the client can exchange an authorization grant for an access token + * using RFC 7523 JWT Bearer grant. + */ +export class CrossAppAccessJwtBearerScenario implements Scenario { + name = 'auth/cross-app-access-jwt-bearer'; + description = + 'Tests RFC 7523 JWT Bearer grant flow for exchanging authorization grant for access token (SEP-990)'; + + private authServer = new ServerLifecycle(); + private mcpServer = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private grantPublicKey?: CryptoKey; + private grantPrivateKey?: CryptoKey; + + async start(): Promise { + this.checks = []; + + // Generate keypair for authorization grant + const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { + extractable: true + }); + this.grantPublicKey = publicKey; + this.grantPrivateKey = privateKey; + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + grantTypesSupported: [ + 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'client_credentials' + ], + tokenEndpointAuthMethodsSupported: ['none'], + onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => { + if (grantType !== 'urn:ietf:params:oauth:grant-type:jwt-bearer') { + this.checks.push({ + id: 'jwt-bearer-grant-type', + name: 'JwtBearerGrantType', + description: `Expected grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, got ${grantType}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_990_ENTERPRISE_OAUTH + ] + }); + return { + error: 'unsupported_grant_type', + errorDescription: 'Only JWT bearer grant is supported' + }; + } + + // Verify assertion + const assertion = body.assertion; + if (!assertion) { + this.checks.push({ + id: 'jwt-bearer-assertion', + name: 'JwtBearerAssertion', + description: 'Missing assertion parameter', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_7523_JWT_BEARER] + }); + return { + error: 'invalid_request', + errorDescription: 'Missing assertion', + statusCode: 400 + }; + } + + // Verify JWT assertion (authorization grant) + try { + // Accept both with and without trailing slash for audience + const withoutSlash = authBaseUrl.replace(/\/+$/, ''); + const withSlash = `${withoutSlash}/`; + + const { payload } = await jose.jwtVerify(assertion, this.grantPublicKey!, { + audience: [withoutSlash, withSlash], + clockTolerance: 30 + }); + + this.checks.push({ + id: 'jwt-bearer-assertion-verified', + name: 'JwtBearerAssertionVerified', + description: + 'Successfully verified authorization grant JWT assertion', + status: 'SUCCESS', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_990_ENTERPRISE_OAUTH + ], + details: { + sub: payload.sub, + iss: payload.iss, + aud: payload.aud + } + }); + + // Return access token + const scopes = body.scope ? body.scope.split(' ') : []; + return { + token: `test-token-${Date.now()}`, + scopes + }; + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + this.checks.push({ + id: 'jwt-bearer-assertion-verified', + name: 'JwtBearerAssertionVerified', + description: `JWT assertion verification failed: ${errorMessage}`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_7523_JWT_BEARER], + details: { error: errorMessage } + }); + return { + error: 'invalid_grant', + errorDescription: `JWT assertion verification failed: ${errorMessage}`, + statusCode: 400 + }; + } + } + }); + + await this.authServer.start(authApp); + + const mcpApp = createServer( + this.checks, + this.mcpServer.getUrl, + this.authServer.getUrl + ); + + await this.mcpServer.start(mcpApp); + + // Generate an authorization grant for the client to use + const authorizationGrant = await new jose.SignJWT({ + sub: DEMO_USER_ID, + grant_type: 'authorization_grant' + }) + .setProtectedHeader({ alg: 'ES256' }) + .setIssuer(this.authServer.getUrl()) + .setAudience(this.authServer.getUrl()) + .setIssuedAt() + .setExpirationTime('5m') + .sign(this.grantPrivateKey!); + + return { + serverUrl: `${this.mcpServer.getUrl()}/mcp`, + context: { + client_id: CONFORMANCE_TEST_CLIENT_ID, + authorization_grant: authorizationGrant, + auth_server_url: this.authServer.getUrl() + } + }; + } + + async stop() { + await this.authServer.stop(); + await this.mcpServer.stop(); + } + + getChecks(): ConformanceCheck[] { + // Ensure we have the JWT bearer check + const hasJwtBearerCheck = this.checks.some( + (c) => c.id === 'jwt-bearer-assertion-verified' + ); + if (!hasJwtBearerCheck) { + this.checks.push({ + id: 'jwt-bearer-assertion-verified', + name: 'JwtBearerAssertionVerified', + 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; + } +} + +/** + * Scenario: Complete Cross-App Access Flow + * + * 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 CrossAppAccessCompleteFlowScenario implements Scenario { + name = 'auth/cross-app-access-complete-flow'; + description = + 'Tests complete SEP-990 flow: token exchange + JWT bearer grant (Enterprise Managed OAuth)'; + + private idpServer = new ServerLifecycle(); + private authServer = new ServerLifecycle(); + private mcpServer = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private idpPublicKey?: CryptoKey; + private idpPrivateKey?: CryptoKey; + private grantKeypairs: Map = new Map(); + + async start(): Promise { + this.checks = []; + + // Generate IDP keypair + const { publicKey, privateKey } = await generateIdpKeypair(); + this.idpPublicKey = publicKey; + this.idpPrivateKey = privateKey; + + // Start IDP server + await this.startIdpServer(); + + // Start auth server with both token exchange and JWT bearer grant support + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + grantTypesSupported: [ + 'urn:ietf:params:oauth:grant-type:token-exchange', + 'urn:ietf:params:oauth:grant-type:jwt-bearer' + ], + tokenEndpointAuthMethodsSupported: ['none'], + onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => { + // Handle token exchange (IDP ID token -> authorization grant) + if (grantType === 'urn:ietf:params:oauth:grant-type:token-exchange') { + return await this.handleTokenExchange(body, timestamp); + } + + // Handle JWT bearer grant (authorization grant -> access token) + if (grantType === 'urn:ietf:params:oauth:grant-type:jwt-bearer') { + return await this.handleJwtBearerGrant( + body, + timestamp, + authBaseUrl + ); + } + + return { + error: 'unsupported_grant_type', + errorDescription: `Unsupported grant type: ${grantType}` + }; + } + }); + + await this.authServer.start(authApp); + + // Start MCP server + const mcpApp = createServer( + this.checks, + this.mcpServer.getUrl, + this.authServer.getUrl + ); + + await this.mcpServer.start(mcpApp); + + // Generate IDP ID token for client + const idpIdToken = await createIdpIdToken( + this.idpPrivateKey!, + this.idpServer.getUrl(), + this.authServer.getUrl() + ); + + return { + serverUrl: `${this.mcpServer.getUrl()}/mcp`, + context: { + client_id: CONFORMANCE_TEST_CLIENT_ID, + idp_id_token: idpIdToken, + idp_issuer: this.idpServer.getUrl(), + auth_server_url: this.authServer.getUrl() + } + }; + } + + private async startIdpServer(): Promise { + const app = express(); + app.use(express.json()); + + 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` + }); + }); + + await this.idpServer.start(app); + } + + private async handleTokenExchange( + body: Record, + timestamp: string + ): Promise { + const subjectToken = body.subject_token; + const subjectTokenType = body.subject_token_type; + + if ( + !subjectToken || + subjectTokenType !== 'urn:ietf:params:oauth:token-type:id_token' + ) { + this.checks.push({ + id: 'complete-flow-token-exchange', + name: 'CompleteFlowTokenExchange', + description: 'Invalid token exchange request', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE] + }); + return { + error: 'invalid_request', + errorDescription: 'Invalid subject_token' + }; + } + + try { + const { payload } = await jose.jwtVerify( + subjectToken, + this.idpPublicKey!, + { + audience: this.authServer.getUrl(), + issuer: this.idpServer.getUrl() + } + ); + + this.checks.push({ + id: 'complete-flow-token-exchange', + name: 'CompleteFlowTokenExchange', + description: 'Successfully exchanged IDP ID token for authorization grant', + status: 'SUCCESS', + timestamp, + specReferences: [ + SpecReferences.RFC_8693_TOKEN_EXCHANGE, + SpecReferences.SEP_990_ENTERPRISE_OAUTH + ] + }); + + // Create authorization grant + const userId = payload.sub as string; + const { publicKey, privateKey } = await jose.generateKeyPair('ES256'); + this.grantKeypairs.set(userId, publicKey); + + const authorizationGrant = await new jose.SignJWT({ + sub: userId, + grant_type: 'authorization_grant' + }) + .setProtectedHeader({ alg: 'ES256' }) + .setIssuer(this.authServer.getUrl()) + .setAudience(this.authServer.getUrl()) + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey); + + return { + token: authorizationGrant, + scopes: [], + additionalFields: { + issued_token_type: + 'urn:ietf:params:oauth:token-type:authorization_grant', + token_type: 'N_A' + } + }; + } catch (e) { + this.checks.push({ + id: 'complete-flow-token-exchange', + name: 'CompleteFlowTokenExchange', + description: `Token exchange failed: ${e}`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE] + }); + return { + error: 'invalid_grant', + errorDescription: 'Invalid ID token' + }; + } + } + + private async handleJwtBearerGrant( + body: Record, + timestamp: string, + authBaseUrl: string + ): Promise { + 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 { + // Decode without verification first to get subject + 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 with the stored public key + const withoutSlash = authBaseUrl.replace(/\/+$/, ''); + const withSlash = `${withoutSlash}/`; + + await jose.jwtVerify(assertion, publicKey, { + audience: [withoutSlash, withSlash], + clockTolerance: 30 + }); + + this.checks.push({ + id: 'complete-flow-jwt-bearer', + name: 'CompleteFlowJwtBearer', + description: + 'Successfully exchanged authorization grant 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; + } +} diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 7f75113..b0079c8 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -23,6 +23,11 @@ import { } from './client-credentials'; import { ResourceMismatchScenario } from './resource-mismatch'; import { PreRegistrationScenario } from './pre-registration'; +import { + CrossAppAccessTokenExchangeScenario, + CrossAppAccessJwtBearerScenario, + CrossAppAccessCompleteFlowScenario +} from './cross-app-access'; // Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ @@ -49,5 +54,8 @@ export const backcompatScenariosList: Scenario[] = [ // Extension scenarios (optional for tier 1 - protocol extensions) export const extensionScenariosList: Scenario[] = [ new ClientCredentialsJwtScenario(), - new ClientCredentialsBasicScenario() + new ClientCredentialsBasicScenario(), + new CrossAppAccessTokenExchangeScenario(), + new CrossAppAccessJwtBearerScenario(), + new CrossAppAccessCompleteFlowScenario() ]; diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index a24e987..4020bfc 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -88,5 +88,17 @@ export const SpecReferences: { [key: string]: SpecReference } = { MCP_PKCE: { id: 'MCP-PKCE-requirement', url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection' + }, + RFC_8693_TOKEN_EXCHANGE: { + id: 'RFC-8693-Token-Exchange', + url: 'https://datatracker.ietf.org/doc/html/rfc8693' + }, + RFC_7523_JWT_BEARER: { + id: 'RFC-7523-JWT-Bearer-Grant', + url: 'https://datatracker.ietf.org/doc/html/rfc7523' + }, + SEP_990_ENTERPRISE_OAUTH: { + id: 'SEP-990-Enterprise-Managed-OAuth', + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-oauth.mdx' } }; diff --git a/src/schemas/context.ts b/src/schemas/context.ts index 2a8a907..87111b1 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -22,6 +22,26 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ name: z.literal('auth/pre-registration'), client_id: z.string(), client_secret: z.string() + }), + z.object({ + name: z.literal('auth/cross-app-access-token-exchange'), + client_id: z.string(), + idp_id_token: z.string(), + idp_issuer: z.string(), + auth_server_url: z.string() + }), + z.object({ + name: z.literal('auth/cross-app-access-jwt-bearer'), + client_id: z.string(), + authorization_grant: z.string(), + auth_server_url: z.string() + }), + z.object({ + name: z.literal('auth/cross-app-access-complete-flow'), + client_id: z.string(), + idp_id_token: z.string(), + idp_issuer: z.string(), + auth_server_url: z.string() }) ]); From 64658c6ee57dcff3fbc305643215b2c7d236312d Mon Sep 17 00:00:00 2001 From: Sagar Viswanatha Date: Fri, 30 Jan 2026 17:52:11 +0530 Subject: [PATCH 2/7] Resolving review changes: Removed redundant tests, updated audience params --- .../clients/typescript/everything-client.ts | 18 +- src/scenarios/client/auth/cross-app-access.ts | 640 ++++-------------- src/scenarios/client/auth/index.ts | 8 +- src/schemas/context.ts | 2 + 4 files changed, 128 insertions(+), 540 deletions(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index d6d07a8..1977c36 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -503,10 +503,11 @@ export async function runCrossAppAccessCompleteFlow( logger.debug('Starting complete cross-app access flow...'); logger.debug('IDP Issuer:', ctx.idp_issuer); + logger.debug('IDP Token Endpoint:', ctx.idp_token_endpoint); logger.debug('Auth Server:', ctx.auth_server_url); - // Step 1: Token Exchange (IDP ID token -> authorization grant) - logger.debug('Step 1: Exchanging IDP ID token for authorization grant...'); + // Step 1: Token Exchange (IDP ID token -> ID-JAG) + logger.debug('Step 1: Exchanging IDP ID token for ID-JAG at IdP...'); const tokenExchangeParams = new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', subject_token: ctx.idp_id_token, @@ -514,7 +515,7 @@ export async function runCrossAppAccessCompleteFlow( client_id: ctx.client_id }); - const tokenExchangeResponse = await fetch(`${ctx.auth_server_url}/token`, { + const tokenExchangeResponse = await fetch(ctx.idp_token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: tokenExchangeParams @@ -526,14 +527,15 @@ export async function runCrossAppAccessCompleteFlow( } const tokenExchangeResult = await tokenExchangeResponse.json(); - const authorizationGrant = tokenExchangeResult.access_token; - logger.debug('Token exchange successful, authorization grant obtained'); + const idJag = tokenExchangeResult.access_token; // ID-JAG (ID-bound JSON Assertion Grant) + logger.debug('Token exchange successful, ID-JAG obtained'); + logger.debug('Issued token type:', tokenExchangeResult.issued_token_type); - // Step 2: JWT Bearer Grant (authorization grant -> access token) - logger.debug('Step 2: Exchanging authorization grant for access token...'); + // Step 2: JWT Bearer Grant (ID-JAG -> access token) + logger.debug('Step 2: Exchanging ID-JAG for access token at Auth Server...'); const jwtBearerParams = new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', - assertion: authorizationGrant, + assertion: idJag, client_id: ctx.client_id }); diff --git a/src/scenarios/client/auth/cross-app-access.ts b/src/scenarios/client/auth/cross-app-access.ts index 4809b3f..a437b53 100644 --- a/src/scenarios/client/auth/cross-app-access.ts +++ b/src/scenarios/client/auth/cross-app-access.ts @@ -8,6 +8,7 @@ import { ServerLifecycle } from './helpers/serverLifecycle'; import { SpecReferences } from './spec-references'; const CONFORMANCE_TEST_CLIENT_ID = 'conformance-test-xaa-client'; +const IDP_CLIENT_ID = 'conformance-test-idp-client'; const DEMO_USER_ID = 'demo-user@example.com'; /** @@ -44,433 +45,6 @@ async function createIdpIdToken( .sign(privateKey); } -/** - * Scenario: Token Exchange Flow (RFC 8693) - * - * Tests that the client can exchange an IDP ID token for an authorization grant - * using RFC 8693 token exchange, and then exchange that grant for an access token - * using RFC 7523 JWT Bearer grant. - */ -export class CrossAppAccessTokenExchangeScenario implements Scenario { - name = 'auth/cross-app-access-token-exchange'; - description = - 'Tests RFC 8693 token exchange flow for converting IDP ID token to authorization grant (SEP-990)'; - - private idpServer = new ServerLifecycle(); - private authServer = new ServerLifecycle(); - private mcpServer = new ServerLifecycle(); - private checks: ConformanceCheck[] = []; - private idpPublicKey?: CryptoKey; - private idpPrivateKey?: CryptoKey; - - async start(): Promise { - this.checks = []; - - // Generate IDP keypair for signing ID tokens - const { publicKey, privateKey } = await generateIdpKeypair(); - this.idpPublicKey = publicKey; - this.idpPrivateKey = privateKey; - - // Start IDP server (simulates enterprise identity provider) - await this.startIdpServer(); - - // Start MCP authorization server with token exchange support - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { - grantTypesSupported: ['urn:ietf:params:oauth:grant-type:token-exchange'], - tokenEndpointAuthMethodsSupported: ['none'], - onTokenRequest: async ({ grantType, body, timestamp }) => { - if (grantType !== 'urn:ietf:params:oauth:grant-type:token-exchange') { - this.checks.push({ - id: 'token-exchange-grant-type', - name: 'TokenExchangeGrantType', - description: `Expected grant_type=urn:ietf:params:oauth:grant-type:token-exchange, got ${grantType}`, - status: 'FAILURE', - timestamp, - specReferences: [ - SpecReferences.RFC_8693_TOKEN_EXCHANGE, - SpecReferences.SEP_990_ENTERPRISE_OAUTH - ] - }); - return { - error: 'unsupported_grant_type', - errorDescription: 'Only token exchange grant is supported' - }; - } - - // Verify subject_token (IDP ID token) - const subjectToken = body.subject_token; - const subjectTokenType = body.subject_token_type; - - if ( - !subjectToken || - subjectTokenType !== 'urn:ietf:params:oauth:token-type:id_token' - ) { - this.checks.push({ - id: 'token-exchange-subject-token', - name: 'TokenExchangeSubjectToken', - description: 'Missing or invalid subject_token or subject_token_type', - status: 'FAILURE', - timestamp, - specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE], - details: { - hasSubjectToken: !!subjectToken, - subjectTokenType: subjectTokenType || 'missing' - } - }); - return { - error: 'invalid_request', - errorDescription: 'Invalid subject_token', - statusCode: 400 - }; - } - - // Verify the ID token signature - try { - const { payload } = await jose.jwtVerify( - subjectToken, - this.idpPublicKey!, - { - audience: this.authServer.getUrl(), - issuer: this.idpServer.getUrl() - } - ); - - this.checks.push({ - id: 'token-exchange-id-token-verified', - name: 'TokenExchangeIdTokenVerified', - description: - 'Successfully verified IDP ID token signature and claims', - status: 'SUCCESS', - timestamp, - specReferences: [ - SpecReferences.RFC_8693_TOKEN_EXCHANGE, - SpecReferences.SEP_990_ENTERPRISE_OAUTH - ], - details: { - sub: payload.sub, - iss: payload.iss, - aud: payload.aud - } - }); - - // Return authorization grant token - const authorizationGrant = await this.createAuthorizationGrant( - payload.sub as string - ); - - return { - token: authorizationGrant, - scopes: [], - // RFC 8693 response format - additionalFields: { - issued_token_type: - 'urn:ietf:params:oauth:token-type:authorization_grant', - token_type: 'N_A' - } - }; - } catch (e) { - const errorMessage = e instanceof Error ? e.message : String(e); - this.checks.push({ - id: 'token-exchange-id-token-verified', - name: 'TokenExchangeIdTokenVerified', - description: `ID token verification failed: ${errorMessage}`, - status: 'FAILURE', - timestamp, - specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE], - details: { error: errorMessage } - }); - return { - error: 'invalid_grant', - errorDescription: `ID token verification failed: ${errorMessage}`, - statusCode: 400 - }; - } - } - }); - - await this.authServer.start(authApp); - - // Start MCP resource server - const mcpApp = createServer( - this.checks, - this.mcpServer.getUrl, - this.authServer.getUrl - ); - - await this.mcpServer.start(mcpApp); - - // Generate an ID token for the client to use - const idpIdToken = await createIdpIdToken( - this.idpPrivateKey!, - this.idpServer.getUrl(), - this.authServer.getUrl() - ); - - return { - serverUrl: `${this.mcpServer.getUrl()}/mcp`, - context: { - client_id: CONFORMANCE_TEST_CLIENT_ID, - idp_id_token: idpIdToken, - idp_issuer: this.idpServer.getUrl(), - auth_server_url: this.authServer.getUrl() - } - }; - } - - private async startIdpServer(): Promise { - const app = express(); - app.use(express.json()); - - // IDP metadata endpoint - app.get('/.well-known/openid-configuration', (req: Request, res: Response) => { - this.checks.push({ - id: 'idp-metadata-discovery', - name: 'IdpMetadataDiscovery', - description: 'Client discovered IDP metadata', - status: 'INFO', - timestamp: new Date().toISOString(), - specReferences: [SpecReferences.SEP_990_ENTERPRISE_OAUTH] - }); - - 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` - }); - }); - - await this.idpServer.start(app); - } - - private async createAuthorizationGrant(userId: string): Promise { - // Create a simple JWT as authorization grant (in real implementation, this would be opaque) - const { publicKey, privateKey } = await jose.generateKeyPair('ES256'); - return await new jose.SignJWT({ - sub: userId, - grant_type: 'authorization_grant' - }) - .setProtectedHeader({ alg: 'ES256' }) - .setIssuer(this.authServer.getUrl()) - .setIssuedAt() - .setExpirationTime('5m') - .sign(privateKey); - } - - async stop() { - await this.idpServer.stop(); - await this.authServer.stop(); - await this.mcpServer.stop(); - } - - getChecks(): ConformanceCheck[] { - // Ensure we have the ID token verification check - const hasIdTokenCheck = this.checks.some( - (c) => c.id === 'token-exchange-id-token-verified' - ); - if (!hasIdTokenCheck) { - this.checks.push({ - id: 'token-exchange-id-token-verified', - name: 'TokenExchangeIdTokenVerified', - description: 'Client did not perform token exchange', - status: 'FAILURE', - timestamp: new Date().toISOString(), - specReferences: [ - SpecReferences.RFC_8693_TOKEN_EXCHANGE, - SpecReferences.SEP_990_ENTERPRISE_OAUTH - ] - }); - } - - return this.checks; - } -} - -/** - * Scenario: JWT Bearer Grant Flow (RFC 7523) - * - * Tests that the client can exchange an authorization grant for an access token - * using RFC 7523 JWT Bearer grant. - */ -export class CrossAppAccessJwtBearerScenario implements Scenario { - name = 'auth/cross-app-access-jwt-bearer'; - description = - 'Tests RFC 7523 JWT Bearer grant flow for exchanging authorization grant for access token (SEP-990)'; - - private authServer = new ServerLifecycle(); - private mcpServer = new ServerLifecycle(); - private checks: ConformanceCheck[] = []; - private grantPublicKey?: CryptoKey; - private grantPrivateKey?: CryptoKey; - - async start(): Promise { - this.checks = []; - - // Generate keypair for authorization grant - const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { - extractable: true - }); - this.grantPublicKey = publicKey; - this.grantPrivateKey = privateKey; - - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { - grantTypesSupported: [ - 'urn:ietf:params:oauth:grant-type:jwt-bearer', - 'client_credentials' - ], - tokenEndpointAuthMethodsSupported: ['none'], - onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => { - if (grantType !== 'urn:ietf:params:oauth:grant-type:jwt-bearer') { - this.checks.push({ - id: 'jwt-bearer-grant-type', - name: 'JwtBearerGrantType', - description: `Expected grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, got ${grantType}`, - status: 'FAILURE', - timestamp, - specReferences: [ - SpecReferences.RFC_7523_JWT_BEARER, - SpecReferences.SEP_990_ENTERPRISE_OAUTH - ] - }); - return { - error: 'unsupported_grant_type', - errorDescription: 'Only JWT bearer grant is supported' - }; - } - - // Verify assertion - const assertion = body.assertion; - if (!assertion) { - this.checks.push({ - id: 'jwt-bearer-assertion', - name: 'JwtBearerAssertion', - description: 'Missing assertion parameter', - status: 'FAILURE', - timestamp, - specReferences: [SpecReferences.RFC_7523_JWT_BEARER] - }); - return { - error: 'invalid_request', - errorDescription: 'Missing assertion', - statusCode: 400 - }; - } - - // Verify JWT assertion (authorization grant) - try { - // Accept both with and without trailing slash for audience - const withoutSlash = authBaseUrl.replace(/\/+$/, ''); - const withSlash = `${withoutSlash}/`; - - const { payload } = await jose.jwtVerify(assertion, this.grantPublicKey!, { - audience: [withoutSlash, withSlash], - clockTolerance: 30 - }); - - this.checks.push({ - id: 'jwt-bearer-assertion-verified', - name: 'JwtBearerAssertionVerified', - description: - 'Successfully verified authorization grant JWT assertion', - status: 'SUCCESS', - timestamp, - specReferences: [ - SpecReferences.RFC_7523_JWT_BEARER, - SpecReferences.SEP_990_ENTERPRISE_OAUTH - ], - details: { - sub: payload.sub, - iss: payload.iss, - aud: payload.aud - } - }); - - // Return access token - const scopes = body.scope ? body.scope.split(' ') : []; - return { - token: `test-token-${Date.now()}`, - scopes - }; - } catch (e) { - const errorMessage = e instanceof Error ? e.message : String(e); - this.checks.push({ - id: 'jwt-bearer-assertion-verified', - name: 'JwtBearerAssertionVerified', - description: `JWT assertion verification failed: ${errorMessage}`, - status: 'FAILURE', - timestamp, - specReferences: [SpecReferences.RFC_7523_JWT_BEARER], - details: { error: errorMessage } - }); - return { - error: 'invalid_grant', - errorDescription: `JWT assertion verification failed: ${errorMessage}`, - statusCode: 400 - }; - } - } - }); - - await this.authServer.start(authApp); - - const mcpApp = createServer( - this.checks, - this.mcpServer.getUrl, - this.authServer.getUrl - ); - - await this.mcpServer.start(mcpApp); - - // Generate an authorization grant for the client to use - const authorizationGrant = await new jose.SignJWT({ - sub: DEMO_USER_ID, - grant_type: 'authorization_grant' - }) - .setProtectedHeader({ alg: 'ES256' }) - .setIssuer(this.authServer.getUrl()) - .setAudience(this.authServer.getUrl()) - .setIssuedAt() - .setExpirationTime('5m') - .sign(this.grantPrivateKey!); - - return { - serverUrl: `${this.mcpServer.getUrl()}/mcp`, - context: { - client_id: CONFORMANCE_TEST_CLIENT_ID, - authorization_grant: authorizationGrant, - auth_server_url: this.authServer.getUrl() - } - }; - } - - async stop() { - await this.authServer.stop(); - await this.mcpServer.stop(); - } - - getChecks(): ConformanceCheck[] { - // Ensure we have the JWT bearer check - const hasJwtBearerCheck = this.checks.some( - (c) => c.id === 'jwt-bearer-assertion-verified' - ); - if (!hasJwtBearerCheck) { - this.checks.push({ - id: 'jwt-bearer-assertion-verified', - name: 'JwtBearerAssertionVerified', - 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; - } -} - /** * Scenario: Complete Cross-App Access Flow * @@ -501,20 +75,15 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { // Start IDP server await this.startIdpServer(); - // Start auth server with both token exchange and JWT bearer grant support + // 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:token-exchange', 'urn:ietf:params:oauth:grant-type:jwt-bearer' ], - tokenEndpointAuthMethodsSupported: ['none'], + tokenEndpointAuthMethodsSupported: ['client_secret_basic', 'private_key_jwt'], onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => { - // Handle token exchange (IDP ID token -> authorization grant) - if (grantType === 'urn:ietf:params:oauth:grant-type:token-exchange') { - return await this.handleTokenExchange(body, timestamp); - } - - // Handle JWT bearer grant (authorization grant -> access token) + // Auth server only handles JWT bearer grant (ID-JAG -> access token) if (grantType === 'urn:ietf:params:oauth:grant-type:jwt-bearer') { return await this.handleJwtBearerGrant( body, @@ -525,7 +94,7 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { return { error: 'unsupported_grant_type', - errorDescription: `Unsupported grant type: ${grantType}` + errorDescription: `Auth server only supports jwt-bearer grant, got ${grantType}` }; } }); @@ -545,15 +114,17 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { const idpIdToken = await createIdpIdToken( this.idpPrivateKey!, this.idpServer.getUrl(), - this.authServer.getUrl() + IDP_CLIENT_ID ); return { serverUrl: `${this.mcpServer.getUrl()}/mcp`, context: { client_id: CONFORMANCE_TEST_CLIENT_ID, + idp_client_id: IDP_CLIENT_ID, idp_id_token: idpIdToken, idp_issuer: this.idpServer.getUrl(), + idp_token_endpoint: `${this.idpServer.getUrl()}/token`, auth_server_url: this.authServer.getUrl() } }; @@ -562,105 +133,124 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { private async startIdpServer(): Promise { 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` + jwks_uri: `${this.idpServer.getUrl()}/.well-known/jwks.json`, + grant_types_supported: ['urn:ietf:params:oauth:grant-type:token-exchange'] }); }); - await this.idpServer.start(app); - } - - private async handleTokenExchange( - body: Record, - timestamp: string - ): Promise { - const subjectToken = body.subject_token; - const subjectTokenType = body.subject_token_type; - - if ( - !subjectToken || - subjectTokenType !== 'urn:ietf:params:oauth:token-type:id_token' - ) { - this.checks.push({ - id: 'complete-flow-token-exchange', - name: 'CompleteFlowTokenExchange', - description: 'Invalid token exchange request', - status: 'FAILURE', - timestamp, - specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE] - }); - return { - error: 'invalid_request', - errorDescription: 'Invalid subject_token' - }; - } - - try { - const { payload } = await jose.jwtVerify( - subjectToken, - this.idpPublicKey!, - { - audience: this.authServer.getUrl(), - issuer: this.idpServer.getUrl() - } - ); - - this.checks.push({ - id: 'complete-flow-token-exchange', - name: 'CompleteFlowTokenExchange', - description: 'Successfully exchanged IDP ID token for authorization grant', - status: 'SUCCESS', - timestamp, - specReferences: [ - SpecReferences.RFC_8693_TOKEN_EXCHANGE, - SpecReferences.SEP_990_ENTERPRISE_OAUTH - ] - }); + // 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; + + // 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; + } - // Create authorization grant - const userId = payload.sub as string; - const { publicKey, privateKey } = await jose.generateKeyPair('ES256'); - this.grantKeypairs.set(userId, publicKey); - - const authorizationGrant = await new jose.SignJWT({ - sub: userId, - grant_type: 'authorization_grant' - }) - .setProtectedHeader({ alg: 'ES256' }) - .setIssuer(this.authServer.getUrl()) - .setAudience(this.authServer.getUrl()) - .setIssuedAt() - .setExpirationTime('5m') - .sign(privateKey); + if ( + !subjectToken || + subjectTokenType !== 'urn:ietf:params:oauth:token-type:id_token' + ) { + this.checks.push({ + id: 'complete-flow-token-exchange', + name: 'CompleteFlowTokenExchange', + description: 'Invalid subject_token or subject_token_type', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE] + }); + res.status(400).json({ + error: 'invalid_request', + error_description: 'Invalid subject_token' + }); + return; + } - return { - token: authorizationGrant, - scopes: [], - additionalFields: { - issued_token_type: - 'urn:ietf:params:oauth:token-type:authorization_grant', + 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', + status: 'SUCCESS', + timestamp, + specReferences: [ + SpecReferences.RFC_8693_TOKEN_EXCHANGE, + SpecReferences.SEP_990_ENTERPRISE_OAUTH + ] + }); + + // Create ID-JAG (ID-bound JSON Assertion Grant) + const userId = payload.sub as string; + const { publicKey, privateKey } = await jose.generateKeyPair('ES256'); + this.grantKeypairs.set(userId, publicKey); + + const idJag = await new jose.SignJWT({ + sub: userId, + grant_type: 'id-jag' + }) + .setProtectedHeader({ alg: 'ES256', typ: 'oauth-id-jag+jwt' }) + .setIssuer(this.idpServer.getUrl()) + .setAudience(this.authServer.getUrl()) + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey); + + res.json({ + access_token: idJag, + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', token_type: 'N_A' - } - }; - } catch (e) { - this.checks.push({ - id: 'complete-flow-token-exchange', - name: 'CompleteFlowTokenExchange', - description: `Token exchange failed: ${e}`, - status: 'FAILURE', - timestamp, - specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE] - }); - return { - error: 'invalid_grant', - errorDescription: 'Invalid ID token' - }; - } + }); + } 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( diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index b0079c8..73c9ddb 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -23,11 +23,7 @@ import { } from './client-credentials'; import { ResourceMismatchScenario } from './resource-mismatch'; import { PreRegistrationScenario } from './pre-registration'; -import { - CrossAppAccessTokenExchangeScenario, - CrossAppAccessJwtBearerScenario, - CrossAppAccessCompleteFlowScenario -} from './cross-app-access'; +import { CrossAppAccessCompleteFlowScenario } from './cross-app-access'; // Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ @@ -55,7 +51,5 @@ export const backcompatScenariosList: Scenario[] = [ export const extensionScenariosList: Scenario[] = [ new ClientCredentialsJwtScenario(), new ClientCredentialsBasicScenario(), - new CrossAppAccessTokenExchangeScenario(), - new CrossAppAccessJwtBearerScenario(), new CrossAppAccessCompleteFlowScenario() ]; diff --git a/src/schemas/context.ts b/src/schemas/context.ts index 87111b1..73475cd 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -39,8 +39,10 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ z.object({ name: z.literal('auth/cross-app-access-complete-flow'), client_id: z.string(), + idp_client_id: z.string(), idp_id_token: z.string(), idp_issuer: z.string(), + idp_token_endpoint: z.string(), auth_server_url: z.string() }) ]); From f52aec6ab659105ff9d43fe5928c2f211cd2a490 Mon Sep 17 00:00:00 2001 From: Sagar Viswanatha Date: Fri, 30 Jan 2026 17:55:24 +0530 Subject: [PATCH 3/7] fix: unused serverUrl parameter in runCrossAppAccessTokenExchange --- examples/clients/typescript/everything-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 1977c36..d3db002 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -376,7 +376,7 @@ registerScenario('auth/pre-registration', runPreRegistration); * Tests the first step of SEP-990 where IDP ID token is exchanged for authorization grant. */ export async function runCrossAppAccessTokenExchange( - serverUrl: string + _serverUrl: string ): Promise { const ctx = parseContext(); if (ctx.name !== 'auth/cross-app-access-token-exchange') { From ec2e5aba781f6122067c248dc5dfdcc5d31d9050 Mon Sep 17 00:00:00 2001 From: Sagar Viswanatha Date: Fri, 30 Jan 2026 17:57:33 +0530 Subject: [PATCH 4/7] chore: apply prettier formatting --- .../clients/typescript/everything-client.ts | 9 +++-- src/scenarios/client/auth/cross-app-access.ts | 36 ++++++++++--------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index d3db002..db0e3d8 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -432,7 +432,9 @@ export async function runCrossAppAccessJwtBearer( ): Promise { const ctx = parseContext(); if (ctx.name !== 'auth/cross-app-access-jwt-bearer') { - throw new Error(`Expected cross-app-access-jwt-bearer context, got ${ctx.name}`); + throw new Error( + `Expected cross-app-access-jwt-bearer context, got ${ctx.name}` + ); } logger.debug('Starting JWT bearer grant flow...'); @@ -485,7 +487,10 @@ export async function runCrossAppAccessJwtBearer( logger.debug('Connection closed successfully'); } -registerScenario('auth/cross-app-access-jwt-bearer', runCrossAppAccessJwtBearer); +registerScenario( + 'auth/cross-app-access-jwt-bearer', + runCrossAppAccessJwtBearer +); /** * Cross-app access: Complete Flow (SEP-990) diff --git a/src/scenarios/client/auth/cross-app-access.ts b/src/scenarios/client/auth/cross-app-access.ts index a437b53..f00644b 100644 --- a/src/scenarios/client/auth/cross-app-access.ts +++ b/src/scenarios/client/auth/cross-app-access.ts @@ -78,18 +78,15 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { // 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' + grantTypesSupported: ['urn:ietf:params:oauth:grant-type:jwt-bearer'], + tokenEndpointAuthMethodsSupported: [ + 'client_secret_basic', + 'private_key_jwt' ], - tokenEndpointAuthMethodsSupported: ['client_secret_basic', 'private_key_jwt'], onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => { // Auth server only handles JWT bearer grant (ID-JAG -> access token) if (grantType === 'urn:ietf:params:oauth:grant-type:jwt-bearer') { - return await this.handleJwtBearerGrant( - body, - timestamp, - authBaseUrl - ); + return await this.handleJwtBearerGrant(body, timestamp, authBaseUrl); } return { @@ -136,15 +133,20 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { 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'] - }); - }); + 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) => { From daf77b88277a168add2fa0ac4b291298e4b21255 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 13 Feb 2026 09:45:10 +0000 Subject: [PATCH 5/7] fix: address PR review comments for SEP-990 conformance tests - Delete unused separate token-exchange and jwt-bearer scenarios, keeping only the complete e2e flow (review comment) - Add missing required token exchange params per SEP-990 spec: requested_token_type, audience, resource (review comment) - Use ctx.idp_client_id for token exchange client_id instead of AS client_id (review comment) - Client discovers resource and auth server via PRM metadata instead of receiving auth_server_url via context (review comment) - Server IdP handler verifies all required token exchange params with detailed error messages (review comment) - Add resource, client_id, jti claims to ID-JAG per SEP-990 spec - Verify ID-JAG typ header (oauth-id-jag+jwt) in JWT bearer handler - Remove auth_server_url from context schema --- .../clients/typescript/everything-client.ts | 166 +++++------------- src/scenarios/client/auth/cross-app-access.ts | 67 +++++-- src/schemas/context.ts | 16 +- 3 files changed, 94 insertions(+), 155 deletions(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index db0e3d8..e7a6d95 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -371,127 +371,6 @@ registerScenario('auth/pre-registration', runPreRegistration); // Cross-App Access (SEP-990) scenarios // ============================================================================ -/** - * Cross-app access: Token Exchange (RFC 8693) - * Tests the first step of SEP-990 where IDP ID token is exchanged for authorization grant. - */ -export async function runCrossAppAccessTokenExchange( - _serverUrl: string -): Promise { - const ctx = parseContext(); - if (ctx.name !== 'auth/cross-app-access-token-exchange') { - throw new Error( - `Expected cross-app-access-token-exchange context, got ${ctx.name}` - ); - } - - logger.debug('Starting token exchange flow...'); - logger.debug('IDP Issuer:', ctx.idp_issuer); - logger.debug('Auth Server:', ctx.auth_server_url); - - // Step 1: Exchange IDP ID token for authorization grant using RFC 8693 - const tokenExchangeParams = new URLSearchParams({ - grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', - subject_token: ctx.idp_id_token, - subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', - client_id: ctx.client_id - }); - - logger.debug('Performing token exchange...'); - const tokenExchangeResponse = await fetch(`${ctx.auth_server_url}/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: tokenExchangeParams - }); - - if (!tokenExchangeResponse.ok) { - const error = await tokenExchangeResponse.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - const tokenExchangeResult = await tokenExchangeResponse.json(); - logger.debug('Token exchange successful'); - logger.debug('Issued token type:', tokenExchangeResult.issued_token_type); - - // Note: In a real implementation, this authorization grant would be used - // in a subsequent JWT bearer grant flow to get an access token - logger.debug('Token exchange flow completed successfully'); -} - -registerScenario( - 'auth/cross-app-access-token-exchange', - runCrossAppAccessTokenExchange -); - -/** - * Cross-app access: JWT Bearer Grant (RFC 7523) - * Tests the second step of SEP-990 where authorization grant is exchanged for access token. - */ -export async function runCrossAppAccessJwtBearer( - serverUrl: string -): Promise { - const ctx = parseContext(); - if (ctx.name !== 'auth/cross-app-access-jwt-bearer') { - throw new Error( - `Expected cross-app-access-jwt-bearer context, got ${ctx.name}` - ); - } - - logger.debug('Starting JWT bearer grant flow...'); - logger.debug('Auth Server:', ctx.auth_server_url); - - // Exchange authorization grant for access token using RFC 7523 - const jwtBearerParams = new URLSearchParams({ - grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', - assertion: ctx.authorization_grant, - client_id: ctx.client_id - }); - - logger.debug('Performing JWT bearer grant...'); - const tokenResponse = await fetch(`${ctx.auth_server_url}/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: jwtBearerParams - }); - - if (!tokenResponse.ok) { - const error = await tokenResponse.text(); - throw new Error(`JWT bearer grant failed: ${error}`); - } - - const tokenResult = await tokenResponse.json(); - logger.debug('JWT bearer grant successful'); - logger.debug('Access token obtained'); - - // Use the access token to connect to MCP server - const client = new Client( - { name: 'conformance-cross-app-access', version: '1.0.0' }, - { capabilities: {} } - ); - - const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { - requestInit: { - headers: { - Authorization: `Bearer ${tokenResult.access_token}` - } - } - }); - - await client.connect(transport); - logger.debug('Successfully connected to MCP server with access token'); - - await client.listTools(); - logger.debug('Successfully listed tools'); - - await transport.close(); - logger.debug('Connection closed successfully'); -} - -registerScenario( - 'auth/cross-app-access-jwt-bearer', - runCrossAppAccessJwtBearer -); - /** * Cross-app access: Complete Flow (SEP-990) * Tests the complete flow: IDP ID token -> authorization grant -> access token -> MCP access. @@ -509,15 +388,50 @@ export async function runCrossAppAccessCompleteFlow( logger.debug('Starting complete cross-app access flow...'); logger.debug('IDP Issuer:', ctx.idp_issuer); logger.debug('IDP Token Endpoint:', ctx.idp_token_endpoint); - logger.debug('Auth Server:', ctx.auth_server_url); - // Step 1: Token Exchange (IDP ID token -> ID-JAG) + // Step 0: Discover resource and auth server from PRM metadata + logger.debug('Step 0: Discovering resource and auth server via PRM...'); + const prmUrl = new URL( + '/.well-known/oauth-protected-resource/mcp', + serverUrl + ); + const prmResponse = await fetch(prmUrl.toString()); + if (!prmResponse.ok) { + throw new Error(`PRM discovery failed: ${prmResponse.status}`); + } + const prm = await prmResponse.json(); + const resource = prm.resource; + const authServerUrl = prm.authorization_servers[0]; + logger.debug('Discovered resource:', resource); + logger.debug('Discovered auth server:', authServerUrl); + + // Discover auth server metadata to find token endpoint + const asMetadataUrl = new URL( + '/.well-known/oauth-authorization-server', + authServerUrl + ); + const asMetadataResponse = await fetch(asMetadataUrl.toString()); + if (!asMetadataResponse.ok) { + throw new Error( + `Auth server metadata discovery failed: ${asMetadataResponse.status}` + ); + } + const asMetadata = await asMetadataResponse.json(); + const asTokenEndpoint = asMetadata.token_endpoint; + const asIssuer = asMetadata.issuer; + logger.debug('Auth server issuer:', asIssuer); + logger.debug('Auth server token endpoint:', asTokenEndpoint); + + // Step 1: Token Exchange at IdP (IDP ID token -> ID-JAG) logger.debug('Step 1: Exchanging IDP ID token for ID-JAG at IdP...'); 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.client_id + client_id: ctx.idp_client_id }); const tokenExchangeResponse = await fetch(ctx.idp_token_endpoint, { @@ -536,7 +450,7 @@ export async function runCrossAppAccessCompleteFlow( logger.debug('Token exchange successful, ID-JAG obtained'); logger.debug('Issued token type:', tokenExchangeResult.issued_token_type); - // Step 2: JWT Bearer Grant (ID-JAG -> access token) + // Step 2: JWT Bearer Grant at AS (ID-JAG -> access token) logger.debug('Step 2: Exchanging ID-JAG for access token at Auth Server...'); const jwtBearerParams = new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', @@ -544,7 +458,7 @@ export async function runCrossAppAccessCompleteFlow( client_id: ctx.client_id }); - const tokenResponse = await fetch(`${ctx.auth_server_url}/token`, { + const tokenResponse = await fetch(asTokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: jwtBearerParams diff --git a/src/scenarios/client/auth/cross-app-access.ts b/src/scenarios/client/auth/cross-app-access.ts index f00644b..bfd7a38 100644 --- a/src/scenarios/client/auth/cross-app-access.ts +++ b/src/scenarios/client/auth/cross-app-access.ts @@ -121,8 +121,7 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { idp_client_id: IDP_CLIENT_ID, idp_id_token: idpIdToken, idp_issuer: this.idpServer.getUrl(), - idp_token_endpoint: `${this.idpServer.getUrl()}/token`, - auth_server_url: this.authServer.getUrl() + idp_token_endpoint: `${this.idpServer.getUrl()}/token` } }; } @@ -154,6 +153,10 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { 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; + const clientId = req.body.client_id; // Only handle token exchange at IdP if (grantType !== 'urn:ietf:params:oauth:grant-type:token-exchange') { @@ -172,21 +175,37 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { return; } - if ( - !subjectToken || - subjectTokenType !== 'urn:ietf:params:oauth:token-type:id_token' - ) { + // 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: 'Invalid subject_token or subject_token_type', + description: `Token exchange missing or invalid required parameters: ${missingParams.join(', ')}`, status: 'FAILURE', timestamp, - specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE] + specReferences: [ + SpecReferences.RFC_8693_TOKEN_EXCHANGE, + SpecReferences.SEP_990_ENTERPRISE_OAUTH + ] }); res.status(400).json({ error: 'invalid_request', - error_description: 'Invalid subject_token' + error_description: `Missing or invalid required parameters: ${missingParams.join(', ')}` }); return; } @@ -205,7 +224,8 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { this.checks.push({ id: 'complete-flow-token-exchange', name: 'CompleteFlowTokenExchange', - description: 'Successfully exchanged IDP ID token for ID-JAG at IdP', + description: + 'Successfully exchanged IDP ID token for ID-JAG at IdP with all required parameters', status: 'SUCCESS', timestamp, specReferences: [ @@ -215,19 +235,22 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { }); // 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); const idJag = await new jose.SignJWT({ sub: userId, - grant_type: 'id-jag' + resource: resource, + client_id: clientId || CONFORMANCE_TEST_CLIENT_ID }) .setProtectedHeader({ alg: 'ES256', typ: 'oauth-id-jag+jwt' }) .setIssuer(this.idpServer.getUrl()) - .setAudience(this.authServer.getUrl()) + .setAudience(audience) .setIssuedAt() .setExpirationTime('5m') + .setJti(crypto.randomUUID()) .sign(privateKey); res.json({ @@ -277,6 +300,23 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { } try { + // 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' + }; + } + // Decode without verification first to get subject const decoded = jose.decodeJwt(assertion); const userId = decoded.sub as string; @@ -298,8 +338,7 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { this.checks.push({ id: 'complete-flow-jwt-bearer', name: 'CompleteFlowJwtBearer', - description: - 'Successfully exchanged authorization grant for access token', + description: 'Successfully exchanged ID-JAG for access token', status: 'SUCCESS', timestamp, specReferences: [ diff --git a/src/schemas/context.ts b/src/schemas/context.ts index 73475cd..ecfcac9 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -23,27 +23,13 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ client_id: z.string(), client_secret: z.string() }), - z.object({ - name: z.literal('auth/cross-app-access-token-exchange'), - client_id: z.string(), - idp_id_token: z.string(), - idp_issuer: z.string(), - auth_server_url: z.string() - }), - z.object({ - name: z.literal('auth/cross-app-access-jwt-bearer'), - client_id: z.string(), - authorization_grant: z.string(), - auth_server_url: z.string() - }), z.object({ name: z.literal('auth/cross-app-access-complete-flow'), client_id: z.string(), idp_client_id: z.string(), idp_id_token: z.string(), idp_issuer: z.string(), - idp_token_endpoint: z.string(), - auth_server_url: z.string() + idp_token_endpoint: z.string() }) ]); From f1778eebdfc4cc3bfea7317d0dfe732d91220e04 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 13 Feb 2026 10:05:40 +0000 Subject: [PATCH 6/7] feat: add client auth and ID-JAG validation to XAA conformance test Server-side (AS) now verifies: - client_secret_basic authentication on JWT bearer grant - ID-JAG typ header is oauth-id-jag+jwt - ID-JAG client_id claim matches the authenticating client (Section 5.1) - ID-JAG resource claim matches the MCP server resource identifier - Client credentials provided via context (client_secret) Server-side (IdP) now: - Sets ID-JAG client_id to the MCP Client's AS client_id (not the IdP client_id), per Section 6.1 Example client now: - Authenticates to AS via client_secret_basic (Authorization: Basic) instead of sending client_id in body - Checks AS metadata grant_types_supported includes jwt-bearer before attempting the flow --- .../clients/typescript/everything-client.ts | 22 ++- src/scenarios/client/auth/cross-app-access.ts | 149 ++++++++++++++++-- src/schemas/context.ts | 1 + 3 files changed, 160 insertions(+), 12 deletions(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index e7a6d95..21804a8 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -422,6 +422,15 @@ export async function runCrossAppAccessCompleteFlow( logger.debug('Auth server issuer:', asIssuer); logger.debug('Auth server token endpoint:', asTokenEndpoint); + // Verify AS supports jwt-bearer grant type + const grantTypes: string[] = asMetadata.grant_types_supported || []; + if (!grantTypes.includes('urn:ietf:params:oauth:grant-type:jwt-bearer')) { + throw new Error( + `Auth server does not support jwt-bearer grant type. Supported: ${grantTypes.join(', ')}` + ); + } + 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...'); const tokenExchangeParams = new URLSearchParams({ @@ -451,16 +460,23 @@ export async function runCrossAppAccessCompleteFlow( logger.debug('Issued token type:', tokenExchangeResult.issued_token_type); // Step 2: JWT Bearer Grant at AS (ID-JAG -> access token) + // Client authenticates via client_secret_basic (RFC 7523 Section 5) logger.debug('Step 2: Exchanging ID-JAG for access token at Auth Server...'); const jwtBearerParams = new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', - assertion: idJag, - client_id: ctx.client_id + assertion: idJag }); + const basicAuth = Buffer.from( + `${encodeURIComponent(ctx.client_id)}:${encodeURIComponent(ctx.client_secret)}` + ).toString('base64'); + const tokenResponse = await fetch(asTokenEndpoint, { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${basicAuth}` + }, body: jwtBearerParams }); diff --git a/src/scenarios/client/auth/cross-app-access.ts b/src/scenarios/client/auth/cross-app-access.ts index bfd7a38..1c93cd8 100644 --- a/src/scenarios/client/auth/cross-app-access.ts +++ b/src/scenarios/client/auth/cross-app-access.ts @@ -8,6 +8,7 @@ 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'; @@ -83,10 +84,23 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { 'client_secret_basic', 'private_key_jwt' ], - onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => { + 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') { - return await this.handleJwtBearerGrant(body, timestamp, authBaseUrl); + const mcpResourceUrl = `${this.mcpServer.getUrl()}/mcp`; + return await this.handleJwtBearerGrant( + body, + timestamp, + authBaseUrl, + authorizationHeader, + mcpResourceUrl + ); } return { @@ -118,6 +132,7 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { 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(), @@ -156,7 +171,6 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { const requestedTokenType = req.body.requested_token_type; const audience = req.body.audience; const resource = req.body.resource; - const clientId = req.body.client_id; // Only handle token exchange at IdP if (grantType !== 'urn:ietf:params:oauth:grant-type:token-exchange') { @@ -240,10 +254,14 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { 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: clientId || CONFORMANCE_TEST_CLIENT_ID + client_id: CONFORMANCE_TEST_CLIENT_ID }) .setProtectedHeader({ alg: 'ES256', typ: 'oauth-id-jag+jwt' }) .setIssuer(this.idpServer.getUrl()) @@ -281,8 +299,77 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { private async handleJwtBearerGrant( body: Record, timestamp: string, - authBaseUrl: string + authBaseUrl: string, + authorizationHeader?: string, + mcpResourceUrl?: string ): Promise { + // 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 ', + 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({ @@ -300,7 +387,7 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { } try { - // Verify the ID-JAG header has the correct typ + // 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({ @@ -317,7 +404,7 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { }; } - // Decode without verification first to get subject + // 4. Decode and verify the ID-JAG const decoded = jose.decodeJwt(assertion); const userId = decoded.sub as string; const publicKey = this.grantKeypairs.get(userId); @@ -326,7 +413,7 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { throw new Error('Unknown authorization grant'); } - // Verify with the stored public key + // Verify signature and audience const withoutSlash = authBaseUrl.replace(/\/+$/, ''); const withSlash = `${withoutSlash}/`; @@ -335,10 +422,54 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { 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 exchanged ID-JAG for access token', + description: + 'Successfully verified client auth, ID-JAG claims, and exchanged for access token', status: 'SUCCESS', timestamp, specReferences: [ diff --git a/src/schemas/context.ts b/src/schemas/context.ts index ecfcac9..9a5e249 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -26,6 +26,7 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ z.object({ name: z.literal('auth/cross-app-access-complete-flow'), client_id: z.string(), + client_secret: z.string(), idp_client_id: z.string(), idp_id_token: z.string(), idp_issuer: z.string(), From 406ee278c10fd3bf22487a063ea1b1d90496fde8 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 13 Feb 2026 12:16:34 +0000 Subject: [PATCH 7/7] fix: share MockTokenVerifier and remove unadvertised auth method - Add shared MockTokenVerifier between AS and MCP server so the MCP server only accepts tokens actually issued by the auth server, matching the pattern used by all other auth scenarios - Remove private_key_jwt from tokenEndpointAuthMethodsSupported since the handler only implements client_secret_basic --- src/scenarios/client/auth/cross-app-access.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/scenarios/client/auth/cross-app-access.ts b/src/scenarios/client/auth/cross-app-access.ts index 1c93cd8..077889d 100644 --- a/src/scenarios/client/auth/cross-app-access.ts +++ b/src/scenarios/client/auth/cross-app-access.ts @@ -4,6 +4,7 @@ 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'; @@ -73,6 +74,10 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { 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(); @@ -80,10 +85,8 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { // 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', - 'private_key_jwt' - ], + tokenEndpointAuthMethodsSupported: ['client_secret_basic'], + tokenVerifier, onTokenRequest: async ({ grantType, body, @@ -112,11 +115,12 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { await this.authServer.start(authApp); - // Start MCP server + // Start MCP server with shared token verifier const mcpApp = createServer( this.checks, this.mcpServer.getUrl, - this.authServer.getUrl + this.authServer.getUrl, + { tokenVerifier } ); await this.mcpServer.start(mcpApp);