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
2 changes: 1 addition & 1 deletion containers/api-proxy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ COPY server.js logging.js metrics.js rate-limiter.js rate-limiter-window.js \
model-config.js key-validation.js server-factory.js startup.js \
proxy-request.js request-headers.js upstream-http.js proxy-guards.js proxy-error-handler.js http-client.js body-handler.js model-discovery.js management.js oidc-token-provider.js \
oidc-token-provider-base.js \
github-oidc.js aws-oidc-token-provider.js gcp-oidc-token-provider.js \
github-oidc.js aws-oidc-token-provider.js aws-sigv4.js gcp-oidc-token-provider.js \
anthropic-oidc-token-provider.js \
ai-credits-pricing.js models-dev-catalog.js models.dev.catalog.json \
provider-pricing-overlays.js runtime-model-catalog.js \
Expand Down
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
32 changes: 32 additions & 0 deletions containers/api-proxy/aws-oidc-token-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const { mintGitHubOidcToken, httpGet } = require('./github-oidc');
const {
BaseOidcTokenProvider,
} = require('./oidc-token-provider-base');
const { signAwsRequest } = require('./aws-sigv4');

/**
* @typedef {Object} AwsCredentials
Expand Down Expand Up @@ -84,6 +85,37 @@ class AwsOidcTokenProvider extends BaseOidcTokenProvider {
return this._region;
}

/**
* Return the only upstream host to which this provider will sign credentials.
* @returns {string}
*/
getBedrockRuntimeHost() {
const suffix = this._region.startsWith('cn-') ? 'amazonaws.com.cn' : 'amazonaws.com';
return `bedrock-runtime.${this._region}.${suffix}`;
}

/**
* Sign a complete outbound Bedrock request without exposing credentials.
* @param {object} request
* @returns {Record<string, string>}
*/
signRequest(request) {
const credentials = this.getCredentials();
if (!credentials) {
throw new Error('AWS temporary credentials are unavailable');
}
const expectedHost = this.getBedrockRuntimeHost();
if (typeof request?.targetHost !== 'string' || request.targetHost.toLowerCase() !== expectedHost) {
throw new Error(`AWS SigV4 signing is restricted to ${expectedHost}`);
}
return signAwsRequest({
...request,
credentials,
region: this._region,
service: 'bedrock-runtime',
});
}

/**
* Exchange GitHub OIDC JWT for temporary AWS credentials via STS.
* Uses the HTTPS query API (no SDK dependency).
Expand Down
Loading
Loading