feat(auth): client conformance for Workload Identity Federation (SEP-1933) (#268)

* feat(auth): add JWT-bearer helper for WIF client conformance (SEP-1933)

Adds createWorkloadJwt and generateWorkloadKeypair to provide reusable,
tested JWT signing infrastructure for the upcoming wif-jwt-bearer scenario
(PR #2). Also extracts JWT_BEARER_GRANT_TYPE constant and migrates
cross-app-access.ts to use it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(auth): add JWT-bearer helper for WIF client conformance (SEP-1933)

Adds createWorkloadJwt and generateWorkloadKeypair to provide reusable,
tested JWT signing infrastructure for the upcoming wif-jwt-bearer scenario
(PR #2). Also extracts JWT_BEARER_GRANT_TYPE constant and migrates
cross-app-access.ts to use it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* style: apply prettier formatting to wif-jwt-bearer.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* style: prettier formatting on everything-client.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wif-jwt-bearer): let errors propagate from broken example clients

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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* style: prettier formatting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(wif-jwt-bearer): add wif-grant-fallback check for unauthorized_client fallback

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 <noreply@anthropic.com>

* style: rename unused scope param in WifGrantFallbackProvider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(wif-jwt-bearer): use realistic SPIFFE and K8s PSAT token formats

valid_jwt uses a SPIFFE JWT-SVID subject (spiffe://conformance-test.local/...)
issued by a SPIRE-style issuer. wrong_audience_jwt uses a Kubernetes PSAT
subject and kubernetes.io claims, mirroring how K8s projected service-account
tokens look in production. Token verification and client behaviour are
unchanged; the formats ground the scenario in real workload identity platforms.

k8s_issuer and k8s_subject added to context and schema for external clients
that want to construct their own K8s-style assertions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style: prettier formatting on wif-jwt-bearer.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(wif-jwt-bearer): drop informational k8s_issuer/k8s_subject from context

These fields were not used by any client handler and are redundant since
the K8s claims are already baked into wrong_audience_jwt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wif-jwt-bearer): address pre-merge review feedback

- Downgrade wif-no-retry, wif-grant-fallback, and wif-assertion-scope-rejected
  to WARNING; RFC 7523 is silent on client retry and grant-type switching, so
  FAILURE status was not traceable to spec text
- Add WifRetryProvider broken client that re-sends JWT-bearer after
  unauthorized_client, making wif-no-retry actually exercisable as a WARNING
- Drop getChecks() sentinel (non-standard pattern that overloaded the
  wif-assertion-verified check ID); test harness detects empty check sets
- Add comment on DRAFT_PROTOCOL_VERSION workaround for runner limitation
- Add comment explaining why both slash/no-slash audience forms are accepted
- Tighten schema comments for audience URL constraint and ES256 literal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wif-jwt-bearer): drop decorative fields and neutralise JWT fixtures

- Remove issuer and subject from context schema and scenario return value;
  neither is validated by the AS or used by the client, making them
  misleading. The iss exclusion rationale is updated to explain why.
- Replace SPIFFE/K8s-PSAT constants and the kubernetes.io additionalClaims
  with neutral values across all three JWT fixtures; no check inspects
  token format, so format flavour was decorative and invited false
  assumptions about fixture semantics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wif-jwt-bearer): remove workaround comment, drop signing_algorithm, annotate retry provider

- Remove self-admitted workaround comment on DRAFT_PROTOCOL_VERSION; the
  classification stands on its own and the comment was inviting challenge
- Drop signing_algorithm from context schema and scenario return; the field
  was redundant once the value was fixed to ES256, and the assertion is opaque
  to the client regardless
- Add comment to WifRetryProvider explaining it deliberately omits the
  hasAttempted guard so the SDK retry reaches the AS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wif-jwt-bearer): export scope constants, remove unused AS field, annotate state machine

- Export WIF_TRIGGER_UNAUTHORIZED_SCOPE and WIF_REJECTED_SCOPE from
  createWorkloadJwt.ts and import them in everything-client.ts; removes
  duplicate bare-string declarations that could silently diverge
- Remove tokenEndpointAuthSigningAlgValuesSupported from mock AS config;
  the SDK does not consume this field in the JWT-bearer flow
