Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions containers/api-proxy/adapter-factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ function createBaseAdapterConfig(env, { keyEnvVar, targetEnvVar, basePathEnvVar,
* @param {() => boolean} [opts.skipModelsFetch]
* @param {Record<string,string>|(() => Record<string,string>)} [opts.modelsFetchHeaders]
* @param {string|null} [opts.modelsCacheKey]
* @param {boolean} [opts.credentialConfigured]
* @param {boolean} [opts.participatesInValidation]
* @param {boolean} [opts.reflectionConfigured]
* @param {string|null} [opts.reflectionModelsPath]
Expand Down Expand Up @@ -118,7 +119,8 @@ function createAdapterMethods(opts) {
skipModelsFetch,
modelsFetchHeaders = validationHeaders,
modelsCacheKey = provider,
participatesInValidation = !!apiKey,
credentialConfigured = !!apiKey,
participatesInValidation = credentialConfigured,
reflectionConfigured = !!apiKey,
reflectionModelsPath = modelsPath,
reflectionExtra = {},
Expand All @@ -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` };
}
Expand All @@ -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.
Expand Down
110 changes: 83 additions & 27 deletions containers/api-proxy/anthropic-adapter-auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,60 +2,116 @@ 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();
});

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();
});
});
14 changes: 10 additions & 4 deletions containers/api-proxy/anthropic-oidc-token-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
22 changes: 21 additions & 1 deletion containers/api-proxy/anthropic-oidc-token-provider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
47 changes: 33 additions & 14 deletions containers/api-proxy/providers/anthropic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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.
*
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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<string,string>, req: import('http').IncomingMessage }} params
* @returns {Record<string, string>}
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions containers/api-proxy/server.custom-auth-header.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions docs/api-proxy-sidecar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 10 additions & 6 deletions docs/auth-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

---

Expand Down
Loading
Loading