diff --git a/containers/api-proxy/providers/anthropic.js b/containers/api-proxy/providers/anthropic.js index 030c242f..c5523173 100644 --- a/containers/api-proxy/providers/anthropic.js +++ b/containers/api-proxy/providers/anthropic.js @@ -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 { @@ -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).'; @@ -164,17 +172,22 @@ function createAnthropicAdapter(env, deps = {}) { * @returns {Record} */ 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'; } @@ -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) { @@ -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, diff --git a/containers/api-proxy/providers/cloud-oidc-init.js b/containers/api-proxy/providers/cloud-oidc-init.js index d364d091..ae8de099 100644 --- a/containers/api-proxy/providers/cloud-oidc-init.js +++ b/containers/api-proxy/providers/cloud-oidc-init.js @@ -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. @@ -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} 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) => 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, staticHeaders: Record) => Record, + * }} + */ +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} buildOidcHeaders + * @param {Record} staticHeaders + * @returns {Record} + */ + resolveAuthHeaders(buildOidcHeaders, staticHeaders) { + const oidcHeaders = resolveOidcAuthHeaders({ oidcProvider, awsOidcProvider, buildOidcHeaders }); + return oidcHeaders !== null ? oidcHeaders : staticHeaders; + }, + }; +} + module.exports = { resolveCloudOidcProviders, + createProviderOidcAuth, }; diff --git a/containers/api-proxy/providers/cloud-oidc-init.test.js b/containers/api-proxy/providers/cloud-oidc-init.test.js index 8f567745..520e9350 100644 --- a/containers/api-proxy/providers/cloud-oidc-init.test.js +++ b/containers/api-proxy/providers/cloud-oidc-init.test.js @@ -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', () => { @@ -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(); + }); +}); diff --git a/containers/api-proxy/providers/copilot.js b/containers/api-proxy/providers/copilot.js index 2bffa0c4..1ed0d8ac 100644 --- a/containers/api-proxy/providers/copilot.js +++ b/containers/api-proxy/providers/copilot.js @@ -22,7 +22,6 @@ const { makeProviderNotConfiguredResponse, makeUnconfiguredHealthResponse, composeBodyTransforms, - createOidcRuntimeAdapterMethods, resolveOidcAuthHeaders, } = require('../proxy-utils'); const { createAdapterMethods, buildProviderAdapter } = require('../adapter-factory'); @@ -39,7 +38,7 @@ const { deriveCopilotApiTarget, isGhesInstance, } = require('./copilot-auth'); -const { resolveCloudOidcProviders } = require('./cloud-oidc-init'); +const { createProviderOidcAuth } = require('./cloud-oidc-init'); const { URL } = require('url'); /** @@ -63,23 +62,15 @@ function createCopilotAdapter(env, deps = {}) { // adapter's OIDC plumbing so the Copilot CLI's direct-BYOK path can exchange a // GitHub Actions OIDC JWT for an upstream cloud token instead of requiring a // static COPILOT_PROVIDER_API_KEY. - const { - authProvider, - oidcProvider, - awsOidcProvider, - oidcConfigured, - } = resolveCloudOidcProviders(env, { skipWhen: !!staticAuthToken }); - // authToken is consumed by the existing validation/models-fetch/auth-header paths. // In OIDC mode staticAuthToken is typically undefined; enablement is determined by // createOidcRuntimeAdapterMethods + oidcConfigured, and the real token is resolved // lazily inside getAuthHeaders. const authToken = staticAuthToken; - const oidcRuntimeMethods = createOidcRuntimeAdapterMethods({ - staticAuthToken: authToken, - oidcProvider, - awsOidcProvider, - }); + const { + authProvider, oidcProvider, awsOidcProvider, oidcConfigured, + runtimeMethods: oidcRuntimeMethods, + } = createProviderOidcAuth(env, { staticAuthToken: authToken, skipWhen: !!staticAuthToken }); // Extra headers to inject on all requests that use the BYOK API key. // Only populated when AWF_BYOK_EXTRA_HEADERS is set; ignored for standard // GitHub OAuth (COPILOT_GITHUB_TOKEN-only) requests. diff --git a/containers/api-proxy/providers/openai.js b/containers/api-proxy/providers/openai.js index ee7c629a..9176cbf4 100644 --- a/containers/api-proxy/providers/openai.js +++ b/containers/api-proxy/providers/openai.js @@ -13,13 +13,11 @@ const { normalizeBasePath, validateAuthHeaderEnv, - createOidcRuntimeAdapterMethods, - resolveOidcAuthHeaders, parseApiTargetAndBasePath, } = require('../proxy-utils'); const { createBaseAdapterConfig, createAdapterMethods, buildProviderAdapter } = require('../adapter-factory'); -const { resolveCloudOidcProviders } = require('./cloud-oidc-init'); +const { createProviderOidcAuth } = require('./cloud-oidc-init'); /** * Create the OpenAI provider adapter. @@ -61,12 +59,13 @@ function createOpenAIAdapter(env, deps = {}) { const bodyTransform = deps.bodyTransform || null; // OIDC auth strategy (Azure OpenAI, AWS Bedrock, GCP Vertex AI) - const { authProvider, oidcProvider, awsOidcProvider, oidcConfigured } = resolveCloudOidcProviders(env); - const oidcRuntimeMethods = createOidcRuntimeAdapterMethods({ - staticAuthToken: apiKey, - oidcProvider, - awsOidcProvider, - }); + const { + authProvider, oidcConfigured, + runtimeMethods: oidcRuntimeMethods, + validationSkip, + skipModelsFetch, + resolveAuthHeaders, + } = createProviderOidcAuth(env, { staticAuthToken: apiKey }); /** * Build a static-key auth header object. * When AWF_OPENAI_AUTH_HEADER is set, uses that header name with the raw key. @@ -88,10 +87,8 @@ function createOpenAIAdapter(env, deps = {}) { defaultTarget: 'api.openai.com', validationPath: '/v1/models', validationHeaders: () => buildStaticAuthHeaders(apiKey), - validationSkip: () => (oidcConfigured - ? { skip: true, reason: 'OIDC auth; validation via token acquisition' } - : null), - skipModelsFetch: () => oidcConfigured, // Models fetched after OIDC init + validationSkip, + skipModelsFetch, modelsPath: '/v1/models', modelsFetchHeaders: () => buildStaticAuthHeaders(apiKey), reflectionConfigured: !!apiKey || oidcConfigured, @@ -107,17 +104,12 @@ function createOpenAIAdapter(env, deps = {}) { isManagementPort: true, adapterMethods, getAuthHeaders() { - const oidcHeaders = resolveOidcAuthHeaders({ - oidcProvider, - awsOidcProvider, - buildOidcHeaders: (token) => (customAuthHeader + return resolveAuthHeaders( + (token) => (customAuthHeader ? { [customAuthHeader]: token } : { 'Authorization': ['Bearer', token].join(' ') }), - }); - if (oidcHeaders !== null) { - return oidcHeaders; - } - return buildStaticAuthHeaders(apiKey); + buildStaticAuthHeaders(apiKey), + ); }, bodyTransform, /** Response returned when port 10000 receives a proxy request but no key is set. */