diff --git a/containers/api-proxy/adapter-factory.js b/containers/api-proxy/adapter-factory.js index 89a46750d..dd15a744d 100644 --- a/containers/api-proxy/adapter-factory.js +++ b/containers/api-proxy/adapter-factory.js @@ -85,6 +85,7 @@ function createBaseAdapterConfig(env, { keyEnvVar, targetEnvVar, basePathEnvVar, * @param {() => boolean} [opts.skipModelsFetch] * @param {Record|(() => Record)} [opts.modelsFetchHeaders] * @param {string|null} [opts.modelsCacheKey] + * @param {boolean} [opts.credentialConfigured] * @param {boolean} [opts.participatesInValidation] * @param {boolean} [opts.reflectionConfigured] * @param {string|null} [opts.reflectionModelsPath] @@ -118,7 +119,8 @@ function createAdapterMethods(opts) { skipModelsFetch, modelsFetchHeaders = validationHeaders, modelsCacheKey = provider, - participatesInValidation = !!apiKey, + credentialConfigured = !!apiKey, + participatesInValidation = credentialConfigured, reflectionConfigured = !!apiKey, reflectionModelsPath = modelsPath, reflectionExtra = {}, @@ -132,7 +134,7 @@ function createAdapterMethods(opts) { const builtValidationProbe = getValidationProbe || (() => { const skip = validationSkip ? validationSkip() : null; if (skip) return skip; - if (!apiKey) return null; + if (!credentialConfigured) return null; if (defaultTarget && rawTarget !== defaultTarget) { return { skip: true, reason: `Custom target ${rawTarget}; validation skipped` }; } @@ -148,7 +150,7 @@ function createAdapterMethods(opts) { const builtModelsFetchConfig = getModelsFetchConfig || (() => { if (skipModelsFetch && skipModelsFetch()) return null; - if (!apiKey || !modelsPath || !modelsCacheKey) return null; + if (!credentialConfigured || !modelsPath || !modelsCacheKey) return null; // Startup model fetch follows provider behavior of honoring explicit basePath // prefixes for OpenAI-compatible gateways, while validation probes use the // canonical default-target endpoint path. diff --git a/containers/api-proxy/anthropic-adapter-auth.test.js b/containers/api-proxy/anthropic-adapter-auth.test.js index c4beb4d84..bb596d904 100644 --- a/containers/api-proxy/anthropic-adapter-auth.test.js +++ b/containers/api-proxy/anthropic-adapter-auth.test.js @@ -2,42 +2,41 @@ const { createAnthropicAdapter } = require('./providers/anthropic'); describe('createAnthropicAdapter — OIDC getAuthHeaders', () => { const fakeReq = { url: '/v1/messages', method: 'POST', headers: {} }; + const oidcEnv = { + AWF_AUTH_TYPE: 'github-oidc', + AWF_AUTH_PROVIDER: 'anthropic', + ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'test-token', + AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID: 'fdrl_test', + AWF_AUTH_ANTHROPIC_ORGANIZATION_ID: 'org-uuid-test', + AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID: 'svac_test', + }; - it('injects Authorization header instead of x-api-key in Anthropic OIDC mode', () => { - const adapter = createAnthropicAdapter({ - AWF_AUTH_TYPE: 'github-oidc', - AWF_AUTH_PROVIDER: 'anthropic', - ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token', - ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'test-token', - AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID: 'fdrl_test', - AWF_AUTH_ANTHROPIC_ORGANIZATION_ID: 'org-uuid-test', - AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID: 'svac_test', - }); - + function createReadyOidcAdapter(env = {}) { + const adapter = createAnthropicAdapter({ ...oidcEnv, ...env }); const provider = adapter.getOidcProvider(); provider._cachedToken = 'sk-ant-oat01-token'; provider._expiresAt = Math.floor(Date.now() / 1000) + 600; + return { adapter, provider }; + } + + it('injects Authorization header instead of x-api-key in Anthropic OIDC mode', () => { + const { adapter, provider } = createReadyOidcAdapter(); const headers = adapter.getAuthHeaders(fakeReq); expect(headers).toEqual({ Authorization: ['Bearer', 'sk-ant-oat01-token'].join(' '), + 'anthropic-beta': 'oauth-2025-04-20', 'anthropic-version': '2023-06-01', }); expect(headers['x-api-key']).toBeUndefined(); + expect(headers['anthropic-beta']).not.toContain('oidc-federation-2026-04-01'); provider.shutdown(); }); it('returns empty auth headers when Anthropic OIDC token is not yet available', () => { - const adapter = createAnthropicAdapter({ - AWF_AUTH_TYPE: 'github-oidc', - AWF_AUTH_PROVIDER: 'anthropic', - ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token', - ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'test-token', - AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID: 'fdrl_test', - AWF_AUTH_ANTHROPIC_ORGANIZATION_ID: 'org-uuid-test', - AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID: 'svac_test', - }); + const adapter = createAnthropicAdapter(oidcEnv); expect(adapter.getAuthHeaders(fakeReq)).toEqual({}); adapter.getOidcProvider().shutdown(); @@ -45,17 +44,74 @@ describe('createAnthropicAdapter — OIDC getAuthHeaders', () => { it('passes AWF_AUTH_ANTHROPIC_TOKEN_URL to Anthropic OIDC provider', () => { const adapter = createAnthropicAdapter({ - AWF_AUTH_TYPE: 'github-oidc', - AWF_AUTH_PROVIDER: 'anthropic', - ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token', - ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'test-token', - AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID: 'fdrl_test', - AWF_AUTH_ANTHROPIC_ORGANIZATION_ID: 'org-uuid-test', - AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID: 'svac_test', + ...oidcEnv, AWF_AUTH_ANTHROPIC_TOKEN_URL: 'https://anthropic.internal.example/v1/oauth/token', }); expect(adapter.getOidcProvider()._tokenEndpoint).toBe('https://anthropic.internal.example/v1/oauth/token'); adapter.getOidcProvider().shutdown(); }); + + it('does not add OAuth or federation betas to static-key requests', () => { + const adapter = createAnthropicAdapter({ ANTHROPIC_API_KEY: 'sk-ant-static' }); + + const headers = adapter.getAuthHeaders(fakeReq); + + expect(headers['x-api-key']).toBe('sk-ant-static'); + expect(headers['anthropic-beta']).toBeUndefined(); + }); + + it('merges and deduplicates client, bearer, and auto-cache beta values', () => { + const { adapter, provider } = createReadyOidcAdapter({ + AWF_ANTHROPIC_AUTO_CACHE: 'true', + }); + const req = { + ...fakeReq, + headers: { + 'anthropic-beta': [ + 'client-beta, oauth-2025-04-20', + 'extended-cache-ttl-2025-04-11,client-beta', + ], + }, + }; + + const headers = adapter.getAuthHeaders(req); + + expect(headers['anthropic-beta']).toBe( + 'client-beta,oauth-2025-04-20,extended-cache-ttl-2025-04-11' + ); + provider.shutdown(); + }); + + it('uses only the OAuth beta for forwarded refresh-token exchanges', () => { + const { adapter, provider } = createReadyOidcAdapter(); + const headers = adapter.getAuthHeaders({ + url: '/v1/oauth/token', + method: 'POST', + headers: {}, + }); + + expect(headers['anthropic-beta']).toBe('oauth-2025-04-20'); + expect(headers['anthropic-beta']).not.toContain('oidc-federation-2026-04-01'); + provider.shutdown(); + }); + + it('adds the OAuth beta to OIDC validation and models requests', () => { + const { adapter, provider } = createReadyOidcAdapter(); + + const validation = adapter.getValidationProbe(); + const models = adapter.getModelsFetchConfig(); + + expect(validation.opts.headers).toEqual(expect.objectContaining({ + Authorization: ['Bearer', 'sk-ant-oat01-token'].join(' '), + 'anthropic-beta': 'oauth-2025-04-20', + })); + expect(models.opts.headers).toEqual(expect.objectContaining({ + Authorization: ['Bearer', 'sk-ant-oat01-token'].join(' '), + 'anthropic-beta': 'oauth-2025-04-20', + })); + expect(validation.opts.headers['anthropic-beta']).not.toContain('oidc-federation-2026-04-01'); + expect(models.opts.headers['anthropic-beta']).not.toContain('oidc-federation-2026-04-01'); + provider.shutdown(); + }); }); diff --git a/containers/api-proxy/anthropic-oidc-token-provider.js b/containers/api-proxy/anthropic-oidc-token-provider.js index fb003fb5a..82902e7a0 100644 --- a/containers/api-proxy/anthropic-oidc-token-provider.js +++ b/containers/api-proxy/anthropic-oidc-token-provider.js @@ -5,6 +5,9 @@ const { BaseOidcTokenProvider, } = require('./oidc-token-provider-base'); +const OAUTH_API_BETA = 'oauth-2025-04-20'; +const OIDC_FEDERATION_BETA = 'oidc-federation-2026-04-01'; + function stringifyError(error) { if (error instanceof Error && error.message) { return error.message; @@ -79,13 +82,16 @@ class AnthropicOidcTokenProvider extends BaseOidcTokenProvider { body.workspace_id = this._workspaceId; } + const headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'anthropic-beta': `${OAUTH_API_BETA},${OIDC_FEDERATION_BETA}`, + }; + const response = await this._httpPost( this._tokenEndpoint, JSON.stringify(body), - { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - } + headers ); if (response.statusCode !== 200) { diff --git a/containers/api-proxy/anthropic-oidc-token-provider.test.js b/containers/api-proxy/anthropic-oidc-token-provider.test.js index bda8f8bd6..a7ab8eaff 100644 --- a/containers/api-proxy/anthropic-oidc-token-provider.test.js +++ b/containers/api-proxy/anthropic-oidc-token-provider.test.js @@ -95,9 +95,12 @@ describe('AnthropicOidcTokenProvider', () => { await provider._exchangeForAnthropicToken('fake-github-jwt'); expect(mockHttpPost).toHaveBeenCalledTimes(1); - const [url, rawBody] = mockHttpPost.mock.calls[0]; + const [url, rawBody, headers] = mockHttpPost.mock.calls[0]; const sent = JSON.parse(rawBody); expect(url).toBe('https://api.anthropic.com/v1/oauth/token'); + expect(headers['anthropic-beta']).toBe( + 'oauth-2025-04-20,oidc-federation-2026-04-01' + ); expect(sent.grant_type).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); expect(sent.assertion).toBe('fake-github-jwt'); expect(sent.federation_rule_id).toBe('fdrl_myrule'); @@ -128,6 +131,23 @@ describe('AnthropicOidcTokenProvider', () => { provider.shutdown(); }); + it('should send federation routing headers to custom token path endpoints', async () => { + const provider = new AnthropicOidcTokenProvider({ + ...BASE_CONFIG, + tokenEndpoint: 'https://anthropic.internal.example/oauth/token', + }); + const mockHttpPost = jest.spyOn(provider, '_httpPost').mockResolvedValue({ + statusCode: 200, + body: JSON.stringify({ access_token: 'sk-ant-oat01-custom', expires_in: 3600 }), + }); + + await provider._exchangeForAnthropicToken('fake-jwt'); + + const [, , headers] = mockHttpPost.mock.calls[0]; + expect(headers['anthropic-beta']).toBe('oauth-2025-04-20,oidc-federation-2026-04-01'); + provider.shutdown(); + }); + it('should fall back to default token endpoint when configured endpoint is whitespace', async () => { const provider = new AnthropicOidcTokenProvider({ ...BASE_CONFIG, diff --git a/containers/api-proxy/providers/anthropic.js b/containers/api-proxy/providers/anthropic.js index 727adba7d..0b8706522 100644 --- a/containers/api-proxy/providers/anthropic.js +++ b/containers/api-proxy/providers/anthropic.js @@ -23,6 +23,8 @@ const { AnthropicOidcTokenProvider } = require('../anthropic-oidc-token-provider const { ANTHROPIC_ENV } = require('../provider-env-constants'); const { bearerAuthHeaders, providerKeyHeaders } = require('./auth-headers'); +const OAUTH_API_BETA = 'oauth-2025-04-20'; + let makeAnthropicTransform, loadCustomTransform, EXTENDED_CACHE_BETA; try { ({ makeAnthropicTransform, loadCustomTransform, EXTENDED_CACHE_BETA } = require('../anthropic-transforms')); @@ -36,6 +38,22 @@ try { } } +function mergeAnthropicBetas(...values) { + const merged = []; + const seen = new Set(); + for (const value of values) { + const normalized = Array.isArray(value) ? value.join(',') : value; + if (!normalized) continue; + for (const beta of normalized.split(',').map(item => item.trim()).filter(Boolean)) { + if (!seen.has(beta)) { + seen.add(beta); + merged.push(beta); + } + } + } + return merged.join(','); +} + /** * Create the Anthropic provider adapter. * @@ -106,10 +124,13 @@ function createAnthropicAdapter(env, deps = {}) { }); } : null, }, - buildOidcHeaders: (token) => bearerAuthHeaders(token), + buildOidcHeaders: (token) => bearerAuthHeaders(token, { + 'anthropic-beta': OAUTH_API_BETA, + }), buildStaticHeaders: () => providerKeyHeaders(authHeaderName, apiKey), createAdapterMethodsOptions: ({ oidcConfigured, oidcProvider, resolveHeaders }) => ({ apiKey, + credentialConfigured: !!apiKey || oidcConfigured, rawTarget, basePath, provider: 'anthropic', @@ -177,8 +198,8 @@ function createAnthropicAdapter(env, deps = {}) { }, /** * Build Anthropic auth headers for this request. - * Merges in the anthropic-version default and anthropic-beta (for auto-cache) - * as needed, without overwriting values already set by the client. + * Merges in the anthropic-version default and required anthropic-beta + * values without dropping values already set by the client. * * @param {{ resolveHeaders: () => Record, req: import('http').IncomingMessage }} params * @returns {Record} @@ -191,22 +212,20 @@ function createAnthropicAdapter(env, deps = {}) { return {}; } const mergedHeaders = { ...headers }; + const authBeta = mergedHeaders['anthropic-beta']; + delete mergedHeaders['anthropic-beta']; if (!req.headers['anthropic-version']) { mergedHeaders['anthropic-version'] = '2023-06-01'; } - if (autoCache && EXTENDED_CACHE_BETA) { - const existing = req.headers['anthropic-beta']; - if (!existing) { - mergedHeaders['anthropic-beta'] = EXTENDED_CACHE_BETA; - } else { - const normalizedExisting = Array.isArray(existing) ? existing.join(',') : existing; - const existingBetas = normalizedExisting.split(',').map(s => s.trim()).filter(Boolean); - if (!existingBetas.includes(EXTENDED_CACHE_BETA)) { - mergedHeaders['anthropic-beta'] = `${normalizedExisting},${EXTENDED_CACHE_BETA}`; - } - } + const mergedBeta = mergeAnthropicBetas( + req.headers['anthropic-beta'], + authBeta, + autoCache ? EXTENDED_CACHE_BETA : undefined + ); + if (authBeta || (autoCache && EXTENDED_CACHE_BETA)) { + mergedHeaders['anthropic-beta'] = mergedBeta; } return mergedHeaders; diff --git a/containers/api-proxy/server.custom-auth-header.test.js b/containers/api-proxy/server.custom-auth-header.test.js index 2f8a7de79..b4be1ccda 100644 --- a/containers/api-proxy/server.custom-auth-header.test.js +++ b/containers/api-proxy/server.custom-auth-header.test.js @@ -77,6 +77,7 @@ describe('createAnthropicAdapter — custom auth header', () => { const headers = adapter.getAuthHeaders(fakeReq); expect(headers).toEqual({ Authorization: 'Bearer oidc-token', + 'anthropic-beta': 'oauth-2025-04-20', 'anthropic-version': '2023-06-01', }); expect(headers['api-key']).toBeUndefined(); diff --git a/docs/api-proxy-sidecar.md b/docs/api-proxy-sidecar.md index bf5e871a2..543450b84 100644 --- a/docs/api-proxy-sidecar.md +++ b/docs/api-proxy-sidecar.md @@ -835,6 +835,10 @@ Exchanges the GitHub OIDC JWT for an Anthropic Workload Identity Federation acce Default OIDC audience: `https://api.anthropic.com` +For compatibility with Anthropic's official SDKs, AWF sends `anthropic-beta: oauth-2025-04-20,oidc-federation-2026-04-01` only on its JWT-bearer `POST /v1/oauth/token` exchange. Requests authenticated with the resulting bearer token send `oauth-2025-04-20`; they do not send the federation beta. Static `x-api-key` requests receive neither value, and forwarded refresh-token exchanges never receive the federation beta. AWF merges required values with client-supplied `anthropic-beta` values and the optional auto-cache beta without duplicates. + +**Official references:** [Anthropic WIF documentation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation) · [Anthropic TypeScript SDK federation exchange](https://github.com/anthropics/anthropic-sdk-typescript/blob/3b45cd3b69c956ac63384fdb09ce1d8109f3fa80/src/lib/credentials/oidc-federation.ts) · [credential beta constants](https://github.com/anthropics/anthropic-sdk-typescript/blob/3b45cd3b69c956ac63384fdb09ce1d8109f3fa80/src/lib/credentials/types.ts) + #### GitHub Actions example (Anthropic) ```yaml diff --git a/docs/auth-matrix.md b/docs/auth-matrix.md index 3a5f45d71..0499cdbe6 100644 --- a/docs/auth-matrix.md +++ b/docs/auth-matrix.md @@ -139,11 +139,11 @@ When `AWF_AUTH_TYPE=github-oidc` and `AWF_AUTH_PROVIDER=anthropic`: **Key behavior change:** When OIDC is active, the auth header switches from `x-api-key` to `Authorization: Bearer`. -:::caution[Anthropic beta-header ambiguity] -Anthropic's current public WIF cURL examples omit `anthropic-beta`, and AWF follows those examples. However, Anthropic's current Python SDK source declares `oauth-2025-04-20` for bearer-authenticated API calls and `oidc-federation-2026-04-01` for JWT-bearer exchanges. AWF sends neither value. Treat this as a compatibility risk between the public raw-HTTP examples and SDK behavior; if Anthropic rejects an exchange or bearer request for a missing beta header, AWF's WIF implementation must be updated. +:::note[Anthropic beta headers] +AWF follows Anthropic's SDK behavior: JWT-bearer `POST /v1/oauth/token` exchanges send `oauth-2025-04-20,oidc-federation-2026-04-01`, while API requests authenticated with the resulting bearer token send `oauth-2025-04-20`. The federation beta is never added to static `x-api-key` requests or forwarded refresh-token exchanges. Client-supplied `anthropic-beta` values are preserved and deduplicated with AWF-required values and the optional auto-cache beta. ::: -**Official docs:** https://platform.claude.com/docs/en/manage-claude/workload-identity-federation +**Official references:** [Anthropic WIF documentation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation) · [Anthropic TypeScript SDK federation exchange](https://github.com/anthropics/anthropic-sdk-typescript/blob/3b45cd3b69c956ac63384fdb09ce1d8109f3fa80/src/lib/credentials/oidc-federation.ts) ### Custom Auth Header @@ -332,9 +332,13 @@ AWS Bedrock requires every request to be signed with [SigV4](https://docs.aws.am | Token URL | `AWF_AUTH_ANTHROPIC_TOKEN_URL` | ❌ (default: `https://api.anthropic.com/v1/oauth/token`) | | Audience | `AWF_AUTH_OIDC_AUDIENCE` | ❌ (default: `https://api.anthropic.com`) | -**Token exchange:** `POST https://api.anthropic.com/v1/oauth/token` (RFC 7523 jwt-bearer) -**Implementation:** `containers/api-proxy/anthropic-oidc-token-provider.js` -**Official docs:** https://platform.claude.com/docs/en/manage-claude/workload-identity-federation +**Token exchange:** `POST https://api.anthropic.com/v1/oauth/token` (RFC 7523 jwt-bearer), with `anthropic-beta: oauth-2025-04-20,oidc-federation-2026-04-01` + +**Bearer API requests:** `anthropic-beta: oauth-2025-04-20` (merged with client and auto-cache beta values) + +**Implementation:** `containers/api-proxy/anthropic-oidc-token-provider.js` + +**Official references:** [Anthropic WIF documentation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation) · [Anthropic TypeScript SDK credential constants](https://github.com/anthropics/anthropic-sdk-typescript/blob/3b45cd3b69c956ac63384fdb09ce1d8109f3fa80/src/lib/credentials/types.ts) --- diff --git a/docs/authentication-architecture.md b/docs/authentication-architecture.md index 40080cdc2..4b94fbc3b 100644 --- a/docs/authentication-architecture.md +++ b/docs/authentication-architecture.md @@ -711,9 +711,12 @@ GitHub JWT ──► sts.googleapis.com/v1/token GitHub JWT ──► api.anthropic.com/v1/oauth/token grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer assertion={github_jwt} + anthropic-beta=oauth-2025-04-20,oidc-federation-2026-04-01 ◄── { access_token: "sk-ant-oat01-...", expires_in: 3600 } ``` +The federation beta is a routing switch used only for the JWT-bearer exchange. It is not added to static-key requests, forwarded refresh-token exchanges, or subsequent API calls. See Anthropic's [WIF documentation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation) and [TypeScript SDK exchange implementation](https://github.com/anthropics/anthropic-sdk-typescript/blob/3b45cd3b69c956ac63384fdb09ce1d8109f3fa80/src/lib/credentials/oidc-federation.ts). + #### Step 4: Credential caching and auto-refresh All token providers cache the exchanged credentials and schedule proactive refresh: @@ -731,9 +734,11 @@ When the agent sends a request to the sidecar, the provider adapter injects the |----------|----------------------| | Azure | `Authorization` header | | GCP | `Authorization` header | -| Anthropic | `Authorization` header | +| Anthropic | `Authorization: Bearer` plus `anthropic-beta: oauth-2025-04-20` | | AWS | *(none — see caution below)* | +For Anthropic bearer requests, AWF merges the OAuth beta with client-supplied `anthropic-beta` values and the optional auto-cache beta, deduplicating exact values. Static `x-api-key` requests do not receive OAuth or federation beta values. + :::danger[AWS OIDC: credentials are minted but never used to sign requests] `AwsOidcTokenProvider` exchanges the GitHub JWT for temporary STS credentials (`AccessKeyId`/`SecretAccessKey`/`SessionToken`) and caches/refreshes them like the other providers, but **no code in the request pipeline signs outgoing requests with SigV4**. There is no AWS SDK or `aws4`-style signing dependency in `containers/api-proxy/package.json`, and `resolveOidcAuthHeaders()` returns an empty header object for the AWS provider. In practice, selecting `AWF_AUTH_PROVIDER=aws` currently produces STS credentials that are never applied to any request — AWS Bedrock (which requires SigV4 with the `bedrock-runtime` service) would reject a request sent this way for lack of an `Authorization` header. Treat this as a credential-lifecycle-only capability until request signing is implemented. :::