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>
This commit is contained in:
sagar-okta 2026-02-13 23:17:54 +05:30 • committed by GitHub
commit 83c446ddf5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -361,15 +361,166 @@ export async function runPreRegistration(serverUrl: string): Promise<void> {
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');
}
registerScenario('auth/pre-registration', runPreRegistration);
// ============================================================================
// Cross-App Access (SEP-990) scenarios
// ============================================================================
/**
* 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<void> {
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('IDP Token Endpoint:', ctx.idp_token_endpoint);
// 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);
// 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({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
requested_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
audience: asIssuer,
resource: resource,
subject_token: ctx.idp_id_token,
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
client_id: ctx.idp_client_id
});
const tokenExchangeResponse = await fetch(ctx.idp_token_endpoint, {
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 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 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
});
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',
Authorization: `Basic ${basicAuth}`
},
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('Complete cross-app access flow completed successfully');
}
registerScenario(
'auth/cross-app-access-complete-flow',
runCrossAppAccessCompleteFlow
);
// ============================================================================
// Main entry point
// ============================================================================