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-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..402b5166 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -19,6 +19,13 @@ 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 } 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 +733,305 @@ 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' + ); + + 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 +const WIF_TRIGGER_UNAUTHORIZED_SCOPE = 'wif.trigger-unauthorized'; + +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(); +} + // ============================================================================ // Main entry point // ============================================================================ diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 929c528f..bc57ff8e 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -1,13 +1,21 @@ 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 +} 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 +223,58 @@ 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); + }); + } +}); + +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 + }); + }); +}); 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..07a41cae --- /dev/null +++ b/src/scenarios/client/auth/wif-jwt-bearer.ts @@ -0,0 +1,334 @@ +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, + generateWorkloadKeypair, + createWorkloadJwt +} from './helpers/createWorkloadJwt.js'; + +const WIF_ISSUER = 'https://wif-idp.conformance-test.local'; +const WIF_SUBJECT = 'conformance:test-workload'; +const WIF_CLIENT_ID = 'conformance-wif-workload'; +const WIF_REJECTED_SCOPE = 'wif.rejected'; +const WIF_TRIGGER_UNAUTHORIZED_SCOPE = 'wif.trigger-unauthorized'; + +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'], + tokenEndpointAuthSigningAlgValuesSupported: ['ES256'], + tokenVerifier, + disableDynamicRegistration: true, + onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => { + 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; client MUST NOT switch grant types after a JWT-bearer failure`, + status: 'FAILURE', + 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: 'FAILURE', + 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. + 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: 'FAILURE', + 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, + issuer: WIF_ISSUER, + subject: WIF_SUBJECT, + audience: authServerUrl, + valid_jwt: validJwt, + wrong_audience_jwt: wrongAudienceJwt, + expired_jwt: expiredJwt, + signing_algorithm: 'ES256' + } + }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const hasVerifiedCheck = this.checks.some( + (c) => c.id === 'wif-assertion-verified' + ); + if (!hasVerifiedCheck) { + const description = this.tokenRequestReceived + ? 'JWT-bearer token request was received but assertion verification did not succeed' + : 'Client did not make a JWT-bearer token request'; + this.checks.push({ + id: 'wif-assertion-verified', + name: 'WifAssertionVerified', + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + } + return this.checks; + } +} diff --git a/src/schemas/context.ts b/src/schemas/context.ts index 12c72af6..8587861c 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -31,6 +31,17 @@ 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(), + issuer: z.string(), + subject: z.string(), + audience: z.string().url(), + valid_jwt: z.string(), + wrong_audience_jwt: z.string(), + expired_jwt: z.string(), + signing_algorithm: z.literal('ES256') }) ]); diff --git a/src/seps/sep-1933.yaml b/src/seps/sep-1933.yaml new file mode 100644 index 00000000..7ecf9cb3 --- /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 MUST NOT retry a JWT-bearer token request after the authorization server has rejected the assertion; each assertion is single-use per authorization flow' + + - check: wif-assertion-scope-rejected + text: 'When the authorization server returns invalid_scope for a JWT-bearer token request, the client MUST surface the error and MUST NOT retry with the same or different scopes' + + - check: wif-grant-fallback + text: 'When the authorization server returns unauthorized_client for a JWT-bearer token request, the client MUST NOT fall back to a different grant type (RFC 7523 §2.1 — use of the JWT-bearer grant type is a deliberate choice; silent grant-type switching hides authentication failures)' + + - text: 'The JWT MUST include an iss (issuer) claim identifying the workload identity provider (RFC 7523 §3)' + excluded: 'Issuer validation is an AS policy decision; the client cannot control or observe which issuers the AS trusts. The scenario uses a run-scoped keypair whose public key is shared directly, making iss validation redundant for client conformance testing.' + + - 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.'