diff --git a/.changeset/wif-workload-identity-provider.md b/.changeset/wif-workload-identity-provider.md new file mode 100644 index 0000000000..4bc5447223 --- /dev/null +++ b/.changeset/wif-workload-identity-provider.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': minor +--- + +Add WorkloadIdentityProvider implementing the SEP-1933 Workload Identity Federation jwt-bearer flow (extension io.modelcontextprotocol/auth/wif). MCP clients can now authenticate with platform-issued workload JWTs such as Kubernetes service account tokens and SPIFFE JWT-SVIDs, with no client secret and no dynamic client registration. diff --git a/docs/clients/machine-auth.md b/docs/clients/machine-auth.md index 1b3c6614bb..bd98011817 100644 --- a/docs/clients/machine-auth.md +++ b/docs/clients/machine-auth.md @@ -98,10 +98,40 @@ Both exchanges behind `CrossAppAccessProvider` are exported as standalone functi All three live in [`client/crossAppAccess`](../api/@modelcontextprotocol/client/client/crossAppAccess.md) in the API reference. +## Authenticate with a workload identity + +**Workload Identity Federation** (WIF, SEP-1933, extension id `io.modelcontextprotocol/auth/wif`) lets a workload that already holds a platform-issued JWT exchange that JWT directly for an MCP access token: a Kubernetes projected service account token, a SPIFFE JWT-SVID, a cloud identity token. No client secret is provisioned anywhere and no dynamic client registration happens; the workload's existing platform identity is the credential. + +`WorkloadIdentityProvider` runs the RFC 7523 `jwt-bearer` grant (`urn:ietf:params:oauth:grant-type:jwt-bearer`) with the workload JWT as the `assertion`, the same grant `CrossAppAccessProvider` uses, but presenting the workload's own JWT instead of a JWT Authorization Grant exchanged from an IdP. + +```ts source="../../examples/guides/clients/machine-auth.examples.ts#workloadIdentity_provider" +function fileAssertionSource(tokenPath: string): WorkloadAssertionCallback { + return async () => (await readWorkloadToken(tokenPath)).trim(); +} + +const authProvider = new WorkloadIdentityProvider({ + clientId: 'reporting-job', + assertion: fileAssertionSource('/var/run/secrets/workload/token') +}); + +const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); +``` + +`clientId` is required by this SDK's auth flow, which operates on stored client information; RFC 7523 and SEP-1933 themselves allow assertion-only requests with no client identifier. The value is sent as plain public-client identification, so pick one your authorization server expects or ignores. `assertion` is either a static JWT string or a `WorkloadAssertionCallback` that returns one per token request, given `{ authorizationServerUrl, resourceUrl, scope, fetchFn }` for the MCP server the SDK just discovered. `scope` sets the requested scope, `clientName` sets the client metadata's display name, and `fetchFn` overrides the fetch implementation handed to the callback. Set `expectedIssuer` whenever the assertion's audience is fixed (a projected token file, a pre-minted JWT): the resource server controls where discovery points, and the pin makes the SDK throw `AuthorizationServerMismatchError` instead of posting a real-audience assertion to an unexpected authorization server. + +::: tip +The audience the assertion is minted for is authorization-server and profile specific. The MCP conformance profile for SEP-1933 expects the authorization server's issuer identifier, which is also where draft-ietf-oauth-rfc7523bis is heading (the draft itself permits either the issuer identifier or the token endpoint URL for authorization grants). In every case the audience names the authorization server, never the MCP server's resource URL: mint against `authorizationServerUrl`, not `resourceUrl`. +::: + +`WorkloadIdentityProvider` is non-interactive: it has no `redirectUrl` and never falls back to an authorization-code grant. Once the authorization server rejects an assertion, the provider refuses to present that same assertion again; refusing that replay is what conformance calls `wif-no-retry`, and it surfaces as `WorkloadAssertionRejectedError`, so reach for a callback instead of a static string whenever the underlying token can expire or be revoked, and the callback should mint a fresh assertion on every call. A static assertion stays refused until the provider sees a different assertion, `invalidateCredentials('all')`, or a new provider instance. There is no refresh token in this flow: a workload holding an expired or rejected JWT re-asserts by fetching a new platform-issued token, it does not refresh the MCP access token. + +See [`examples/oauth-workload-identity`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/oauth-workload-identity) for a runnable, self-verifying end-to-end example. + ## Recap - Every flow on this page plugs in through the same `authProvider` option on `StreamableHTTPClientTransport`. - `ClientCredentialsProvider` runs the `client_credentials` grant with a shared secret; `PrivateKeyJwtProvider` runs the same grant with a signed JWT assertion in its place. - An `AuthProvider` with only `token()` is enough when something outside the SDK owns the token; without `onUnauthorized`, a 401 throws `UnauthorizedError`. - `CrossAppAccessProvider` chains an enterprise IdP token through a JWT Authorization Grant to an MCP access token (SEP-990), and both exchanges are exported standalone. +- `WorkloadIdentityProvider` presents a platform-issued workload JWT (Kubernetes, SPIFFE, cloud identity tokens) directly as an RFC 7523 `jwt-bearer` assertion (SEP-1933); it never retries a rejected assertion unchanged and never falls back to an interactive grant. - Authenticating an end user belongs on [OAuth](./oauth.md). diff --git a/examples/README.md b/examples/README.md index fc57f2de7d..75d1b73fd0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -53,6 +53,7 @@ The one exception to the generic commands is the reference pair: [`cli-client/`] | [`bearer-auth-web/`](./bearer-auth-web/README.md) | Web-standard twin: host/origin guards + `requireBearerAuth` + `createMcpHandler` as one fetch handler | http | dual | | [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual | | [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual | +| [`oauth-workload-identity/`](./oauth-workload-identity/README.md) | OAuth `jwt-bearer` Workload Identity Federation (SEP-1933): in-repo AS + `WorkloadIdentityProvider` reading a projected workload JWT | http | dual | | [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern | ## HTTP hosting variants diff --git a/examples/guides/clients/machine-auth.examples.ts b/examples/guides/clients/machine-auth.examples.ts index 373c5bdc04..9e94a4ce2d 100644 --- a/examples/guides/clients/machine-auth.examples.ts +++ b/examples/guides/clients/machine-auth.examples.ts @@ -12,8 +12,13 @@ * @module */ /* eslint-disable @typescript-eslint/no-unused-vars */ -import type { AuthProvider } from '@modelcontextprotocol/client'; -import { CrossAppAccessProvider, discoverAndRequestJwtAuthGrant, PrivateKeyJwtProvider } from '@modelcontextprotocol/client'; +import type { AuthProvider, WorkloadAssertionCallback } from '@modelcontextprotocol/client'; +import { + CrossAppAccessProvider, + discoverAndRequestJwtAuthGrant, + PrivateKeyJwtProvider, + WorkloadIdentityProvider +} from '@modelcontextprotocol/client'; // "Authenticate with client credentials" — the page's lead block. The import line // is part of the region so the first fence on the page names where the providers @@ -84,6 +89,27 @@ function crossAppAccess_provider(getIdToken: () => Promise) { return transport; } +// "Authenticate with a workload identity" - SEP-1933 Workload Identity +// Federation. The assertion is a platform-issued workload JWT (a Kubernetes +// projected service account token, a SPIFFE JWT-SVID, a cloud identity +// token) presented directly as the RFC 7523 jwt-bearer grant's assertion. +function workloadIdentity_provider(readWorkloadToken: (path: string) => Promise) { + //#region workloadIdentity_provider + function fileAssertionSource(tokenPath: string): WorkloadAssertionCallback { + return async () => (await readWorkloadToken(tokenPath)).trim(); + } + + const authProvider = new WorkloadIdentityProvider({ + clientId: 'reporting-job', + assertion: fileAssertionSource('/var/run/secrets/workload/token') + }); + + const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); + //#endregion workloadIdentity_provider + return transport; +} + void bearerToken_provider; void privateKeyJwt_provider; void crossAppAccess_provider; +void workloadIdentity_provider; diff --git a/examples/oauth-workload-identity/README.md b/examples/oauth-workload-identity/README.md new file mode 100644 index 0000000000..1c1033f20b --- /dev/null +++ b/examples/oauth-workload-identity/README.md @@ -0,0 +1,40 @@ +# oauth-workload-identity + +Workload Identity Federation (WIF, [SEP-1933](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1933), extension id `io.modelcontextprotocol/auth/wif`) over the RFC 7523 **`jwt-bearer`** grant, fully self-verifying with no browser and no client secret. + +A workload running on a modern platform already holds a signed statement of its own identity: a Kubernetes projected service account token, a SPIFFE JWT-SVID, a cloud instance identity token. WIF is the flow that turns that platform-issued JWT into an MCP access token: the client posts it to the Authorization Server's token endpoint as `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` with the JWT in the `assertion` parameter, and the AS returns a Bearer token if it trusts that issuer for that subject. + +The point is that no long-lived secret is provisioned anywhere. `client_credentials` (see [`../oauth-client-credentials/`](../oauth-client-credentials/README.md)) needs a `client_secret` to be minted, stored, distributed and rotated; WIF replaces it with a short-lived credential the platform mints on its own schedule and the workload never has to keep. `WorkloadIdentityProvider` is non-interactive by construction: it exposes no `redirectUrl`, and it refuses to re-present an assertion the AS has already rejected rather than retrying or falling back to an interactive grant. + +## What runs + +- `server.ts` starts one process playing three roles: + - a **workload issuer** with no listener: an ES256 keypair generated at startup, standing in for the Kubernetes API server or a SPIFFE server. It mints one workload JWT (`iss` the issuer, `sub` `spiffe://demo.example/mcp-workload`, `aud` the AS issuer identifier, five-minute `exp`, random `jti`) and writes it to a file, the way the kubelet projects a service account token into a pod. + - a minimal **`jwt-bearer`-only Authorization Server** on `--port + 1`. Its RFC 8414 metadata advertises `grant_types_supported: ['urn:ietf:params:oauth:grant-type:jwt-bearer']` and `token_endpoint_auth_methods_supported: ['none']`. Its `/token` endpoint verifies the assertion with `jose.jwtVerify` against the workload issuer's key (issuer, subject and audience all checked) and mints an opaque access token. `@mcp-examples/shared`'s AS helper only implements `client_credentials`, so this story ships its own token endpoint. + - the MCP **resource server** on `--port` - `createMcpHandler` behind `requireBearerAuth` from `@modelcontextprotocol/express`, advertising the AS via `mcpAuthMetadataRouter` (RFC 9728 + RFC 8414). +- `client.ts` first asserts a bare request is `401` with a `WWW-Authenticate` challenge, then connects with a `WorkloadIdentityProvider` whose `assertion` is a `fileAssertionSource(path)` callback. The SDK auth driver discovers the AS from the challenge, posts the `jwt-bearer` grant to `/token`, attaches the returned Bearer token, and the `whoami` tool's `ctx.authInfo` carries the federated workload subject and granted `scopes` end to end. + +Both halves derive the token file path from the MCP port (`os.tmpdir()/mcp-wif-workload-token-.jwt`), so no handshake is needed between the two processes. Set `WIF_WORKLOAD_TOKEN_PATH` on both to point at a different location; the server writes it fresh and fails at startup rather than overwriting a file that already exists there. + +## Run it + +```bash +pnpm --filter @mcp-examples/oauth-workload-identity server -- --http --port 3000 +pnpm --filter @mcp-examples/oauth-workload-identity client -- --http http://127.0.0.1:3000/mcp +``` + +The client prints the tool list and exits `0`; any mismatch throws and exits non-zero. HTTP-only; runs on both protocol eras (the client honours `--legacy` via `parseExampleArgs().era`). + +## Audience: what the assertion's `aud` must say + +The workload JWT's `aud` is the **authorization server's issuer identifier** - the `issuer` value from its RFC 8414 metadata, here `http://127.0.0.1:`. That is what the MCP conformance profile expects, and where [draft-ietf-oauth-rfc7523bis](https://datatracker.ietf.org/doc/draft-ietf-oauth-rfc7523bis/) is heading (the draft permits either the issuer identifier or the token endpoint URL for authorization grants). The assertion is addressed to the AS that will consume it, never to the MCP server the resulting access token is spent on; an AS must reject an assertion whose audience does not name it. + +When the assertion comes from a callback, the callback owns that decision, which is why `WorkloadAssertionContext` hands it the discovered `authorizationServerUrl` (and `resourceUrl`) - a token-service call can mint an assertion for exactly the AS the SDK just discovered. This example's assertion is pre-minted at startup instead, so its audience is fixed. The demo AS accepts the issuer identifier with or without a trailing slash, because platform token services differ on that detail. + +## Where assertions come from in production + +- **Kubernetes projected service account tokens.** A `serviceAccountToken` volume projection with `audience: ` writes a JWT into the pod and the kubelet rewrites that file in place as the token rotates. `fileAssertionSource` re-reads the file on every token request, which is exactly what that rotation pattern requires; read it once at startup instead and the process pins a credential that stops working when it expires. +- **SPIFFE JWT-SVIDs.** A workload fetches a JWT-SVID for the AS audience from the SPIFFE Workload API over its local socket, so the assertion never touches the filesystem. That is a callback that calls the Workload API instead of `readFile`, with the same `WorkloadAssertionCallback` shape. +- **Cloud instance and workload identity tokens.** GitHub Actions OIDC, GCP's identity token endpoint, Azure managed identity and similar services expose a metadata or token endpoint that mints an audience-scoped JWT on demand; the callback receives `fetchFn` for exactly that call. + +`fileAssertionSource` lives in this example rather than in `@modelcontextprotocol/client`: the client package's root entry stays runtime-neutral for browser and Cloudflare Workers bundlers, so it cannot reach for `node:fs`. It is four lines, and copying it is the intended path. diff --git a/examples/oauth-workload-identity/client.ts b/examples/oauth-workload-identity/client.ts new file mode 100644 index 0000000000..19293e9f72 --- /dev/null +++ b/examples/oauth-workload-identity/client.ts @@ -0,0 +1,100 @@ +/** + * Self-verifying Workload Identity Federation client (SEP-1933). + * + * 1. A bare request is `401` with a `WWW-Authenticate` challenge that names the + * Protected Resource Metadata URL. + * 2. A `Client` with a {@linkcode WorkloadIdentityProvider} on its transport + * follows that challenge → AS metadata → `POST /token` with + * `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` and the workload + * JWT as `assertion` → Bearer token → reaches the `whoami` tool, whose + * `ctx.authInfo` carries the federated workload subject and granted scopes. + * + * No browser, no readline, no client secret. The only credential is the JWT the + * platform already put on disk for this workload. + */ +import { readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { check, parseExampleArgs } from '@mcp-examples/shared'; +import type { WorkloadAssertionCallback } from '@modelcontextprotocol/client'; +import { + Client, + StreamableHTTPClientTransport, + WorkloadAssertionRejectedError, + WorkloadIdentityProvider +} from '@modelcontextprotocol/client'; + +const { url, era } = parseExampleArgs(); + +// Same derivation as `server.ts`: the projected token sits at a port-derived +// path so neither half needs an out-of-band handshake. +const tokenPath = process.env.WIF_WORKLOAD_TOKEN_PATH ?? path.join(tmpdir(), `mcp-wif-workload-token-${new URL(url).port}.jwt`); +// The AS this workload's token was minted for (server.ts runs it on port + 1). +const expectedIssuer = `http://127.0.0.1:${Number(new URL(url).port) + 1}`; + +/** + * Reads the workload JWT from disk on every token request. Kubernetes rewrites a + * projected service account token in place as it rotates, so re-reading per call + * is what keeps a long-lived process from pinning an assertion until it expires. + */ +function fileAssertionSource(tokenFile: string): WorkloadAssertionCallback { + return async () => { + const jwt = await readFile(tokenFile, 'utf8'); + return jwt.trim(); + }; +} + +// Unauthenticated → 401 + WWW-Authenticate naming the PRM URL. +const unauth = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }) +}); +check.equal(unauth.status, 401, 'bare request must be 401'); +check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/); +check.match(unauth.headers.get('www-authenticate') ?? '', /oauth-protected-resource/); + +// Authenticated by exchanging the workload JWT for an access token. The file's +// audience is fixed, so pin the issuer: if a compromised resource server pointed +// discovery at a different AS, the SDK throws AuthorizationServerMismatchError +// instead of posting the real-audience assertion there. +const provider = new WorkloadIdentityProvider({ + clientId: 'demo-workload', + assertion: fileAssertionSource(tokenPath), + expectedIssuer +}); +const client = new Client( + { name: 'workload-identity-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); +await client.connect(new StreamableHTTPClientTransport(new URL(url), { authProvider: provider })); + +const tokens = provider.tokens(); +check.ok(tokens?.access_token, 'WorkloadIdentityProvider exchanged the workload JWT for an access_token'); +check.equal(tokens?.token_type, 'Bearer'); + +const { tools } = await client.listTools(); +console.log(`tools: ${tools.map(t => t.name).join(', ')}`); +check.ok( + tools.some(t => t.name === 'whoami'), + 'the federated token reaches the tool list' +); + +const result = await client.callTool({ name: 'whoami', arguments: {} }); +const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''; +const seen = JSON.parse(text) as { clientId: string; scopes: string[]; workloadSubject: string }; +check.equal(seen.clientId, 'demo-workload', 'ctx.authInfo.clientId round-trips'); +check.equal(seen.workloadSubject, 'spiffe://demo.example/mcp-workload', 'the assertion subject reached the resource server'); +check.ok(seen.scopes.includes('mcp:tools'), 'ctx.authInfo.scopes carries the granted scope'); + +// Rejected assertions are not retried (the conformance suite's `wif-no-retry` check): a bad audience or +// an expired file surfaces as one loud error, not a retry loop or a silent fall +// back to an interactive grant. A second provider carrying a bogus assertion proves it. +const rejectedProvider = new WorkloadIdentityProvider({ clientId: 'demo-workload', assertion: 'not.a.jwt' }); +const rejectedClient = new Client({ name: 'workload-identity-client', version: '1.0.0' }); +const rejectedConnect = rejectedClient.connect(new StreamableHTTPClientTransport(new URL(url), { authProvider: rejectedProvider })); +await check.rejects(rejectedConnect, WorkloadAssertionRejectedError); +console.log('rejected assertion: connect failed loudly, no retry and no interactive fallback'); + +await client.close(); diff --git a/examples/oauth-workload-identity/package.json b/examples/oauth-workload-identity/package.json new file mode 100644 index 0000000000..7f3124a370 --- /dev/null +++ b/examples/oauth-workload-identity/package.json @@ -0,0 +1,32 @@ +{ + "name": "@mcp-examples/oauth-workload-identity", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "cors": "catalog:runtimeServerOnly", + "express": "catalog:runtimeServerOnly", + "jose": "catalog:runtimeClientOnly", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@types/cors": "catalog:devTools", + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "dual", + "path": "/mcp", + "//": "Workload Identity Federation is HTTP-layer and era-agnostic; the client honours --legacy via parseExampleArgs().era. The server writes the demo workload JWT to a port-derived path under os.tmpdir() that the client re-reads." + } +} diff --git a/examples/oauth-workload-identity/server.ts b/examples/oauth-workload-identity/server.ts new file mode 100644 index 0000000000..7124f4fc91 --- /dev/null +++ b/examples/oauth-workload-identity/server.ts @@ -0,0 +1,231 @@ +/** + * Workload Identity Federation (SEP-1933) over the RFC 7523 **`jwt-bearer`** + * grant: a workload swaps a platform-issued JWT for an MCP access token, with + * no client secret and no browser anywhere in the flow. + * + * One process plays three roles, two of them listening on adjacent ports: + * + * - a **workload issuer** (no listener): an ES256 keypair minted at startup, + * standing in for a Kubernetes API server or a SPIFFE server. It writes one + * short-lived workload JWT to a file, the way the kubelet projects a service + * account token into a pod. + * - `:PORT+1` - a minimal **Authorization Server** that implements the + * `urn:ietf:params:oauth:grant-type:jwt-bearer` grant only. It verifies the + * assertion against the workload issuer's key, checks the issuer, subject and + * audience, then mints an opaque access token. `@mcp-examples/shared`'s AS + * helper is `client_credentials`-only, so this story ships its own token + * endpoint. + * - `:PORT` - the MCP **Resource Server**: `createMcpHandler` behind + * `requireBearerAuth`, advertising the AS via `mcpAuthMetadataRouter` + * (RFC 9728 Protected Resource Metadata + RFC 8414 AS metadata). + * + * DEMO ONLY - NOT FOR PRODUCTION. A real AS fetches the workload issuer's JWKS + * (`jose.createRemoteJWKSet`) instead of holding its public key in-process, and a + * real workload reads its assertion from a projected volume or a SPIFFE Workload + * API socket instead of a temp file this process wrote. + */ +import { randomUUID } from 'node:crypto'; +import { rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; +import { + createMcpExpressApp, + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth +} from '@modelcontextprotocol/express'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import express from 'express'; +import * as jose from 'jose'; +import * as z from 'zod/v4'; + +const JWT_BEARER_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; + +const { port } = parseExampleArgs(); +const AUTH_PORT = port + 1; +// 127.0.0.1 (not `localhost`) so the PRM `resource` value matches the URL the +// runner passes the client byte-for-byte - the SDK auth driver enforces that. +const mcpServerUrl = new URL(`http://127.0.0.1:${port}/mcp`); +const authServerUrl = new URL(`http://127.0.0.1:${AUTH_PORT}/`); +// RFC 8414 issuer identifier: the AS base URL with no trailing slash. This is +// also the audience the workload assertion must carry. +const issuer = authServerUrl.href.replace(/\/$/, ''); + +// The workload identity the AS trusts, and the client_id it is federated to. +const WORKLOAD_ISSUER = 'https://workload-issuer.example'; +const WORKLOAD_SUBJECT = 'spiffe://demo.example/mcp-workload'; +const DEMO_CLIENT_ID = 'demo-workload'; + +// ---- Workload issuer: mint the projected token ---- +const { privateKey, publicKey } = await jose.generateKeyPair('ES256'); +const workloadJwt = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'ES256' }) + .setIssuer(WORKLOAD_ISSUER) + .setSubject(WORKLOAD_SUBJECT) + .setAudience(issuer) + .setIssuedAt() + .setExpirationTime('5m') + .setJti(randomUUID()) + .sign(privateKey); + +// Both halves derive the same path from the MCP port, so the client needs no +// out-of-band handshake. In a pod this path is the projected-volume mount point +// (`/var/run/secrets/tokens/...`); `WIF_WORKLOAD_TOKEN_PATH` overrides it. +const envTokenPath = process.env.WIF_WORKLOAD_TOKEN_PATH; +const tokenPath = envTokenPath ?? path.join(tmpdir(), `mcp-wif-workload-token-${port}.jwt`); +// Only the derived temp path is this process's to manage: remove a leftover from +// a previous run and delete it again on exit. An env-supplied path is the user's; +// never delete it, and `wx` below refuses to replace an existing file there. +if (!envTokenPath) { + rmSync(tokenPath, { force: true }); + process.on('exit', () => rmSync(tokenPath, { force: true })); +} +// `wx` (O_CREAT | O_EXCL) refuses to follow a pre-planted symlink at the +// well-known path and fails closed if anything reappears between rm and open. +writeFileSync(tokenPath, workloadJwt, { mode: 0o600, flag: 'wx' }); + +// ---- Authorization Server (jwt-bearer only) ---- +const metadata: OAuthMetadata = { + issuer, + token_endpoint: `${issuer}/token`, + // Required by the RFC 8414 schema even though this AS has no interactive leg. + authorization_endpoint: `${issuer}/authorize`, + response_types_supported: [], + grant_types_supported: [JWT_BEARER_GRANT], + // The workload authenticates with the assertion itself, so there is no + // separate client credential to present at the token endpoint. + token_endpoint_auth_methods_supported: ['none'], + scopes_supported: ['mcp:tools'] +}; + +const issuedTokens = new Map(); + +const asApp = express(); +asApp.use(cors()); +asApp.use(express.urlencoded({ extended: false })); + +asApp.get('/.well-known/oauth-authorization-server', (_req, res) => { + res.json(metadata); +}); + +asApp.post('/token', async (req, res) => { + const body = req.body as Record; + if (body.grant_type !== JWT_BEARER_GRANT) { + res.status(400).json({ error: 'unsupported_grant_type' }); + return; + } + if (!body.assertion) { + res.status(400).json({ error: 'invalid_request' }); + return; + } + try { + // RFC 7523 section 3: the assertion must name this AS as its audience + // and come from an issuer the AS trusts for the subject it claims. + // Accept the issuer identifier with or without its trailing slash so a + // workload that appended one still federates. + await jose.jwtVerify(body.assertion, publicKey, { + // Pin the algorithm so a caller cannot pick the verification algorithm for us. + algorithms: ['ES256'], + issuer: WORKLOAD_ISSUER, + subject: WORKLOAD_SUBJECT, + audience: [issuer, `${issuer}/`], + clockTolerance: 5 + }); + } catch (error) { + console.error(`[auth-server] assertion rejected: ${(error as Error).message}`); + res.status(400).json({ error: 'invalid_grant' }); + return; + } + // Bind the issued identity to the verified workload, not to request + // parameters: federation policy decides what this subject may act as. A + // request may omit client_id entirely (the assertion is the credential), + // but it must not claim a different one. + if (body.client_id !== undefined && body.client_id !== DEMO_CLIENT_ID) { + console.error(`[auth-server] client_id ${body.client_id} is not federated to ${WORKLOAD_SUBJECT}`); + res.status(400).json({ error: 'invalid_grant' }); + return; + } + const scopes = (body.scope ?? '').split(' ').filter(Boolean); + if (!scopes.every(scope => metadata.scopes_supported!.includes(scope))) { + res.status(400).json({ error: 'invalid_scope' }); + return; + } + const accessToken = randomUUID(); + const expiresIn = 300; + issuedTokens.set(accessToken, { + token: accessToken, + clientId: DEMO_CLIENT_ID, + scopes, + expiresAt: Math.floor(Date.now() / 1000) + expiresIn, + extra: { workloadSubject: WORKLOAD_SUBJECT } + }); + console.error(`[auth-server] federated ${WORKLOAD_SUBJECT} to an access token for ${scopes.join(' ') || '(no scopes)'}`); + res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: expiresIn, scope: scopes.join(' ') }); +}); + +asApp.listen(AUTH_PORT, '127.0.0.1', () => console.error(`[auth-server] jwt-bearer AS on ${authServerUrl.href}`)); + +// ---- Resource Server (MCP) ---- +const verifier: OAuthTokenVerifier = { + async verifyAccessToken(token): Promise { + const info = issuedTokens.get(token); + if (!info) throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); + // Model expiry explicitly even in the demo so copy-paste users don't ship a fail-open verifier. + if (info.expiresAt !== undefined && Math.floor(Date.now() / 1000) >= info.expiresAt) { + issuedTokens.delete(token); + throw new OAuthError(OAuthErrorCode.InvalidToken, 'token expired'); + } + return info; + } +}; + +const handler = createMcpHandler(ctx => { + const server = new McpServer({ name: 'oauth-workload-identity-example', version: '1.0.0' }); + server.registerTool( + 'whoami', + { description: 'Returns the federated workload identity and its granted scopes.', inputSchema: z.object({}) }, + async () => ({ + content: [ + { + type: 'text', + text: JSON.stringify({ + clientId: ctx.authInfo?.clientId, + scopes: ctx.authInfo?.scopes, + workloadSubject: ctx.authInfo?.extra?.workloadSubject + }) + } + ] + }) + ); + return server; +}); + +const app = createMcpExpressApp(); +app.use( + mcpAuthMetadataRouter({ + oauthMetadata: metadata, + resourceServerUrl: mcpServerUrl, + scopesSupported: ['mcp:tools'], + resourceName: 'oauth-workload-identity example' + }) +); +const auth = requireBearerAuth({ + verifier, + requiredScopes: ['mcp:tools'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) +}); +// `requireBearerAuth` sets `req.auth`; `toNodeHandler` reads it and passes it +// to the factory as `ctx.authInfo`. +const node = toNodeHandler(handler); +app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); + +app.listen(port, '127.0.0.1', () => { + console.error(`[resource-server] MCP on ${mcpServerUrl.href}`); + console.error(`[workload-issuer] projected token at ${tokenPath}`); +}); diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 9ebc6fd251..8a1d849e3d 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -396,8 +396,10 @@ export interface OAuthClientProvider { prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; /** - * Saves the resolved authorization-server **issuer**. Called after a successful - * token exchange (timing changed in v2: was post-discovery, now post-`saveTokens`). + * Saves the resolved authorization-server **issuer**. Called as soon as discovery + * resolves the issuer, before any token request; assertion-based providers + * (Cross-App Access, Workload Identity) rely on that timing to mint + * audience-correct assertions. * * @deprecated Superseded by the `issuer` stamp on stored tokens / client credentials * (SEP-2352). {@linkcode auth} still **writes** this for back-compat with providers diff --git a/packages/client/src/client/authErrors.ts b/packages/client/src/client/authErrors.ts index e8925b2f86..bfb3d4fdee 100644 --- a/packages/client/src/client/authErrors.ts +++ b/packages/client/src/client/authErrors.ts @@ -222,3 +222,36 @@ export class InsufficientScopeError extends OAuthClientFlowError { this.errorDescription = init.errorDescription; } } + +/** + * Thrown by {@linkcode index.WorkloadIdentityProvider.prepareTokenRequest | WorkloadIdentityProvider.prepareTokenRequest} + * when the assertion source hands back the exact assertion whose credentials were + * invalidated after a prior token exchange (recorded via + * {@linkcode index.WorkloadIdentityProvider.invalidateCredentials | invalidateCredentials('tokens')}, + * which `auth()` calls when the authorization server rejects the exchange and + * hosts may call directly). + * The provider refuses to replay it unchanged - a best-effort guard for the + * conformance suite's `wif-no-retry` check (not mandated by RFC 7523 or + * SEP-1933) - so callers must supply a fresh assertion from a + * {@linkcode index.WorkloadAssertionCallback | WorkloadAssertionCallback} before retrying. + * + * Intentionally does **not** extend `OAuthError`: the rejection is detected + * locally by the provider from its own replay memory, not parsed from an + * authorization-server error response, so there is nothing here for `auth()`'s + * `OAuthError` retry path to act on. + */ +export class WorkloadAssertionRejectedError extends OAuthClientFlowError { + static { + Object.defineProperty(this, 'mcpBrand', { value: 'mcp.WorkloadAssertionRejectedError' }); + } + + constructor() { + super( + 'The authorization server rejected the token exchange that presented this workload ' + + 'assertion (or the host invalidated the credential); refusing to present the same ' + + 'assertion again. Supply a fresh assertion from a WorkloadAssertionCallback, and ' + + 'check that the authorization server trusts the assertion issuer, that the audience ' + + 'and expiry are correct, and that the client_id is registered.' + ); + } +} diff --git a/packages/client/src/client/authExtensions.examples.ts b/packages/client/src/client/authExtensions.examples.ts index 668cdd3504..d9b7e585df 100644 --- a/packages/client/src/client/authExtensions.examples.ts +++ b/packages/client/src/client/authExtensions.examples.ts @@ -7,7 +7,7 @@ * @module */ -import { ClientCredentialsProvider, createPrivateKeyJwtAuth, PrivateKeyJwtProvider } from './authExtensions'; +import { ClientCredentialsProvider, createPrivateKeyJwtAuth, PrivateKeyJwtProvider, WorkloadIdentityProvider } from './authExtensions'; import { StreamableHTTPClientTransport } from './streamableHttp'; /** @@ -60,3 +60,22 @@ function PrivateKeyJwtProvider_basicUsage(pemEncodedPrivateKey: string, serverUr //#endregion PrivateKeyJwtProvider_basicUsage return transport; } + +/** + * Example: Using WorkloadIdentityProvider for Workload Identity Federation (SEP-1933). + */ +function WorkloadIdentityProvider_basicUsage(serverUrl: URL, mintWorkloadJwt: (options: { audience: string }) => Promise) { + //#region WorkloadIdentityProvider_basicUsage + const provider = new WorkloadIdentityProvider({ + assertion: async ctx => { + return await mintWorkloadJwt({ audience: ctx.authorizationServerUrl }); + }, + clientId: 'my-workload-client' + }); + + const transport = new StreamableHTTPClientTransport(serverUrl, { + authProvider: provider + }); + //#endregion WorkloadIdentityProvider_basicUsage + return transport; +} diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index 073b42ee0c..90f068658d 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -9,6 +9,7 @@ import type { FetchLike, OAuthClientMetadata, StoredOAuthClientInformation, Stor import type { CryptoKey, JWK } from 'jose'; import type { AddClientAuthentication, OAuthClientProvider } from './auth'; +import { WorkloadAssertionRejectedError } from './authErrors'; /** * Helper to produce a `private_key_jwt` client authentication function. @@ -732,3 +733,285 @@ export class CrossAppAccessProvider implements OAuthClientProvider { return params; } } + +/** + * Context provided to the assertion callback in {@linkcode WorkloadIdentityProvider}. + * Contains discovered information needed to mint or select a workload assertion. + */ +export interface WorkloadAssertionContext { + /** + * The authorization server's `issuer` identifier: `metadata.issuer` when the + * authorization server publishes RFC 8414 metadata, otherwise the authorization + * server URL discovered via RFC 9728 protected resource metadata. + * + * This is the value the workload JWT's `aud` should be minted against + * (draft-ietf-oauth-rfc7523bis), not {@linkcode WorkloadAssertionContext.resourceUrl}. + */ + authorizationServerUrl: string; + + /** + * The resource URL of the target MCP server, when discovered + * via RFC 9728 protected resource metadata. + */ + resourceUrl?: string; + + /** + * Optional scope being requested for the MCP server. + */ + scope?: string; + + /** + * Fetch function to use for HTTP requests (e.g., to reach a local token issuer). + */ + fetchFn: FetchLike; +} + +/** + * Callback function type that provides a workload JWT assertion. + * + * The callback receives context about the target MCP server (authorization server URL, + * resource URL, scope) and should return a workload JWT (for example a Kubernetes + * projected service account token or a SPIFFE JWT-SVID) minted for that authorization + * server's audience. + */ +export type WorkloadAssertionCallback = (context: WorkloadAssertionContext) => string | Promise; + +/** + * Options for creating a {@linkcode WorkloadIdentityProvider}. + */ +export interface WorkloadIdentityProviderOptions { + /** + * The workload JWT assertion presented to the authorization server, either as a + * static string or as a callback that returns a fresh assertion per token request. + * + * The callback receives the discovered authorization server URL, resource URL, + * and requested scope, and owns the audience decision for the assertion it returns. + */ + assertion: string | WorkloadAssertionCallback; + + /** + * The `client_id` sent as public-client identification with the token request. + * Required by this SDK's auth flow, which operates on stored client information; + * RFC 7523 and SEP-1933 allow assertion-only requests without one, so choose a + * value the authorization server expects or ignores. + */ + clientId: string; + + /** + * Optional client name for metadata. + */ + clientName?: string; + + /** + * Space-separated scopes values requested by the client. + */ + scope?: string; + + /** + * Custom fetch implementation. Defaults to global fetch. + */ + fetchFn?: FetchLike; + + /** + * The authorization server's `issuer` identifier this workload identity was registered with. + * Seeds the SEP-2352 issuer stamp; see {@linkcode ClientCredentialsProviderOptions.expectedIssuer}. + */ + expectedIssuer?: string; +} + +/** + * OAuth provider for Workload Identity Federation (SEP-1933) using the JWT bearer grant. + * + * This provider is designed for non-interactive workloads that already hold a + * platform-issued JWT (for example a Kubernetes projected service account token or a + * SPIFFE JWT-SVID) and exchange it directly for an access token via the + * `urn:ietf:params:oauth:grant-type:jwt-bearer` grant + * ({@link https://datatracker.ietf.org/doc/html/rfc7523#section-2.1 | RFC 7523 Section 2.1}). + * + * @example + * ```ts source="./authExtensions.examples.ts#WorkloadIdentityProvider_basicUsage" + * const provider = new WorkloadIdentityProvider({ + * assertion: async ctx => { + * return await mintWorkloadJwt({ audience: ctx.authorizationServerUrl }); + * }, + * clientId: 'my-workload-client' + * }); + * + * const transport = new StreamableHTTPClientTransport(serverUrl, { + * authProvider: provider + * }); + * ``` + */ +export class WorkloadIdentityProvider implements OAuthClientProvider { + private _tokens?: StoredOAuthTokens; + private _clientInfo: StoredOAuthClientInformation; + private _clientMetadata: OAuthClientMetadata; + private _assertion: string | WorkloadAssertionCallback; + private _fetchFn: FetchLike; + private _authorizationServerUrl?: string; + private _resourceUrl?: string; + private _lastAssertion?: string; + private _rejectedAssertion?: string; + + constructor(options: WorkloadIdentityProviderOptions) { + this._clientInfo = { + client_id: options.clientId, + issuer: options.expectedIssuer + }; + this._clientMetadata = { + client_name: options.clientName ?? 'workload-identity-client', + redirect_uris: [], + grant_types: ['urn:ietf:params:oauth:grant-type:jwt-bearer'], + token_endpoint_auth_method: 'none', + scope: options.scope + }; + this._assertion = options.assertion; + this._fetchFn = options.fetchFn ?? fetch; + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): StoredOAuthClientInformation { + return this._clientInfo; + } + + // No saveClientInformation: the client identity is constructor-supplied; the SEP-2352 stamp + // check enforces `expectedIssuer` and auth() throws + // AuthorizationServerMismatchError(expectedIssuer, resolved) on mismatch. + + tokens(): StoredOAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: StoredOAuthTokens): void { + this._tokens = tokens; + // Clears only the rejection, not `_lastAssertion`, so a rejection verdict arriving after this success (e.g. from a concurrent flow) can still name the assertion it refuses. + this._rejectedAssertion = undefined; + } + + /** + * Records the authorization server's verdict on the last assertion handed out. + * `'tokens'`: `auth()` calls this only after the authorization server rejects an + * exchange that presented an assertion; the provider then refuses to re-present + * that exact assertion, a best-effort guard for the conformance suite's + * `wif-no-retry` check (not mandated by RFC 7523 or SEP-1933). Under overlapping + * flows the recorded assertion may not be the exact one that failed; that is + * conservative and fails closed. + * `'discovery'`: clears the cached authorization server and resource URLs so a + * later flow cannot mint against a stale issuer; `auth()` repopulates them on + * its next discovery pass. + * `'all'`: a host-driven reset (not a rejection verdict) that clears all cached + * state. `'client'` and `'verifier'` are no-ops; this provider keeps no such + * state. + */ + invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void { + switch (scope) { + case 'tokens': { + this._tokens = undefined; + this._rejectedAssertion = this._lastAssertion; + break; + } + case 'discovery': { + this._authorizationServerUrl = undefined; + this._resourceUrl = undefined; + break; + } + case 'all': { + this._tokens = undefined; + this._lastAssertion = undefined; + this._rejectedAssertion = undefined; + this._authorizationServerUrl = undefined; + this._resourceUrl = undefined; + break; + } + default: { + break; + } + } + } + + redirectToAuthorization(): void { + throw new Error('WorkloadIdentityProvider is non-interactive and does not support authorization redirects'); + } + + saveCodeVerifier(): void { + // Not used for jwt-bearer + } + + codeVerifier(): string { + throw new Error('codeVerifier is not used for jwt-bearer flow'); + } + + /** + * Saves the authorization server URL discovered during OAuth flow. + * This is called by the auth() function after RFC 9728 discovery. + */ + saveAuthorizationServerUrl(authorizationServerUrl: string): void { + this._authorizationServerUrl = authorizationServerUrl; + } + + /** + * Returns the cached authorization server URL if available. + */ + authorizationServerUrl(): string | undefined { + return this._authorizationServerUrl; + } + + /** + * Saves the resource URL discovered during OAuth flow. + * This is called by the auth() function after RFC 9728 discovery. + */ + saveResourceUrl?(resourceUrl: string): void { + this._resourceUrl = resourceUrl; + } + + /** + * Returns the cached resource URL if available. + */ + resourceUrl?(): string | undefined { + return this._resourceUrl; + } + + async prepareTokenRequest(scope?: string): Promise { + let assertion: string; + if (typeof this._assertion === 'function') { + const authServerUrl = this._authorizationServerUrl; + if (!authServerUrl) { + throw new Error('Authorization server URL not available. Ensure auth() has been called first.'); + } + + assertion = await this._assertion({ + authorizationServerUrl: authServerUrl, + resourceUrl: this._resourceUrl, + scope, + fetchFn: this._fetchFn + }); + } else { + assertion = this._assertion; + } + + if (assertion === this._rejectedAssertion) { + throw new WorkloadAssertionRejectedError(); + } + this._lastAssertion = assertion; + + // Return params for JWT bearer grant per RFC 7523; auth() sets the RFC 8707 + // resource parameter after the provider returns. + const params = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion + }); + + if (scope) { + params.set('scope', scope); + } + + return params; + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index cfe9f88389..58749e1bfd 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -50,7 +50,8 @@ export { InsufficientScopeError, IssuerMismatchError, OAuthClientFlowError, - RegistrationRejectedError + RegistrationRejectedError, + WorkloadAssertionRejectedError } from './client/authErrors'; export type { AssertionCallback, @@ -58,14 +59,18 @@ export type { CrossAppAccessContext, CrossAppAccessProviderOptions, PrivateKeyJwtProviderOptions, - StaticPrivateKeyJwtProviderOptions + StaticPrivateKeyJwtProviderOptions, + WorkloadAssertionCallback, + WorkloadAssertionContext, + WorkloadIdentityProviderOptions } from './client/authExtensions'; export { ClientCredentialsProvider, createPrivateKeyJwtAuth, CrossAppAccessProvider, PrivateKeyJwtProvider, - StaticPrivateKeyJwtProvider + StaticPrivateKeyJwtProvider, + WorkloadIdentityProvider } from './client/authExtensions'; export type { CacheableRequestOptions, CallToolRequestOptions, ClientOptions, ConnectOptions, McpSubscription } from './client/client'; export { Client } from './client/client'; diff --git a/packages/client/test/client/errorBrandConformance.test.ts b/packages/client/test/client/errorBrandConformance.test.ts index 7265b8c29b..c079d7a051 100644 --- a/packages/client/test/client/errorBrandConformance.test.ts +++ b/packages/client/test/client/errorBrandConformance.test.ts @@ -82,7 +82,8 @@ describe('error brand conformance (client export surface)', () => { OAuthClientFlowError: 'mcp.OAuthClientFlowError', RegistrationRejectedError: 'mcp.RegistrationRejectedError', SseError: 'mcp.SseError', - UnauthorizedError: 'mcp.UnauthorizedError' + UnauthorizedError: 'mcp.UnauthorizedError', + WorkloadAssertionRejectedError: 'mcp.WorkloadAssertionRejectedError' }); }); diff --git a/packages/client/test/client/workloadIdentity.test.ts b/packages/client/test/client/workloadIdentity.test.ts new file mode 100644 index 0000000000..945cb5f8f8 --- /dev/null +++ b/packages/client/test/client/workloadIdentity.test.ts @@ -0,0 +1,512 @@ +import type { FetchLike } from '@modelcontextprotocol/core-internal'; +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core-internal'; +import { createMockOAuthFetch } from '@modelcontextprotocol/test-helpers'; +import { describe, expect, it, vi } from 'vitest'; + +import { auth } from '../../src/client/auth'; +import { WorkloadAssertionRejectedError } from '../../src/client/authErrors'; +import type { WorkloadAssertionContext, WorkloadIdentityProviderOptions } from '../../src/client/authExtensions'; +import { WorkloadIdentityProvider } from '../../src/client/authExtensions'; + +const RESOURCE_SERVER_URL = 'https://mcp.example.com/'; +const AUTH_SERVER_URL = 'https://auth.example.com'; +const STATIC_ASSERTION = 'header.payload.signature'; + +function makeProvider(overrides: Partial = {}): WorkloadIdentityProvider { + const provider = new WorkloadIdentityProvider({ + clientId: 'wif-client', + assertion: STATIC_ASSERTION, + ...overrides + }); + + // The auth() flow normally calls these after RFC 9728 discovery + provider.saveAuthorizationServerUrl(AUTH_SERVER_URL); + provider.saveResourceUrl?.(RESOURCE_SERVER_URL); + return provider; +} + +describe('WorkloadIdentityProvider token request', () => { + it('sends the jwt-bearer grant with a static assertion', async () => { + const params = await makeProvider().prepareTokenRequest(); + + expect(params.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); + expect(params.get('assertion')).toBe(STATIC_ASSERTION); + expect(params.get('scope')).toBeNull(); + expect(params.get('resource')).toBeNull(); + }); + + it('resolves a static assertion without discovery state', async () => { + const provider = new WorkloadIdentityProvider({ + clientId: 'wif-client', + assertion: STATIC_ASSERTION + }); + + const params = await provider.prepareTokenRequest(); + + expect(params.get('assertion')).toBe(STATIC_ASSERTION); + }); + + it('prefers the framework-supplied scope over the configured scope', async () => { + const params = await makeProvider({ scope: 'configured' }).prepareTokenRequest('challenge.scope'); + + expect(params.get('scope')).toBe('challenge.scope'); + }); + + it('exposes the configured scope through clientMetadata instead of setting it directly', async () => { + const provider = makeProvider({ scope: 'configured' }); + + expect(provider.clientMetadata.scope).toBe('configured'); + + // fetchToken falls back to clientMetadata.scope when auth() resolves no scope, + // so prepareTokenRequest itself only uses its argument + const params = await provider.prepareTokenRequest(); + expect(params.get('scope')).toBeNull(); + }); + + it('invokes the assertion callback with the discovered context', async () => { + let seenContext: WorkloadAssertionContext | undefined; + const customFetch = vi.fn(fetch); + + const provider = makeProvider({ + assertion: async context => { + seenContext = context; + return 'callback.jwt.value'; + }, + fetchFn: customFetch + }); + + const params = await provider.prepareTokenRequest('requested.scope'); + + expect(params.get('assertion')).toBe('callback.jwt.value'); + expect(seenContext).toMatchObject({ + authorizationServerUrl: AUTH_SERVER_URL, + resourceUrl: RESOURCE_SERVER_URL, + scope: 'requested.scope' + }); + expect(seenContext?.fetchFn).toBe(customFetch); + }); + + it('passes undefined resourceUrl to the callback when none was discovered', async () => { + let seenContext: WorkloadAssertionContext | undefined; + + const provider = new WorkloadIdentityProvider({ + clientId: 'wif-client', + assertion: async context => { + seenContext = context; + return 'callback.jwt.value'; + } + }); + provider.saveAuthorizationServerUrl(AUTH_SERVER_URL); + + await provider.prepareTokenRequest(); + + expect(seenContext).toBeDefined(); + expect(seenContext?.resourceUrl).toBeUndefined(); + }); + + it('throws when the assertion callback needs an authorization server URL that is not available', async () => { + const provider = new WorkloadIdentityProvider({ + clientId: 'wif-client', + assertion: async () => 'callback.jwt.value' + }); + + await expect(provider.prepareTokenRequest()).rejects.toThrow( + 'Authorization server URL not available. Ensure auth() has been called first.' + ); + }); +}); + +describe('WorkloadIdentityProvider non-interactive contract', () => { + it('returns undefined for redirectUrl', () => { + expect(makeProvider().redirectUrl).toBeUndefined(); + }); + + it('throws from redirectToAuthorization', () => { + expect(() => makeProvider().redirectToAuthorization()).toThrow( + 'WorkloadIdentityProvider is non-interactive and does not support authorization redirects' + ); + }); + + it('throws from codeVerifier (PKCE is not used)', () => { + expect(() => makeProvider().codeVerifier()).toThrow('codeVerifier is not used for jwt-bearer flow'); + }); + + it('accepts saveCodeVerifier as a no-op (PKCE is not used)', () => { + expect(() => makeProvider().saveCodeVerifier()).not.toThrow(); + }); +}); + +describe('WorkloadIdentityProvider client information and state', () => { + it('returns the configured client_id', () => { + expect(makeProvider().clientInformation()).toMatchObject({ client_id: 'wif-client' }); + }); + + it('stamps expectedIssuer onto the stored client information (SEP-2352)', () => { + const provider = makeProvider({ expectedIssuer: AUTH_SERVER_URL }); + + expect(provider.clientInformation()).toMatchObject({ + client_id: 'wif-client', + issuer: AUTH_SERVER_URL + }); + }); + + it('has correct client metadata', () => { + const metadata = makeProvider().clientMetadata; + + expect(metadata.client_name).toBe('workload-identity-client'); + expect(metadata.redirect_uris).toEqual([]); + expect(metadata.grant_types).toEqual(['urn:ietf:params:oauth:grant-type:jwt-bearer']); + expect(metadata.token_endpoint_auth_method).toBe('none'); + + expect(makeProvider({ clientName: 'custom-wif-client' }).clientMetadata.client_name).toBe('custom-wif-client'); + }); + + it('stores and retrieves tokens in memory', () => { + const provider = makeProvider(); + + expect(provider.tokens()).toBeUndefined(); + + provider.saveTokens({ access_token: 'stored-token', token_type: 'Bearer' }); + expect(provider.tokens()?.access_token).toBe('stored-token'); + }); +}); + +describe('WorkloadIdentityProvider rejection memory', () => { + it('refuses to re-present an assertion the authorization server rejected', async () => { + const provider = makeProvider(); + + await provider.prepareTokenRequest(); + provider.invalidateCredentials('tokens'); + + await expect(provider.prepareTokenRequest()).rejects.toBeInstanceOf(WorkloadAssertionRejectedError); + }); + + it('hands out the same assertion again while nothing has been rejected', async () => { + const provider = makeProvider(); + + await provider.prepareTokenRequest(); + + const params = await provider.prepareTokenRequest(); + expect(params.get('assertion')).toBe(STATIC_ASSERTION); + }); + + it('records a rejection that arrives after a concurrent flow already succeeded', async () => { + const provider = makeProvider(); + + await provider.prepareTokenRequest(); + provider.saveTokens({ access_token: 'granted-token', token_type: 'Bearer' }); + provider.invalidateCredentials('tokens'); + + await expect(provider.prepareTokenRequest()).rejects.toBeInstanceOf(WorkloadAssertionRejectedError); + }); + + it('forgets a rejection once a later flow is confirmed by saveTokens', async () => { + let assertion = 'jwt.rejected'; + const provider = makeProvider({ assertion: () => assertion }); + + await provider.prepareTokenRequest(); + provider.invalidateCredentials('tokens'); + + assertion = 'jwt.fresh'; + await provider.prepareTokenRequest(); + provider.saveTokens({ access_token: 'granted-token', token_type: 'Bearer' }); + + assertion = 'jwt.rejected'; + await expect(provider.prepareTokenRequest()).resolves.toBeDefined(); + }); + + it('allows a retry when the callback supplies a fresh assertion', async () => { + let counter = 0; + const provider = makeProvider({ assertion: () => `jwt.${counter++}` }); + + await provider.prepareTokenRequest(); + provider.invalidateCredentials('tokens'); + + const params = await provider.prepareTokenRequest(); + expect(params.get('assertion')).toBe('jwt.1'); + }); + + it('reuses a static assertion across separately confirmed auth cycles', async () => { + const provider = makeProvider(); + + for (let cycle = 0; cycle < 2; cycle++) { + const params = await provider.prepareTokenRequest(); + expect(params.get('assertion')).toBe(STATIC_ASSERTION); + provider.saveTokens({ access_token: `token-${cycle}`, token_type: 'Bearer' }); + } + + await expect(provider.prepareTokenRequest()).resolves.toBeDefined(); + }); + + it("clears the rejection and cached discovery URLs on invalidateCredentials('all')", async () => { + const provider = makeProvider(); + + await provider.prepareTokenRequest(); + provider.invalidateCredentials('tokens'); + provider.invalidateCredentials('all'); + + expect(provider.authorizationServerUrl()).toBeUndefined(); + expect(provider.resourceUrl?.()).toBeUndefined(); + + const params = await provider.prepareTokenRequest(); + expect(params.get('assertion')).toBe(STATIC_ASSERTION); + }); + + it("drops the stored access token on invalidateCredentials('tokens')", () => { + const provider = makeProvider(); + provider.saveTokens({ access_token: 'stored-token', token_type: 'Bearer' }); + + provider.invalidateCredentials('tokens'); + + expect(provider.tokens()).toBeUndefined(); + }); + + it('keeps stored tokens for scopes that name state it does not hold', () => { + const provider = makeProvider(); + provider.saveTokens({ access_token: 'stored-token', token_type: 'Bearer' }); + + provider.invalidateCredentials('client'); + provider.invalidateCredentials('verifier'); + provider.invalidateCredentials('discovery'); + + expect(provider.tokens()?.access_token).toBe('stored-token'); + }); + + it("drops cached discovery URLs on invalidateCredentials('discovery') but keeps tokens", () => { + const provider = makeProvider(); + provider.saveTokens({ access_token: 'stored-token', token_type: 'Bearer' }); + + provider.invalidateCredentials('discovery'); + + expect(provider.authorizationServerUrl()).toBeUndefined(); + expect(provider.resourceUrl?.()).toBeUndefined(); + expect(provider.tokens()?.access_token).toBe('stored-token'); + }); +}); + +describe('WorkloadIdentityProvider (end-to-end with auth())', () => { + it('successfully authenticates using the jwt-bearer flow', async () => { + let assertionCallbackInvoked = false; + let assertionUsed = ''; + + const provider = new WorkloadIdentityProvider({ + assertion: async context => { + assertionCallbackInvoked = true; + expect(context.authorizationServerUrl).toBe(AUTH_SERVER_URL); + expect(context.resourceUrl).toBe(RESOURCE_SERVER_URL); + expect(context.scope).toBeUndefined(); + expect(context.fetchFn).toBeDefined(); + return 'workload.jwt.assertion'; + }, + clientId: 'wif-client', + clientName: 'wif-test-client' + }); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); + + assertionUsed = params.get('assertion') || ''; + expect(assertionUsed).toBe('workload.jwt.assertion'); + + expect(params.get('resource')).toBe(RESOURCE_SERVER_URL); + + expect(params.get('client_id')).toBe('wif-client'); + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toBeNull(); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + expect(assertionCallbackInvoked).toBe(true); + expect(assertionUsed).toBe('workload.jwt.assertion'); + + const tokens = provider.tokens(); + expect(tokens).toBeTruthy(); + expect(tokens?.access_token).toBe('test-access-token'); + }); +}); + +/** + * Hand-rolled fetch mock in the auth.test.ts style: createMockOAuthFetch cannot + * serve error responses or count calls, so these tests script the token endpoint + * themselves and log every token request body. + */ +function createScriptedOAuthFetch(respondToTokenRequest: (body: URLSearchParams) => Response): { + fetchMock: FetchLike; + tokenRequestBodies: URLSearchParams[]; +} { + const tokenRequestBodies: URLSearchParams[] = []; + + const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit): Promise => { + const url = input instanceof URL ? input : new URL(input); + + if (url.origin === new URL(RESOURCE_SERVER_URL).origin && url.pathname === '/.well-known/oauth-protected-resource') { + return Response.json({ + resource: RESOURCE_SERVER_URL, + authorization_servers: [AUTH_SERVER_URL] + }); + } + + if (url.origin === AUTH_SERVER_URL && url.pathname === '/.well-known/oauth-authorization-server') { + return Response.json({ + issuer: AUTH_SERVER_URL, + authorization_endpoint: `${AUTH_SERVER_URL}/authorize`, + token_endpoint: `${AUTH_SERVER_URL}/token`, + response_types_supported: ['code'], + grant_types_supported: ['urn:ietf:params:oauth:grant-type:jwt-bearer'], + token_endpoint_auth_methods_supported: ['none'] + }); + } + + if (url.origin === AUTH_SERVER_URL && url.pathname === '/token') { + const body = new URLSearchParams(init?.body as URLSearchParams); + tokenRequestBodies.push(body); + return respondToTokenRequest(body); + } + + throw new Error(`Unexpected URL in scripted OAuth fetch: ${url.toString()}`); + }); + + return { fetchMock, tokenRequestBodies }; +} + +function createFailingOAuthFetch(tokenErrorCode: string): { fetchMock: FetchLike; tokenRequestBodies: URLSearchParams[] } { + return createScriptedOAuthFetch(() => Response.json({ error: tokenErrorCode }, { status: 400 })); +} + +function grantedTokenResponse(): Response { + return Response.json({ access_token: 'test-access-token', token_type: 'Bearer' }); +} + +describe('WorkloadIdentityProvider failure paths through auth()', () => { + it('invalid_grant with a static assertion: replay refusal surfaces, exactly one token request', async () => { + const provider = new WorkloadIdentityProvider({ + clientId: 'wif-client', + assertion: STATIC_ASSERTION + }); + const { fetchMock, tokenRequestBodies } = createFailingOAuthFetch('invalid_grant'); + + // auth() retries once on invalid_grant, but the provider's replay refusal fires before a second token request is sent. + await expect(auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock })).rejects.toBeInstanceOf( + WorkloadAssertionRejectedError + ); + + expect(tokenRequestBodies).toHaveLength(1); + expect(tokenRequestBodies[0]!.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); + expect(tokenRequestBodies[0]!.get('assertion')).toBe(STATIC_ASSERTION); + }); + + it('invalid_client with a static assertion: replay refusal surfaces as the typed error, exactly one token request', async () => { + const provider = new WorkloadIdentityProvider({ + clientId: 'wif-client', + assertion: STATIC_ASSERTION + }); + const { fetchMock, tokenRequestBodies } = createFailingOAuthFetch('invalid_client'); + + await expect(auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock })).rejects.toBeInstanceOf( + WorkloadAssertionRejectedError + ); + + expect(tokenRequestBodies).toHaveLength(1); + expect(tokenRequestBodies[0]!.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); + }); + + it('invalid_grant with a fresh-assertion callback: re-run allowed, error still surfaces after exactly two jwt-bearer requests', async () => { + let counter = 0; + const provider = new WorkloadIdentityProvider({ + clientId: 'wif-client', + assertion: () => `workload.jwt.${counter++}` + }); + const { fetchMock, tokenRequestBodies } = createFailingOAuthFetch('invalid_grant'); + + const rejection = await auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock }).then( + () => undefined, + error => error + ); + + expect(rejection).toBeInstanceOf(OAuthError); + expect((rejection as OAuthError).code).toBe(OAuthErrorCode.InvalidGrant); + + expect(tokenRequestBodies).toHaveLength(2); + expect(tokenRequestBodies[0]!.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); + expect(tokenRequestBodies[1]!.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); + expect(tokenRequestBodies[0]!.get('assertion')).toBe('workload.jwt.0'); + expect(tokenRequestBodies[1]!.get('assertion')).toBe('workload.jwt.1'); + expect(tokenRequestBodies.filter(body => body.get('grant_type') === 'authorization_code')).toHaveLength(0); + }); + + it('invalid_scope surfaces without retry, grant fallback, or redirect', async () => { + const provider = new WorkloadIdentityProvider({ + clientId: 'wif-client', + assertion: STATIC_ASSERTION + }); + const redirectSpy = vi.spyOn(provider, 'redirectToAuthorization'); + const { fetchMock, tokenRequestBodies } = createFailingOAuthFetch('invalid_scope'); + + const rejection = await auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock }).then( + () => undefined, + error => error + ); + + expect(rejection).toBeInstanceOf(OAuthError); + expect((rejection as OAuthError).code).toBe(OAuthErrorCode.InvalidScope); + + // invalid_scope is outside auth()'s recoverable set, so authInternal runs once + expect(tokenRequestBodies).toHaveLength(1); + expect(tokenRequestBodies[0]!.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); + expect(tokenRequestBodies.filter(body => body.get('grant_type') === 'authorization_code')).toHaveLength(0); + expect(redirectSpy).not.toHaveBeenCalled(); + }); + + it('authorizes two overlapping auth() flows on one provider', async () => { + const provider = new WorkloadIdentityProvider({ + clientId: 'wif-client', + assertion: STATIC_ASSERTION + }); + const { fetchMock, tokenRequestBodies } = createScriptedOAuthFetch(grantedTokenResponse); + + const results = await Promise.all([ + auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock }), + auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock }) + ]); + + expect(results).toEqual(['AUTHORIZED', 'AUTHORIZED']); + expect(tokenRequestBodies).toHaveLength(2); + expect(tokenRequestBodies.map(body => body.get('assertion'))).toEqual([STATIC_ASSERTION, STATIC_ASSERTION]); + }); + + it('re-presents a static assertion after a transient token-endpoint failure', async () => { + const provider = new WorkloadIdentityProvider({ + clientId: 'wif-client', + assertion: STATIC_ASSERTION + }); + let failNextTokenRequest = true; + const { fetchMock, tokenRequestBodies } = createScriptedOAuthFetch(() => { + if (failNextTokenRequest) { + failNextTokenRequest = false; + // A network-level failure, not an OAuth verdict on the assertion + throw new TypeError('fetch failed'); + } + return grantedTokenResponse(); + }); + + await expect(auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock })).rejects.toThrow(TypeError); + + const result = await auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock }); + + expect(result).toBe('AUTHORIZED'); + expect(provider.tokens()?.access_token).toBe('test-access-token'); + expect(tokenRequestBodies).toHaveLength(2); + expect(tokenRequestBodies[1]!.get('assertion')).toBe(STATIC_ASSERTION); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 839b152070..b06f098674 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -803,6 +803,43 @@ importers: specifier: catalog:devTools version: 4.21.0 + examples/oauth-workload-identity: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + jose: + specifier: catalog:runtimeClientOnly + version: 6.2.2 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@types/cors': + specifier: catalog:devTools + version: 2.8.19 + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/parallel-calls: dependencies: '@hono/node-server': diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 6711cdc30f..42b895a00c 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -22,15 +22,13 @@ client: # (none: SEP-2468/2352/2350/837/2207/990 burned by the auth bundle; the # last referee-side gap — conformance#361 callback-iss — closed at alpha.6) # - # --- SEP-1932 (DPoP) / SEP-1933 (WIF) — extension-tagged auth scenarios, new in the alpha.10 referee --- - # The OAuth client implements neither DPoP proofs (RFC 9449) nor the - # urn:ietf:params:oauth:grant-type:jwt-bearer grant, so every check in - # these scenarios fails. Client-side extension scenarios are selected only - # by `--suite all`; the 2026 leg cannot flag them stale (extension - # scenarios never match a --spec-version filter). + # --- SEP-1932 (DPoP) - extension-tagged auth scenarios, new in the alpha.10 referee --- + # The OAuth client does not implement DPoP proofs (RFC 9449), so every + # check in these scenarios fails. Client-side extension scenarios are + # selected only by `--suite all`; the 2026 leg cannot flag them stale + # (extension scenarios never match a --spec-version filter). - auth/dpop - auth/dpop-nonce - - auth/wif-jwt-bearer server: # --- SEP-2663 (io.modelcontextprotocol/tasks) — server SDK does not implement the tasks extension --- diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index 3ba48ccbf7..f912dfd598 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -18,7 +18,8 @@ import { CrossAppAccessProvider, PrivateKeyJwtProvider, requestJwtAuthorizationGrant, - StreamableHTTPClientTransport + StreamableHTTPClientTransport, + WorkloadIdentityProvider } from '@modelcontextprotocol/client'; import * as z from 'zod/v4'; @@ -73,6 +74,13 @@ 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() }) ]); @@ -629,6 +637,45 @@ async function runCrossAppAccessCompleteFlow(serverUrl: string): Promise { registerScenario('auth/cross-app-access-complete-flow', runCrossAppAccessCompleteFlow); registerScenario('auth/enterprise-managed-authorization', runCrossAppAccessCompleteFlow); +// ============================================================================ +// Workload Identity Federation scenario (SEP-1933) +// ============================================================================ + +/** + * Workload Identity Federation (SEP-1933) using the jwt-bearer grant with a + * workload-issued assertion. The client only ever presents `valid_jwt`; the + * wrong-audience and expired assertions carried in the context are for the + * referee to observe independently, not for this handler to send. + */ +async function runWifJwtBearer(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/wif-jwt-bearer') { + throw new Error(`Expected auth/wif-jwt-bearer context, got ${ctx.name}`); + } + + const provider = new WorkloadIdentityProvider({ + clientId: ctx.client_id, + assertion: 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 workload identity (jwt-bearer) auth'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/wif-jwt-bearer', runWifJwtBearer); + // ============================================================================ // Pre-registration scenario (no dynamic client registration) // ============================================================================