From 1456a84e0f65fdcb1c667a90c6aa4911c9cb1252 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 12 May 2026 13:25:08 +0100 Subject: [PATCH 01/10] feat(auth): add WIF JWT-bearer scenario and negative tests (SEP-1933) Adds the auth/wif-jwt-bearer client conformance scenario using the RFC 7523 JWT-bearer grant (urn:ietf:params:oauth:grant-type:jwt-bearer). The scenario pre-signs valid, wrong-audience, and expired JWTs on start() to simulate cloud workload identity tokens. The conformance AS verifies the assertion and emits per-class checks (wif-assertion-verified, wif-assertion-missing, wif-assertion-audience, wif-assertion-expired, wif-assertion-malformed). Broken example clients exercise the missing-assertion and wrong-audience failure paths. Co-Authored-By: Claude Sonnet 4.6 --- .../typescript/auth-test-wif-no-assertion.ts | 15 ++ .../auth-test-wif-wrong-audience.ts | 15 ++ .../clients/typescript/everything-client.ts | 154 +++++++++++ src/scenarios/client/auth/index.test.ts | 38 ++- src/scenarios/client/auth/index.ts | 4 +- src/scenarios/client/auth/spec-references.ts | 4 + src/scenarios/client/auth/wif-jwt-bearer.ts | 248 ++++++++++++++++++ src/schemas/context.ts | 10 + 8 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 examples/clients/typescript/auth-test-wif-no-assertion.ts create mode 100644 examples/clients/typescript/auth-test-wif-wrong-audience.ts create mode 100644 src/scenarios/client/auth/wif-jwt-bearer.ts 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-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..2339e6ad 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,153 @@ registerScenario( runEnterpriseManagedAuthorization ); +// ============================================================================ +// WIF JWT-bearer scenario +// ============================================================================ + +class WifJwtBearerProvider implements OAuthClientProvider { + private _tokens?: OAuthTokens; + private _clientInfo?: OAuthClientInformation; + private readonly _clientMetadata: OAuthClientMetadata; + + // Pass null to deliberately omit the assertion (for missing-assertion negative tests). + constructor(private readonly assertion: string | null) { + 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 | undefined { + 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 { + const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE }); + if (this.assertion !== null) params.set('assertion', this.assertion); + if (scope) params.set('scope', scope); + 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); + + 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); + + 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 + }); + + try { + await client.connect(transport); + await client.listTools(); + await transport.close(); + } catch { + // Expected — server rejects wrong audience + } +} + +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); + + const client = new Client( + { name: 'conformance-wif-no-assertion', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + try { + await client.connect(transport); + await client.listTools(); + await transport.close(); + } catch { + // Expected — server rejects missing assertion + } +} + // ============================================================================ // Main entry point // ============================================================================ diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 929c528f..38ecfaf1 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -1,13 +1,18 @@ 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 +} 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 +220,34 @@ 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 + }); + }); +}); 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..9a3efe3a --- /dev/null +++ b/src/scenarios/client/auth/wif-jwt-bearer.ts @@ -0,0 +1,248 @@ +import * as jose from 'jose'; +import type { + Scenario, + ConformanceCheck, + ScenarioUrls, + ScenarioSpecTag +} 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'; + +export class WifJwtBearerScenario implements Scenario { + name = 'auth/wif-jwt-bearer'; + specVersions: ScenarioSpecTag[] = ['extension']; + 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[] = []; + + async start(): Promise { + this.checks = []; + + 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, + onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => { + 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 + ] + }); + 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 + ] + }); + return { + error: 'invalid_request', + errorDescription: 'Missing assertion parameter' + }; + } + + try { + const withoutSlash = authBaseUrl.replace(/\/+$/, ''); + const withSlash = `${withoutSlash}/`; + // iss is not validated here: the keypair is generated per start() call and + // the public key closure already binds the assertion to this specific run. + // The scenario exercises client behaviour, not AS issuer policy. + 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', + status: 'SUCCESS', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + + const scopes = body.scope ? body.scope.split(' ') : []; + return { + token: `test-token-${Date.now()}`, + scopes + }; + } 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 + ] + }); + 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 + ] + }); + 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 + ] + }); + 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, + 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: { + 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) { + this.checks.push({ + id: 'wif-assertion-verified', + name: 'WifAssertionVerified', + description: + 'Client did not make a JWT-bearer token request', + 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..4ca61e87 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'), + 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') }) ]); From 6962b10d0cdbae0a163cd4fb30035962c646281f Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 12 May 2026 13:25:35 +0100 Subject: [PATCH 02/10] style: apply prettier formatting to wif-jwt-bearer.ts Co-Authored-By: Claude Sonnet 4.6 --- src/scenarios/client/auth/wif-jwt-bearer.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/scenarios/client/auth/wif-jwt-bearer.ts b/src/scenarios/client/auth/wif-jwt-bearer.ts index 9a3efe3a..c6b83047 100644 --- a/src/scenarios/client/auth/wif-jwt-bearer.ts +++ b/src/scenarios/client/auth/wif-jwt-bearer.ts @@ -65,7 +65,8 @@ export class WifJwtBearerScenario implements Scenario { this.checks.push({ id: 'wif-assertion-missing', name: 'WifAssertionMissing', - description: 'Missing assertion parameter in JWT-bearer token request', + description: + 'Missing assertion parameter in JWT-bearer token request', status: 'FAILURE', timestamp, specReferences: [ @@ -233,8 +234,7 @@ export class WifJwtBearerScenario implements Scenario { this.checks.push({ id: 'wif-assertion-verified', name: 'WifAssertionVerified', - description: - 'Client did not make a JWT-bearer token request', + description: 'Client did not make a JWT-bearer token request', status: 'FAILURE', timestamp: new Date().toISOString(), specReferences: [ From 8e8eaba6bd7f20a44c29da9c147cb9dd696691e5 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 12 May 2026 15:14:56 +0100 Subject: [PATCH 03/10] fix(auth): add expired-assertion negative test; clarify wif-jwt-bearer comments - Add runWifJwtBearerExpiredAssertion + auth-test-wif-expired-assertion.ts to exercise the wif-assertion-expired check path (was dead code) - Clarify WifAssertionVerified description to note iss is not validated - Add comment explaining clockTolerance: 5 is intentional (same-run keypair) - Add comment explaining numeric expiresIn is absolute epoch seconds Co-Authored-By: Claude Sonnet 4.6 --- .../auth-test-wif-expired-assertion.ts | 15 ++++++++++ .../clients/typescript/everything-client.ts | 28 +++++++++++++++++++ src/scenarios/client/auth/index.test.ts | 11 +++++++- src/scenarios/client/auth/wif-jwt-bearer.ts | 12 +++++--- 4 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 examples/clients/typescript/auth-test-wif-expired-assertion.ts 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/everything-client.ts b/examples/clients/typescript/everything-client.ts index 2339e6ad..26ca4db1 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -880,6 +880,34 @@ export async function runWifJwtBearerMissingAssertion( } } +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); + + const client = new Client( + { name: 'conformance-wif-jwt-bearer-expired', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + try { + await client.connect(transport); + await client.listTools(); + await transport.close(); + } catch { + // Expected — server rejects the expired assertion + } +} + // ============================================================================ // Main entry point // ============================================================================ diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 38ecfaf1..38e32aba 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -11,7 +11,8 @@ import { import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm'; import { runWifJwtBearerWrongAudience, - runWifJwtBearerMissingAssertion + runWifJwtBearerMissingAssertion, + runWifJwtBearerExpiredAssertion } 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'; @@ -250,4 +251,12 @@ describe('WIF JWT-bearer negative tests', () => { 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 + }); + }); }); diff --git a/src/scenarios/client/auth/wif-jwt-bearer.ts b/src/scenarios/client/auth/wif-jwt-bearer.ts index c6b83047..a71e7964 100644 --- a/src/scenarios/client/auth/wif-jwt-bearer.ts +++ b/src/scenarios/client/auth/wif-jwt-bearer.ts @@ -83,9 +83,11 @@ export class WifJwtBearerScenario implements Scenario { try { const withoutSlash = authBaseUrl.replace(/\/+$/, ''); const withSlash = `${withoutSlash}/`; - // iss is not validated here: the keypair is generated per start() call and - // the public key closure already binds the assertion to this specific run. - // The scenario exercises client behaviour, not AS issuer policy. + // 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 @@ -95,7 +97,7 @@ export class WifJwtBearerScenario implements Scenario { id: 'wif-assertion-verified', name: 'WifAssertionVerified', description: - 'Workload JWT assertion verified — signature, audience, and expiry are valid', + 'Workload JWT assertion verified — signature, audience, and expiry are valid (iss not validated; keypair is run-scoped)', status: 'SUCCESS', timestamp, specReferences: [ @@ -194,6 +196,8 @@ export class WifJwtBearerScenario implements Scenario { 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 }) ]); From e2334800224e68c6199d3f7d9e5751df556ff18e Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 12 May 2026 15:31:57 +0100 Subject: [PATCH 04/10] fix(wif-jwt-bearer): disable DCR, detect retries, improve getChecks description - Add WIF_CLIENT_ID constant; pre-seed provider's clientInformation() so the SDK skips Dynamic Client Registration entirely (disableDynamicRegistration on the auth server + pre-seeded client_id on the provider side) - Add failedOnce/tokenRequestReceived tracking on the scenario; reject and record wif-no-retry FAILURE if the client attempts a second token request after the first fails - Add hasAttempted guard in WifJwtBearerProvider.prepareTokenRequest() to throw on retry from the client side - Fix getChecks() sentinel description: distinguish "no request made" from "request made but verification failed" - Add client_id to context and Zod schema Co-Authored-By: Claude Sonnet 4.6 --- .../clients/typescript/everything-client.ts | 27 +++++++++----- src/scenarios/client/auth/wif-jwt-bearer.ts | 36 ++++++++++++++++++- src/schemas/context.ts | 1 + 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 26ca4db1..bb7187ca 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -739,11 +739,16 @@ registerScenario( class WifJwtBearerProvider implements OAuthClientProvider { private _tokens?: OAuthTokens; - private _clientInfo?: OAuthClientInformation; + private _clientInfo: OAuthClientInformation; private readonly _clientMetadata: OAuthClientMetadata; - - // Pass null to deliberately omit the assertion (for missing-assertion negative tests). - constructor(private readonly assertion: string | null) { + private hasAttempted = false; + + // Pass null for assertion to deliberately omit it (missing-assertion negative tests). + constructor( + private readonly assertion: string | null, + clientId: string + ) { + this._clientInfo = { client_id: clientId }; this._clientMetadata = { client_name: 'conformance-wif-jwt-bearer', redirect_uris: [], @@ -760,7 +765,7 @@ class WifJwtBearerProvider implements OAuthClientProvider { return this._clientMetadata; } - clientInformation(): OAuthClientInformation | undefined { + clientInformation(): OAuthClientInformation { return this._clientInfo; } @@ -787,6 +792,10 @@ class WifJwtBearerProvider implements OAuthClientProvider { } 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); if (scope) params.set('scope', scope); @@ -800,7 +809,7 @@ export async function runWifJwtBearer(serverUrl: string): Promise { throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`); } - const provider = new WifJwtBearerProvider(ctx.valid_jwt); + const provider = new WifJwtBearerProvider(ctx.valid_jwt, ctx.client_id); const client = new Client( { name: 'conformance-wif-jwt-bearer', version: '1.0.0' }, @@ -831,7 +840,7 @@ export async function runWifJwtBearerWrongAudience( throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`); } - const provider = new WifJwtBearerProvider(ctx.wrong_audience_jwt); + 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' }, @@ -860,7 +869,7 @@ export async function runWifJwtBearerMissingAssertion( } // BUG: null omits the assertion parameter from the token request - const provider = new WifJwtBearerProvider(null); + const provider = new WifJwtBearerProvider(null, ctx.client_id); const client = new Client( { name: 'conformance-wif-no-assertion', version: '1.0.0' }, @@ -888,7 +897,7 @@ export async function runWifJwtBearerExpiredAssertion( throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`); } - const provider = new WifJwtBearerProvider(ctx.expired_jwt); + const provider = new WifJwtBearerProvider(ctx.expired_jwt, ctx.client_id); const client = new Client( { name: 'conformance-wif-jwt-bearer-expired', version: '1.0.0' }, diff --git a/src/scenarios/client/auth/wif-jwt-bearer.ts b/src/scenarios/client/auth/wif-jwt-bearer.ts index a71e7964..502eeb0e 100644 --- a/src/scenarios/client/auth/wif-jwt-bearer.ts +++ b/src/scenarios/client/auth/wif-jwt-bearer.ts @@ -18,6 +18,7 @@ import { const WIF_ISSUER = 'https://wif-idp.conformance-test.local'; const WIF_SUBJECT = 'conformance:test-workload'; +const WIF_CLIENT_ID = 'conformance-wif-workload'; export class WifJwtBearerScenario implements Scenario { name = 'auth/wif-jwt-bearer'; @@ -28,9 +29,13 @@ export class WifJwtBearerScenario implements Scenario { 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(); @@ -41,7 +46,27 @@ export class WifJwtBearerScenario implements Scenario { tokenEndpointAuthMethodsSupported: ['none'], tokenEndpointAuthSigningAlgValuesSupported: ['ES256'], tokenVerifier, + disableDynamicRegistration: true, onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => { + if (this.tokenRequestReceived && this.failedOnce) { + 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', @@ -54,6 +79,7 @@ export class WifJwtBearerScenario implements Scenario { SpecReferences.SEP_1933_WIF ] }); + this.failedOnce = true; return { error: 'unsupported_grant_type', errorDescription: `Only ${JWT_BEARER_GRANT_TYPE} grant is supported` @@ -74,6 +100,7 @@ export class WifJwtBearerScenario implements Scenario { SpecReferences.SEP_1933_WIF ] }); + this.failedOnce = true; return { error: 'invalid_request', errorDescription: 'Missing assertion parameter' @@ -126,6 +153,7 @@ export class WifJwtBearerScenario implements Scenario { SpecReferences.SEP_1933_WIF ] }); + this.failedOnce = true; return { error: 'invalid_grant', errorDescription: 'JWT assertion is expired' @@ -149,6 +177,7 @@ export class WifJwtBearerScenario implements Scenario { SpecReferences.SEP_1933_WIF ] }); + this.failedOnce = true; return { error: 'invalid_grant', errorDescription: 'JWT assertion audience is invalid' @@ -166,6 +195,7 @@ export class WifJwtBearerScenario implements Scenario { SpecReferences.SEP_1933_WIF ] }); + this.failedOnce = true; return { error: 'invalid_grant', errorDescription: `JWT assertion verification failed: ${msg}` @@ -214,6 +244,7 @@ export class WifJwtBearerScenario implements Scenario { return { serverUrl: `${this.server.getUrl()}/mcp`, context: { + client_id: WIF_CLIENT_ID, issuer: WIF_ISSUER, subject: WIF_SUBJECT, audience: authServerUrl, @@ -235,10 +266,13 @@ export class WifJwtBearerScenario implements Scenario { (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: 'Client did not make a JWT-bearer token request', + description, status: 'FAILURE', timestamp: new Date().toISOString(), specReferences: [ diff --git a/src/schemas/context.ts b/src/schemas/context.ts index 4ca61e87..8587861c 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -34,6 +34,7 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ }), z.object({ name: z.literal('auth/wif-jwt-bearer'), + client_id: z.string(), issuer: z.string(), subject: z.string(), audience: z.string().url(), From 090972754afcca6d94a2d84a22462c2a0ac8debc Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 12 May 2026 15:33:06 +0100 Subject: [PATCH 05/10] style: prettier formatting on everything-client.ts Co-Authored-By: Claude Sonnet 4.6 --- examples/clients/typescript/everything-client.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index bb7187ca..40cab37b 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -840,7 +840,10 @@ export async function runWifJwtBearerWrongAudience( throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`); } - const provider = new WifJwtBearerProvider(ctx.wrong_audience_jwt, ctx.client_id); + 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' }, From 84e44294f0be3533df6804d0b1bb964fe3c2130e Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 12 May 2026 17:14:40 +0100 Subject: [PATCH 06/10] fix(wif-jwt-bearer): let errors propagate from broken example clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The try/catch blocks were silently swallowing auth failures, so the negative tests passed purely because expectedFailureSlugs found the AS-emitted check — not because client error-surfacing was verified. allowClientError: true on the test cases handles the non-zero exit. Co-Authored-By: Claude Sonnet 4.6 --- .../clients/typescript/everything-client.ts | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 40cab37b..4d0b34f7 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -854,13 +854,9 @@ export async function runWifJwtBearerWrongAudience( authProvider: provider }); - try { - await client.connect(transport); - await client.listTools(); - await transport.close(); - } catch { - // Expected — server rejects wrong audience - } + await client.connect(transport); + await client.listTools(); + await transport.close(); } export async function runWifJwtBearerMissingAssertion( @@ -883,13 +879,9 @@ export async function runWifJwtBearerMissingAssertion( authProvider: provider }); - try { - await client.connect(transport); - await client.listTools(); - await transport.close(); - } catch { - // Expected — server rejects missing assertion - } + await client.connect(transport); + await client.listTools(); + await transport.close(); } export async function runWifJwtBearerExpiredAssertion( @@ -911,13 +903,9 @@ export async function runWifJwtBearerExpiredAssertion( authProvider: provider }); - try { - await client.connect(transport); - await client.listTools(); - await transport.close(); - } catch { - // Expected — server rejects the expired assertion - } + await client.connect(transport); + await client.listTools(); + await transport.close(); } // ============================================================================ From 5011069c72871ed6d5c9ed03acc246ce0afc90bb Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Thu, 21 May 2026 13:37:47 +0200 Subject: [PATCH 07/10] fix(wif-jwt-bearer): tag as draft, add scope-rejected check, add SEP traceability - Change specVersions from ['extension'] to [DRAFT_PROTOCOL_VERSION] and add source = { introducedIn: DRAFT_PROTOCOL_VERSION } so the scenario is reachable via --spec-version draft (extension tag excluded it from all spec-version runs) - Move registration from extensionScenariosList to draftScenariosList in index.ts to match the tag/list convention enforced by spec-version.test.ts - Add wif-assertion-scope-rejected check: AS returns invalid_scope for a valid JWT when the client requests the reserved 'wif.rejected' scope; verify client surfaces the error and does not retry - Add runWifJwtBearerScopeRejected to everything-client.ts; add optional scope param to WifJwtBearerProvider; add CLI entry point and vitest case - Add src/seps/sep-1933.yaml with requirements mapped to each check ID, covering the three deferred checks (iss, sub, jti) with exclusion rationale Co-Authored-By: Claude Sonnet 4.6 --- .../auth-test-wif-scope-rejected.ts | 15 ++++++++ .../clients/typescript/everything-client.ts | 35 +++++++++++++++++-- src/scenarios/client/auth/index.test.ts | 11 +++++- src/scenarios/client/auth/wif-jwt-bearer.ts | 30 +++++++++++++--- src/seps/sep-1933.yaml | 35 +++++++++++++++++++ 5 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 examples/clients/typescript/auth-test-wif-scope-rejected.ts create mode 100644 src/seps/sep-1933.yaml 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/everything-client.ts b/examples/clients/typescript/everything-client.ts index 4d0b34f7..7b0733d5 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -746,7 +746,8 @@ class WifJwtBearerProvider implements OAuthClientProvider { // Pass null for assertion to deliberately omit it (missing-assertion negative tests). constructor( private readonly assertion: string | null, - clientId: string + clientId: string, + private readonly scope?: string ) { this._clientInfo = { client_id: clientId }; this._clientMetadata = { @@ -798,7 +799,8 @@ class WifJwtBearerProvider implements OAuthClientProvider { this.hasAttempted = true; const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE }); if (this.assertion !== null) params.set('assertion', this.assertion); - if (scope) params.set('scope', scope); + const effectiveScope = this.scope ?? scope; + if (effectiveScope) params.set('scope', effectiveScope); return params; } } @@ -908,6 +910,35 @@ export async function runWifJwtBearerExpiredAssertion( 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(); +} + // ============================================================================ // Main entry point // ============================================================================ diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 38e32aba..e55cfb91 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -12,7 +12,8 @@ import { runClient as badPrmClient } from '../../../../examples/clients/typescri import { runWifJwtBearerWrongAudience, runWifJwtBearerMissingAssertion, - runWifJwtBearerExpiredAssertion + runWifJwtBearerExpiredAssertion, + runWifJwtBearerScopeRejected } 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'; @@ -259,4 +260,12 @@ describe('WIF JWT-bearer negative tests', () => { 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 + }); + }); }); diff --git a/src/scenarios/client/auth/wif-jwt-bearer.ts b/src/scenarios/client/auth/wif-jwt-bearer.ts index 502eeb0e..40c1de68 100644 --- a/src/scenarios/client/auth/wif-jwt-bearer.ts +++ b/src/scenarios/client/auth/wif-jwt-bearer.ts @@ -3,8 +3,9 @@ import type { Scenario, ConformanceCheck, ScenarioUrls, - ScenarioSpecTag + SpecVersion } from '../../../types'; +import { DRAFT_PROTOCOL_VERSION } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { MockTokenVerifier } from './helpers/mockTokenVerifier'; @@ -19,10 +20,12 @@ import { 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'; export class WifJwtBearerScenario implements Scenario { name = 'auth/wif-jwt-bearer'; - specVersions: ScenarioSpecTag[] = ['extension']; + 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)'; @@ -133,10 +136,29 @@ export class WifJwtBearerScenario implements Scenario { ] }); - const scopes = body.scope ? body.scope.split(' ') : []; + const scopeList = body.scope ? body.scope.split(' ') : []; + 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 + scopes: scopeList }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); diff --git a/src/seps/sep-1933.yaml b/src/seps/sep-1933.yaml new file mode 100644 index 00000000..f215f7e6 --- /dev/null +++ b/src/seps/sep-1933.yaml @@ -0,0 +1,35 @@ +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' + + - 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.' From 0d9659a6907608769bd7db27bb61c55c1d4fde85 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Thu, 21 May 2026 13:39:31 +0200 Subject: [PATCH 08/10] style: prettier formatting Co-Authored-By: Claude Sonnet 4.6 --- src/scenarios/client/auth/wif-jwt-bearer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/scenarios/client/auth/wif-jwt-bearer.ts b/src/scenarios/client/auth/wif-jwt-bearer.ts index 40c1de68..eebbb6da 100644 --- a/src/scenarios/client/auth/wif-jwt-bearer.ts +++ b/src/scenarios/client/auth/wif-jwt-bearer.ts @@ -153,7 +153,8 @@ export class WifJwtBearerScenario implements Scenario { this.failedOnce = true; return { error: 'invalid_scope', - errorDescription: 'Requested scope is not permitted for this grant' + errorDescription: + 'Requested scope is not permitted for this grant' }; } return { From 1cd8ddb76ab4e6ac1e9bb294b5ec5411f600e53f Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Thu, 21 May 2026 21:20:08 +0200 Subject: [PATCH 09/10] feat(wif-jwt-bearer): add wif-grant-fallback check for unauthorized_client fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client bug class: WIF client receives unauthorized_client from JWT-bearer grant and silently switches to authorization_code instead of surfacing the error. The MCP SDK retries after UnauthorizedClientError (auth.js:152-154), calling prepareTokenRequest() a second time. WifGrantFallbackProvider exploits this by returning authorization_code params on the second call. Spec anchor: RFC 7523 §2.1 — clients MUST use the JWT-bearer grant type; silent grant-type switching hides authentication failures. Co-Authored-By: Claude Sonnet 4.6 --- .../auth-test-wif-grant-fallback.ts | 15 +++ .../clients/typescript/everything-client.ts | 93 +++++++++++++++++++ src/scenarios/client/auth/index.test.ts | 11 ++- src/scenarios/client/auth/wif-jwt-bearer.ts | 25 +++++ src/seps/sep-1933.yaml | 3 + 5 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 examples/clients/typescript/auth-test-wif-grant-fallback.ts 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/everything-client.ts b/examples/clients/typescript/everything-client.ts index 7b0733d5..b374e952 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -939,6 +939,99 @@ export async function runWifJwtBearerScopeRejected( 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 e55cfb91..bc57ff8e 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -13,7 +13,8 @@ import { runWifJwtBearerWrongAudience, runWifJwtBearerMissingAssertion, runWifJwtBearerExpiredAssertion, - runWifJwtBearerScopeRejected + 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'; @@ -268,4 +269,12 @@ describe('WIF JWT-bearer negative tests', () => { 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/wif-jwt-bearer.ts b/src/scenarios/client/auth/wif-jwt-bearer.ts index eebbb6da..07a41cae 100644 --- a/src/scenarios/client/auth/wif-jwt-bearer.ts +++ b/src/scenarios/client/auth/wif-jwt-bearer.ts @@ -21,6 +21,7 @@ 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'; @@ -52,6 +53,23 @@ export class WifJwtBearerScenario implements Scenario { 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', @@ -137,6 +155,13 @@ export class WifJwtBearerScenario implements Scenario { }); 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', diff --git a/src/seps/sep-1933.yaml b/src/seps/sep-1933.yaml index f215f7e6..7ecf9cb3 100644 --- a/src/seps/sep-1933.yaml +++ b/src/seps/sep-1933.yaml @@ -25,6 +25,9 @@ requirements: - 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.' From 8820bfef8e110bebf9ddada9293e2b9ce37bda37 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Thu, 21 May 2026 21:20:40 +0200 Subject: [PATCH 10/10] style: rename unused scope param in WifGrantFallbackProvider Co-Authored-By: Claude Sonnet 4.6 --- examples/clients/typescript/everything-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index b374e952..402b5166 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -992,7 +992,7 @@ class WifGrantFallbackProvider implements OAuthClientProvider { throw new Error('codeVerifier is not used for JWT-bearer flow'); } - prepareTokenRequest(scope?: string): URLSearchParams { + prepareTokenRequest(_scope?: string): URLSearchParams { this.attemptCount++; if (this.attemptCount === 1) { const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE });