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:
parent
c82fd65cfa
commit
83c446ddf5
5 changed files with 727 additions and 3 deletions
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
550
src/scenarios/client/auth/cross-app-access.ts
Normal file
550
src/scenarios/client/auth/cross-app-access.ts
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
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 { MockTokenVerifier } from './helpers/mockTokenVerifier';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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<string> {
|
||||
return await new jose.SignJWT({
|
||||
sub: userId,
|
||||
email: userId,
|
||||
aud: audience
|
||||
})
|
||||
.setProtectedHeader({ alg: 'ES256' })
|
||||
.setIssuer(idpIssuer)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('1h')
|
||||
.sign(privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, CryptoKey> = new Map();
|
||||
|
||||
async start(): Promise<ScenarioUrls> {
|
||||
this.checks = [];
|
||||
|
||||
// Generate IDP keypair
|
||||
const { publicKey, privateKey } = await generateIdpKeypair();
|
||||
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();
|
||||
|
||||
// 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'],
|
||||
tokenEndpointAuthMethodsSupported: ['client_secret_basic'],
|
||||
tokenVerifier,
|
||||
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') {
|
||||
const mcpResourceUrl = `${this.mcpServer.getUrl()}/mcp`;
|
||||
return await this.handleJwtBearerGrant(
|
||||
body,
|
||||
timestamp,
|
||||
authBaseUrl,
|
||||
authorizationHeader,
|
||||
mcpResourceUrl
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'unsupported_grant_type',
|
||||
errorDescription: `Auth server only supports jwt-bearer grant, got ${grantType}`
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
await this.authServer.start(authApp);
|
||||
|
||||
// Start MCP server with shared token verifier
|
||||
const mcpApp = createServer(
|
||||
this.checks,
|
||||
this.mcpServer.getUrl,
|
||||
this.authServer.getUrl,
|
||||
{ tokenVerifier }
|
||||
);
|
||||
|
||||
await this.mcpServer.start(mcpApp);
|
||||
|
||||
// Generate IDP ID token for client
|
||||
const idpIdToken = await createIdpIdToken(
|
||||
this.idpPrivateKey!,
|
||||
this.idpServer.getUrl(),
|
||||
IDP_CLIENT_ID
|
||||
);
|
||||
|
||||
return {
|
||||
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(),
|
||||
idp_token_endpoint: `${this.idpServer.getUrl()}/token`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async startIdpServer(): Promise<void> {
|
||||
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`,
|
||||
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) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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: `Token exchange missing or invalid required parameters: ${missingParams.join(', ')}`,
|
||||
status: 'FAILURE',
|
||||
timestamp,
|
||||
specReferences: [
|
||||
SpecReferences.RFC_8693_TOKEN_EXCHANGE,
|
||||
SpecReferences.SEP_990_ENTERPRISE_OAUTH
|
||||
]
|
||||
});
|
||||
res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description: `Missing or invalid required parameters: ${missingParams.join(', ')}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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 with all required parameters',
|
||||
status: 'SUCCESS',
|
||||
timestamp,
|
||||
specReferences: [
|
||||
SpecReferences.RFC_8693_TOKEN_EXCHANGE,
|
||||
SpecReferences.SEP_990_ENTERPRISE_OAUTH
|
||||
]
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
// 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: CONFORMANCE_TEST_CLIENT_ID
|
||||
})
|
||||
.setProtectedHeader({ alg: 'ES256', typ: 'oauth-id-jag+jwt' })
|
||||
.setIssuer(this.idpServer.getUrl())
|
||||
.setAudience(audience)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('5m')
|
||||
.setJti(crypto.randomUUID())
|
||||
.sign(privateKey);
|
||||
|
||||
res.json({
|
||||
access_token: idJag,
|
||||
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
|
||||
token_type: 'N_A'
|
||||
});
|
||||
} 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(
|
||||
body: Record<string, string>,
|
||||
timestamp: string,
|
||||
authBaseUrl: string,
|
||||
authorizationHeader?: string,
|
||||
mcpResourceUrl?: string
|
||||
): Promise<any> {
|
||||
// 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 <base64(client_id:client_secret)>',
|
||||
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({
|
||||
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 {
|
||||
// 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({
|
||||
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'
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Decode and verify the ID-JAG
|
||||
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 signature and audience
|
||||
const withoutSlash = authBaseUrl.replace(/\/+$/, '');
|
||||
const withSlash = `${withoutSlash}/`;
|
||||
|
||||
await jose.jwtVerify(assertion, publicKey, {
|
||||
audience: [withoutSlash, withSlash],
|
||||
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 verified client auth, ID-JAG claims, and exchanged 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from './client-credentials';
|
||||
import { ResourceMismatchScenario } from './resource-mismatch';
|
||||
import { PreRegistrationScenario } from './pre-registration';
|
||||
import { CrossAppAccessCompleteFlowScenario } from './cross-app-access';
|
||||
|
||||
// Auth scenarios (required for tier 1)
|
||||
export const authScenariosList: Scenario[] = [
|
||||
|
|
@ -49,5 +50,6 @@ export const backcompatScenariosList: Scenario[] = [
|
|||
// Extension scenarios (optional for tier 1 - protocol extensions)
|
||||
export const extensionScenariosList: Scenario[] = [
|
||||
new ClientCredentialsJwtScenario(),
|
||||
new ClientCredentialsBasicScenario()
|
||||
new ClientCredentialsBasicScenario(),
|
||||
new CrossAppAccessCompleteFlowScenario()
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,6 +22,15 @@ 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-complete-flow'),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string(),
|
||||
idp_client_id: z.string(),
|
||||
idp_id_token: z.string(),
|
||||
idp_issuer: z.string(),
|
||||
idp_token_endpoint: z.string()
|
||||
})
|
||||
]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue