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
68 changes: 68 additions & 0 deletions containers/api-proxy/adapter-factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,73 @@ function createProviderAuthScaffold(env, deps = {}, { keyEnvVar, targetEnvVar, b
return { apiKey, rawTarget, basePath, bodyTransform };
}

/**
* Create an OIDC-aware adapter using the shared auth/header/runtime scaffold.
*
* @param {object} opts
* @param {Record<string, string|undefined>} opts.env
* @param {object} [opts.oidcAuthOptions]
* @param {(token: string) => Record<string,string>} opts.buildOidcHeaders
* @param {() => Record<string,string>} opts.buildStaticHeaders
* @param {object|((ctx: object) => object)} opts.createAdapterMethodsOptions
* @param {object|((ctx: object) => object)} opts.buildAdapterOptions
* @param {(ctx: object) => Record<string,string>} [opts.getAuthHeaders]
* @returns {import('./providers/index').ProviderAdapter}
*/
function createOidcAwareProviderAdapter({
env,
oidcAuthOptions = {},
buildOidcHeaders,
buildStaticHeaders,
createAdapterMethodsOptions,
buildAdapterOptions,
getAuthHeaders,
}) {
const { createProviderOidcHeaderStrategy } = require('./providers/cloud-oidc-init');
const oidc = createProviderOidcHeaderStrategy(env, oidcAuthOptions, {
buildOidcHeaders,
buildStaticHeaders,
});
const {
authProvider,
oidcProvider,
awsOidcProvider,
oidcConfigured,
runtimeMethods,
validationSkip,
skipModelsFetch,
resolveHeaders,
} = oidc;
const context = {
authProvider,
oidcProvider,
awsOidcProvider,
oidcConfigured,
runtimeMethods,
validationSkip,
skipModelsFetch,
resolveHeaders,
};
const adapterMethods = createAdapterMethods(typeof createAdapterMethodsOptions === 'function'
? createAdapterMethodsOptions(context)
: createAdapterMethodsOptions);
const adapterOptions = typeof buildAdapterOptions === 'function'
? buildAdapterOptions({ ...context, adapterMethods })
: buildAdapterOptions;

return buildProviderAdapter({
...adapterOptions,
adapterMethods,
getAuthHeaders: getAuthHeaders
? (req) => getAuthHeaders({ ...context, req })
: (() => resolveHeaders()),
extra: {
...runtimeMethods,
...(adapterOptions.extra || {}),
},
});
}

/**
* Assemble a provider adapter object from its constituent parts.
*
Expand Down Expand Up @@ -338,6 +405,7 @@ function buildProviderAdapter({
module.exports = {
createBaseAdapterConfig,
createProviderAuthScaffold,
createOidcAwareProviderAdapter,
createAdapterMethods,
buildProviderAdapter,
};
68 changes: 68 additions & 0 deletions containers/api-proxy/adapter-factory.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -505,3 +505,71 @@ describe('createProviderAuthScaffold', () => {
expect(result.rawTarget).toBe('custom.example.com');
});
});

describe('createOidcAwareProviderAdapter', () => {
const { createOidcAwareProviderAdapter } = require('./adapter-factory');

it('wires static auth headers and runtime methods into a provider adapter', () => {
const adapter = createOidcAwareProviderAdapter({
env: {},
oidcAuthOptions: { staticAuthToken: 'static-key' },
buildOidcHeaders: (token) => ({ Authorization: ['Bearer', token].join(' ') }),
buildStaticHeaders: () => ({ 'x-api-key': 'static-key' }),
createAdapterMethodsOptions: ({ validationSkip, skipModelsFetch }) => ({
apiKey: 'static-key',
rawTarget: 'api.example.com',
basePath: '',
provider: 'test',
port: 10099,
modelsPath: '/v1/models',
validationPath: '/v1/models',
validationHeaders: () => ({ 'x-api-key': 'static-key' }),
validationSkip,
skipModelsFetch,
}),
buildAdapterOptions: {
name: 'test',
port: 10099,
isManagementPort: false,
},
});

expect(adapter.isEnabled()).toBe(true);
expect(adapter.getAuthHeaders()).toEqual({ 'x-api-key': 'static-key' });
expect(adapter.getTargetHost()).toBe('api.example.com');
});

it('uses custom getAuthHeaders and exposes OIDC runtime methods when configured', () => {
const adapter = createOidcAwareProviderAdapter({
env: {},
oidcAuthOptions: {
oidcProviderFactory: () => ({ isReady: () => true, getToken: () => 'oidc-token' }),
},
buildOidcHeaders: (token) => ({ Authorization: ['Bearer', token].join(' ') }),
buildStaticHeaders: () => ({ 'x-api-key': 'static-key' }),
createAdapterMethodsOptions: ({ oidcConfigured }) => ({
apiKey: undefined,
rawTarget: 'api.example.com',
basePath: '',
provider: 'test',
port: 10099,
modelsPath: '/v1/models',
reflectionConfigured: oidcConfigured,
}),
buildAdapterOptions: {
name: 'test',
port: 10099,
isManagementPort: false,
},
getAuthHeaders: ({ resolveHeaders }) => ({
...resolveHeaders(),
'x-extra': '1',
}),
});

expect(adapter.isEnabled()).toBe(true);
const headers = adapter.getAuthHeaders();
expect(headers['x-extra']).toBe('1');
expect(typeof headers.Authorization).toBe('string');
});
});
192 changes: 93 additions & 99 deletions containers/api-proxy/providers/anthropic.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@ const {
const {
validateAuthHeaderEnv,
} = require('../oidc-adapter-utils');
const { createProviderAuthScaffold, createAdapterMethods, buildProviderAdapter } = require('../adapter-factory');
const { createProviderAuthScaffold, createOidcAwareProviderAdapter } = require('../adapter-factory');
const { AnthropicOidcTokenProvider } = require('../anthropic-oidc-token-provider');
const { ANTHROPIC_ENV } = require('../provider-env-constants');
const { createProviderOidcHeaderStrategy } = require('./cloud-oidc-init');
const { bearerAuthHeaders, providerKeyHeaders } = require('./auth-headers');

let makeAnthropicTransform, loadCustomTransform, EXTENDED_CACHE_BETA;
Expand Down Expand Up @@ -60,38 +59,6 @@ function createAnthropicAdapter(env, deps = {}) {
const oidcRequested = (env.AWF_AUTH_TYPE || '').trim().toLowerCase() === 'github-oidc'
&& (env.AWF_AUTH_PROVIDER || '').trim().toLowerCase() === 'anthropic';

const {
oidcProvider, oidcConfigured,
runtimeMethods: oidcRuntimeMethods,
resolveHeaders: resolveOidcHeaders,
} = createProviderOidcHeaderStrategy(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();
return new AnthropicOidcTokenProvider({
requestUrl,
requestToken,
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',
});
} : null,
}, {
buildOidcHeaders: (token) => bearerAuthHeaders(token),
buildStaticHeaders: () => providerKeyHeaders(authHeaderName, apiKey),
});

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).';

// ── Anthropic-specific optimisations ──────────────────────────────────────
const autoCache = (env.AWF_ANTHROPIC_AUTO_CACHE === '1' || env.AWF_ANTHROPIC_AUTO_CACHE === 'true');
const cacheTailTtl = (() => {
Expand All @@ -117,54 +84,107 @@ function createAnthropicAdapter(env, deps = {}) {
// Build the composed transform once at construction time to avoid
// re-allocating the wrapper function on every request.
const composedBodyTransform = composeBodyTransforms(depsBodyTransform, optimisationsTransform);
const adapterMethods = createAdapterMethods({
apiKey,
rawTarget,
basePath,
provider: 'anthropic',
port: 10001,
defaultTarget: 'api.anthropic.com',
validationPath: '/v1/messages',
validationMethod: 'POST',
validationBody: '{}',
validationHeaders: () => ({
...resolveOidcHeaders(),
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
}),
validationSkip: () => {
if (!oidcConfigured) return null;
// After OIDC init, validate using the acquired token
if (oidcProvider.isReady()) return null;
return { skip: true, reason: 'OIDC auth; token not yet available' };
return createOidcAwareProviderAdapter({
env,
oidcAuthOptions: {
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();
return new AnthropicOidcTokenProvider({
requestUrl,
requestToken,
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',
});
} : null,
},
skipModelsFetch: () => oidcConfigured && !oidcProvider?.isReady(),
modelsPath: '/v1/models',
modelsFetchHeaders: () => ({
...resolveOidcHeaders(),
'anthropic-version': '2023-06-01',
}),
reflectionConfigured: !!apiKey || oidcRequested,
reflectionExtra: () => ({
auth_type: oidcRequested ? 'github-oidc/anthropic' : 'static-key',
buildOidcHeaders: (token) => bearerAuthHeaders(token),
buildStaticHeaders: () => providerKeyHeaders(authHeaderName, apiKey),
createAdapterMethodsOptions: ({ oidcConfigured, oidcProvider, resolveHeaders }) => ({
apiKey,
rawTarget,
basePath,
provider: 'anthropic',
port: 10001,
defaultTarget: 'api.anthropic.com',
validationPath: '/v1/messages',
validationMethod: 'POST',
validationBody: '{}',
validationHeaders: () => ({
...resolveHeaders(),
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
}),
validationSkip: () => {
if (!oidcConfigured) return null;
// After OIDC init, validate using the acquired token
if (oidcProvider.isReady()) return null;
return { skip: true, reason: 'OIDC auth; token not yet available' };
},
skipModelsFetch: () => oidcConfigured && !oidcProvider?.isReady(),
modelsPath: '/v1/models',
modelsFetchHeaders: () => ({
...resolveHeaders(),
'anthropic-version': '2023-06-01',
}),
reflectionConfigured: !!apiKey || oidcRequested,
reflectionExtra: () => ({
auth_type: oidcRequested ? 'github-oidc/anthropic' : 'static-key',
}),
}),
});

return buildProviderAdapter({
name: 'anthropic',
port: 10001,
isManagementPort: false,
adapterMethods,
buildAdapterOptions: ({ oidcConfigured }) => {
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).';
return {
name: 'anthropic',
port: 10001,
isManagementPort: false,
bodyTransform: composedBodyTransform,
missingCredentialResponse: {
kind: 'provider_not_configured',
message: 'Credentials for Anthropic (port 10001) are not configured. Set ANTHROPIC_API_KEY to enable this provider.',
},
unconfiguredResponseWhen: () => (oidcRequested
? {
kind: 'plain_error',
statusCode: 503,
message: oidcUnavailableError,
}
: null),
healthServiceName: 'awf-api-proxy-anthropic',
missingCredentialMessage: 'ANTHROPIC_API_KEY not configured in api-proxy sidecar',
unavailableWhen: () => oidcRequested ? { message: oidcUnavailableError, status: 'unavailable' } : null,
extra: {
// Exposed for introspection (logging, tests)
_autoCache: autoCache,
_cacheTailTtl: cacheTailTtl,
_dropTools: dropTools,
_stripAnsi: stripAnsi,
_transformFile: transformFile,
_customTransformLoaded: !!customTransform,
_optimisationsTransform: optimisationsTransform,
},
};
},
/**
* 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.
*
* @param {import('http').IncomingMessage} req
* @param {{ resolveHeaders: () => Record<string,string>, req: import('http').IncomingMessage }} params
* @returns {Record<string, string>}
*/
getAuthHeaders(req) {
const headers = resolveOidcHeaders();
getAuthHeaders({ resolveHeaders, req }) {
const headers = resolveHeaders();

// OIDC configured but token not yet ready: fail closed so static creds are not leaked.
if (Object.keys(headers).length === 0) {
Expand All @@ -191,32 +211,6 @@ function createAnthropicAdapter(env, deps = {}) {

return mergedHeaders;
},
bodyTransform: composedBodyTransform,
missingCredentialResponse: {
kind: 'provider_not_configured',
message: 'Credentials for Anthropic (port 10001) are not configured. Set ANTHROPIC_API_KEY to enable this provider.',
},
unconfiguredResponseWhen: () => (oidcRequested
? {
kind: 'plain_error',
statusCode: 503,
message: oidcUnavailableError,
}
: null),
healthServiceName: 'awf-api-proxy-anthropic',
missingCredentialMessage: 'ANTHROPIC_API_KEY not configured in api-proxy sidecar',
unavailableWhen: () => oidcRequested ? { message: oidcUnavailableError, status: 'unavailable' } : null,
extra: {
...oidcRuntimeMethods,
// Exposed for introspection (logging, tests)
_autoCache: autoCache,
_cacheTailTtl: cacheTailTtl,
_dropTools: dropTools,
_stripAnsi: stripAnsi,
_transformFile: transformFile,
_customTransformLoaded: !!customTransform,
_optimisationsTransform: optimisationsTransform,
},
});
}

Expand Down
Loading
Loading