From 0ce281b0f1b1d9eb6ae7984a8fadb5c47bec7f01 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 28 Jul 2026 11:52:21 -0500 Subject: [PATCH 01/17] feat(client): add WorkloadIdentityProvider for SEP-1933 workload identity federation Co-Authored-By: Claude Fable 5 --- packages/client/src/client/authExtensions.ts | 231 +++++++++++++++ .../test/client/workloadIdentity.test.ts | 274 ++++++++++++++++++ 2 files changed, 505 insertions(+) create mode 100644 packages/client/test/client/workloadIdentity.test.ts diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index 073b42ee0c..0b103923a2 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -732,3 +732,234 @@ 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 URL of the target MCP server. + * Discovered via RFC 9728 protected resource metadata. + */ + 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` registered with the MCP server's authorization server. + */ + 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 + * const provider = new WorkloadIdentityProvider({ + * assertion: async ctx => { + * // Return a workload JWT minted for the authorization server's audience + * 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; + + 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; + } + + redirectToAuthorization(): void { + throw new Error( + 'WorkloadIdentityProvider is non-interactive: the authorization server rejected the ' + + 'jwt-bearer flow and the client attempted an authorization redirect. Check that the ' + + 'authorization server supports urn:ietf:params:oauth:grant-type:jwt-bearer and ' + + 'trusts the workload issuer.' + ); + } + + saveCodeVerifier(): void { + throw new Error('saveCodeVerifier is not used for jwt-bearer flow'); + } + + 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; + } + + // 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/test/client/workloadIdentity.test.ts b/packages/client/test/client/workloadIdentity.test.ts new file mode 100644 index 0000000000..c940b4f13b --- /dev/null +++ b/packages/client/test/client/workloadIdentity.test.ts @@ -0,0 +1,274 @@ +import { createMockOAuthFetch } from '@modelcontextprotocol/test-helpers'; +import { describe, expect, it, vi } from 'vitest'; + +import { auth } from '../../src/client/auth'; +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(); + }); + + it('does not set the resource parameter (auth() sets it after the provider returns)', async () => { + const params = await makeProvider().prepareTokenRequest(); + + 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('uses the framework-supplied scope argument', async () => { + const params = await makeProvider().prepareTokenRequest('challenge.scope'); + + expect(params.get('scope')).toBe('challenge.scope'); + }); + + 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 provider = makeProvider({ + assertion: async context => { + seenContext = context; + return 'callback.jwt.value'; + } + }); + + 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).toBeDefined(); + }); + + it('passes a custom fetchFn to the assertion callback', async () => { + const customFetch = vi.fn(fetch); + let capturedFetchFn: unknown; + + const provider = makeProvider({ + assertion: async context => { + capturedFetchFn = context.fetchFn; + return 'callback.jwt.value'; + }, + fetchFn: customFetch + }); + + await provider.prepareTokenRequest(); + + expect(capturedFetchFn).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 a descriptive error from redirectToAuthorization', () => { + expect(() => makeProvider().redirectToAuthorization()).toThrow(/non-interactive/); + expect(() => makeProvider().redirectToAuthorization()).toThrow(/jwt-bearer/); + expect(() => makeProvider().redirectToAuthorization()).toThrow(/workload issuer/); + }); + + it('throws from codeVerifier (PKCE is not used)', () => { + expect(() => makeProvider().codeVerifier()).toThrow('codeVerifier is not used for jwt-bearer flow'); + }); + + it('throws from saveCodeVerifier (PKCE is not used)', () => { + expect(() => makeProvider().saveCodeVerifier()).toThrow('saveCodeVerifier is not used for jwt-bearer flow'); + }); +}); + +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'); + }); + + it('uses a custom client name when provided', () => { + 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'); + }); + + it('stores and retrieves authorization server URL', () => { + const provider = new WorkloadIdentityProvider({ + clientId: 'wif-client', + assertion: STATIC_ASSERTION + }); + + expect(provider.authorizationServerUrl?.()).toBeUndefined(); + + provider.saveAuthorizationServerUrl(AUTH_SERVER_URL); + expect(provider.authorizationServerUrl?.()).toBe(AUTH_SERVER_URL); + }); + + it('stores and retrieves resource URL', () => { + const provider = new WorkloadIdentityProvider({ + clientId: 'wif-client', + assertion: STATIC_ASSERTION + }); + + expect(provider.resourceUrl?.()).toBeUndefined(); + + provider.saveResourceUrl?.(RESOURCE_SERVER_URL); + expect(provider.resourceUrl?.()).toBe(RESOURCE_SERVER_URL); + }); +}); + +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); + + // Public client: client_id travels in the body, no Authorization header + 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'); + }); +}); From e864e8d2093dcf842bba96b5137069722aeaffb2 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 28 Jul 2026 11:55:49 -0500 Subject: [PATCH 02/17] feat(client): fail loudly on replayed workload assertions Co-Authored-By: Claude Fable 5 --- packages/client/src/client/authExtensions.ts | 17 ++++++++ .../test/client/workloadIdentity.test.ts | 41 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index 0b103923a2..276920db75 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -843,6 +843,8 @@ export class WorkloadIdentityProvider implements OAuthClientProvider { private _fetchFn: FetchLike; private _authorizationServerUrl?: string; private _resourceUrl?: string; + private _lastAssertion?: string; + private _lastAssertionConfirmed = true; constructor(options: WorkloadIdentityProviderOptions) { this._clientInfo = { @@ -881,6 +883,7 @@ export class WorkloadIdentityProvider implements OAuthClientProvider { } saveTokens(tokens: StoredOAuthTokens): void { + this._lastAssertionConfirmed = true; this._tokens = tokens; } @@ -949,6 +952,20 @@ export class WorkloadIdentityProvider implements OAuthClientProvider { assertion = this._assertion; } + // saveTokens confirms the last handed-out assertion; re-presenting an unconfirmed + // one unchanged would replay a credential the authorization server already rejected. + if (!this._lastAssertionConfirmed && assertion === this._lastAssertion) { + throw new Error( + 'Workload assertion was rejected by the authorization server and would be ' + + 're-sent unchanged. Refusing to retry with the same credential ' + + '(SEP-1933 wif-no-retry). Provide a fresh assertion via a ' + + 'WorkloadAssertionCallback or fix the assertion (issuer trust, audience, ' + + 'expiry) before retrying.' + ); + } + this._lastAssertion = assertion; + this._lastAssertionConfirmed = false; + // Return params for JWT bearer grant per RFC 7523; auth() sets the RFC 8707 // resource parameter after the provider returns. const params = new URLSearchParams({ diff --git a/packages/client/test/client/workloadIdentity.test.ts b/packages/client/test/client/workloadIdentity.test.ts index c940b4f13b..88fe78e53e 100644 --- a/packages/client/test/client/workloadIdentity.test.ts +++ b/packages/client/test/client/workloadIdentity.test.ts @@ -220,6 +220,47 @@ describe('WorkloadIdentityProvider client information and state', () => { }); }); +describe('WorkloadIdentityProvider rejection memory', () => { + it('refuses to re-present the same assertion after an unconfirmed attempt', async () => { + const provider = makeProvider(); + + await provider.prepareTokenRequest(); + + await expect(provider.prepareTokenRequest()).rejects.toThrow(/rejected|same credential|wif-no-retry/i); + }); + + it('allows a new request after saveTokens confirmed the previous one', async () => { + const provider = makeProvider(); + + await provider.prepareTokenRequest(); + provider.saveTokens({ access_token: 'granted-token', token_type: 'Bearer' }); + + 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(); + + 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(); + }); +}); + describe('WorkloadIdentityProvider (end-to-end with auth())', () => { it('successfully authenticates using the jwt-bearer flow', async () => { let assertionCallbackInvoked = false; From 50cc7bed69e2a973ec214d9b4456c6c2258e4878 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 28 Jul 2026 12:03:08 -0500 Subject: [PATCH 03/17] test(client): cover WorkloadIdentityProvider failure paths through the auth() flow Co-Authored-By: Claude Fable 5 --- .../test/client/workloadIdentity.test.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/packages/client/test/client/workloadIdentity.test.ts b/packages/client/test/client/workloadIdentity.test.ts index 88fe78e53e..760785c74a 100644 --- a/packages/client/test/client/workloadIdentity.test.ts +++ b/packages/client/test/client/workloadIdentity.test.ts @@ -1,3 +1,5 @@ +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'; @@ -313,3 +315,109 @@ describe('WorkloadIdentityProvider (end-to-end with auth())', () => { 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 the failure-path tests script the + * token endpoint themselves and log every token request body. + */ +function createFailingOAuthFetch(tokenErrorCode: string): { 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 === RESOURCE_SERVER_URL.slice(0, -1) && 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') { + tokenRequestBodies.push(new URLSearchParams(init?.body as URLSearchParams)); + return Response.json({ error: tokenErrorCode }, { status: 400 }); + } + + throw new Error(`Unexpected URL in scripted OAuth fetch: ${url.toString()}`); + }); + + return { fetchMock, tokenRequestBodies }; +} + +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() invalidates tokens on invalid_grant and re-runs authInternal once; + // the provider refuses to replay the identical rejected assertion on that + // re-run, so the rejection carries the wif-no-retry refusal. + await expect(auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock })).rejects.toThrow(/wif-no-retry/); + + 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_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(); + }); +}); From 0313bd02fba5259c80f3072a36ec7301ecefbd17 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 28 Jul 2026 12:08:03 -0500 Subject: [PATCH 04/17] feat(client): export WorkloadIdentityProvider from package root Co-Authored-By: Claude Fable 5 --- packages/client/src/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index cfe9f88389..3a3519ab5f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -58,14 +58,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'; From 493c32234a3e64efd1d9bc260de6e0977d356276 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 28 Jul 2026 12:15:36 -0500 Subject: [PATCH 05/17] test(conformance): wire WorkloadIdentityProvider into auth/wif-jwt-bearer and un-baseline it Co-Authored-By: Claude Fable 5 --- test/conformance/expected-failures.yaml | 12 +++--- test/conformance/src/everythingClient.ts | 49 +++++++++++++++++++++++- 2 files changed, 53 insertions(+), 8 deletions(-) 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..b7180785b9 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() }) ]); @@ -574,6 +582,45 @@ async function runClientCredentialsBasic(serverUrl: string): Promise { registerScenario('auth/client-credentials-basic', runClientCredentialsBasic); +// ============================================================================ +// 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); + /** * Cross-App Access (SEP-990 Enterprise Managed Authorization). * From c984e95e54a09f534c050fe9632cf20cc32ccb59 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 28 Jul 2026 12:30:52 -0500 Subject: [PATCH 06/17] docs(examples): add self-verifying workload identity federation example Co-Authored-By: Claude Fable 5 --- examples/README.md | 1 + examples/oauth-workload-identity/README.md | 40 ++++ examples/oauth-workload-identity/client.ts | 85 +++++++ examples/oauth-workload-identity/package.json | 32 +++ examples/oauth-workload-identity/server.ts | 207 ++++++++++++++++++ pnpm-lock.yaml | 37 ++++ 6 files changed, 402 insertions(+) create mode 100644 examples/oauth-workload-identity/README.md create mode 100644 examples/oauth-workload-identity/client.ts create mode 100644 examples/oauth-workload-identity/package.json create mode 100644 examples/oauth-workload-identity/server.ts 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/oauth-workload-identity/README.md b/examples/oauth-workload-identity/README.md new file mode 100644 index 0000000000..a42ca86988 --- /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. + +## 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 [draft-ietf-oauth-rfc7523bis](https://datatracker.ietf.org/doc/draft-ietf-oauth-rfc7523bis/) requires: the assertion is addressed to the AS that will consume it, not to the MCP server the resulting access token is spent on. Sending the resource URL instead is the single most common WIF misconfiguration, and the AS is required to reject 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 once at startup 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/AWS/Azure workload 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..391c47f551 --- /dev/null +++ b/examples/oauth-workload-identity/client.ts @@ -0,0 +1,85 @@ +/** + * 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, 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`); + +/** + * 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. +const provider = new WorkloadIdentityProvider({ + clientId: 'demo-workload', + assertion: fileAssertionSource(tokenPath) +}); +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 provider refuses to re-present an +// assertion the AS already turned down (SEP-1933 `wif-no-retry`), so a bad +// audience or an expired file surfaces as one loud error instead of a retry +// loop or a silent fall back to an interactive grant. + +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..741b8729a3 --- /dev/null +++ b/examples/oauth-workload-identity/server.ts @@ -0,0 +1,207 @@ +/** + * 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 { 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 tokenPath = process.env.WIF_WORKLOAD_TOKEN_PATH ?? path.join(tmpdir(), `mcp-wif-workload-token-${port}.jwt`); +writeFileSync(tokenPath, workloadJwt, { mode: 0o600 }); + +// ---- 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'] +}; + +/** Access tokens this AS has issued, keyed by token value. */ +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, { + 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; + } + const scopes = (body.scope ?? '').split(' ').filter(Boolean); + const accessToken = randomUUID(); + const expiresIn = 300; + issuedTokens.set(accessToken, { + token: accessToken, + clientId: body.client_id ?? 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, () => 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, () => { + console.error(`[resource-server] MCP on ${mcpServerUrl.href}`); + console.error(`[workload-issuer] projected token at ${tokenPath}`); +}); 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': From 8ac51937ca800a6fdd181f7ef6290bbddabf21c7 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 28 Jul 2026 13:00:38 -0500 Subject: [PATCH 07/17] docs(client): document WorkloadIdentityProvider and add changeset Co-Authored-By: Claude Fable 5 --- .changeset/wif-workload-identity-provider.md | 5 ++++ docs/clients/machine-auth.md | 30 +++++++++++++++++++ .../guides/clients/machine-auth.examples.ts | 30 +++++++++++++++++-- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 .changeset/wif-workload-identity-provider.md 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..aba02c68a3 100644 --- a/docs/clients/machine-auth.md +++ b/docs/clients/machine-auth.md @@ -88,6 +88,35 @@ const transport = new StreamableHTTPClientTransport(new URL('https://api.example The SDK discovers the MCP server's authorization server and resource URL (RFC 9728) before it calls `assertion`, then hands them in on `ctx` together with the negotiated `scope` and the transport's `fetchFn`. Pass them through so the IdP issues a grant bound to the right audience and resource. +## 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; the authorization server still needs one to look up even though there is no secret to check against it. `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, `expectedIssuer` pins the credential the same way it does for `ClientCredentialsProvider`, `clientName` sets the client metadata's display name, and `fetchFn` overrides the fetch implementation handed to the callback. + +::: tip +The workload JWT's `aud` must name the authorization server's issuer identifier, not the MCP server's resource URL (draft-ietf-oauth-rfc7523bis). When minting a JWT inside the callback, mint it against `authorizationServerUrl`, not `resourceUrl`. +::: + +`WorkloadIdentityProvider` is non-interactive: it has no `redirectUrl` and never falls back to an authorization-code grant. A rejected assertion is never re-sent unchanged; refusing that replay is what conformance calls `wif-no-retry`, 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. 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. + ## Drop to the token-exchange utilities Both exchanges behind `CrossAppAccessProvider` are exported as standalone functions for flows the provider does not cover — caching grants across transports, a non-standard IdP step, your own token store. @@ -104,4 +133,5 @@ All three live in [`client/crossAppAccess`](../api/@modelcontextprotocol/client/ - `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/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; From 46e322feada949ae32da7f2bf59e94ee67067294 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 28 Jul 2026 13:34:39 -0500 Subject: [PATCH 08/17] fix(client): key workload assertion rejection memory off credential invalidation Co-Authored-By: Claude Fable 5 --- docs/clients/machine-auth.md | 22 +-- examples/oauth-workload-identity/client.ts | 12 +- examples/oauth-workload-identity/server.ts | 6 +- packages/client/src/client/authExtensions.ts | 58 ++++--- .../test/client/workloadIdentity.test.ts | 151 +++++++++++++++--- test/conformance/src/everythingClient.ts | 78 ++++----- 6 files changed, 232 insertions(+), 95 deletions(-) diff --git a/docs/clients/machine-auth.md b/docs/clients/machine-auth.md index aba02c68a3..3d6ed4d61d 100644 --- a/docs/clients/machine-auth.md +++ b/docs/clients/machine-auth.md @@ -88,6 +88,16 @@ const transport = new StreamableHTTPClientTransport(new URL('https://api.example The SDK discovers the MCP server's authorization server and resource URL (RFC 9728) before it calls `assertion`, then hands them in on `ctx` together with the negotiated `scope` and the transport's `fetchFn`. Pass them through so the IdP issues a grant bound to the right audience and resource. +## Drop to the token-exchange utilities + +Both exchanges behind `CrossAppAccessProvider` are exported as standalone functions for flows the provider does not cover — caching grants across transports, a non-standard IdP step, your own token store. + +- `requestJwtAuthorizationGrant` exchanges an ID Token for a JWT Authorization Grant at a known IdP token endpoint (RFC 8693). +- `discoverAndRequestJwtAuthGrant` performs the same exchange, discovering the IdP's token endpoint from `idpUrl` first. +- `exchangeJwtAuthGrant` exchanges a JWT Authorization Grant for an access token at the MCP server's authorization server (RFC 7523). + +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. @@ -113,20 +123,10 @@ const transport = new StreamableHTTPClientTransport(new URL('https://api.example The workload JWT's `aud` must name the authorization server's issuer identifier, not the MCP server's resource URL (draft-ietf-oauth-rfc7523bis). When minting a JWT inside the callback, mint it against `authorizationServerUrl`, not `resourceUrl`. ::: -`WorkloadIdentityProvider` is non-interactive: it has no `redirectUrl` and never falls back to an authorization-code grant. A rejected assertion is never re-sent unchanged; refusing that replay is what conformance calls `wif-no-retry`, 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. 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. +`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`, 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. 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. -## Drop to the token-exchange utilities - -Both exchanges behind `CrossAppAccessProvider` are exported as standalone functions for flows the provider does not cover — caching grants across transports, a non-standard IdP step, your own token store. - -- `requestJwtAuthorizationGrant` exchanges an ID Token for a JWT Authorization Grant at a known IdP token endpoint (RFC 8693). -- `discoverAndRequestJwtAuthGrant` performs the same exchange, discovering the IdP's token endpoint from `idpUrl` first. -- `exchangeJwtAuthGrant` exchanges a JWT Authorization Grant for an access token at the MCP server's authorization server (RFC 7523). - -All three live in [`client/crossAppAccess`](../api/@modelcontextprotocol/client/client/crossAppAccess.md) in the API reference. - ## Recap - Every flow on this page plugs in through the same `authProvider` option on `StreamableHTTPClientTransport`. diff --git a/examples/oauth-workload-identity/client.ts b/examples/oauth-workload-identity/client.ts index 391c47f551..dec16446a2 100644 --- a/examples/oauth-workload-identity/client.ts +++ b/examples/oauth-workload-identity/client.ts @@ -77,9 +77,13 @@ 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 provider refuses to re-present an -// assertion the AS already turned down (SEP-1933 `wif-no-retry`), so a bad -// audience or an expired file surfaces as one loud error instead of a retry -// loop or a silent fall back to an interactive grant. +// Rejected assertions are not retried (SEP-1933 `wif-no-retry`): 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, /wif-no-retry/); +console.log('rejected assertion: connect failed loudly, no retry and no interactive fallback'); await client.close(); diff --git a/examples/oauth-workload-identity/server.ts b/examples/oauth-workload-identity/server.ts index 741b8729a3..34459c0f35 100644 --- a/examples/oauth-workload-identity/server.ts +++ b/examples/oauth-workload-identity/server.ts @@ -121,6 +121,8 @@ asApp.post('/token', async (req, res) => { // 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}/`], @@ -145,7 +147,7 @@ asApp.post('/token', async (req, res) => { res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: expiresIn, scope: scopes.join(' ') }); }); -asApp.listen(AUTH_PORT, () => console.error(`[auth-server] jwt-bearer AS on ${authServerUrl.href}`)); +asApp.listen(AUTH_PORT, '127.0.0.1', () => console.error(`[auth-server] jwt-bearer AS on ${authServerUrl.href}`)); // ---- Resource Server (MCP) ---- const verifier: OAuthTokenVerifier = { @@ -201,7 +203,7 @@ const auth = requireBearerAuth({ const node = toNodeHandler(handler); app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); -app.listen(port, () => { +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/authExtensions.ts b/packages/client/src/client/authExtensions.ts index 276920db75..8030e2a7ed 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -739,8 +739,12 @@ export class CrossAppAccessProvider implements OAuthClientProvider { */ export interface WorkloadAssertionContext { /** - * The authorization server URL of the target MCP server. - * Discovered via RFC 9728 protected resource metadata. + * 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; @@ -844,7 +848,7 @@ export class WorkloadIdentityProvider implements OAuthClientProvider { private _authorizationServerUrl?: string; private _resourceUrl?: string; private _lastAssertion?: string; - private _lastAssertionConfirmed = true; + private _rejectedAssertion?: string; constructor(options: WorkloadIdentityProviderOptions) { this._clientInfo = { @@ -883,21 +887,39 @@ export class WorkloadIdentityProvider implements OAuthClientProvider { } saveTokens(tokens: StoredOAuthTokens): void { - this._lastAssertionConfirmed = true; this._tokens = tokens; + // A granted token settles the credential state: whatever was presented worked, + // and any earlier rejection no longer describes this provider's situation. + this._lastAssertion = undefined; + this._rejectedAssertion = undefined; + } + + /** + * Records the authorization server's verdict on the last assertion this provider + * handed out. `auth()` calls this only from its OAuth error paths, so a + * `'tokens'` invalidation means the authorization server rejected the flow that + * presented that assertion, and it must not be presented again unchanged + * (SEP-1933 `wif-no-retry`). `'all'` is a host-driven reset rather than a + * rejection, so it clears the memory instead of recording one. The remaining + * scopes name state this provider does not keep. + */ + invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void { + if (scope === 'tokens') { + this._tokens = undefined; + this._rejectedAssertion = this._lastAssertion; + } else if (scope === 'all') { + this._tokens = undefined; + this._lastAssertion = undefined; + this._rejectedAssertion = undefined; + } } redirectToAuthorization(): void { - throw new Error( - 'WorkloadIdentityProvider is non-interactive: the authorization server rejected the ' + - 'jwt-bearer flow and the client attempted an authorization redirect. Check that the ' + - 'authorization server supports urn:ietf:params:oauth:grant-type:jwt-bearer and ' + - 'trusts the workload issuer.' - ); + throw new Error('WorkloadIdentityProvider is non-interactive and does not support authorization redirects'); } saveCodeVerifier(): void { - throw new Error('saveCodeVerifier is not used for jwt-bearer flow'); + // Not used for jwt-bearer } codeVerifier(): string { @@ -952,19 +974,15 @@ export class WorkloadIdentityProvider implements OAuthClientProvider { assertion = this._assertion; } - // saveTokens confirms the last handed-out assertion; re-presenting an unconfirmed - // one unchanged would replay a credential the authorization server already rejected. - if (!this._lastAssertionConfirmed && assertion === this._lastAssertion) { + if (assertion === this._rejectedAssertion) { throw new Error( - 'Workload assertion was rejected by the authorization server and would be ' + - 're-sent unchanged. Refusing to retry with the same credential ' + - '(SEP-1933 wif-no-retry). Provide a fresh assertion via a ' + - 'WorkloadAssertionCallback or fix the assertion (issuer trust, audience, ' + - 'expiry) before retrying.' + 'The authorization server rejected this workload assertion, so the provider ' + + 'refuses to present it again unchanged (SEP-1933 wif-no-retry). Supply a fresh ' + + 'assertion from a WorkloadAssertionCallback, and check that the authorization ' + + 'server trusts the assertion issuer and that the audience and expiry are correct.' ); } this._lastAssertion = assertion; - this._lastAssertionConfirmed = false; // Return params for JWT bearer grant per RFC 7523; auth() sets the RFC 8707 // resource parameter after the provider returns. diff --git a/packages/client/test/client/workloadIdentity.test.ts b/packages/client/test/client/workloadIdentity.test.ts index 760785c74a..640341fdba 100644 --- a/packages/client/test/client/workloadIdentity.test.ts +++ b/packages/client/test/client/workloadIdentity.test.ts @@ -31,11 +31,7 @@ describe('WorkloadIdentityProvider token request', () => { 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(); - }); - - it('does not set the resource parameter (auth() sets it after the provider returns)', async () => { - const params = await makeProvider().prepareTokenRequest(); - + // auth() sets the RFC 8707 resource parameter after the provider returns expect(params.get('resource')).toBeNull(); }); @@ -146,18 +142,18 @@ describe('WorkloadIdentityProvider non-interactive contract', () => { expect(makeProvider().redirectUrl).toBeUndefined(); }); - it('throws a descriptive error from redirectToAuthorization', () => { - expect(() => makeProvider().redirectToAuthorization()).toThrow(/non-interactive/); - expect(() => makeProvider().redirectToAuthorization()).toThrow(/jwt-bearer/); - expect(() => makeProvider().redirectToAuthorization()).toThrow(/workload issuer/); + 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('throws from saveCodeVerifier (PKCE is not used)', () => { - expect(() => makeProvider().saveCodeVerifier()).toThrow('saveCodeVerifier is not used for jwt-bearer flow'); + it('accepts saveCodeVerifier as a no-op (PKCE is not used)', () => { + expect(() => makeProvider().saveCodeVerifier()).not.toThrow(); }); }); @@ -223,15 +219,28 @@ describe('WorkloadIdentityProvider client information and state', () => { }); describe('WorkloadIdentityProvider rejection memory', () => { - it('refuses to re-present the same assertion after an unconfirmed attempt', async () => { + it('refuses to re-present an assertion the authorization server rejected', async () => { + const provider = makeProvider(); + + await provider.prepareTokenRequest(); + // auth() invalidates tokens when the token endpoint rejects the grant + provider.invalidateCredentials('tokens'); + + await expect(provider.prepareTokenRequest()).rejects.toThrow(/wif-no-retry/); + }); + + it('hands out the same assertion again while nothing has been rejected', async () => { const provider = makeProvider(); await provider.prepareTokenRequest(); - await expect(provider.prepareTokenRequest()).rejects.toThrow(/rejected|same credential|wif-no-retry/i); + // An unconfirmed attempt is not a rejected one: concurrent flows and transient + // transport failures both leave the assertion perfectly presentable. + const params = await provider.prepareTokenRequest(); + expect(params.get('assertion')).toBe(STATIC_ASSERTION); }); - it('allows a new request after saveTokens confirmed the previous one', async () => { + it('allows a new request after saveTokens', async () => { const provider = makeProvider(); await provider.prepareTokenRequest(); @@ -240,11 +249,27 @@ describe('WorkloadIdentityProvider rejection memory', () => { await expect(provider.prepareTokenRequest()).resolves.toBeDefined(); }); + 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'); @@ -261,6 +286,38 @@ describe('WorkloadIdentityProvider rejection memory', () => { await expect(provider.prepareTokenRequest()).resolves.toBeDefined(); }); + + it("clears the rejection on invalidateCredentials('all')", async () => { + const provider = makeProvider(); + + await provider.prepareTokenRequest(); + provider.invalidateCredentials('tokens'); + // A host-driven reset is not a verdict on the assertion + provider.invalidateCredentials('all'); + + 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'); + }); }); describe('WorkloadIdentityProvider (end-to-end with auth())', () => { @@ -318,10 +375,13 @@ describe('WorkloadIdentityProvider (end-to-end with auth())', () => { /** * Hand-rolled fetch mock in the auth.test.ts style: createMockOAuthFetch cannot - * serve error responses or count calls, so the failure-path tests script the - * token endpoint themselves and log every token request body. + * serve error responses or count calls, so these tests script the token endpoint + * themselves and log every token request body. */ -function createFailingOAuthFetch(tokenErrorCode: string): { fetchMock: FetchLike; tokenRequestBodies: URLSearchParams[] } { +function createScriptedOAuthFetch(respondToTokenRequest: (body: URLSearchParams) => Response): { + fetchMock: FetchLike; + tokenRequestBodies: URLSearchParams[]; +} { const tokenRequestBodies: URLSearchParams[] = []; const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit): Promise => { @@ -346,8 +406,9 @@ function createFailingOAuthFetch(tokenErrorCode: string): { fetchMock: FetchLike } if (url.origin === AUTH_SERVER_URL && url.pathname === '/token') { - tokenRequestBodies.push(new URLSearchParams(init?.body as URLSearchParams)); - return Response.json({ error: tokenErrorCode }, { status: 400 }); + 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()}`); @@ -356,6 +417,14 @@ function createFailingOAuthFetch(tokenErrorCode: string): { fetchMock: FetchLike 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({ @@ -420,4 +489,48 @@ describe('WorkloadIdentityProvider failure paths through auth()', () => { 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); + + // The second flow reaches prepareTokenRequest before the first one has saved its + // tokens, so an unconfirmed assertion must not read as a rejected one. + 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/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index b7180785b9..f912dfd598 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -582,45 +582,6 @@ async function runClientCredentialsBasic(serverUrl: string): Promise { registerScenario('auth/client-credentials-basic', runClientCredentialsBasic); -// ============================================================================ -// 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); - /** * Cross-App Access (SEP-990 Enterprise Managed Authorization). * @@ -676,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) // ============================================================================ From 535fadb3925e02ca7778a75c5a55ae156716ce78 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 28 Jul 2026 13:45:38 -0500 Subject: [PATCH 09/17] fix(client): keep rejection memory armed after concurrent success A rejection verdict arriving after another flow's saveTokens cleared the last-handed-out assertion was recorded as undefined, silently disarming the wif-no-retry guard. saveTokens now clears only the rejection itself. Co-Authored-By: Claude Fable 5 --- packages/client/src/client/authExtensions.ts | 26 ++++++++++++------- .../test/client/workloadIdentity.test.ts | 13 ++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index 8030e2a7ed..b09e05ea54 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -890,18 +890,22 @@ export class WorkloadIdentityProvider implements OAuthClientProvider { this._tokens = tokens; // A granted token settles the credential state: whatever was presented worked, // and any earlier rejection no longer describes this provider's situation. - this._lastAssertion = undefined; + // _lastAssertion stays recorded so a rejection verdict arriving after this + // success (from a concurrent flow) can still name the assertion it refuses. this._rejectedAssertion = undefined; } /** * Records the authorization server's verdict on the last assertion this provider * handed out. `auth()` calls this only from its OAuth error paths, so a - * `'tokens'` invalidation means the authorization server rejected the flow that - * presented that assertion, and it must not be presented again unchanged - * (SEP-1933 `wif-no-retry`). `'all'` is a host-driven reset rather than a - * rejection, so it clears the memory instead of recording one. The remaining - * scopes name state this provider does not keep. + * `'tokens'` invalidation means the authorization server rejected an exchange + * that presented an assertion, and that assertion must not be presented again + * unchanged (SEP-1933 `wif-no-retry`). With overlapping flows on one provider + * the verdict may name the most recently handed-out assertion rather than the + * exact one the failing flow sent; that is conservative and fails closed. + * `'all'` is a host-driven reset rather than a rejection, so it clears the + * memory instead of recording one. The remaining scopes name state this + * provider does not keep. */ invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void { if (scope === 'tokens') { @@ -976,10 +980,12 @@ export class WorkloadIdentityProvider implements OAuthClientProvider { if (assertion === this._rejectedAssertion) { throw new Error( - 'The authorization server rejected this workload assertion, so the provider ' + - 'refuses to present it again unchanged (SEP-1933 wif-no-retry). Supply a fresh ' + - 'assertion from a WorkloadAssertionCallback, and check that the authorization ' + - 'server trusts the assertion issuer and that the audience and expiry are correct.' + 'The authorization server rejected the token exchange that presented this workload ' + + 'assertion, so the provider refuses to present it again unchanged (SEP-1933 ' + + 'wif-no-retry). 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 with ' + + 'the authorization server.' ); } this._lastAssertion = assertion; diff --git a/packages/client/test/client/workloadIdentity.test.ts b/packages/client/test/client/workloadIdentity.test.ts index 640341fdba..fb91549c8e 100644 --- a/packages/client/test/client/workloadIdentity.test.ts +++ b/packages/client/test/client/workloadIdentity.test.ts @@ -249,6 +249,19 @@ describe('WorkloadIdentityProvider rejection memory', () => { await expect(provider.prepareTokenRequest()).resolves.toBeDefined(); }); + it('records a rejection that arrives after a concurrent flow already succeeded', async () => { + const provider = makeProvider(); + + // Flow A presents the assertion and wins; flow B loses the race and its + // rejection verdict lands after A's saveTokens. The guard must still know + // which assertion the verdict refers to. + await provider.prepareTokenRequest(); + provider.saveTokens({ access_token: 'granted-token', token_type: 'Bearer' }); + provider.invalidateCredentials('tokens'); + + await expect(provider.prepareTokenRequest()).rejects.toThrow(/wif-no-retry/); + }); + it('forgets a rejection once a later flow is confirmed by saveTokens', async () => { let assertion = 'jwt.rejected'; const provider = makeProvider({ assertion: () => assertion }); From ac538e3539c41aa63b134a202f5009125878e5c5 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 28 Jul 2026 14:44:20 -0500 Subject: [PATCH 10/17] fix: address review feedback on invalidation scopes and example AS policy invalidateCredentials('discovery') now drops the cached issuer and resource URLs so a callback cannot mint against a stale audience. The example AS binds the issued identity and scopes to the verified workload instead of copying them from the request, and the projected token file is created with O_EXCL so a pre-planted symlink fails closed. Co-Authored-By: Claude Fable 5 --- examples/oauth-workload-identity/server.ts | 22 ++++++++++-- packages/client/src/client/authExtensions.ts | 36 ++++++++++++++----- .../test/client/workloadIdentity.test.ts | 20 +++++++++++ 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/examples/oauth-workload-identity/server.ts b/examples/oauth-workload-identity/server.ts index 34459c0f35..ed8fb07c0e 100644 --- a/examples/oauth-workload-identity/server.ts +++ b/examples/oauth-workload-identity/server.ts @@ -25,7 +25,7 @@ * API socket instead of a temp file this process wrote. */ import { randomUUID } from 'node:crypto'; -import { writeFileSync } from 'node:fs'; +import { rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -78,7 +78,10 @@ const workloadJwt = await new jose.SignJWT({}) // 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 tokenPath = process.env.WIF_WORKLOAD_TOKEN_PATH ?? path.join(tmpdir(), `mcp-wif-workload-token-${port}.jwt`); -writeFileSync(tokenPath, workloadJwt, { mode: 0o600 }); +// `wx` (O_CREAT | O_EXCL) refuses to follow a pre-planted symlink at this +// well-known path and fails closed if anything reappears between rm and open. +rmSync(tokenPath, { force: true }); +writeFileSync(tokenPath, workloadJwt, { mode: 0o600, flag: 'wx' }); // ---- Authorization Server (jwt-bearer only) ---- const metadata: OAuthMetadata = { @@ -133,12 +136,25 @@ asApp.post('/token', async (req, res) => { 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: body.client_id ?? DEMO_CLIENT_ID, + clientId: DEMO_CLIENT_ID, scopes, expiresAt: Math.floor(Date.now() / 1000) + expiresIn, extra: { workloadSubject: WORKLOAD_SUBJECT } diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index b09e05ea54..820bc9fe5b 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -904,17 +904,35 @@ export class WorkloadIdentityProvider implements OAuthClientProvider { * the verdict may name the most recently handed-out assertion rather than the * exact one the failing flow sent; that is conservative and fails closed. * `'all'` is a host-driven reset rather than a rejection, so it clears the - * memory instead of recording one. The remaining scopes name state this - * provider does not keep. + * memory instead of recording one. `'discovery'` drops the cached + * authorization server and resource URLs so a later flow cannot mint an + * assertion against a stale issuer; `auth()` repopulates them on its next + * discovery pass. `'client'` and `'verifier'` name state this provider does + * not keep. */ invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void { - if (scope === 'tokens') { - this._tokens = undefined; - this._rejectedAssertion = this._lastAssertion; - } else if (scope === 'all') { - this._tokens = undefined; - this._lastAssertion = undefined; - this._rejectedAssertion = undefined; + 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; + } } } diff --git a/packages/client/test/client/workloadIdentity.test.ts b/packages/client/test/client/workloadIdentity.test.ts index fb91549c8e..3bbd6e0819 100644 --- a/packages/client/test/client/workloadIdentity.test.ts +++ b/packages/client/test/client/workloadIdentity.test.ts @@ -331,6 +331,26 @@ describe('WorkloadIdentityProvider rejection memory', () => { 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'); + }); + + it("drops cached discovery URLs on invalidateCredentials('all')", () => { + const provider = makeProvider(); + + provider.invalidateCredentials('all'); + + expect(provider.authorizationServerUrl()).toBeUndefined(); + expect(provider.resourceUrl?.()).toBeUndefined(); + }); }); describe('WorkloadIdentityProvider (end-to-end with auth())', () => { From 4731c8b5a3335434113328928eda9edbfed5700e Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 28 Jul 2026 15:42:40 -0500 Subject: [PATCH 11/17] docs: clarify clientId as an SDK constraint and soften wif-no-retry attribution clientId is required by this SDK's stored-client-information flow, not by RFC 7523 or SEP-1933, which allow assertion-only requests. The audience tip now attributes the issuer-identifier expectation to the MCP conformance profile rather than to rfc7523bis, which permits either the issuer identifier or the token endpoint URL for authorization grants. The no-retry guard is described as matching the conformance suite's non-normative warning instead of as a SEP-1933 requirement. The example no longer deletes a user-supplied WIF_WORKLOAD_TOKEN_PATH file and removes its derived temp token on exit. Co-Authored-By: Claude Fable 5 --- docs/clients/machine-auth.md | 4 ++-- examples/oauth-workload-identity/server.ts | 13 ++++++++++--- packages/client/src/client/authExtensions.ts | 15 ++++++++++----- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/clients/machine-auth.md b/docs/clients/machine-auth.md index 3d6ed4d61d..9ab4803265 100644 --- a/docs/clients/machine-auth.md +++ b/docs/clients/machine-auth.md @@ -117,10 +117,10 @@ const authProvider = new WorkloadIdentityProvider({ const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); ``` -`clientId` is required; the authorization server still needs one to look up even though there is no secret to check against it. `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, `expectedIssuer` pins the credential the same way it does for `ClientCredentialsProvider`, `clientName` sets the client metadata's display name, and `fetchFn` overrides the fetch implementation handed to the callback. +`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, `expectedIssuer` pins the credential the same way it does for `ClientCredentialsProvider`, `clientName` sets the client metadata's display name, and `fetchFn` overrides the fetch implementation handed to the callback. ::: tip -The workload JWT's `aud` must name the authorization server's issuer identifier, not the MCP server's resource URL (draft-ietf-oauth-rfc7523bis). When minting a JWT inside the callback, mint it against `authorizationServerUrl`, not `resourceUrl`. +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`, 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. 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. diff --git a/examples/oauth-workload-identity/server.ts b/examples/oauth-workload-identity/server.ts index ed8fb07c0e..6edaa9b5bb 100644 --- a/examples/oauth-workload-identity/server.ts +++ b/examples/oauth-workload-identity/server.ts @@ -77,10 +77,17 @@ const workloadJwt = await new jose.SignJWT({}) // 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 tokenPath = process.env.WIF_WORKLOAD_TOKEN_PATH ?? path.join(tmpdir(), `mcp-wif-workload-token-${port}.jwt`); -// `wx` (O_CREAT | O_EXCL) refuses to follow a pre-planted symlink at this +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. -rmSync(tokenPath, { force: true }); writeFileSync(tokenPath, workloadJwt, { mode: 0o600, flag: 'wx' }); // ---- Authorization Server (jwt-bearer only) ---- diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index 820bc9fe5b..2a2cab4a0f 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -789,7 +789,10 @@ export interface WorkloadIdentityProviderOptions { assertion: string | WorkloadAssertionCallback; /** - * The `client_id` registered with the MCP server's authorization server. + * 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; @@ -899,8 +902,10 @@ export class WorkloadIdentityProvider implements OAuthClientProvider { * Records the authorization server's verdict on the last assertion this provider * handed out. `auth()` calls this only from its OAuth error paths, so a * `'tokens'` invalidation means the authorization server rejected an exchange - * that presented an assertion, and that assertion must not be presented again - * unchanged (SEP-1933 `wif-no-retry`). With overlapping flows on one provider + * that presented an assertion, and this provider then refuses to re-present that + * assertion unchanged. That refusal is a best-effort guard matching the + * conformance suite's `wif-no-retry` warning; neither RFC 7523 nor SEP-1933 + * mandates it. With overlapping flows on one provider * the verdict may name the most recently handed-out assertion rather than the * exact one the failing flow sent; that is conservative and fails closed. * `'all'` is a host-driven reset rather than a rejection, so it clears the @@ -999,8 +1004,8 @@ export class WorkloadIdentityProvider implements OAuthClientProvider { if (assertion === this._rejectedAssertion) { throw new Error( 'The authorization server rejected the token exchange that presented this workload ' + - 'assertion, so the provider refuses to present it again unchanged (SEP-1933 ' + - 'wif-no-retry). Supply a fresh assertion from a WorkloadAssertionCallback, and ' + + 'assertion, so the provider refuses to present it again unchanged (the conformance ' + + 'wif-no-retry check). 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 with ' + 'the authorization server.' From 3f5530b4e42dc4433ed74af86343aa42e2095a92 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 28 Jul 2026 16:52:17 -0500 Subject: [PATCH 12/17] docs(examples): correct audience attribution and assertion-source claims in WIF readme Co-Authored-By: Claude Fable 5 --- examples/oauth-workload-identity/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/oauth-workload-identity/README.md b/examples/oauth-workload-identity/README.md index a42ca86988..c4d3f7f32e 100644 --- a/examples/oauth-workload-identity/README.md +++ b/examples/oauth-workload-identity/README.md @@ -14,7 +14,7 @@ The point is that no long-lived secret is provisioned anywhere. `client_credenti - 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. +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 will not replace a file that already exists there. ## Run it @@ -27,14 +27,14 @@ The client prints the tool list and exits `0`; any mismatch throws and exits non ## 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 [draft-ietf-oauth-rfc7523bis](https://datatracker.ietf.org/doc/draft-ietf-oauth-rfc7523bis/) requires: the assertion is addressed to the AS that will consume it, not to the MCP server the resulting access token is spent on. Sending the resource URL instead is the single most common WIF misconfiguration, and the AS is required to reject it. +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 once at startup and the process pins a credential that stops working when it expires. +- **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/AWS/Azure workload 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. +- **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. From c11fd6f4faad164b081a26708c5805b8853c9140 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 28 Jul 2026 17:25:10 -0500 Subject: [PATCH 13/17] refactor: compress comment blocks to their load-bearing facts Co-Authored-By: Claude Fable 5 --- examples/oauth-workload-identity/server.ts | 1 - packages/client/src/client/authExtensions.ts | 34 ++++++++----------- .../test/client/workloadIdentity.test.ts | 15 +------- 3 files changed, 15 insertions(+), 35 deletions(-) diff --git a/examples/oauth-workload-identity/server.ts b/examples/oauth-workload-identity/server.ts index 6edaa9b5bb..7124f4fc91 100644 --- a/examples/oauth-workload-identity/server.ts +++ b/examples/oauth-workload-identity/server.ts @@ -104,7 +104,6 @@ const metadata: OAuthMetadata = { scopes_supported: ['mcp:tools'] }; -/** Access tokens this AS has issued, keyed by token value. */ const issuedTokens = new Map(); const asApp = express(); diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index 2a2cab4a0f..8cdbc8b8e8 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -831,7 +831,6 @@ export interface WorkloadIdentityProviderOptions { * ```ts * const provider = new WorkloadIdentityProvider({ * assertion: async ctx => { - * // Return a workload JWT minted for the authorization server's audience * return await mintWorkloadJwt({ audience: ctx.authorizationServerUrl }); * }, * clientId: 'my-workload-client' @@ -891,29 +890,24 @@ export class WorkloadIdentityProvider implements OAuthClientProvider { saveTokens(tokens: StoredOAuthTokens): void { this._tokens = tokens; - // A granted token settles the credential state: whatever was presented worked, - // and any earlier rejection no longer describes this provider's situation. - // _lastAssertion stays recorded so a rejection verdict arriving after this - // success (from a concurrent flow) can still name the assertion it refuses. + // 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 this provider - * handed out. `auth()` calls this only from its OAuth error paths, so a - * `'tokens'` invalidation means the authorization server rejected an exchange - * that presented an assertion, and this provider then refuses to re-present that - * assertion unchanged. That refusal is a best-effort guard matching the - * conformance suite's `wif-no-retry` warning; neither RFC 7523 nor SEP-1933 - * mandates it. With overlapping flows on one provider - * the verdict may name the most recently handed-out assertion rather than the - * exact one the failing flow sent; that is conservative and fails closed. - * `'all'` is a host-driven reset rather than a rejection, so it clears the - * memory instead of recording one. `'discovery'` drops the cached - * authorization server and resource URLs so a later flow cannot mint an - * assertion against a stale issuer; `auth()` repopulates them on its next - * discovery pass. `'client'` and `'verifier'` name state this provider does - * not keep. + * 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) { diff --git a/packages/client/test/client/workloadIdentity.test.ts b/packages/client/test/client/workloadIdentity.test.ts index 3bbd6e0819..67e5acb381 100644 --- a/packages/client/test/client/workloadIdentity.test.ts +++ b/packages/client/test/client/workloadIdentity.test.ts @@ -31,7 +31,6 @@ describe('WorkloadIdentityProvider token request', () => { 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(); - // auth() sets the RFC 8707 resource parameter after the provider returns expect(params.get('resource')).toBeNull(); }); @@ -223,7 +222,6 @@ describe('WorkloadIdentityProvider rejection memory', () => { const provider = makeProvider(); await provider.prepareTokenRequest(); - // auth() invalidates tokens when the token endpoint rejects the grant provider.invalidateCredentials('tokens'); await expect(provider.prepareTokenRequest()).rejects.toThrow(/wif-no-retry/); @@ -234,8 +232,6 @@ describe('WorkloadIdentityProvider rejection memory', () => { await provider.prepareTokenRequest(); - // An unconfirmed attempt is not a rejected one: concurrent flows and transient - // transport failures both leave the assertion perfectly presentable. const params = await provider.prepareTokenRequest(); expect(params.get('assertion')).toBe(STATIC_ASSERTION); }); @@ -252,9 +248,6 @@ describe('WorkloadIdentityProvider rejection memory', () => { it('records a rejection that arrives after a concurrent flow already succeeded', async () => { const provider = makeProvider(); - // Flow A presents the assertion and wins; flow B loses the race and its - // rejection verdict lands after A's saveTokens. The guard must still know - // which assertion the verdict refers to. await provider.prepareTokenRequest(); provider.saveTokens({ access_token: 'granted-token', token_type: 'Bearer' }); provider.invalidateCredentials('tokens'); @@ -305,7 +298,6 @@ describe('WorkloadIdentityProvider rejection memory', () => { await provider.prepareTokenRequest(); provider.invalidateCredentials('tokens'); - // A host-driven reset is not a verdict on the assertion provider.invalidateCredentials('all'); const params = await provider.prepareTokenRequest(); @@ -384,7 +376,6 @@ describe('WorkloadIdentityProvider (end-to-end with auth())', () => { expect(params.get('resource')).toBe(RESOURCE_SERVER_URL); - // Public client: client_id travels in the body, no Authorization header expect(params.get('client_id')).toBe('wif-client'); const headers = new Headers(init?.headers); expect(headers.get('Authorization')).toBeNull(); @@ -466,9 +457,7 @@ describe('WorkloadIdentityProvider failure paths through auth()', () => { }); const { fetchMock, tokenRequestBodies } = createFailingOAuthFetch('invalid_grant'); - // auth() invalidates tokens on invalid_grant and re-runs authInternal once; - // the provider refuses to replay the identical rejected assertion on that - // re-run, so the rejection carries the wif-no-retry refusal. + // 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.toThrow(/wif-no-retry/); expect(tokenRequestBodies).toHaveLength(1); @@ -530,8 +519,6 @@ describe('WorkloadIdentityProvider failure paths through auth()', () => { }); const { fetchMock, tokenRequestBodies } = createScriptedOAuthFetch(grantedTokenResponse); - // The second flow reaches prepareTokenRequest before the first one has saved its - // tokens, so an unconfirmed assertion must not read as a rejected one. const results = await Promise.all([ auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock }), auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock }) From 1d02907346c37811fd3f9f7423e4497d0b120976 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Tue, 28 Jul 2026 17:34:28 -0500 Subject: [PATCH 14/17] test(client): fold redundant workload identity tests into their canonical peers Co-Authored-By: Claude Fable 5 --- .../test/client/workloadIdentity.test.ts | 78 ++----------------- 1 file changed, 8 insertions(+), 70 deletions(-) diff --git a/packages/client/test/client/workloadIdentity.test.ts b/packages/client/test/client/workloadIdentity.test.ts index 67e5acb381..a466b9669c 100644 --- a/packages/client/test/client/workloadIdentity.test.ts +++ b/packages/client/test/client/workloadIdentity.test.ts @@ -45,12 +45,6 @@ describe('WorkloadIdentityProvider token request', () => { expect(params.get('assertion')).toBe(STATIC_ASSERTION); }); - it('uses the framework-supplied scope argument', async () => { - const params = await makeProvider().prepareTokenRequest('challenge.scope'); - - expect(params.get('scope')).toBe('challenge.scope'); - }); - it('prefers the framework-supplied scope over the configured scope', async () => { const params = await makeProvider({ scope: 'configured' }).prepareTokenRequest('challenge.scope'); @@ -70,12 +64,14 @@ describe('WorkloadIdentityProvider token request', () => { 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'); @@ -86,24 +82,7 @@ describe('WorkloadIdentityProvider token request', () => { resourceUrl: RESOURCE_SERVER_URL, scope: 'requested.scope' }); - expect(seenContext?.fetchFn).toBeDefined(); - }); - - it('passes a custom fetchFn to the assertion callback', async () => { - const customFetch = vi.fn(fetch); - let capturedFetchFn: unknown; - - const provider = makeProvider({ - assertion: async context => { - capturedFetchFn = context.fetchFn; - return 'callback.jwt.value'; - }, - fetchFn: customFetch - }); - - await provider.prepareTokenRequest(); - - expect(capturedFetchFn).toBe(customFetch); + expect(seenContext?.fetchFn).toBe(customFetch); }); it('passes undefined resourceUrl to the callback when none was discovered', async () => { @@ -177,9 +156,7 @@ describe('WorkloadIdentityProvider client information and state', () => { 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'); - }); - it('uses a custom client name when provided', () => { expect(makeProvider({ clientName: 'custom-wif-client' }).clientMetadata.client_name).toBe('custom-wif-client'); }); @@ -191,30 +168,6 @@ describe('WorkloadIdentityProvider client information and state', () => { provider.saveTokens({ access_token: 'stored-token', token_type: 'Bearer' }); expect(provider.tokens()?.access_token).toBe('stored-token'); }); - - it('stores and retrieves authorization server URL', () => { - const provider = new WorkloadIdentityProvider({ - clientId: 'wif-client', - assertion: STATIC_ASSERTION - }); - - expect(provider.authorizationServerUrl?.()).toBeUndefined(); - - provider.saveAuthorizationServerUrl(AUTH_SERVER_URL); - expect(provider.authorizationServerUrl?.()).toBe(AUTH_SERVER_URL); - }); - - it('stores and retrieves resource URL', () => { - const provider = new WorkloadIdentityProvider({ - clientId: 'wif-client', - assertion: STATIC_ASSERTION - }); - - expect(provider.resourceUrl?.()).toBeUndefined(); - - provider.saveResourceUrl?.(RESOURCE_SERVER_URL); - expect(provider.resourceUrl?.()).toBe(RESOURCE_SERVER_URL); - }); }); describe('WorkloadIdentityProvider rejection memory', () => { @@ -236,15 +189,6 @@ describe('WorkloadIdentityProvider rejection memory', () => { expect(params.get('assertion')).toBe(STATIC_ASSERTION); }); - it('allows a new request after saveTokens', async () => { - const provider = makeProvider(); - - await provider.prepareTokenRequest(); - provider.saveTokens({ access_token: 'granted-token', token_type: 'Bearer' }); - - await expect(provider.prepareTokenRequest()).resolves.toBeDefined(); - }); - it('records a rejection that arrives after a concurrent flow already succeeded', async () => { const provider = makeProvider(); @@ -293,13 +237,16 @@ describe('WorkloadIdentityProvider rejection memory', () => { await expect(provider.prepareTokenRequest()).resolves.toBeDefined(); }); - it("clears the rejection on invalidateCredentials('all')", async () => { + 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); }); @@ -334,15 +281,6 @@ describe('WorkloadIdentityProvider rejection memory', () => { expect(provider.resourceUrl?.()).toBeUndefined(); expect(provider.tokens()?.access_token).toBe('stored-token'); }); - - it("drops cached discovery URLs on invalidateCredentials('all')", () => { - const provider = makeProvider(); - - provider.invalidateCredentials('all'); - - expect(provider.authorizationServerUrl()).toBeUndefined(); - expect(provider.resourceUrl?.()).toBeUndefined(); - }); }); describe('WorkloadIdentityProvider (end-to-end with auth())', () => { From a0800b54523e587184e2828f3ae6b071191c6862 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Wed, 29 Jul 2026 08:07:32 -0500 Subject: [PATCH 15/17] refactor(client): surface workload assertion replay refusal as a typed error WorkloadAssertionRejectedError extends OAuthClientFlowError so consumers can catch the refusal programmatically instead of matching message text. It deliberately does not extend OAuthError: the rejection is detected locally from replay memory, not parsed from an AS response. Also gives the provider's JSDoc example a type-checked authExtensions.examples.ts region like its siblings. Co-Authored-By: Claude Fable 5 --- docs/clients/machine-auth.md | 2 +- examples/oauth-workload-identity/client.ts | 11 +++++-- packages/client/src/client/authErrors.ts | 31 +++++++++++++++++++ .../src/client/authExtensions.examples.ts | 21 ++++++++++++- packages/client/src/client/authExtensions.ts | 12 ++----- packages/client/src/index.ts | 3 +- .../test/client/errorBrandConformance.test.ts | 3 +- .../test/client/workloadIdentity.test.ts | 9 ++++-- 8 files changed, 73 insertions(+), 19 deletions(-) diff --git a/docs/clients/machine-auth.md b/docs/clients/machine-auth.md index 9ab4803265..fd62e6d95b 100644 --- a/docs/clients/machine-auth.md +++ b/docs/clients/machine-auth.md @@ -123,7 +123,7 @@ const transport = new StreamableHTTPClientTransport(new URL('https://api.example 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`, 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. 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. +`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. 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. diff --git a/examples/oauth-workload-identity/client.ts b/examples/oauth-workload-identity/client.ts index dec16446a2..74bb8a72b4 100644 --- a/examples/oauth-workload-identity/client.ts +++ b/examples/oauth-workload-identity/client.ts @@ -18,7 +18,12 @@ import path from 'node:path'; import { check, parseExampleArgs } from '@mcp-examples/shared'; import type { WorkloadAssertionCallback } from '@modelcontextprotocol/client'; -import { Client, StreamableHTTPClientTransport, WorkloadIdentityProvider } from '@modelcontextprotocol/client'; +import { + Client, + StreamableHTTPClientTransport, + WorkloadAssertionRejectedError, + WorkloadIdentityProvider +} from '@modelcontextprotocol/client'; const { url, era } = parseExampleArgs(); @@ -77,13 +82,13 @@ 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 (SEP-1933 `wif-no-retry`): a bad audience or +// 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, /wif-no-retry/); +await check.rejects(rejectedConnect, WorkloadAssertionRejectedError); console.log('rejected assertion: connect failed loudly, no retry and no interactive fallback'); await client.close(); diff --git a/packages/client/src/client/authErrors.ts b/packages/client/src/client/authErrors.ts index e8925b2f86..08e08acd0d 100644 --- a/packages/client/src/client/authErrors.ts +++ b/packages/client/src/client/authErrors.ts @@ -222,3 +222,34 @@ 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 the authorization + * server already rejected in a prior token exchange (recorded via + * {@linkcode index.WorkloadIdentityProvider.invalidateCredentials | invalidateCredentials('tokens')}). + * 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; 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 8cdbc8b8e8..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. @@ -828,7 +829,7 @@ export interface WorkloadIdentityProviderOptions { * ({@link https://datatracker.ietf.org/doc/html/rfc7523#section-2.1 | RFC 7523 Section 2.1}). * * @example - * ```ts + * ```ts source="./authExtensions.examples.ts#WorkloadIdentityProvider_basicUsage" * const provider = new WorkloadIdentityProvider({ * assertion: async ctx => { * return await mintWorkloadJwt({ audience: ctx.authorizationServerUrl }); @@ -996,14 +997,7 @@ export class WorkloadIdentityProvider implements OAuthClientProvider { } if (assertion === this._rejectedAssertion) { - throw new Error( - 'The authorization server rejected the token exchange that presented this workload ' + - 'assertion, so the provider refuses to present it again unchanged (the conformance ' + - 'wif-no-retry check). 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 with ' + - 'the authorization server.' - ); + throw new WorkloadAssertionRejectedError(); } this._lastAssertion = assertion; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 3a3519ab5f..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, 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 index a466b9669c..981384506f 100644 --- a/packages/client/test/client/workloadIdentity.test.ts +++ b/packages/client/test/client/workloadIdentity.test.ts @@ -4,6 +4,7 @@ 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'; @@ -177,7 +178,7 @@ describe('WorkloadIdentityProvider rejection memory', () => { await provider.prepareTokenRequest(); provider.invalidateCredentials('tokens'); - await expect(provider.prepareTokenRequest()).rejects.toThrow(/wif-no-retry/); + await expect(provider.prepareTokenRequest()).rejects.toBeInstanceOf(WorkloadAssertionRejectedError); }); it('hands out the same assertion again while nothing has been rejected', async () => { @@ -196,7 +197,7 @@ describe('WorkloadIdentityProvider rejection memory', () => { provider.saveTokens({ access_token: 'granted-token', token_type: 'Bearer' }); provider.invalidateCredentials('tokens'); - await expect(provider.prepareTokenRequest()).rejects.toThrow(/wif-no-retry/); + await expect(provider.prepareTokenRequest()).rejects.toBeInstanceOf(WorkloadAssertionRejectedError); }); it('forgets a rejection once a later flow is confirmed by saveTokens', async () => { @@ -396,7 +397,9 @@ describe('WorkloadIdentityProvider failure paths through auth()', () => { 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.toThrow(/wif-no-retry/); + 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'); From 0ea8aa5e41bd320a91630a66c58c04338f62f9f7 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Wed, 29 Jul 2026 08:41:19 -0500 Subject: [PATCH 16/17] fix(client): recommend issuer pinning for fixed-audience assertions and cover invalid_client The example pins expectedIssuer and the docs recommend it whenever the assertion audience is fixed: discovery is steered by the resource server, and the pin stops a real-audience assertion from being posted to an unexpected authorization server. The replay-refusal message no longer asserts an AS verdict on the host-invalidation path, and an invalid_client test pins the one-request typed-error behavior. Co-Authored-By: Claude Fable 5 --- docs/clients/machine-auth.md | 2 +- examples/oauth-workload-identity/README.md | 2 +- examples/oauth-workload-identity/client.ts | 10 ++++++++-- packages/client/src/client/authErrors.ts | 16 +++++++++------- .../client/test/client/workloadIdentity.test.ts | 15 +++++++++++++++ 5 files changed, 34 insertions(+), 11 deletions(-) diff --git a/docs/clients/machine-auth.md b/docs/clients/machine-auth.md index fd62e6d95b..1ff5b1a611 100644 --- a/docs/clients/machine-auth.md +++ b/docs/clients/machine-auth.md @@ -117,7 +117,7 @@ const authProvider = new WorkloadIdentityProvider({ 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, `expectedIssuer` pins the credential the same way it does for `ClientCredentialsProvider`, `clientName` sets the client metadata's display name, and `fetchFn` overrides the fetch implementation handed to the callback. +`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`. diff --git a/examples/oauth-workload-identity/README.md b/examples/oauth-workload-identity/README.md index c4d3f7f32e..1c1033f20b 100644 --- a/examples/oauth-workload-identity/README.md +++ b/examples/oauth-workload-identity/README.md @@ -14,7 +14,7 @@ The point is that no long-lived secret is provisioned anywhere. `client_credenti - 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 will not replace a file that already exists there. +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 diff --git a/examples/oauth-workload-identity/client.ts b/examples/oauth-workload-identity/client.ts index 74bb8a72b4..19293e9f72 100644 --- a/examples/oauth-workload-identity/client.ts +++ b/examples/oauth-workload-identity/client.ts @@ -30,6 +30,8 @@ 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 @@ -53,10 +55,14 @@ 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. +// 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) + assertion: fileAssertionSource(tokenPath), + expectedIssuer }); const client = new Client( { name: 'workload-identity-client', version: '1.0.0' }, diff --git a/packages/client/src/client/authErrors.ts b/packages/client/src/client/authErrors.ts index 08e08acd0d..bfb3d4fdee 100644 --- a/packages/client/src/client/authErrors.ts +++ b/packages/client/src/client/authErrors.ts @@ -225,9 +225,11 @@ export class InsufficientScopeError extends OAuthClientFlowError { /** * Thrown by {@linkcode index.WorkloadIdentityProvider.prepareTokenRequest | WorkloadIdentityProvider.prepareTokenRequest} - * when the assertion source hands back the exact assertion the authorization - * server already rejected in a prior token exchange (recorded via - * {@linkcode index.WorkloadIdentityProvider.invalidateCredentials | invalidateCredentials('tokens')}). + * 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 @@ -246,10 +248,10 @@ export class WorkloadAssertionRejectedError extends OAuthClientFlowError { constructor() { super( 'The authorization server rejected the token exchange that presented this workload ' + - 'assertion; 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.' + '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/test/client/workloadIdentity.test.ts b/packages/client/test/client/workloadIdentity.test.ts index 981384506f..eacb64d308 100644 --- a/packages/client/test/client/workloadIdentity.test.ts +++ b/packages/client/test/client/workloadIdentity.test.ts @@ -406,6 +406,21 @@ describe('WorkloadIdentityProvider failure paths through auth()', () => { 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({ From 978669a420a8a43c8ce7c6d1caea483145e6c1f2 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Wed, 29 Jul 2026 11:10:47 -0500 Subject: [PATCH 17/17] docs: correct saveAuthorizationServerUrl timing doc and note static-assertion recovery The interface doc claimed the hook fires post-saveTokens; the only call site runs right after discovery resolves the issuer, and assertion-based providers depend on that pre-token timing (the workload identity e2e test pins the ordering). Also documents how a refused static assertion is recovered and replaces a brittle origin string slice in the test mock. Co-Authored-By: Claude Fable 5 --- docs/clients/machine-auth.md | 2 +- packages/client/src/client/auth.ts | 6 ++++-- packages/client/test/client/workloadIdentity.test.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/clients/machine-auth.md b/docs/clients/machine-auth.md index 1ff5b1a611..bd98011817 100644 --- a/docs/clients/machine-auth.md +++ b/docs/clients/machine-auth.md @@ -123,7 +123,7 @@ const transport = new StreamableHTTPClientTransport(new URL('https://api.example 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. 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. +`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. 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/test/client/workloadIdentity.test.ts b/packages/client/test/client/workloadIdentity.test.ts index eacb64d308..945cb5f8f8 100644 --- a/packages/client/test/client/workloadIdentity.test.ts +++ b/packages/client/test/client/workloadIdentity.test.ts @@ -350,7 +350,7 @@ function createScriptedOAuthFetch(respondToTokenRequest: (body: URLSearchParams) const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit): Promise => { const url = input instanceof URL ? input : new URL(input); - if (url.origin === RESOURCE_SERVER_URL.slice(0, -1) && url.pathname === '/.well-known/oauth-protected-resource') { + 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]