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
51 changes: 43 additions & 8 deletions containers/api-proxy/anthropic-oidc-token-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ const {
* @typedef {Object} AnthropicOidcTokenProviderConfig
* @property {string} requestUrl - ACTIONS_ID_TOKEN_REQUEST_URL
* @property {string} requestToken - ACTIONS_ID_TOKEN_REQUEST_TOKEN
* @property {string} federationRuleId - Anthropic federation rule ID (e.g. fdrl_...)
* @property {string} organizationId - Anthropic organization UUID
* @property {string} serviceAccountId - Anthropic service account ID (e.g. svac_...)
* @property {string} [workspaceId] - Anthropic workspace ID (required when the federation rule covers multiple workspaces)
* @property {string} [oidcAudience] - Audience for GitHub OIDC token (default: https://api.anthropic.com)
* @property {string} [scope] - Optional OAuth scope
* @property {number} [retryDelayMs] - Retry delay after failed refresh (default: 30000)
* @property {number} [maxInitRetries] - Maximum retries for initial token acquisition (default: 3)
*/
Expand All @@ -21,13 +24,32 @@ class AnthropicOidcTokenProvider extends BaseOidcTokenProvider {
*/
constructor(config) {
super('anthropic_oidc', config);

if (!config.federationRuleId) {
throw new Error('AnthropicOidcTokenProvider requires federationRuleId');
}
if (!config.organizationId) {
throw new Error('AnthropicOidcTokenProvider requires organizationId');
}
if (!config.serviceAccountId) {
throw new Error('AnthropicOidcTokenProvider requires serviceAccountId');
}

this._requestUrl = config.requestUrl;
this._requestToken = config.requestToken;
this._federationRuleId = config.federationRuleId;
this._organizationId = config.organizationId;
this._serviceAccountId = config.serviceAccountId;
// Normalize empty strings to undefined so workspace_id is never sent as ""
const ws = config.workspaceId != null ? config.workspaceId.trim() : undefined;
this._workspaceId = ws || undefined;
this._oidcAudience = config.oidcAudience || 'https://api.anthropic.com';
this._scope = config.scope;

/** @type {string|null} */
this._cachedToken = null;

// Stored as instance method so tests can spy/stub without module-level mocking
this._httpPost = httpPost;
}

/**
Expand All @@ -36,13 +58,20 @@ class AnthropicOidcTokenProvider extends BaseOidcTokenProvider {
* @returns {Promise<{access_token: string, expires_in: number}>}
*/
async _exchangeForAnthropicToken(oidcJwt) {
const response = await httpPost(
const body = {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: oidcJwt,
federation_rule_id: this._federationRuleId,
organization_id: this._organizationId,
service_account_id: this._serviceAccountId,
};
if (this._workspaceId !== undefined) {
body.workspace_id = this._workspaceId;
}
Comment on lines +61 to +70

const response = await this._httpPost(
'https://api.anthropic.com/v1/oauth/token',
JSON.stringify({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: oidcJwt,
...(this._scope !== undefined ? { scope: this._scope } : {}),
}),
JSON.stringify(body),
{
'Content-Type': 'application/json',
'Accept': 'application/json',
Expand Down Expand Up @@ -91,13 +120,19 @@ class AnthropicOidcTokenProvider extends BaseOidcTokenProvider {
_getInitSuccessLogContext() {
return {
audience: this._oidcAudience,
federation_rule_id: this._federationRuleId,
organization_id: this._organizationId,
service_account_id: this._serviceAccountId,
expires_in_secs: this._expiresAt - Math.floor(Date.now() / 1000),
};
}

_getInitFailureLogContext() {
return {
audience: this._oidcAudience,
federation_rule_id: this._federationRuleId,
organization_id: this._organizationId,
service_account_id: this._serviceAccountId,
};
}
}
Expand Down
119 changes: 111 additions & 8 deletions containers/api-proxy/anthropic-oidc-token-provider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ function createMockServer(handlers = {}) {
}, handlers);
}

/** Minimal valid config for tests that don't exercise the exchange step */
const BASE_CONFIG = {
requestUrl: 'http://localhost:0/token',
requestToken: 'test',
federationRuleId: 'fdrl_test',
organizationId: 'org-uuid-test',
serviceAccountId: 'svac_test',
};

describe('AnthropicOidcTokenProvider', () => {
let mockServer;
let serverPort;
Expand All @@ -46,6 +55,9 @@ describe('AnthropicOidcTokenProvider', () => {
const provider = new AnthropicOidcTokenProvider({
requestUrl: `http://127.0.0.1:${serverPort}/token`,
requestToken: 'mock-request-token',
federationRuleId: 'fdrl_abc123',
organizationId: 'org-uuid-abc',
serviceAccountId: 'svac_abc123',
});

provider._exchangeForAnthropicToken = async (jwt) => {
Expand All @@ -54,6 +66,9 @@ describe('AnthropicOidcTokenProvider', () => {
JSON.stringify({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
federation_rule_id: 'fdrl_abc123',
organization_id: 'org-uuid-abc',
service_account_id: 'svac_abc123',
}),
{
'Content-Type': 'application/json',
Expand All @@ -72,6 +87,94 @@ describe('AnthropicOidcTokenProvider', () => {
provider.shutdown();
});

it('should include required WIF fields in the exchange request body', async () => {
const provider = new AnthropicOidcTokenProvider({
requestUrl: 'http://127.0.0.1/token',
requestToken: 'mock-token',
federationRuleId: 'fdrl_myrule',
organizationId: 'org-00000000-0000-0000-0000-000000000001',
serviceAccountId: 'svac_myaccount',
workspaceId: 'wrkspc_myworkspace',
});

// Spy on the instance _httpPost to capture what the real _exchangeForAnthropicToken sends
const mockHttpPost = jest.spyOn(provider, '_httpPost').mockResolvedValue({
statusCode: 200,
body: JSON.stringify({ access_token: 'sk-ant-oat01-wif-token', expires_in: 3600 }),
});

Comment on lines +90 to +105
try {
await provider._exchangeForAnthropicToken('fake-github-jwt');

expect(mockHttpPost).toHaveBeenCalledTimes(1);
const [url, rawBody] = mockHttpPost.mock.calls[0];
const sent = JSON.parse(rawBody);
expect(url).toBe('https://api.anthropic.com/v1/oauth/token');
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');
expect(sent.organization_id).toBe('org-00000000-0000-0000-0000-000000000001');
expect(sent.service_account_id).toBe('svac_myaccount');
expect(sent.workspace_id).toBe('wrkspc_myworkspace');
} finally {
provider.shutdown();
}
});

it('should omit workspace_id from the exchange request when not provided', async () => {
const provider = new AnthropicOidcTokenProvider({
requestUrl: 'http://127.0.0.1/token',
requestToken: 'test',
federationRuleId: 'fdrl_norule',
organizationId: 'org-uuid',
serviceAccountId: 'svac_nows',
});

// Verify the real exchange body does not contain workspace_id
const mockHttpPost = jest.spyOn(provider, '_httpPost').mockResolvedValue({
statusCode: 200,
body: JSON.stringify({ access_token: 'sk-ant-oat01-mock', expires_in: 3600 }),
});

await provider._exchangeForAnthropicToken('fake-jwt');

const [, rawBody] = mockHttpPost.mock.calls[0];
const sent = JSON.parse(rawBody);
expect(sent.workspace_id).toBeUndefined();

provider.shutdown();
Comment on lines +125 to +145
});

it('should include workspace_id in exchange body only when provided', async () => {
const withWs = new AnthropicOidcTokenProvider({
...BASE_CONFIG,
workspaceId: 'wrkspc_abc',
});
const withoutWs = new AnthropicOidcTokenProvider(BASE_CONFIG);
const withEmptyWs = new AnthropicOidcTokenProvider({
...BASE_CONFIG,
workspaceId: ' ',
});

expect(withWs._workspaceId).toBe('wrkspc_abc');
expect(withoutWs._workspaceId).toBeUndefined();
// empty / whitespace-only workspaceId must be normalized to undefined
expect(withEmptyWs._workspaceId).toBeUndefined();

withWs.shutdown();
withoutWs.shutdown();
withEmptyWs.shutdown();
});

it('should throw when required WIF fields are missing', () => {
expect(() => new AnthropicOidcTokenProvider({ ...BASE_CONFIG, federationRuleId: '' }))
.toThrow(/federationRuleId/);
expect(() => new AnthropicOidcTokenProvider({ ...BASE_CONFIG, organizationId: '' }))
.toThrow(/organizationId/);
expect(() => new AnthropicOidcTokenProvider({ ...BASE_CONFIG, serviceAccountId: '' }))
.toThrow(/serviceAccountId/);
});

it('should request GitHub OIDC token with the Anthropic audience by default', async () => {
const oidcServer = http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
Expand All @@ -89,6 +192,9 @@ describe('AnthropicOidcTokenProvider', () => {
provider = new AnthropicOidcTokenProvider({
requestUrl: `http://127.0.0.1:${oidcPort}/token`,
requestToken: 'custom-request-token',
federationRuleId: 'fdrl_test',
organizationId: 'org-uuid-test',
serviceAccountId: 'svac_test',
});

provider._exchangeForAnthropicToken = jest.fn().mockResolvedValue({
Expand All @@ -108,10 +214,7 @@ describe('AnthropicOidcTokenProvider', () => {
});

it('should return null when not initialized', () => {
const provider = new AnthropicOidcTokenProvider({
requestUrl: 'http://localhost:0/token',
requestToken: 'test',
});
const provider = new AnthropicOidcTokenProvider(BASE_CONFIG);

expect(provider.isReady()).toBe(false);
expect(provider.getToken()).toBeNull();
Expand All @@ -130,6 +233,9 @@ describe('AnthropicOidcTokenProvider', () => {
const provider = new AnthropicOidcTokenProvider({
requestUrl: `http://127.0.0.1:${failPort}/token`,
requestToken: 'bad-token',
federationRuleId: 'fdrl_test',
organizationId: 'org-uuid-test',
serviceAccountId: 'svac_test',
retryDelayMs: 10,
maxInitRetries: 2,
});
Expand All @@ -144,10 +250,7 @@ describe('AnthropicOidcTokenProvider', () => {
});

it('should use https://api.anthropic.com as default audience', () => {
const provider = new AnthropicOidcTokenProvider({
requestUrl: 'http://localhost/token',
requestToken: 'test',
});
const provider = new AnthropicOidcTokenProvider(BASE_CONFIG);

expect(provider._oidcAudience).toBe('https://api.anthropic.com');
provider.shutdown();
Expand Down
8 changes: 8 additions & 0 deletions containers/api-proxy/providers/anthropic.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,17 @@ function createAnthropicAdapter(env, deps = {}) {
const requestUrl = env.ACTIONS_ID_TOKEN_REQUEST_URL;
const requestToken = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
if (requestUrl && requestToken) {
const federationRuleId = env.AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID;
const organizationId = env.AWF_AUTH_ANTHROPIC_ORGANIZATION_ID;
const serviceAccountId = env.AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID;
const workspaceId = env.AWF_AUTH_ANTHROPIC_WORKSPACE_ID;
oidcProvider = new AnthropicOidcTokenProvider({
requestUrl,
requestToken,
federationRuleId,
organizationId,
serviceAccountId,
...(workspaceId !== undefined ? { workspaceId } : {}),
oidcAudience: env.AWF_AUTH_OIDC_AUDIENCE || 'https://api.anthropic.com',
});
Comment on lines +58 to 70
}
Expand Down
6 changes: 6 additions & 0 deletions containers/api-proxy/server.auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,9 @@ describe('createAnthropicAdapter — OIDC getAuthHeaders', () => {
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 provider = adapter.getOidcProvider();
Expand All @@ -381,6 +384,9 @@ describe('createAnthropicAdapter — OIDC getAuthHeaders', () => {
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',
});

expect(adapter.getAuthHeaders(fakeReq)).toEqual({});
Expand Down
3 changes: 3 additions & 0 deletions containers/api-proxy/server.lifecycle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,9 @@ describe('provider adapter alwaysBind', () => {
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',
});

expect(adapter.isEnabled()).toBe(false);
Expand Down
15 changes: 13 additions & 2 deletions docs/api-proxy-sidecar.md
Original file line number Diff line number Diff line change
Expand Up @@ -782,7 +782,14 @@ Exchanges the GitHub OIDC JWT for an Anthropic Workload Identity Federation acce

#### Anthropic-specific environment variables

Anthropic does not require any provider-specific variables beyond the common OIDC settings.
| Environment variable | Required | Description |
|---|---|---|
| `AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID` | ✅ | Anthropic federation rule ID (e.g. `fdrl_...`) |
| `AWF_AUTH_ANTHROPIC_ORGANIZATION_ID` | ✅ | Anthropic organization UUID |
| `AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID` | ✅ | Anthropic service account ID (e.g. `svac_...`) |
| `AWF_AUTH_ANTHROPIC_WORKSPACE_ID` | Conditional¹ | Anthropic workspace ID (e.g. `wrkspc_...`) |

¹ Required when the federation rule covers multiple workspaces. May be omitted when the rule is scoped to a single workspace.

Default OIDC audience: `https://api.anthropic.com`

Expand All @@ -799,8 +806,12 @@ jobs:
env:
AWF_AUTH_TYPE: github-oidc
AWF_AUTH_PROVIDER: anthropic
AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID: fdrl_...
AWF_AUTH_ANTHROPIC_ORGANIZATION_ID: <your-org-uuid>
AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID: svac_...
# AWF_AUTH_ANTHROPIC_WORKSPACE_ID: wrkspc_... # required for multi-workspace rules
run: |
sudo --preserve-env=AWF_AUTH_TYPE,AWF_AUTH_PROVIDER \
sudo --preserve-env=AWF_AUTH_TYPE,AWF_AUTH_PROVIDER,AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID,AWF_AUTH_ANTHROPIC_ORGANIZATION_ID,AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID,AWF_AUTH_ANTHROPIC_WORKSPACE_ID \
awf --enable-api-proxy \
--allow-domains api.anthropic.com \
-- your-agent-command
Expand Down
12 changes: 10 additions & 2 deletions docs/awf-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,8 +494,16 @@ Exchanges the GitHub OIDC JWT for an Anthropic Workload Identity Federation
token via `https://api.anthropic.com/v1/oauth/token`. The sidecar injects
the resulting token as an `Authorization` header on upstream requests.

Anthropic requires no provider-specific environment variables beyond the
common OIDC settings.
| Config path | Environment variable | Required | Default |
|-------------|----------------------|----------|---------|
| `apiProxy.auth.anthropicFederationRuleId` | `AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID` | ✅ | — |
| `apiProxy.auth.anthropicOrganizationId` | `AWF_AUTH_ANTHROPIC_ORGANIZATION_ID` | ✅ | — |
| `apiProxy.auth.anthropicServiceAccountId` | `AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID` | ✅ | — |
| `apiProxy.auth.anthropicWorkspaceId` | `AWF_AUTH_ANTHROPIC_WORKSPACE_ID` | Conditional¹ | — |

¹ `AWF_AUTH_ANTHROPIC_WORKSPACE_ID` is required when the federation rule covers
multiple workspaces. When the rule is scoped to a single workspace, it may be
omitted.

Default OIDC audience: `https://api.anthropic.com`

Expand Down
Loading
Loading