Skip to content

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

Open
coding-bobo wants to merge 18 commits into
modelcontextprotocol:mainfrom
coding-bobo:worktree-feat+wif-jwt-helper
Open

feat(auth): client conformance for Workload Identity Federation (SEP-1933)#268
coding-bobo wants to merge 18 commits into
modelcontextprotocol:mainfrom
coding-bobo:worktree-feat+wif-jwt-helper

Conversation

@coding-bobo
Copy link
Copy Markdown

@coding-bobo coding-bobo commented May 12, 2026

Motivation

As autonomous workloads increasingly interact with MCP servers, the existing authorization mechanisms - primarily designed around human-centric OAuth flows or statically registered client credentials - impose operational friction and do not align with how modern platforms provision identity. Workloads running under Kubernetes, SPIFFE/SPIRE, or cloud-native runtimes already receive short-lived, cryptographically verifiable JWTs that reflect their runtime identity. Requiring those workloads to additionally register as OAuth clients or manage long-lived secrets adds complexity and weakens security posture.

SEP-1933 addresses this by letting MCP directly leverage platform-provided credentials via the JWT Bearer grant (RFC 7523), eliminating a separate client identity lifecycle while preserving strong authentication guarantees. This PR adds client conformance coverage for that flow, so any MCP client that claims SEP-1933 support can be checked against the same behavioural contract regardless of the underlying workload identity platform.

Scope

Conformance tests are scoped to client behaviour. From the client's perspective, it receives a pre-issued JWT assertion (via scenario context) and must present it correctly at the token endpoint. OIDC discovery, signing-key resolution, issuer-allowlist policy, and jti replay detection are authorization-server concerns and are deliberately deferred to AS conformance work - they don't exercise any new client code path.

What's included

Signing helper

  • helpers/createWorkloadJwt.ts: generateWorkloadKeypair(alg?) and createWorkloadJwt(opts), plus JWT_BEARER_GRANT_TYPE and DEFAULT_WORKLOAD_JWT_ALG constants. Keypair generation is intentionally separated from signing so the scenario can sign with a key the AS does not trust without API gymnastics. expiresIn accepts either a duration string or an absolute epoch number, so already-expired assertions can be constructed deterministically for negative tests.
  • helpers/createWorkloadJwt.test.ts: 13 unit tests (round-trip sign+verify, default lifetime, expired-token construction via numeric epoch, unique jti, array audience preservation per RFC 7519 §4.1.3, reserved-claim protection, caller-supplied jti/notBefore, algorithm override).
  • cross-app-access.ts: two bare URN literals replaced with JWT_BEARER_GRANT_TYPE. No behaviour change.

API shape mirrors existing patterns in client-credentials.ts (keypair + PEM export) and cross-app-access.ts (SignJWT chain).

Scenario and clients

  • src/scenarios/client/auth/wif-jwt-bearer.ts: generates an ES256 keypair per start(), pre-signs three JWT variants (valid_jwt, wrong_audience_jwt, expired_jwt) with neutral fixture values, passes them via context, and configures the conformance AS with grant_types: [jwt-bearer] and token_endpoint_auth_method: none. DCR is disabled and the client is pre-seeded with WIF_CLIENT_ID, matching the SEP-1933 model where trust is established at the issuer level rather than per-client.
  • src/scenarios/client/auth/spec-references.ts: RFC_7523_JWT_BEARER and SEP_1933_WIF references.
  • src/seps/sep-1933.yaml: requirement traceability.
  • src/schemas/context.ts: auth/wif-jwt-bearer context schema.
  • examples/clients/typescript/everything-client.ts: WifJwtBearerProvider, runWifJwtBearer, and six broken-client runners.
  • examples/clients/typescript/auth-test-wif-*.ts: CLI entrypoints for each broken variant.
  • src/scenarios/client/auth/index.ts: scenario registration in draftScenariosList.
  • src/scenarios/client/auth/index.test.ts: happy-path via draft loop + 6 negative tests.

Checks implemented (9)

Check ID What it verifies Severity
wif-grant-type Client sends grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer FAILURE
wif-assertion-missing Client includes the assertion parameter FAILURE
wif-assertion-verified JWT signature, audience, and expiry are valid FAILURE
wif-assertion-expired Client surfaces invalid_grant for an expired assertion FAILURE
wif-assertion-audience Client surfaces invalid_grant for a wrong-audience assertion FAILURE
wif-assertion-malformed Client surfaces invalid_grant for a bad-signature assertion FAILURE
wif-no-retry Client does not retry after a JWT-bearer failure WARNING
wif-assertion-scope-rejected Client surfaces invalid_scope and does not retry WARNING
wif-grant-fallback Client does not fall back to authorization_code after unauthorized_client WARNING

wif-no-retry, wif-grant-fallback, and wif-assertion-scope-rejected are WARNING because RFC 7523 is silent on client retry and grant-type switching behaviour; they will be upgraded to FAILURE once SEP-1933 lands normative spec text.

Three requirements are explicitly deferred in src/seps/sep-1933.yaml (iss, sub, jti); all are AS policy decisions. The scenario does not expose iss or sub in context because the client presents a pre-signed token and has no mechanism to vary those claims.

Coverage vs. issue #223

Issue #223 lists seven negative checks. This PR covers all seven, plus additional client-observable checks:

  • Directly from the issue: wif-assertion-expired (expired exp), wif-assertion-audience (wrong aud), wif-assertion-missing (missing assertion), wif-assertion-scope-rejected (invalid_scope), wif-grant-fallback (unauthorized_client without grant-type fallback).
  • Added beyond the issue: wif-grant-type, wif-assertion-malformed, wif-no-retry, wif-assertion-verified.

The remaining three issue checks (invalid_grant/unauthorised sub, invalid_grant/replayed jti, issuer allowlist) exercise AS policy rather than client code paths and are deferred to AS conformance work.

Testing

  • npx vitest run src/scenarios/client/auth/helpers/createWorkloadJwt.test.ts - 13 tests pass
  • npx vitest run src/scenarios/client/auth/index.test.ts - 49 tests pass (6 WIF negative tests)
  • npx vitest run - full suite passes (194 tests, 16 files, no regressions)
  • npm run check - typecheck and lint clean

Closes / relates

  • Closes modelcontextprotocol/conformance#223
  • Implements client-side reference test coverage for modelcontextprotocol/modelcontextprotocol#1933

@coding-bobo coding-bobo force-pushed the worktree-feat+wif-jwt-helper branch from 5f756a9 to 7e41fee Compare May 21, 2026 11:30
@coding-bobo coding-bobo changed the title feat(auth): JWT-bearer helper for WIF client conformance (SEP-1933) feat(auth): WIF JWT-bearer client conformance scenario (SEP-1933) May 21, 2026
@coding-bobo coding-bobo changed the title feat(auth): WIF JWT-bearer client conformance scenario (SEP-1933) feat(auth): client conformance for Workload Identity Federation (SEP-1933) May 21, 2026
coding-bobo and others added 18 commits May 21, 2026 23:02
Adds createWorkloadJwt and generateWorkloadKeypair to provide reusable,
tested JWT signing infrastructure for the upcoming wif-jwt-bearer scenario
(PR modelcontextprotocol#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>
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>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r 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>
…escription

- 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>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
…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>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lient 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>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… 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>
- 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>
- 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>
…m, 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>
…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>
@coding-bobo coding-bobo force-pushed the worktree-feat+wif-jwt-helper branch from c5b736e to e5e13c4 Compare May 21, 2026 21:05
@coding-bobo coding-bobo marked this pull request as ready for review May 21, 2026 21:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant