From 3348fd73c1c0d3a9d2cefb49017389fae6b196e5 Mon Sep 17 00:00:00 2001 From: Sagar Viswanatha Date: Wed, 21 Jan 2026 11:03:59 +0530 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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) => {