- Add comment to onTokenRequest clarifying wif-no-retry and wif-grant-fallback
  fire on any second request after any failure, not only post-unauthorized_client

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wif-jwt-bearer): address pcarleton review comments

- Move all broken WIF provider classes and runners to wif-broken-clients.ts;
  everything-client.ts now contains only the conformant WifJwtBearerProvider
- Add sentinel in getChecks() for !tokenRequestReceived to prevent a silent
  pass when the client never sends a request
- Drop wif-assertion-scope-rejected check; the no-retry behaviour after
  invalid_scope is already covered by wif-no-retry, and sep-1933.yaml updated
  accordingly
- Remove audience from context schema and scenario return; the field was
  unused by any client implementation
- Expand scenario description to tell a client implementer what to implement
- Apply suggested description fix to wif-grant-fallback (does not assume
  unauthorized_client was the specific error that preceded the fallback)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wif-jwt-bearer): drop number type from expiresIn, use jose duration strings

Jose supports negative duration strings ('-60s', '60 seconds ago') that produce
already-expired tokens without needing absolute epoch arithmetic. Drop the
number overload, update the expired_jwt call site to '-60s', and update the
unit test accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Otto 2026-07-02 15:13:14 +02:00 • committed by GitHub
commit 1ca3bc301b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1229 additions and 4 deletions

View file

@ -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 './wif-broken-clients.js';
import { runAsCli } from './helpers/cliRunner.js';
runAsCli(
runWifJwtBearerExpiredAssertion,
import.meta.url,
'auth-test-wif-expired-assertion <server-url>'
);

View file

@ -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 './wif-broken-clients.js';
import { runAsCli } from './helpers/cliRunner.js';
runAsCli(
runWifJwtBearerGrantFallback,
import.meta.url,
'auth-test-wif-grant-fallback <server-url>'
);

View file

@ -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 './wif-broken-clients.js';
import { runAsCli } from './helpers/cliRunner.js';
runAsCli(
runWifJwtBearerMissingAssertion,
import.meta.url,
'auth-test-wif-no-assertion <server-url>'
);

View file

@ -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 './wif-broken-clients.js';
import { runAsCli } from './helpers/cliRunner.js';
runAsCli(
runWifJwtBearerRetry,
import.meta.url,
'auth-test-wif-retry <server-url>'
);

View file

@ -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 './wif-broken-clients.js';
import { runAsCli } from './helpers/cliRunner.js';
runAsCli(
runWifJwtBearerScopeRejected,
import.meta.url,
'auth-test-wif-scope-rejected <server-url>'
);

View file

@ -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 './wif-broken-clients.js';
import { runAsCli } from './helpers/cliRunner.js';
runAsCli(
runWifJwtBearerWrongAudience,
import.meta.url,
'auth-test-wif-wrong-audience <server-url>'
);

View file

@ -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 { DRAFT_PROTOCOL_VERSION } from '../../../src/types.js';
@ -990,6 +997,107 @@ async function runMRTRClient(serverUrl: string): Promise<void> {
registerScenario('sep-2322-client-request-state', runMRTRClient);
// ============================================================================
// 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<void> {
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);
// ============================================================================
// Main entry point
// ============================================================================

View file

