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
74 changes: 41 additions & 33 deletions containers/api-proxy/providers/anthropic.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ const {
makeProviderNotConfiguredResponse,
makeUnconfiguredHealthResponse,
validateAuthHeaderEnv,
resolveOidcAuthHeaders,
} = require('../proxy-utils');
const { createBaseAdapterConfig, createAdapterMethods, buildProviderAdapter } = require('../adapter-factory');
const { AnthropicOidcTokenProvider } = require('../anthropic-oidc-token-provider');
const { createProviderOidcAuth } = require('./cloud-oidc-init');

let makeAnthropicTransform, loadCustomTransform, EXTENDED_CACHE_BETA;
try {
Expand Down Expand Up @@ -49,32 +51,38 @@ function createAnthropicAdapter(env, deps = {}) {
defaultTarget: 'api.anthropic.com',
});
const authHeaderName = validateAuthHeaderEnv('AWF_ANTHROPIC_AUTH_HEADER', env.AWF_ANTHROPIC_AUTH_HEADER, 'x-api-key');
const authType = (env.AWF_AUTH_TYPE || '').trim().toLowerCase();
const authProvider = (env.AWF_AUTH_PROVIDER || '').trim().toLowerCase();
const oidcRequested = authType === 'github-oidc' && authProvider === 'anthropic';
let oidcProvider = null;
if (oidcRequested) {
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;

// oidcRequested tracks whether the caller asked for Anthropic OIDC, regardless
// of whether the token env vars (ACTIONS_ID_TOKEN_REQUEST_*) are also present.
// This lets getUnconfiguredResponse() give a more helpful error message when
// OIDC was asked for but could not be fully initialised.
const oidcRequested = (env.AWF_AUTH_TYPE || '').trim().toLowerCase() === 'github-oidc'
&& (env.AWF_AUTH_PROVIDER || '').trim().toLowerCase() === 'anthropic';

const {
oidcProvider, oidcConfigured,
runtimeMethods: oidcRuntimeMethods,
} = createProviderOidcAuth(env, {
staticAuthToken: apiKey,
oidcProviderFactory: oidcRequested ? (env) => {
const requestUrl = env.ACTIONS_ID_TOKEN_REQUEST_URL;
const requestToken = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
if (!requestUrl || !requestToken) return null;
const workspaceId = env.AWF_AUTH_ANTHROPIC_WORKSPACE_ID;
const tokenEndpoint = (env.AWF_AUTH_ANTHROPIC_TOKEN_URL || '').trim();
oidcProvider = new AnthropicOidcTokenProvider({
return new AnthropicOidcTokenProvider({
requestUrl,
requestToken,
federationRuleId,
organizationId,
serviceAccountId,
federationRuleId: env.AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID,
organizationId: env.AWF_AUTH_ANTHROPIC_ORGANIZATION_ID,
serviceAccountId: env.AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID,
...(workspaceId !== undefined ? { workspaceId } : {}),
...(tokenEndpoint ? { tokenEndpoint } : {}),
oidcAudience: env.AWF_AUTH_OIDC_AUDIENCE || 'https://api.anthropic.com',
});
}
}
const oidcConfigured = !!oidcProvider;
} : null,
});

const oidcUnavailableError = oidcConfigured
? 'Anthropic OIDC token unavailable; retry shortly'
: 'Anthropic OIDC requires ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN (permissions: id-token: write).';
Expand Down Expand Up @@ -164,17 +172,22 @@ function createAnthropicAdapter(env, deps = {}) {
* @returns {Record<string, string>}
*/
getAuthHeaders(req) {
const headers = {};
if (oidcProvider) {
const token = oidcProvider.getToken();
if (!token) {
return {};
}
headers['Authorization'] = 'Bearer ' + token;
} else {
headers[authHeaderName] = apiKey;
const oidcHeaders = resolveOidcAuthHeaders({
oidcProvider,
awsOidcProvider: null,
buildOidcHeaders: (token) => ({ 'Authorization': 'Bearer ' + token }),
});

// oidcHeaders === null → OIDC not configured; fall through to static key.
// oidcHeaders === {} → OIDC configured, token not yet ready; return empty so the
// request fails authentication rather than leaking the static key.
// oidcHeaders === {...} → OIDC token available; use it.
if (oidcHeaders !== null && Object.keys(oidcHeaders).length === 0) {
return {};
}

const headers = oidcHeaders !== null ? { ...oidcHeaders } : { [authHeaderName]: apiKey };

if (!req.headers['anthropic-version']) {
headers['anthropic-version'] = '2023-06-01';
}
Expand All @@ -195,11 +208,6 @@ function createAnthropicAdapter(env, deps = {}) {
return headers;
},
bodyTransform: composedBodyTransform,
/**
* The stub server does NOT count toward the startup validation latch —
* only the fully-configured server (when ANTHROPIC_API_KEY is set) does.
*/
isEnabled() { return !!apiKey || !!oidcProvider?.isReady(); },
/** Response returned for all requests when no ANTHROPIC_API_KEY is configured. */
getUnconfiguredResponse() {
if (oidcRequested) {
Expand All @@ -222,7 +230,7 @@ function createAnthropicAdapter(env, deps = {}) {
return makeUnconfiguredHealthResponse('awf-api-proxy-anthropic', 'ANTHROPIC_API_KEY not configured in api-proxy sidecar');
},
extra: {
getOidcProvider() { return oidcProvider; },
...oidcRuntimeMethods,
// Exposed for introspection (logging, tests)
_autoCache: autoCache,
_cacheTailTtl: cacheTailTtl,
Expand Down
102 changes: 102 additions & 0 deletions containers/api-proxy/providers/cloud-oidc-init.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
'use strict';

const { OidcTokenProvider } = require('../oidc-token-provider');
const {
createOidcRuntimeAdapterMethods,
resolveOidcAuthHeaders,
} = require('../proxy-utils');

/**
* @typedef {object} OidcAuthProvider
* @property {() => boolean} isReady
* @property {() => string} getToken
*/

/**
* Resolve cloud OIDC providers (Azure/AWS/GCP) from environment variables.
Expand Down Expand Up @@ -75,6 +85,98 @@ function resolveCloudOidcProviders(env, options = {}) {
};
}

/**
* Create the OIDC auth bundle for a provider adapter.
*
* Bundles provider resolution, runtime adapter methods, and header-resolution
* helpers into a single call so each provider adapter does not need to repeat
* the same three-step OIDC setup.
*
* @param {Record<string, string|undefined>} env - Environment variables
* @param {object} [options]
* @param {string|undefined} [options.staticAuthToken]
* Static credential used by `runtimeMethods.isEnabled()`.
* @param {boolean} [options.skipWhen=false]
* Skip cloud OIDC initialisation (e.g. when static auth takes precedence).
* Only used when `oidcProviderFactory` is not provided.
* @param {((env: Record<string, string|undefined>) => OidcAuthProvider|null|undefined)|null} [options.oidcProviderFactory]
* Optional factory for providers that use a custom OIDC token class (e.g.
* Anthropic). When provided, takes precedence over `resolveCloudOidcProviders`.
* The factory receives `env` and should return a provider instance or
* `null`/`undefined` when not configured. Returned providers must implement
* `isReady()` and `getToken()`.
* @returns {{
* authProvider: string,
* oidcProvider: any,
* awsOidcProvider: any,
* oidcConfigured: boolean,
* runtimeMethods: { isEnabled: () => boolean, getOidcProvider: () => any, getAwsOidcProvider: () => any },
* validationSkip: () => ({ skip: true, reason: string }|null),
* skipModelsFetch: () => boolean,
* resolveAuthHeaders: (buildOidcHeaders: (token: string) => Record<string, string>, staticHeaders: Record<string, string>) => Record<string, string>,
* }}
*/
function createProviderOidcAuth(env, {
staticAuthToken = undefined,
skipWhen = false,
oidcProviderFactory = null,
} = {}) {
let authProvider, oidcProvider, awsOidcProvider, oidcConfigured;

if (typeof oidcProviderFactory === 'function') {
authProvider = (env.AWF_AUTH_PROVIDER || '').trim().toLowerCase() || 'unknown';
oidcProvider = oidcProviderFactory(env) || null;
awsOidcProvider = null;
oidcConfigured = !!oidcProvider;
} else {
({ authProvider, oidcProvider, awsOidcProvider, oidcConfigured } =
resolveCloudOidcProviders(env, { skipWhen }));
}

const runtimeMethods = createOidcRuntimeAdapterMethods({
staticAuthToken,
oidcProvider,
awsOidcProvider,
});

return {
authProvider,
oidcProvider,
awsOidcProvider,
oidcConfigured,
runtimeMethods,

/** Skip startup validation when OIDC is configured; token is acquired asynchronously. */
validationSkip() {
return oidcConfigured
? { skip: true, reason: 'OIDC auth; validation via token acquisition' }
: null;
},

/** Skip startup model fetch when OIDC is configured; models fetched after OIDC init. */
skipModelsFetch() {
return oidcConfigured;
},

/**
* Resolve auth headers for a request: OIDC when configured, otherwise static.
*
* Returns the OIDC-built headers when a bearer-compatible token is available,
* an empty object when OIDC is configured but the token is not yet ready, or
* `staticHeaders` when OIDC is not configured at all.
*
* @param {(token: string) => Record<string, string>} buildOidcHeaders
* @param {Record<string, string>} staticHeaders
* @returns {Record<string, string>}
*/
resolveAuthHeaders(buildOidcHeaders, staticHeaders) {
const oidcHeaders = resolveOidcAuthHeaders({ oidcProvider, awsOidcProvider, buildOidcHeaders });
return oidcHeaders !== null ? oidcHeaders : staticHeaders;
},
};
}

module.exports = {
resolveCloudOidcProviders,
createProviderOidcAuth,
};
134 changes: 133 additions & 1 deletion containers/api-proxy/providers/cloud-oidc-init.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict';

const { resolveCloudOidcProviders } = require('./cloud-oidc-init');
const { resolveCloudOidcProviders, createProviderOidcAuth } = require('./cloud-oidc-init');

describe('resolveCloudOidcProviders', () => {
it('returns no providers when github-oidc is not configured', () => {
Expand Down Expand Up @@ -77,3 +77,135 @@ describe('resolveCloudOidcProviders', () => {
result.oidcProvider.shutdown();
});
});

describe('createProviderOidcAuth', () => {
it('returns no providers and a disabled bundle when OIDC is not configured', () => {
const auth = createProviderOidcAuth({});

expect(auth.authProvider).toBe('azure');
expect(auth.oidcProvider).toBeNull();
expect(auth.awsOidcProvider).toBeNull();
expect(auth.oidcConfigured).toBe(false);
expect(auth.runtimeMethods.isEnabled()).toBe(false);
expect(auth.validationSkip()).toBeNull();
expect(auth.skipModelsFetch()).toBe(false);
});

it('isEnabled() returns true when staticAuthToken is set (no OIDC)', () => {
const auth = createProviderOidcAuth({}, { staticAuthToken: 'my-api-key' });

expect(auth.oidcConfigured).toBe(false);
expect(auth.runtimeMethods.isEnabled()).toBe(true);
});

it('creates Azure OIDC provider and marks validationSkip/skipModelsFetch', () => {
const auth = createProviderOidcAuth({
AWF_AUTH_TYPE: 'github-oidc',
ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token',
ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'test-token',
AWF_AUTH_AZURE_TENANT_ID: 'tenant-uuid',
AWF_AUTH_AZURE_CLIENT_ID: 'client-uuid',
});

expect(auth.authProvider).toBe('azure');
expect(auth.oidcProvider).toBeTruthy();
expect(auth.awsOidcProvider).toBeNull();
expect(auth.oidcConfigured).toBe(true);
expect(auth.validationSkip()).toEqual({ skip: true, reason: 'OIDC auth; validation via token acquisition' });
expect(auth.skipModelsFetch()).toBe(true);

auth.oidcProvider.shutdown();
});

it('skipWhen=true suppresses OIDC initialisation', () => {
const auth = createProviderOidcAuth({
AWF_AUTH_TYPE: 'github-oidc',
ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token',
ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'test-token',
AWF_AUTH_AZURE_TENANT_ID: 'tenant-uuid',
AWF_AUTH_AZURE_CLIENT_ID: 'client-uuid',
}, { skipWhen: true });

expect(auth.oidcProvider).toBeNull();
expect(auth.oidcConfigured).toBe(false);
});

it('resolveAuthHeaders returns OIDC headers when a token is available', () => {
const auth = createProviderOidcAuth({}, {
// Bypass resolveCloudOidcProviders by using a factory
oidcProviderFactory: () => ({ isReady: () => true, getToken: () => 'oidc-token' }),
});

const headers = auth.resolveAuthHeaders(
(token) => ({ Authorization: 'Bearer ' + token }),
{ 'x-api-key': 'static' },
);

expect(headers).toEqual({ Authorization: 'Bearer oidc-token' });
});

it('resolveAuthHeaders returns empty object when OIDC is configured but token not yet ready', () => {
const auth = createProviderOidcAuth({}, {
oidcProviderFactory: () => ({ isReady: () => false, getToken: () => '' }),
});

const headers = auth.resolveAuthHeaders(
(token) => ({ Authorization: 'Bearer ' + token }),
{ 'x-api-key': 'static' },
);

expect(headers).toEqual({});
});

it('resolveAuthHeaders returns staticHeaders when OIDC is not configured', () => {
const auth = createProviderOidcAuth({});

const headers = auth.resolveAuthHeaders(
(token) => ({ Authorization: 'Bearer ' + token }),
{ 'x-api-key': 'static-key' },
);

expect(headers).toEqual({ 'x-api-key': 'static-key' });
});

it('uses custom oidcProviderFactory when provided', () => {
const mockProvider = { isReady: () => true, getToken: () => 'custom-token' };
const factory = jest.fn().mockReturnValue(mockProvider);

const auth = createProviderOidcAuth({ AWF_AUTH_PROVIDER: 'custom-provider' }, {
oidcProviderFactory: factory,
});

expect(factory).toHaveBeenCalledWith(expect.objectContaining({ AWF_AUTH_PROVIDER: 'custom-provider' }));
expect(auth.authProvider).toBe('custom-provider');
expect(auth.oidcProvider).toBe(mockProvider);
expect(auth.awsOidcProvider).toBeNull();
expect(auth.oidcConfigured).toBe(true);
});

it('oidcProviderFactory returning null/undefined results in oidcConfigured=false', () => {
const auth = createProviderOidcAuth({}, {
oidcProviderFactory: () => null,
});

expect(auth.oidcProvider).toBeNull();
expect(auth.oidcConfigured).toBe(false);
expect(auth.validationSkip()).toBeNull();
});

it('falls back to resolveCloudOidcProviders when oidcProviderFactory is not a function', () => {
const auth = createProviderOidcAuth({
AWF_AUTH_TYPE: 'github-oidc',
ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token',
ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'test-token',
AWF_AUTH_AZURE_TENANT_ID: 'tenant-uuid',
AWF_AUTH_AZURE_CLIENT_ID: 'client-uuid',
}, { oidcProviderFactory: {} });

expect(auth.authProvider).toBe('azure');
expect(auth.oidcProvider).toBeTruthy();
expect(auth.oidcConfigured).toBe(true);

auth.oidcProvider.shutdown();
});
});
Loading
Loading