Skip to content

Commit 83c446d

Browse files
feat: add conformance tests for SEP-990 (#110)
* feat: add conformance tests for SEP-990 * Resolving review changes: Removed redundant tests, updated audience params * fix: unused serverUrl parameter in runCrossAppAccessTokenExchange * chore: apply prettier formatting * 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 * 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 * 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 --------- Co-authored-by: Paul Carleton <paulc@anthropic.com>
1 parent c82fd65 commit 83c446d

File tree

5 files changed

+727
-3
lines changed

5 files changed

+727
-3
lines changed

examples/clients/typescript/everything-client.ts

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,14 +361,165 @@ export async function runPreRegistration(serverUrl: string): Promise<void> {
361361
await client.listTools();
362362
logger.debug('Successfully listed tools');
363363

364+
await transport.close();
365+
logger.debug('Connection closed successfully');
366+
}
367+
368+
registerScenario('auth/pre-registration', runPreRegistration);
369+
370+
// ============================================================================
371+
// Cross-App Access (SEP-990) scenarios
372+
// ============================================================================
373+
374+
/**
375+
* Cross-app access: Complete Flow (SEP-990)
376+
* Tests the complete flow: IDP ID token -> authorization grant -> access token -> MCP access.
377+
*/
378+
export async function runCrossAppAccessCompleteFlow(
379+
serverUrl: string
380+
): Promise<void> {
381+
const ctx = parseContext();
382+
if (ctx.name !== 'auth/cross-app-access-complete-flow') {
383+
throw new Error(
384+
`Expected cross-app-access-complete-flow context, got ${ctx.name}`
385+
);
386+
}
387+
388+
logger.debug('Starting complete cross-app access flow...');
389+
logger.debug('IDP Issuer:', ctx.idp_issuer);
390+
logger.debug('IDP Token Endpoint:', ctx.idp_token_endpoint);
391+
392+
// Step 0: Discover resource and auth server from PRM metadata
393+
logger.debug('Step 0: Discovering resource and auth server via PRM...');
394+
const prmUrl = new URL(
395+
'/.well-known/oauth-protected-resource/mcp',
396+
serverUrl
397+
);
398+
const prmResponse = await fetch(prmUrl.toString());
399+
if (!prmResponse.ok) {
400+
throw new Error(`PRM discovery failed: ${prmResponse.status}`);
401+
}
402+
const prm = await prmResponse.json();
403+
const resource = prm.resource;
404+
const authServerUrl = prm.authorization_servers[0];
405+
logger.debug('Discovered resource:', resource);
406+
logger.debug('Discovered auth server:', authServerUrl);
407+
408+
// Discover auth server metadata to find token endpoint
409+
const asMetadataUrl = new URL(
410+
'/.well-known/oauth-authorization-server',
411+
authServerUrl
412+
);
413+
const asMetadataResponse = await fetch(asMetadataUrl.toString());
414+
if (!asMetadataResponse.ok) {
415+
throw new Error(
416+
`Auth server metadata discovery failed: ${asMetadataResponse.status}`
417+
);
418+
}
419+
const asMetadata = await asMetadataResponse.json();
420+
const asTokenEndpoint = asMetadata.token_endpoint;
421+
const asIssuer = asMetadata.issuer;
422+
logger.debug('Auth server issuer:', asIssuer);
423+
logger.debug('Auth server token endpoint:', asTokenEndpoint);
424+
425+
// Verify AS supports jwt-bearer grant type
426+
const grantTypes: string[] = asMetadata.grant_types_supported || [];
427+
if (!grantTypes.includes('urn:ietf:params:oauth:grant-type:jwt-bearer')) {
428+
throw new Error(
429+
`Auth server does not support jwt-bearer grant type. Supported: ${grantTypes.join(', ')}`
430+
);
431+
}
432+
logger.debug('Auth server supports jwt-bearer grant type');
433+
434+
// Step 1: Token Exchange at IdP (IDP ID token -> ID-JAG)
435+
logger.debug('Step 1: Exchanging IDP ID token for ID-JAG at IdP...');
436+
const tokenExchangeParams = new URLSearchParams({
437+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
438+
requested_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
439+
audience: asIssuer,
440+
resource: resource,
441+
subject_token: ctx.idp_id_token,
442+
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
443+
client_id: ctx.idp_client_id
444+
});
445+
446+
const tokenExchangeResponse = await fetch(ctx.idp_token_endpoint, {
447+
method: 'POST',
448+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
449+
body: tokenExchangeParams
450+
});
451+
452+
if (!tokenExchangeResponse.ok) {
453+
const error = await tokenExchangeResponse.text();
454+
throw new Error(`Token exchange failed: ${error}`);
455+
}
456+
457+
const tokenExchangeResult = await tokenExchangeResponse.json();
458+
const idJag = tokenExchangeResult.access_token; // ID-JAG (ID-bound JSON Assertion Grant)
459+
logger.debug('Token exchange successful, ID-JAG obtained');
460+
logger.debug('Issued token type:', tokenExchangeResult.issued_token_type);
461+
462+
// Step 2: JWT Bearer Grant at AS (ID-JAG -> access token)
463+
// Client authenticates via client_secret_basic (RFC 7523 Section 5)
464+
logger.debug('Step 2: Exchanging ID-JAG for access token at Auth Server...');
465+
const jwtBearerParams = new URLSearchParams({
466+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
467+
assertion: idJag
468+
});
469+
470+
const basicAuth = Buffer.from(
471+
`${encodeURIComponent(ctx.client_id)}:${encodeURIComponent(ctx.client_secret)}`
472+
).toString('base64');
473+
474+
const tokenResponse = await fetch(asTokenEndpoint, {
475+
method: 'POST',
476+
headers: {
477+
'Content-Type': 'application/x-www-form-urlencoded',
478+
Authorization: `Basic ${basicAuth}`
479+
},
480+
body: jwtBearerParams
481+
});
482+
483+
if (!tokenResponse.ok) {
484+
const error = await tokenResponse.text();
485+
throw new Error(`JWT bearer grant failed: ${error}`);
486+
}
487+
488+
const tokenResult = await tokenResponse.json();
489+
logger.debug('JWT bearer grant successful, access token obtained');
490+
491+
// Step 3: Use access token to access MCP server
492+
logger.debug('Step 3: Accessing MCP server with access token...');
493+
const client = new Client(
494+
{ name: 'conformance-cross-app-access', version: '1.0.0' },
495+
{ capabilities: {} }
496+
);
497+
498+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
499+
requestInit: {
500+
headers: {
501+
Authorization: `Bearer ${tokenResult.access_token}`
502+
}
503+
}
504+
});
505+
506+
await client.connect(transport);
507+
logger.debug('Successfully connected to MCP server');
508+
509+
await client.listTools();
510+
logger.debug('Successfully listed tools');
511+
364512
await client.callTool({ name: 'test-tool', arguments: {} });
365513
logger.debug('Successfully called tool');
366514

367515
await transport.close();
368-
logger.debug('Connection closed successfully');
516+
logger.debug('Complete cross-app access flow completed successfully');
369517
}
370518

371-
registerScenario('auth/pre-registration', runPreRegistration);
519+
registerScenario(
520+
'auth/cross-app-access-complete-flow',
521+
runCrossAppAccessCompleteFlow
522+
);
372523

373524
// ============================================================================
374525
// Main entry point

0 commit comments

Comments
 (0)