@ -0,0 +1,303 @@
#!/usr/bin/env node
/**
* Broken WIF client implementations for negative conformance tests.
*
* Each class/runner exercises a specific non-compliant behaviour.
* WifJwtBearerProvider (the well-behaved client) lives in everything-client.ts.
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.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 { ClientConformanceContextSchema } from '../../../src/schemas/context.js';
function parseWifContext() {
const raw = process.env.MCP_CONFORMANCE_CONTEXT;
if (!raw) throw new Error('MCP_CONFORMANCE_CONTEXT not set');
const ctx = ClientConformanceContextSchema.parse(JSON.parse(raw));
if (ctx.name !== 'auth/wif-jwt-bearer') {
throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`);
}
return ctx;
}
// Base class with OAuthClientProvider boilerplate shared across all broken variants.
abstract class WifProviderBase implements OAuthClientProvider {
private _tokens?: OAuthTokens;
protected _clientInfo: OAuthClientInformation;
protected readonly _clientMetadata: OAuthClientMetadata;
constructor(clientId: string, clientName: string) {
this._clientInfo = { client_id: clientId };
this._clientMetadata = {
client_name: clientName,
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');
}
abstract prepareTokenRequest(_scope?: string): URLSearchParams;
}
async function runWifBrokenClient(
serverUrl: string,
provider: WifProviderBase,
clientName: string
): Promise<void> {
const client = new Client(
{ name: clientName, version: '1.0.0' },
{ capabilities: {} }
);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
authProvider: provider
});
await client.connect(transport);
await client.listTools();
await transport.close();
}
// ---------------------------------------------------------------------------
// Wrong audience
// ---------------------------------------------------------------------------
// BUG: presents a JWT whose aud does not match the AS
class WifWrongAudienceProvider extends WifProviderBase {
constructor(
private readonly assertion: string,
clientId: string
) {
super(clientId, 'conformance-wif-wrong-audience');
}
prepareTokenRequest(_scope?: string): URLSearchParams {
const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE });
params.set('assertion', this.assertion);
return params;
}
}
export async function runWifJwtBearerWrongAudience(
serverUrl: string
): Promise<void> {
const ctx = parseWifContext();
await runWifBrokenClient(
serverUrl,
new WifWrongAudienceProvider(ctx.wrong_audience_jwt, ctx.client_id),
'conformance-wif-wrong-audience'
);
}
// ---------------------------------------------------------------------------
// Missing assertion
// ---------------------------------------------------------------------------
// BUG: omits the assertion parameter entirely
class WifMissingAssertionProvider extends WifProviderBase {
constructor(clientId: string) {
super(clientId, 'conformance-wif-no-assertion');
}
prepareTokenRequest(_scope?: string): URLSearchParams {
return new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE });
}
}
export async function runWifJwtBearerMissingAssertion(
serverUrl: string
): Promise<void> {
const ctx = parseWifContext();
await runWifBrokenClient(
serverUrl,
new WifMissingAssertionProvider(ctx.client_id),
'conformance-wif-no-assertion'
);
}
// ---------------------------------------------------------------------------
// Expired assertion
// ---------------------------------------------------------------------------
// BUG: presents a JWT whose exp has already passed
class WifExpiredAssertionProvider extends WifProviderBase {
constructor(
private readonly assertion: string,
clientId: string
) {
super(clientId, 'conformance-wif-expired-assertion');
}
prepareTokenRequest(_scope?: string): URLSearchParams {
const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE });
params.set('assertion', this.assertion);
return params;
}
}
export async function runWifJwtBearerExpiredAssertion(
serverUrl: string
): Promise<void> {
const ctx = parseWifContext();
await runWifBrokenClient(
serverUrl,
new WifExpiredAssertionProvider(ctx.expired_jwt, ctx.client_id),
'conformance-wif-expired-assertion'
);
}
// ---------------------------------------------------------------------------
// Scope rejected
// ---------------------------------------------------------------------------
// BUG: requests a scope the AS does not permit for JWT-bearer grant
class WifScopeRejectedProvider extends WifProviderBase {
constructor(
private readonly assertion: string,
clientId: string
) {
super(clientId, 'conformance-wif-scope-rejected');
}
prepareTokenRequest(_scope?: string): URLSearchParams {
const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE });
params.set('assertion', this.assertion);
params.set('scope', WIF_REJECTED_SCOPE);
return params;
}
}
export async function runWifJwtBearerScopeRejected(
serverUrl: string
): Promise<void> {
const ctx = parseWifContext();
await runWifBrokenClient(
serverUrl,
new WifScopeRejectedProvider(ctx.valid_jwt, ctx.client_id),
'conformance-wif-scope-rejected'
);
}
// ---------------------------------------------------------------------------
// Grant fallback
// ---------------------------------------------------------------------------
// BUG: falls back to authorization_code after receiving unauthorized_client
class WifGrantFallbackProvider extends WifProviderBase {
private attemptCount = 0;
constructor(
private readonly assertion: string,
clientId: string
) {
super(clientId, 'conformance-wif-grant-fallback');
}
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<void> {
const ctx = parseWifContext();
await runWifBrokenClient(
serverUrl,
new WifGrantFallbackProvider(ctx.valid_jwt, ctx.client_id),
'conformance-wif-grant-fallback'
);
}
// ---------------------------------------------------------------------------
// Retry
// ---------------------------------------------------------------------------
// BUG: retries JWT-bearer after receiving unauthorized_client.
// Deliberately omits a hasAttempted guard so the SDK retry reaches the AS
// and the wif-no-retry check fires.
class WifRetryProvider extends WifProviderBase {
private attemptCount = 0;
constructor(
private readonly assertion: string,
clientId: string
) {
super(clientId, 'conformance-wif-retry');
}
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) {
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<void> {
const ctx = parseWifContext();
await runWifBrokenClient(
serverUrl,
new WifRetryProvider(ctx.valid_jwt, ctx.client_id),
'conformance-wif-retry'
);
}

View file

@ -4,6 +4,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';
@ -88,7 +89,7 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario {
// Start auth server with JWT bearer grant support only
// Token exchange is handled by IdP
const authApp = createAuthServer(ctx, 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 ({
@ -99,7 +100,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,

View file

@ -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 negative duration string as expiresIn for already-expired tokens', 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,
expiresIn: '-60s'
});
const payload = jose.decodeJwt(token);
expect(payload.exp).toBeLessThan(before);
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');
});
});

View file

@ -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', '-60s', '60 seconds ago'). Use a negative offset to construct already-expired tokens for negative tests. */
expiresIn?: string;
jwtId?: string;
issuedAt?: number;
notBefore?: number;
algorithm?: string;
additionalClaims?: Record<string, unknown>;
}
export async function createWorkloadJwt(
opts: CreateWorkloadJwtOptions
): Promise<string> {
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<string, unknown> = 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<WorkloadKeypair> {
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 };
}

