diff --git a/examples/clients/typescript/auth-test-wif-expired-assertion.ts b/examples/clients/typescript/auth-test-wif-expired-assertion.ts new file mode 100644 index 00000000..f7ee31f6 --- /dev/null +++ b/examples/clients/typescript/auth-test-wif-expired-assertion.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +/** + * Broken WIF client: presents a JWT that is already expired. + * BUG: Uses expired_jwt instead of valid_jwt — server rejects with invalid_grant. + */ + +import { runWifJwtBearerExpiredAssertion } from './everything-client.js'; +import { runAsCli } from './helpers/cliRunner.js'; + +runAsCli( + runWifJwtBearerExpiredAssertion, + import.meta.url, + 'auth-test-wif-expired-assertion ' +); diff --git a/examples/clients/typescript/auth-test-wif-grant-fallback.ts b/examples/clients/typescript/auth-test-wif-grant-fallback.ts new file mode 100644 index 00000000..6a8f0aec --- /dev/null +++ b/examples/clients/typescript/auth-test-wif-grant-fallback.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +/** + * Broken WIF client: falls back to authorization_code after receiving unauthorized_client. + * BUG: switches grant type instead of surfacing the error. + */ + +import { runWifJwtBearerGrantFallback } from './everything-client.js'; +import { runAsCli } from './helpers/cliRunner.js'; + +runAsCli( + runWifJwtBearerGrantFallback, + import.meta.url, + 'auth-test-wif-grant-fallback ' +); diff --git a/examples/clients/typescript/auth-test-wif-no-assertion.ts b/examples/clients/typescript/auth-test-wif-no-assertion.ts new file mode 100644 index 00000000..71b31581 --- /dev/null +++ b/examples/clients/typescript/auth-test-wif-no-assertion.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +/** + * Broken WIF client: omits the assertion parameter from the token request. + * BUG: Does not include assertion in JWT-bearer grant — server rejects with invalid_request. + */ + +import { runWifJwtBearerMissingAssertion } from './everything-client.js'; +import { runAsCli } from './helpers/cliRunner.js'; + +runAsCli( + runWifJwtBearerMissingAssertion, + import.meta.url, + 'auth-test-wif-no-assertion ' +); diff --git a/examples/clients/typescript/auth-test-wif-retry.ts b/examples/clients/typescript/auth-test-wif-retry.ts new file mode 100644 index 00000000..c79e48af --- /dev/null +++ b/examples/clients/typescript/auth-test-wif-retry.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +/** + * Broken WIF client: retries JWT-bearer after receiving unauthorized_client. + * BUG: retries instead of surfacing the error. + */ + +import { runWifJwtBearerRetry } from './everything-client.js'; +import { runAsCli } from './helpers/cliRunner.js'; + +runAsCli( + runWifJwtBearerRetry, + import.meta.url, + 'auth-test-wif-retry ' +); diff --git a/examples/clients/typescript/auth-test-wif-scope-rejected.ts b/examples/clients/typescript/auth-test-wif-scope-rejected.ts new file mode 100644 index 00000000..c0561375 --- /dev/null +++ b/examples/clients/typescript/auth-test-wif-scope-rejected.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +/** + * Broken WIF client: requests a scope the AS does not permit for JWT-bearer grant. + * BUG: Includes 'wif.rejected' in the scope parameter — AS returns invalid_scope. + */ + +import { runWifJwtBearerScopeRejected } from './everything-client.js'; +import { runAsCli } from './helpers/cliRunner.js'; + +runAsCli( + runWifJwtBearerScopeRejected, + import.meta.url, + 'auth-test-wif-scope-rejected ' +); diff --git a/examples/clients/typescript/auth-test-wif-wrong-audience.ts b/examples/clients/typescript/auth-test-wif-wrong-audience.ts new file mode 100644 index 00000000..24949794 --- /dev/null +++ b/examples/clients/typescript/auth-test-wif-wrong-audience.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +/** + * Broken WIF client: presents a JWT with the wrong audience. + * BUG: Uses wrong_audience_jwt instead of valid_jwt — server rejects with invalid_grant. + */ + +import { runWifJwtBearerWrongAudience } from './everything-client.js'; +import { runAsCli } from './helpers/cliRunner.js'; + +runAsCli( + runWifJwtBearerWrongAudience, + import.meta.url, + 'auth-test-wif-wrong-audience ' +); diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index a9cf90cc..c37ed0cd 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -19,6 +19,17 @@ import { ClientCredentialsProvider, PrivateKeyJwtProvider } from '@modelcontextprotocol/sdk/client/auth-extensions.js'; +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { + OAuthClientInformation, + OAuthClientMetadata, + OAuthTokens +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import { + JWT_BEARER_GRANT_TYPE, + WIF_TRIGGER_UNAUTHORIZED_SCOPE, + WIF_REJECTED_SCOPE +} from '../../../src/scenarios/client/auth/helpers/createWorkloadJwt.js'; import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { ClientConformanceContextSchema } from '../../../src/schemas/context.js'; import { @@ -726,6 +737,391 @@ registerScenario( runEnterpriseManagedAuthorization ); +// ============================================================================ +// WIF JWT-bearer scenario +// ============================================================================ + +class WifJwtBearerProvider implements OAuthClientProvider { + private _tokens?: OAuthTokens; + private _clientInfo: OAuthClientInformation; + private readonly _clientMetadata: OAuthClientMetadata; + private hasAttempted = false; + + // Pass null for assertion to deliberately omit it (missing-assertion negative tests). + constructor( + private readonly assertion: string | null, + clientId: string, + private readonly scope?: string + ) { + this._clientInfo = { client_id: clientId }; + this._clientMetadata = { + client_name: 'conformance-wif-jwt-bearer', + redirect_uris: [], + grant_types: [JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method: 'none' + }; + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for JWT-bearer flow'); + } + + saveCodeVerifier(): void {} + + codeVerifier(): string { + throw new Error('codeVerifier is not used for JWT-bearer flow'); + } + + prepareTokenRequest(scope?: string): URLSearchParams { + if (this.hasAttempted) { + throw new Error('JWT-bearer grant must not be retried after failure'); + } + this.hasAttempted = true; + const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE }); + if (this.assertion !== null) params.set('assertion', this.assertion); + const effectiveScope = this.scope ?? scope; + if (effectiveScope) params.set('scope', effectiveScope); + return params; + } +} + +export async function runWifJwtBearer(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/wif-jwt-bearer') { + throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`); + } + + const provider = new WifJwtBearerProvider(ctx.valid_jwt, ctx.client_id); + + const client = new Client( + { name: 'conformance-wif-jwt-bearer', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + logger.debug('Successfully connected with JWT-bearer assertion'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/wif-jwt-bearer', runWifJwtBearer); + +export async function runWifJwtBearerWrongAudience( + serverUrl: string +): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/wif-jwt-bearer') { + throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`); + } + + const provider = new WifJwtBearerProvider( + ctx.wrong_audience_jwt, + ctx.client_id + ); + + const client = new Client( + { name: 'conformance-wif-jwt-bearer-wrong-aud', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + await client.listTools(); + await transport.close(); +} + +export async function runWifJwtBearerMissingAssertion( + serverUrl: string +): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/wif-jwt-bearer') { + throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`); + } + + // BUG: null omits the assertion parameter from the token request + const provider = new WifJwtBearerProvider(null, ctx.client_id); + + const client = new Client( + { name: 'conformance-wif-no-assertion', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + await client.listTools(); + await transport.close(); +} + +export async function runWifJwtBearerExpiredAssertion( + serverUrl: string +): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/wif-jwt-bearer') { + throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`); + } + + const provider = new WifJwtBearerProvider(ctx.expired_jwt, ctx.client_id); + + const client = new Client( + { name: 'conformance-wif-jwt-bearer-expired', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + await client.listTools(); + await transport.close(); +} + +export async function runWifJwtBearerScopeRejected( + serverUrl: string +): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/wif-jwt-bearer') { + throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`); + } + + // BUG: requests a scope the AS does not permit for JWT-bearer grant + const provider = new WifJwtBearerProvider( + ctx.valid_jwt, + ctx.client_id, + WIF_REJECTED_SCOPE + ); + + const client = new Client( + { name: 'conformance-wif-scope-rejected', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + await client.listTools(); + await transport.close(); +} + +// BUG: falls back to authorization_code after receiving unauthorized_client +class WifGrantFallbackProvider implements OAuthClientProvider { + private attemptCount = 0; + private _clientInfo: OAuthClientInformation; + private readonly _clientMetadata: OAuthClientMetadata; + + constructor( + private readonly assertion: string, + clientId: string + ) { + this._clientInfo = { client_id: clientId }; + this._clientMetadata = { + client_name: 'conformance-wif-grant-fallback', + redirect_uris: [], + grant_types: [JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method: 'none' + }; + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return undefined; + } + + saveTokens(): void {} + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for JWT-bearer flow'); + } + + saveCodeVerifier(): void {} + + codeVerifier(): string { + throw new Error('codeVerifier is not used for JWT-bearer flow'); + } + + prepareTokenRequest(_scope?: string): URLSearchParams { + this.attemptCount++; + if (this.attemptCount === 1) { + const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE }); + params.set('assertion', this.assertion); + params.set('scope', WIF_TRIGGER_UNAUTHORIZED_SCOPE); + return params; + } + // BUG: switches to authorization_code instead of surfacing the error + return new URLSearchParams({ + grant_type: 'authorization_code', + code: 'fake-fallback-code' + }); + } +} + +export async function runWifJwtBearerGrantFallback( + serverUrl: string +): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/wif-jwt-bearer') { + throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`); + } + + const provider = new WifGrantFallbackProvider(ctx.valid_jwt, ctx.client_id); + + const client = new Client( + { name: 'conformance-wif-grant-fallback', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + await client.listTools(); + await transport.close(); +} + +// BUG: retries JWT-bearer after receiving unauthorized_client. +// Deliberately omits the hasAttempted guard used by WifJwtBearerProvider so +// the SDK retry reaches the AS and the wif-no-retry check fires. +class WifRetryProvider implements OAuthClientProvider { + private attemptCount = 0; + private _clientInfo: OAuthClientInformation; + private readonly _clientMetadata: OAuthClientMetadata; + + constructor( + private readonly assertion: string, + clientId: string + ) { + this._clientInfo = { client_id: clientId }; + this._clientMetadata = { + client_name: 'conformance-wif-retry', + redirect_uris: [], + grant_types: [JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method: 'none' + }; + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return undefined; + } + + saveTokens(): void {} + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for JWT-bearer flow'); + } + + saveCodeVerifier(): void {} + + codeVerifier(): string { + throw new Error('codeVerifier is not used for JWT-bearer flow'); + } + + prepareTokenRequest(_scope?: string): URLSearchParams { + this.attemptCount++; + const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE }); + params.set('assertion', this.assertion); + if (this.attemptCount === 1) { + // Trigger unauthorized_client on first attempt + params.set('scope', WIF_TRIGGER_UNAUTHORIZED_SCOPE); + } + // BUG: retries JWT-bearer instead of surfacing the error + return params; + } +} + +export async function runWifJwtBearerRetry(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/wif-jwt-bearer') { + throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`); + } + + const provider = new WifRetryProvider(ctx.valid_jwt, ctx.client_id); + + const client = new Client( + { name: 'conformance-wif-retry', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + await client.listTools(); + await transport.close(); +} + // ============================================================================ // Main entry point // ============================================================================ diff --git a/src/scenarios/client/auth/enterprise-managed-authorization.ts b/src/scenarios/client/auth/enterprise-managed-authorization.ts index ddadfbf5..ee75e042 100644 --- a/src/scenarios/client/auth/enterprise-managed-authorization.ts +++ b/src/scenarios/client/auth/enterprise-managed-authorization.ts @@ -3,6 +3,7 @@ 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 { JWT_BEARER_GRANT_TYPE } from './helpers/createWorkloadJwt.js'; import { createServer } from './helpers/createServer'; import { MockTokenVerifier } from './helpers/mockTokenVerifier'; import { ServerLifecycle } from './helpers/serverLifecycle'; @@ -87,7 +88,7 @@ export class EnterpriseManagedAuthorizationScenario 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: [JWT_BEARER_GRANT_TYPE], tokenEndpointAuthMethodsSupported: ['client_secret_basic'], tokenVerifier, onTokenRequest: async ({ @@ -98,7 +99,7 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { authorizationHeader }) => { // Auth server only handles JWT bearer grant (ID-JAG -> access token) - if (grantType === 'urn:ietf:params:oauth:grant-type:jwt-bearer') { + if (grantType === JWT_BEARER_GRANT_TYPE) { const mcpResourceUrl = `${this.mcpServer.getUrl()}/mcp`; return await this.handleJwtBearerGrant( body, diff --git a/src/scenarios/client/auth/helpers/createWorkloadJwt.test.ts b/src/scenarios/client/auth/helpers/createWorkloadJwt.test.ts new file mode 100644 index 00000000..8cafa91f --- /dev/null +++ b/src/scenarios/client/auth/helpers/createWorkloadJwt.test.ts @@ -0,0 +1,194 @@ +import * as jose from 'jose'; +import { describe, it, expect } from 'vitest'; +import { + JWT_BEARER_GRANT_TYPE, + DEFAULT_WORKLOAD_JWT_ALG, + createWorkloadJwt, + generateWorkloadKeypair +} from './createWorkloadJwt.js'; + +describe('constants', () => { + it('JWT_BEARER_GRANT_TYPE matches the IANA-registered URN format', () => { + expect(JWT_BEARER_GRANT_TYPE).toMatch(/^urn:ietf:params:oauth:grant-type:/); + expect(JWT_BEARER_GRANT_TYPE).toBe( + 'urn:ietf:params:oauth:grant-type:jwt-bearer' + ); + }); + + it('DEFAULT_WORKLOAD_JWT_ALG is ES256', () => { + expect(DEFAULT_WORKLOAD_JWT_ALG).toBe('ES256'); + }); +}); + +describe('generateWorkloadKeypair', () => { + it('returns an ES256 keypair with PEM and JWK', async () => { + const kp = await generateWorkloadKeypair(); + expect(kp.privateKeyPem).toMatch(/^-----BEGIN PRIVATE KEY-----/); + expect(kp.publicJwk.kty).toBe('EC'); + expect(kp.publicJwk.crv).toBe('P-256'); + expect(kp.publicKey).toBeDefined(); + expect(kp.privateKey).toBeDefined(); + }); + + it('uses the specified algorithm', async () => { + const kp = await generateWorkloadKeypair('RS256'); + expect(kp.publicJwk.kty).toBe('RSA'); + }); +}); + +describe('createWorkloadJwt', () => { + it('produces a verifiable JWT with all standard claims', async () => { + const kp = await generateWorkloadKeypair(); + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'system:serviceaccount:prod:my-app', + audience: 'https://as.example/token', + privateKey: kp.privateKey + }); + + const { payload } = await jose.jwtVerify(token, kp.publicKey, { + issuer: 'https://issuer.example', + audience: 'https://as.example/token' + }); + + expect(payload.iss).toBe('https://issuer.example'); + expect(payload.sub).toBe('system:serviceaccount:prod:my-app'); + expect(payload.aud).toBe('https://as.example/token'); + expect(typeof payload.exp).toBe('number'); + expect(typeof payload.iat).toBe('number'); + expect(typeof payload.jti).toBe('string'); + }); + + it('defaults exp to approximately 5 minutes after iat', async () => { + const kp = await generateWorkloadKeypair(); + const before = Math.floor(Date.now() / 1000); + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'workload', + audience: 'https://as.example/token', + privateKey: kp.privateKey + }); + const after = Math.floor(Date.now() / 1000); + + const { payload } = await jose.jwtVerify(token, kp.publicKey); + const lifetime = (payload.exp as number) - (payload.iat as number); + expect(lifetime).toBeGreaterThanOrEqual(5 * 60 - 2); + expect(lifetime).toBeLessThanOrEqual(5 * 60 + 2); + expect(payload.iat as number).toBeGreaterThanOrEqual(before); + expect(payload.iat as number).toBeLessThanOrEqual(after); + }); + + it('accepts a numeric absolute epoch as expiresIn for already-expired tokens', async () => { + const kp = await generateWorkloadKeypair(); + const pastExp = Math.floor(Date.now() / 1000) - 60; + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'workload', + audience: 'https://as.example/token', + privateKey: kp.privateKey, + expiresIn: pastExp + }); + + const payload = jose.decodeJwt(token); + expect(payload.exp).toBe(pastExp); + await expect(jose.jwtVerify(token, kp.publicKey)).rejects.toThrow(); + }); + + it('generates a unique jti on each call with identical inputs', async () => { + const kp = await generateWorkloadKeypair(); + const opts = { + issuer: 'https://issuer.example', + subject: 'workload', + audience: 'https://as.example/token', + privateKey: kp.privateKey + }; + const t1 = await createWorkloadJwt(opts); + const t2 = await createWorkloadJwt(opts); + + const { payload: p1 } = await jose.jwtVerify(t1, kp.publicKey); + const { payload: p2 } = await jose.jwtVerify(t2, kp.publicKey); + expect(p1.jti).not.toBe(p2.jti); + }); + + it('preserves an array audience as an array', async () => { + const kp = await generateWorkloadKeypair(); + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'workload', + audience: ['https://as.example/token', 'https://other.example'], + privateKey: kp.privateKey + }); + + const { payload } = await jose.jwtVerify(token, kp.publicKey, { + audience: 'https://as.example/token' + }); + expect(Array.isArray(payload.aud)).toBe(true); + expect(payload.aud).toContain('https://as.example/token'); + expect(payload.aud).toContain('https://other.example'); + }); + + it('merges additionalClaims without overriding reserved claims', async () => { + const kp = await generateWorkloadKeypair(); + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'workload', + audience: 'https://as.example/token', + privateKey: kp.privateKey, + additionalClaims: { + custom: 'value', + iss: 'should-be-ignored', + sub: 'should-be-ignored' + } + }); + + const { payload } = await jose.jwtVerify(token, kp.publicKey); + expect(payload.custom).toBe('value'); + expect(payload.iss).toBe('https://issuer.example'); + expect(payload.sub).toBe('workload'); + }); + + it('allows caller-supplied jwtId', async () => { + const kp = await generateWorkloadKeypair(); + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'workload', + audience: 'https://as.example/token', + privateKey: kp.privateKey, + jwtId: 'fixed-jti-for-replay-test' + }); + + const { payload } = await jose.jwtVerify(token, kp.publicKey); + expect(payload.jti).toBe('fixed-jti-for-replay-test'); + }); + + it('sets notBefore when specified', async () => { + const kp = await generateWorkloadKeypair(); + const nbf = Math.floor(Date.now() / 1000) + 3600; + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'workload', + audience: 'https://as.example/token', + privateKey: kp.privateKey, + notBefore: nbf + }); + + const payload = jose.decodeJwt(token); + expect(payload.nbf).toBe(nbf); + }); + + it('uses the specified algorithm when signing', async () => { + const kp = await generateWorkloadKeypair('RS256'); + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'workload', + audience: 'https://as.example/token', + privateKey: kp.privateKey, + algorithm: 'RS256' + }); + + const { payload } = await jose.jwtVerify(token, kp.publicKey, { + algorithms: ['RS256'] + }); + expect(payload.iss).toBe('https://issuer.example'); + }); +}); diff --git a/src/scenarios/client/auth/helpers/createWorkloadJwt.ts b/src/scenarios/client/auth/helpers/createWorkloadJwt.ts new file mode 100644 index 00000000..804c76ae --- /dev/null +++ b/src/scenarios/client/auth/helpers/createWorkloadJwt.ts @@ -0,0 +1,86 @@ +import * as jose from 'jose'; + +export const JWT_BEARER_GRANT_TYPE = + 'urn:ietf:params:oauth:grant-type:jwt-bearer'; +export const DEFAULT_WORKLOAD_JWT_ALG = 'ES256'; + +// Scope values used by the WIF scenario's mock AS and broken-client runners. +// Exported here so both sides stay in sync without manual duplication. +export const WIF_TRIGGER_UNAUTHORIZED_SCOPE = 'wif.trigger-unauthorized'; +export const WIF_REJECTED_SCOPE = 'wif.rejected'; + +export interface CreateWorkloadJwtOptions { + issuer: string; + subject: string; + audience: string | string[]; + privateKey: jose.CryptoKey; + /** Jose duration string (e.g. '5m') or absolute epoch seconds. Use a number to construct already-expired tokens for negative tests. */ + expiresIn?: string | number; + jwtId?: string; + issuedAt?: number; + notBefore?: number; + algorithm?: string; + additionalClaims?: Record; +} + +export async function createWorkloadJwt( + opts: CreateWorkloadJwtOptions +): Promise { + const { + issuer, + subject, + audience, + privateKey, + expiresIn = '5m', + jwtId = crypto.randomUUID(), + issuedAt, + notBefore, + algorithm = DEFAULT_WORKLOAD_JWT_ALG, + additionalClaims + } = opts; + + // additionalClaims are merged first; reserved claims set via builder methods + // overwrite any same-named key already in the payload, so callers cannot + // accidentally override iss/sub/aud/exp/iat/jti via additionalClaims. + const extra: Record = additionalClaims + ? { ...additionalClaims } + : {}; + + let builder = new jose.SignJWT(extra) + .setProtectedHeader({ alg: algorithm }) + .setIssuer(issuer) + .setSubject(subject) + .setAudience(audience) + .setExpirationTime(expiresIn) + .setJti(jwtId); + + if (issuedAt !== undefined) { + builder = builder.setIssuedAt(issuedAt); + } else { + builder = builder.setIssuedAt(); + } + + if (notBefore !== undefined) { + builder = builder.setNotBefore(notBefore); + } + + return builder.sign(privateKey); +} + +export interface WorkloadKeypair { + publicKey: jose.CryptoKey; + privateKey: jose.CryptoKey; + privateKeyPem: string; + publicJwk: jose.JWK; +} + +export async function generateWorkloadKeypair( + alg: string = DEFAULT_WORKLOAD_JWT_ALG +): Promise { + const { publicKey, privateKey } = await jose.generateKeyPair(alg, { + extractable: true + }); + const privateKeyPem = await jose.exportPKCS8(privateKey); + const publicJwk = await jose.exportJWK(publicKey); + return { publicKey, privateKey, privateKeyPem, publicJwk }; +} diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 929c528f..8f6f0b6b 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -1,13 +1,22 @@ import { authScenariosList, backcompatScenariosList, - draftScenariosList + draftScenariosList, + extensionScenariosList } from './index'; import { runClientAgainstScenario, InlineClientRunner } from './test_helpers/testClient'; import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm'; +import { + runWifJwtBearerWrongAudience, + runWifJwtBearerMissingAssertion, + runWifJwtBearerExpiredAssertion, + runWifJwtBearerScopeRejected, + runWifJwtBearerGrantFallback, + runWifJwtBearerRetry +} from '../../../../examples/clients/typescript/everything-client'; import { runClient as noCimdClient } from '../../../../examples/clients/typescript/auth-test-no-cimd'; import { runClient as ignoreScopeClient } from '../../../../examples/clients/typescript/auth-test-ignore-scope'; import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes'; @@ -215,3 +224,69 @@ describe('Negative tests', () => { }); }); }); + +describe('Client Extension Scenarios', () => { + for (const scenario of extensionScenariosList) { + test(`${scenario.name} passes`, async () => { + const clientFn = getHandler(scenario.name); + if (!clientFn) { + throw new Error(`No handler registered for scenario: ${scenario.name}`); + } + const runner = new InlineClientRunner(clientFn); + await runClientAgainstScenario(runner, scenario.name); + }); + } +}); + +// allowClientError: true because broken clients receive an error response from +// the AS and will throw. The AS-side check is the authoritative conformance +// signal; client process exit behaviour is not asserted here. +describe('WIF JWT-bearer negative tests', () => { + test('client presents JWT with wrong audience', async () => { + const runner = new InlineClientRunner(runWifJwtBearerWrongAudience); + await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', { + expectedFailureSlugs: ['wif-assertion-audience'], + allowClientError: true + }); + }); + + test('client omits assertion from JWT-bearer request', async () => { + const runner = new InlineClientRunner(runWifJwtBearerMissingAssertion); + await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', { + expectedFailureSlugs: ['wif-assertion-missing'], + allowClientError: true + }); + }); + + test('client presents expired JWT assertion', async () => { + const runner = new InlineClientRunner(runWifJwtBearerExpiredAssertion); + await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', { + expectedFailureSlugs: ['wif-assertion-expired'], + allowClientError: true + }); + }); + + test('client requests a scope the AS rejects for JWT-bearer grant', async () => { + const runner = new InlineClientRunner(runWifJwtBearerScopeRejected); + await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', { + expectedFailureSlugs: ['wif-assertion-scope-rejected'], + allowClientError: true + }); + }); + + test('client falls back to authorization_code after unauthorized_client', async () => { + const runner = new InlineClientRunner(runWifJwtBearerGrantFallback); + await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', { + expectedFailureSlugs: ['wif-grant-fallback'], + allowClientError: true + }); + }); + + test('client retries JWT-bearer after unauthorized_client', async () => { + const runner = new InlineClientRunner(runWifJwtBearerRetry); + await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', { + expectedFailureSlugs: ['wif-no-retry'], + allowClientError: true + }); + }); +}); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 92e87f69..c0a5c743 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -24,6 +24,7 @@ import { import { ResourceMismatchScenario } from './resource-mismatch'; import { PreRegistrationScenario } from './pre-registration'; import { EnterpriseManagedAuthorizationScenario } from './enterprise-managed-authorization'; +import { WifJwtBearerScenario } from './wif-jwt-bearer'; import { OfflineAccessScopeScenario, OfflineAccessNotSupportedScenario @@ -75,5 +76,6 @@ export const draftScenariosList: Scenario[] = [ new IssParameterNotAdvertisedScenario(), new IssParameterSupportedMissingScenario(), new IssParameterWrongIssuerScenario(), - new IssParameterUnexpectedScenario() + new IssParameterUnexpectedScenario(), + new WifJwtBearerScenario() ]; diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 908a04eb..63a3cd2a 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -109,5 +109,9 @@ export const SpecReferences: { [key: string]: SpecReference } = { SEP_2207_REFRESH_TOKEN_GUIDANCE: { id: 'SEP-2207-Refresh-Token-Guidance', url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207' + }, + SEP_1933_WIF: { + id: 'SEP-1933-Workload-Identity-Federation', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1933' } }; diff --git a/src/scenarios/client/auth/wif-jwt-bearer.ts b/src/scenarios/client/auth/wif-jwt-bearer.ts new file mode 100644 index 00000000..8b162316 --- /dev/null +++ b/src/scenarios/client/auth/wif-jwt-bearer.ts @@ -0,0 +1,316 @@ +import * as jose from 'jose'; +import type { + Scenario, + ConformanceCheck, + ScenarioUrls, + SpecVersion +} from '../../../types'; +import { DRAFT_PROTOCOL_VERSION } 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'; +import { + JWT_BEARER_GRANT_TYPE, + WIF_TRIGGER_UNAUTHORIZED_SCOPE, + WIF_REJECTED_SCOPE, + generateWorkloadKeypair, + createWorkloadJwt +} from './helpers/createWorkloadJwt.js'; + +const WIF_ISSUER = 'https://idp.conformance-test.local'; +const WIF_SUBJECT = 'conformance-workload'; +const WIF_CLIENT_ID = 'conformance-wif-workload'; + +export class WifJwtBearerScenario implements Scenario { + name = 'auth/wif-jwt-bearer'; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Tests OAuth JWT-bearer grant (RFC 7523 §2.1) for workload identity federation (SEP-1933)'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private tokenRequestReceived = false; + private failedOnce = false; + + async start(): Promise { + this.checks = []; + this.tokenRequestReceived = false; + this.failedOnce = false; + + const { publicKey, privateKey } = await generateWorkloadKeypair(); + + const tokenVerifier = new MockTokenVerifier(this.checks); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + grantTypesSupported: [JWT_BEARER_GRANT_TYPE], + tokenEndpointAuthMethodsSupported: ['none'], + tokenVerifier, + disableDynamicRegistration: true, + onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => { + // wif-no-retry and wif-grant-fallback fire on any second request after + // any first failure, not only after unauthorized_client specifically. + if (this.tokenRequestReceived && this.failedOnce) { + if (grantType !== JWT_BEARER_GRANT_TYPE) { + this.checks.push({ + id: 'wif-grant-fallback', + name: 'WifGrantFallback', + description: `Client fell back to ${grantType} grant after receiving unauthorized_client; clients should not switch grant types after a JWT-bearer failure`, + status: 'WARNING', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + return { + error: 'unsupported_grant_type', + errorDescription: 'Only JWT-bearer grant is supported' + }; + } + this.checks.push({ + id: 'wif-no-retry', + name: 'WifNoRetry', + description: + 'Client retried JWT-bearer token request after a failure instead of giving up', + status: 'WARNING', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + return { + error: 'invalid_request', + errorDescription: 'Retry not allowed for JWT-bearer grant' + }; + } + this.tokenRequestReceived = true; + if (grantType !== JWT_BEARER_GRANT_TYPE) { + this.checks.push({ + id: 'wif-grant-type', + name: 'WifGrantType', + description: `Expected grant_type=${JWT_BEARER_GRANT_TYPE}, got ${grantType}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + this.failedOnce = true; + return { + error: 'unsupported_grant_type', + errorDescription: `Only ${JWT_BEARER_GRANT_TYPE} grant is supported` + }; + } + + const assertion = body.assertion; + if (!assertion) { + this.checks.push({ + id: 'wif-assertion-missing', + name: 'WifAssertionMissing', + description: + 'Missing assertion parameter in JWT-bearer token request', + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + this.failedOnce = true; + return { + error: 'invalid_request', + errorDescription: 'Missing assertion parameter' + }; + } + + try { + const withoutSlash = authBaseUrl.replace(/\/+$/, ''); + const withSlash = `${withoutSlash}/`; + // iss is not validated: the keypair is generated per start() call and + // the public key closure binds the assertion to this run. This scenario + // tests client behaviour, not AS issuer policy. + // clockTolerance of 5s is sufficient because JWTs are signed and consumed + // within the same test run; skew from a real IdP is not a factor here. + // Both slash forms are accepted because the SDK constructs the audience + // from the AS metadata URL, which may or may not carry a trailing slash + // depending on how the metadata endpoint was discovered. + await jose.jwtVerify(assertion, publicKey, { + audience: [withoutSlash, withSlash], + clockTolerance: 5 + }); + + this.checks.push({ + id: 'wif-assertion-verified', + name: 'WifAssertionVerified', + description: + 'Workload JWT assertion verified — signature, audience, and expiry are valid (iss not validated; keypair is run-scoped)', + status: 'SUCCESS', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + + const scopeList = body.scope ? body.scope.split(' ') : []; + if (scopeList.includes(WIF_TRIGGER_UNAUTHORIZED_SCOPE)) { + this.failedOnce = true; + return { + error: 'unauthorized_client', + errorDescription: 'Client not authorized for JWT-bearer grant' + }; + } + if (scopeList.includes(WIF_REJECTED_SCOPE)) { + this.checks.push({ + id: 'wif-assertion-scope-rejected', + name: 'WifAssertionScopeRejected', + description: + 'AS returned invalid_scope for a valid JWT-bearer assertion; client should surface the error and not retry', + status: 'WARNING', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + this.failedOnce = true; + return { + error: 'invalid_scope', + errorDescription: + 'Requested scope is not permitted for this grant' + }; + } + return { + token: `test-token-${Date.now()}`, + scopes: scopeList + }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + + if (e instanceof jose.errors.JWTExpired) { + this.checks.push({ + id: 'wif-assertion-expired', + name: 'WifAssertionExpired', + description: `JWT-bearer assertion is expired: ${msg}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + this.failedOnce = true; + return { + error: 'invalid_grant', + errorDescription: 'JWT assertion is expired' + }; + } + + // JWTExpired extends JWTClaimValidationFailed; check aud specifically so + // other claim failures (iss, nbf, etc.) fall through to malformed. + if ( + e instanceof jose.errors.JWTClaimValidationFailed && + e.claim === 'aud' + ) { + this.checks.push({ + id: 'wif-assertion-audience', + name: 'WifAssertionAudience', + description: `JWT-bearer assertion audience claim is invalid: ${msg}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + this.failedOnce = true; + return { + error: 'invalid_grant', + errorDescription: 'JWT assertion audience is invalid' + }; + } + + this.checks.push({ + id: 'wif-assertion-malformed', + name: 'WifAssertionMalformed', + description: `JWT-bearer assertion is malformed or has an invalid signature: ${msg}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + this.failedOnce = true; + return { + error: 'invalid_grant', + errorDescription: `JWT assertion verification failed: ${msg}` + }; + } + } + }); + + await this.authServer.start(authApp); + + const authServerUrl = this.authServer.getUrl(); + + const [validJwt, wrongAudienceJwt, expiredJwt] = await Promise.all([ + createWorkloadJwt({ + issuer: WIF_ISSUER, + subject: WIF_SUBJECT, + audience: authServerUrl, + privateKey + }), + createWorkloadJwt({ + issuer: WIF_ISSUER, + subject: WIF_SUBJECT, + audience: 'https://wrong.example', + privateKey + }), + createWorkloadJwt({ + issuer: WIF_ISSUER, + subject: WIF_SUBJECT, + audience: authServerUrl, + privateKey, + // Absolute epoch seconds in the past; jose treats a number as an absolute + // epoch timestamp, producing a token that is already expired. + expiresIn: Math.floor(Date.now() / 1000) - 60 + }) + ]); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { tokenVerifier } + ); + + await this.server.start(app); + + return { + serverUrl: `${this.server.getUrl()}/mcp`, + context: { + client_id: WIF_CLIENT_ID, + audience: authServerUrl, + valid_jwt: validJwt, + wrong_audience_jwt: wrongAudienceJwt, + expired_jwt: expiredJwt + } + }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + return this.checks; + } +} diff --git a/src/schemas/context.ts b/src/schemas/context.ts index 12c72af6..ace6a936 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -31,6 +31,16 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ idp_id_token: z.string(), idp_issuer: z.string(), idp_token_endpoint: z.string() + }), + z.object({ + name: z.literal('auth/wif-jwt-bearer'), + client_id: z.string(), + // RFC 7523 does not require aud to be a URL, but this scenario's test AS + // is always addressed by URL, so the constraint is intentional here. + audience: z.string().url(), + valid_jwt: z.string(), + wrong_audience_jwt: z.string(), + expired_jwt: z.string() }) ]); diff --git a/src/seps/sep-1933.yaml b/src/seps/sep-1933.yaml new file mode 100644 index 00000000..64e8e369 --- /dev/null +++ b/src/seps/sep-1933.yaml @@ -0,0 +1,38 @@ +sep: 1933 +spec_url: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1933 +requirements: + - check: wif-grant-type + text: 'The request includes grant_type: urn:ietf:params:oauth:grant-type:jwt-bearer (RFC 7523 §2.1 — clients MUST use this grant type for JWT-bearer authorization grants)' + + - check: wif-assertion-missing + text: 'The request includes assertion: (RFC 7523 §2.1 — the assertion parameter is REQUIRED and MUST contain the JWT)' + + - check: wif-assertion-verified + text: 'Client successfully authenticates to the MCP authorization server using a JWT-bearer grant with a valid workload identity JWT assertion' + + - check: wif-assertion-expired + text: 'The JWT MUST contain an exp claim and the authorization server MUST reject assertions whose exp has passed (RFC 7523 §3 — client MUST surface this error and not silently ignore it)' + + - check: wif-assertion-audience + text: 'The JWT MUST contain an aud claim identifying the authorization server (RFC 7523 §3 — client MUST surface invalid audience errors and not silently ignore them)' + + - check: wif-assertion-malformed + text: 'The authorization server MUST reject JWTs with invalid signatures or malformed claims (RFC 7523 §3 — client MUST surface verification failures)' + + - check: wif-no-retry + text: 'Clients should not retry a JWT-bearer token request after the authorization server has rejected the assertion (WARNING until SEP-1933 lands normative spec text; RFC 7523 is silent on client retry behaviour)' + + - check: wif-assertion-scope-rejected + text: 'When the authorization server returns invalid_scope for a JWT-bearer token request, the client should surface the error and not retry (WARNING until SEP-1933 lands normative spec text)' + + - check: wif-grant-fallback + text: 'When the authorization server returns unauthorized_client for a JWT-bearer token request, the client should not fall back to a different grant type (WARNING until SEP-1933 lands normative spec text; RFC 7523 §2.1 defines the grant but does not forbid grant-type switching after failure)' + + - text: 'The JWT MUST include an iss (issuer) claim identifying the workload identity provider (RFC 7523 §3)' + excluded: 'Issuer validation is AS policy; the client presents a pre-signed token and cannot vary the iss value. The scenario does not expose issuer in context because it is not a client-observable parameter — the client has no mechanism to select or alter the iss of an assertion it did not construct.' + + - text: 'The JWT MUST include a sub (subject) claim identifying the workload (RFC 7523 §3)' + excluded: 'Subject allowlist enforcement is AS policy; the client has no control over the subject in a pre-signed IdP token. From the client perspective this collapses to a generic invalid_grant response, already covered by existing checks.' + + - text: 'The JWT MUST include a jti (JWT ID) claim to prevent replay (RFC 7523 §3, recommended)' + excluded: 'Replay detection is AS policy; the client cannot influence jti uniqueness across requests. No client-observable protocol difference from other invalid_grant responses.'