View file

@ -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/wif-broken-clients';
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';
@ -265,3 +274,68 @@ 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', {
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
});
});
});

View file

@ -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
@ -79,5 +80,6 @@ export const draftScenariosList: Scenario[] = [
new IssParameterWrongIssuerScenario(),
new IssParameterUnexpectedScenario(),
new IssParameterNormalizedVariantScenario(),
new MetadataIssuerMismatchScenario()
new MetadataIssuerMismatchScenario(),
new WifJwtBearerScenario()
];

View file

@ -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'
}
};

View file

@ -0,0 +1,321 @@
import * as jose from 'jose';
import type {
Scenario,
ConformanceCheck,
ScenarioUrls,
SpecVersion
} from '../../../types';
import type { ScenarioContext } from '../../../mock-server';
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 the RFC 7523 JWT-bearer grant for workload identity federation (SEP-1933). ' +
'The client must: use grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, ' +
'include the workload JWT as the assertion parameter, and surface errors ' +
'(invalid_grant, invalid_scope, unauthorized_client) without retrying or switching grant types.';
private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
private tokenRequestReceived = false;
private failedOnce = false;
async start(ctx: ScenarioContext): Promise<ScenarioUrls> {
this.checks = [];
this.tokenRequestReceived = false;
this.failedOnce = false;
const { publicKey, privateKey } = await generateWorkloadKeypair();
const tokenVerifier = new MockTokenVerifier(this.checks);
const authApp = createAuthServer(ctx, 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 a JWT-bearer token request was rejected; 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.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: '-60s'
})
]);
const app = createServer(
ctx,
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,
valid_jwt: validJwt,
wrong_audience_jwt: wrongAudienceJwt,
expired_jwt: expiredJwt
}
};
}
async stop() {
await this.authServer.stop();
await this.server.stop();
}
getChecks(): ConformanceCheck[] {
if (!this.tokenRequestReceived) {
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;
}
}

View file

@ -31,6 +31,13 @@ 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(),
valid_jwt: z.string(),
wrong_audience_jwt: z.string(),
expired_jwt: z.string()
})
]);

35
src/seps/sep-1933.yaml Normal file
View file

@ -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: <workload-jwt> (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, including after invalid_scope responses (WARNING until SEP-1933 lands normative spec text; RFC 7523 is silent on client retry behaviour)'
- 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.'