diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 32e1993b4..8dde31567 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -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 \ diff --git a/containers/api-proxy/adapter-factory.js b/containers/api-proxy/adapter-factory.js index 89a46750d..dd15a744d 100644 --- a/containers/api-proxy/adapter-factory.js +++ b/containers/api-proxy/adapter-factory.js @@ -85,6 +85,7 @@ function createBaseAdapterConfig(env, { keyEnvVar, targetEnvVar, basePathEnvVar, * @param {() => boolean} [opts.skipModelsFetch] * @param {Record|(() => Record)} [opts.modelsFetchHeaders] * @param {string|null} [opts.modelsCacheKey] + * @param {boolean} [opts.credentialConfigured] * @param {boolean} [opts.participatesInValidation] * @param {boolean} [opts.reflectionConfigured] * @param {string|null} [opts.reflectionModelsPath] @@ -118,7 +119,8 @@ function createAdapterMethods(opts) { skipModelsFetch, modelsFetchHeaders = validationHeaders, modelsCacheKey = provider, - participatesInValidation = !!apiKey, + credentialConfigured = !!apiKey, + participatesInValidation = credentialConfigured, reflectionConfigured = !!apiKey, reflectionModelsPath = modelsPath, reflectionExtra = {}, @@ -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` }; } @@ -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. diff --git a/containers/api-proxy/anthropic-adapter-auth.test.js b/containers/api-proxy/anthropic-adapter-auth.test.js index c4beb4d84..bb596d904 100644 --- a/containers/api-proxy/anthropic-adapter-auth.test.js +++ b/containers/api-proxy/anthropic-adapter-auth.test.js @@ -2,42 +2,41 @@ 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(); @@ -45,17 +44,74 @@ describe('createAnthropicAdapter — OIDC getAuthHeaders', () => { 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(); + }); }); diff --git a/containers/api-proxy/anthropic-oidc-token-provider.js b/containers/api-proxy/anthropic-oidc-token-provider.js index fb003fb5a..82902e7a0 100644 --- a/containers/api-proxy/anthropic-oidc-token-provider.js +++ b/containers/api-proxy/anthropic-oidc-token-provider.js @@ -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; @@ -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) { diff --git a/containers/api-proxy/anthropic-oidc-token-provider.test.js b/containers/api-proxy/anthropic-oidc-token-provider.test.js index bda8f8bd6..a7ab8eaff 100644 --- a/containers/api-proxy/anthropic-oidc-token-provider.test.js +++ b/containers/api-proxy/anthropic-oidc-token-provider.test.js @@ -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'); @@ -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, diff --git a/containers/api-proxy/aws-oidc-token-provider.js b/containers/api-proxy/aws-oidc-token-provider.js index 74547279e..855cdd4b8 100644 --- a/containers/api-proxy/aws-oidc-token-provider.js +++ b/containers/api-proxy/aws-oidc-token-provider.js @@ -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 @@ -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} + */ + 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). diff --git a/containers/api-proxy/aws-oidc-token-provider.test.js b/containers/api-proxy/aws-oidc-token-provider.test.js index 8ba0aca8e..d8a8958c4 100644 --- a/containers/api-proxy/aws-oidc-token-provider.test.js +++ b/containers/api-proxy/aws-oidc-token-provider.test.js @@ -179,6 +179,114 @@ describe('AwsOidcTokenProvider', () => { provider.shutdown(); }); + it('should sign Bedrock requests with cached temporary credentials', () => { + const provider = new AwsOidcTokenProvider({ + requestUrl: 'http://localhost/token', + requestToken: 'test', + roleArn: 'arn:aws:iam::123456789012:role/my-role', + region: 'us-east-1', + }); + provider._cachedCredentials = { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'secret', + sessionToken: 'session-token', + }; + provider._expiresAt = Math.floor(Date.now() / 1000) + 3600; + + const headers = provider.signRequest({ + method: 'POST', + path: '/model/test/invoke', + headers: { 'content-type': 'application/json' }, + body: Buffer.from('{}'), + targetHost: 'bedrock-runtime.us-east-1.amazonaws.com', + now: new Date('2024-01-02T03:04:05.000Z'), + }); + + expect(headers.Authorization).toContain( + 'Credential=AKIDEXAMPLE/20240102/us-east-1/bedrock-runtime/aws4_request', + ); + expect(headers['x-amz-security-token']).toBe('session-token'); + provider.shutdown(); + }); + + it('should fail closed and trigger refresh when credentials are unavailable', () => { + const provider = new AwsOidcTokenProvider({ + requestUrl: 'http://localhost/token', + requestToken: 'test', + roleArn: 'arn:aws:iam::123456789012:role/my-role', + region: 'us-east-1', + }); + provider._scheduleRefresh = jest.fn(); + + expect(() => provider.signRequest({ + method: 'POST', + path: '/model/test/invoke', + headers: {}, + body: Buffer.from('{}'), + targetHost: 'bedrock-runtime.us-east-1.amazonaws.com', + })).toThrow('AWS temporary credentials are unavailable'); + expect(provider._scheduleRefresh).toHaveBeenCalledWith(0); + provider.shutdown(); + }); + + it('should use refreshed credentials for subsequent signatures', () => { + const provider = new AwsOidcTokenProvider({ + requestUrl: 'http://localhost/token', + requestToken: 'test', + roleArn: 'arn:aws:iam::123456789012:role/my-role', + region: 'us-east-1', + }); + const request = { + method: 'POST', + path: '/model/test/invoke', + headers: {}, + body: Buffer.from('{}'), + targetHost: 'bedrock-runtime.us-east-1.amazonaws.com', + now: new Date('2024-01-02T03:04:05.000Z'), + }; + provider._expiresAt = Math.floor(Date.now() / 1000) + 3600; + provider._cachedCredentials = { + accessKeyId: 'FIRSTKEY', + secretAccessKey: 'first-secret', + sessionToken: 'first-token', + }; + expect(provider.signRequest(request).Authorization).toContain('Credential=FIRSTKEY/'); + + provider._cachedCredentials = { + accessKeyId: 'REFRESHEDKEY', + secretAccessKey: 'refreshed-secret', + sessionToken: 'refreshed-token', + }; + const refreshed = provider.signRequest(request); + expect(refreshed.Authorization).toContain('Credential=REFRESHEDKEY/'); + expect(refreshed['x-amz-security-token']).toBe('refreshed-token'); + provider.shutdown(); + }); + + it('should refuse to sign credentials for a non-Bedrock host', () => { + const provider = new AwsOidcTokenProvider({ + requestUrl: 'http://localhost/token', + requestToken: 'test', + roleArn: 'arn:aws:iam::123456789012:role/my-role', + region: 'us-east-1', + }); + provider._cachedCredentials = { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'secret', + sessionToken: 'session-token', + }; + provider._expiresAt = Math.floor(Date.now() / 1000) + 3600; + + expect(() => provider.signRequest({ + method: 'POST', + path: '/', + headers: {}, + body: Buffer.alloc(0), + targetHost: 'example.com', + })).toThrow('AWS SigV4 signing is restricted'); + provider.shutdown(); + }); + it('should handle initialization failure gracefully', async () => { await testInitializationFailure( AwsOidcTokenProvider, @@ -226,15 +334,49 @@ describe('OpenAI adapter with AWS OIDC', () => { ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'test-token', AWF_AUTH_AWS_ROLE_ARN: 'arn:aws:iam::123456789012:role/my-role', AWF_AUTH_AWS_REGION: 'us-east-1', + OPENAI_API_TARGET: 'bedrock-runtime.us-east-1.amazonaws.com', }); expect(adapter.getOidcProvider()).toBeNull(); expect(adapter.getAwsOidcProvider()).not.toBeNull(); + expect(adapter.getRequestSigner()).toEqual(expect.any(Function)); expect(adapter.getReflectionInfo().auth_type).toBe('github-oidc/aws'); adapter.getAwsOidcProvider().shutdown(); }); + it('should sign OpenAI-adapter requests without exposing credentials as auth headers', () => { + const adapter = createOpenAIAdapter({ + AWF_AUTH_TYPE: 'github-oidc', + AWF_AUTH_PROVIDER: 'aws', + ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'test-token', + AWF_AUTH_AWS_ROLE_ARN: 'arn:aws:iam::123456789012:role/my-role', + AWF_AUTH_AWS_REGION: 'us-east-1', + OPENAI_API_TARGET: 'bedrock-runtime.us-east-1.amazonaws.com', + }); + const provider = adapter.getAwsOidcProvider(); + provider._cachedCredentials = { + accessKeyId: 'OPENAIKEY', + secretAccessKey: 'secret', + sessionToken: 'openai-session', + }; + provider._expiresAt = Math.floor(Date.now() / 1000) + 3600; + + expect(adapter.getAuthHeaders({ url: '/', method: 'POST' })).toEqual({}); + const signed = adapter.getRequestSigner()({ + method: 'POST', + path: '/model/test/invoke', + headers: {}, + body: Buffer.from('{}'), + targetHost: adapter.getTargetHost(), + now: new Date('2024-01-02T03:04:05.000Z'), + }); + expect(signed.Authorization).toContain('Credential=OPENAIKEY/'); + expect(signed['x-amz-security-token']).toBe('openai-session'); + provider.shutdown(); + }); + it('should not create AWS provider when required vars are missing', () => { const adapter = createOpenAIAdapter({ AWF_AUTH_TYPE: 'github-oidc', diff --git a/containers/api-proxy/aws-sigv4.js b/containers/api-proxy/aws-sigv4.js new file mode 100644 index 000000000..ee5e832b2 --- /dev/null +++ b/containers/api-proxy/aws-sigv4.js @@ -0,0 +1,156 @@ +'use strict'; + +const crypto = require('crypto'); + +const SIGNING_HEADER_NAMES = new Set([ + 'authorization', + 'host', + 'x-amz-content-sha256', + 'x-amz-date', + 'x-amz-security-token', +]); + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function hmac(key, value) { + return crypto.createHmac('sha256', key).update(value).digest(); +} + +function encodeRfc3986(value) { + return encodeURIComponent(value).replace(/[!'()*]/g, character => + `%${character.charCodeAt(0).toString(16).toUpperCase()}`); +} + +function decodeUriComponent(value, label) { + try { + return decodeURIComponent(value); + } catch { + throw new Error(`Cannot sign AWS request with malformed ${label}`); + } +} + +function canonicalizePath(pathname) { + if (!pathname) return '/'; + const canonical = pathname + .split('/') + .map(segment => encodeRfc3986(decodeUriComponent(segment, 'request path'))) + .join('/'); + return canonical.startsWith('/') ? canonical : `/${canonical}`; +} + +function canonicalizeQuery(query) { + if (!query) return ''; + return query + .split('&') + .map(parameter => { + const separator = parameter.indexOf('='); + const rawName = separator === -1 ? parameter : parameter.slice(0, separator); + const rawValue = separator === -1 ? '' : parameter.slice(separator + 1); + return [ + encodeRfc3986(decodeUriComponent(rawName, 'query string')), + encodeRfc3986(decodeUriComponent(rawValue, 'query string')), + ]; + }) + .sort(([leftName, leftValue], [rightName, rightValue]) => { + if (leftName !== rightName) return leftName < rightName ? -1 : 1; + if (leftValue === rightValue) return 0; + return leftValue < rightValue ? -1 : 1; + }) + .map(([name, value]) => `${name}=${value}`) + .join('&'); +} + +function removeSigningHeaders(headers) { + const unsignedHeaders = {}; + for (const [name, value] of Object.entries(headers || {})) { + if (!SIGNING_HEADER_NAMES.has(name.toLowerCase())) { + unsignedHeaders[name] = value; + } + } + return unsignedHeaders; +} + +function formatAmzDate(date) { + return date.toISOString().replace(/[:-]|\.\d{3}/g, ''); +} + +/** + * Sign an AWS request with Signature Version 4. + * + * Only the stable AWS-required headers are signed. Other request headers remain + * intact but outside SignedHeaders so Node can apply its normal transport rules. + */ +function signAwsRequest({ + credentials, + region, + service = 'bedrock-runtime', + method, + path, + headers = {}, + body = Buffer.alloc(0), + targetHost, + now = new Date(), +}) { + if (!credentials?.accessKeyId || !credentials?.secretAccessKey || !credentials?.sessionToken) { + throw new Error('AWS temporary credentials are unavailable'); + } + if (!region || !targetHost || !method || !path) { + throw new Error('AWS request signing context is incomplete'); + } + if (!(now instanceof Date) || Number.isNaN(now.getTime())) { + throw new Error('AWS request signing date is invalid'); + } + + const querySeparator = path.indexOf('?'); + const pathname = querySeparator === -1 ? path : path.slice(0, querySeparator); + const query = querySeparator === -1 ? '' : path.slice(querySeparator + 1); + const payloadHash = sha256(body); + const amzDate = formatAmzDate(now); + const dateStamp = amzDate.slice(0, 8); + const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`; + const signedHeaders = 'host;x-amz-content-sha256;x-amz-date;x-amz-security-token'; + const canonicalHeaders = + `host:${targetHost.toLowerCase()}\n` + + `x-amz-content-sha256:${payloadHash}\n` + + `x-amz-date:${amzDate}\n` + + `x-amz-security-token:${credentials.sessionToken.trim()}\n`; + const canonicalRequest = [ + method.toUpperCase(), + canonicalizePath(pathname), + canonicalizeQuery(query), + canonicalHeaders, + signedHeaders, + payloadHash, + ].join('\n'); + const stringToSign = [ + 'AWS4-HMAC-SHA256', + amzDate, + credentialScope, + sha256(canonicalRequest), + ].join('\n'); + + const dateKey = hmac(`AWS4${credentials.secretAccessKey}`, dateStamp); + const regionKey = hmac(dateKey, region); + const serviceKey = hmac(regionKey, service); + const signingKey = hmac(serviceKey, 'aws4_request'); + const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex'); + + return { + ...removeSigningHeaders(headers), + host: targetHost, + 'x-amz-content-sha256': payloadHash, + 'x-amz-date': amzDate, + 'x-amz-security-token': credentials.sessionToken, + Authorization: + `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${credentialScope}, ` + + `SignedHeaders=${signedHeaders}, Signature=${signature}`, + }; +} + +module.exports = { + canonicalizePath, + canonicalizeQuery, + signAwsRequest, +}; diff --git a/containers/api-proxy/aws-sigv4.test.js b/containers/api-proxy/aws-sigv4.test.js new file mode 100644 index 000000000..2b3e6a52a --- /dev/null +++ b/containers/api-proxy/aws-sigv4.test.js @@ -0,0 +1,81 @@ +'use strict'; + +const { + canonicalizePath, + canonicalizeQuery, + signAwsRequest, +} = require('./aws-sigv4'); + +describe('AWS SigV4 signing', () => { + const credentials = { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + sessionToken: 'session-token-example', + }; + + test('signs method, path, sorted query, body hash, host, region, and service', () => { + const headers = signAwsRequest({ + credentials, + region: 'us-east-1', + service: 'bedrock-runtime', + method: 'POST', + path: '/model/anthropic.claude-v2/invoke?z=last&a=hello%20world&a=first', + headers: { 'content-type': 'application/json' }, + body: Buffer.from('{"prompt":"Hello"}'), + targetHost: 'bedrock-runtime.us-east-1.amazonaws.com', + now: new Date('2024-01-02T03:04:05.000Z'), + }); + + expect(headers).toEqual({ + 'content-type': 'application/json', + host: 'bedrock-runtime.us-east-1.amazonaws.com', + 'x-amz-content-sha256': 'fa15bd108b18eb610f5410b1446e7c2c59e0656c6c8eb42321a9c8ad65358450', + 'x-amz-date': '20240102T030405Z', + 'x-amz-security-token': 'session-token-example', + Authorization: + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20240102/us-east-1/bedrock-runtime/aws4_request, ' + + 'SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, ' + + 'Signature=839d2e015ef6dbda647df1efb61512a3ac993e86d592d92d465980ceba0aa9a4', + }); + }); + + test('canonicalizes encoded path segments and duplicate query parameters', () => { + expect(canonicalizePath('/model/my%20model/invoke')).toBe('/model/my%20model/invoke'); + expect(canonicalizeQuery('z=last&a=hello+world&a=first&empty')).toBe( + 'a=first&a=hello%2Bworld&empty=&z=last', + ); + }); + + test('replaces stale signing headers when a request is retried', () => { + const headers = signAwsRequest({ + credentials, + region: 'us-east-1', + method: 'POST', + path: '/model/test/invoke', + headers: { + Authorization: 'stale', + Host: 'stale.example.com', + 'X-Amz-Date': '20000101T000000Z', + 'X-Amz-Security-Token': 'stale-token', + 'X-Amz-Content-Sha256': 'stale-hash', + }, + body: Buffer.from('{}'), + targetHost: 'bedrock-runtime.us-east-1.amazonaws.com', + now: new Date('2024-01-02T03:04:05.000Z'), + }); + + expect(headers.Authorization).toContain('Credential=AKIDEXAMPLE/'); + expect(headers['x-amz-security-token']).toBe('session-token-example'); + expect(Object.keys(headers).filter(name => name.toLowerCase() === 'authorization')).toHaveLength(1); + }); + + test('fails closed when temporary credentials are incomplete', () => { + expect(() => signAwsRequest({ + credentials: { accessKeyId: 'AKIDEXAMPLE', secretAccessKey: 'secret' }, + region: 'us-east-1', + method: 'GET', + path: '/', + targetHost: 'bedrock-runtime.us-east-1.amazonaws.com', + })).toThrow('AWS temporary credentials are unavailable'); + }); +}); diff --git a/containers/api-proxy/oidc-adapter-utils.js b/containers/api-proxy/oidc-adapter-utils.js index c92f23acd..8f8406094 100644 --- a/containers/api-proxy/oidc-adapter-utils.js +++ b/containers/api-proxy/oidc-adapter-utils.js @@ -49,7 +49,8 @@ function validateAuthHeaderEnv(envVarName, rawValue, defaultHeader) { * @returns {{ * isEnabled: () => boolean, * getOidcProvider: () => unknown, - * getAwsOidcProvider: () => unknown + * getAwsOidcProvider: () => unknown, + * getRequestSigner: () => (((request: object) => Record)|null) * }} */ function createOidcRuntimeAdapterMethods({ staticAuthToken, oidcProvider, awsOidcProvider }) { @@ -59,6 +60,11 @@ function createOidcRuntimeAdapterMethods({ staticAuthToken, oidcProvider, awsOid }, getOidcProvider() { return oidcProvider; }, getAwsOidcProvider() { return awsOidcProvider; }, + getRequestSigner() { + return awsOidcProvider + ? request => awsOidcProvider.signRequest(request) + : null; + }, }; } diff --git a/containers/api-proxy/providers/anthropic.js b/containers/api-proxy/providers/anthropic.js index 727adba7d..0b8706522 100644 --- a/containers/api-proxy/providers/anthropic.js +++ b/containers/api-proxy/providers/anthropic.js @@ -23,6 +23,8 @@ const { AnthropicOidcTokenProvider } = require('../anthropic-oidc-token-provider const { ANTHROPIC_ENV } = require('../provider-env-constants'); const { bearerAuthHeaders, providerKeyHeaders } = require('./auth-headers'); +const OAUTH_API_BETA = 'oauth-2025-04-20'; + let makeAnthropicTransform, loadCustomTransform, EXTENDED_CACHE_BETA; try { ({ makeAnthropicTransform, loadCustomTransform, EXTENDED_CACHE_BETA } = require('../anthropic-transforms')); @@ -36,6 +38,22 @@ try { } } +function mergeAnthropicBetas(...values) { + const merged = []; + const seen = new Set(); + for (const value of values) { + const normalized = Array.isArray(value) ? value.join(',') : value; + if (!normalized) continue; + for (const beta of normalized.split(',').map(item => item.trim()).filter(Boolean)) { + if (!seen.has(beta)) { + seen.add(beta); + merged.push(beta); + } + } + } + return merged.join(','); +} + /** * Create the Anthropic provider adapter. * @@ -106,10 +124,13 @@ function createAnthropicAdapter(env, deps = {}) { }); } : null, }, - buildOidcHeaders: (token) => bearerAuthHeaders(token), + buildOidcHeaders: (token) => bearerAuthHeaders(token, { + 'anthropic-beta': OAUTH_API_BETA, + }), buildStaticHeaders: () => providerKeyHeaders(authHeaderName, apiKey), createAdapterMethodsOptions: ({ oidcConfigured, oidcProvider, resolveHeaders }) => ({ apiKey, + credentialConfigured: !!apiKey || oidcConfigured, rawTarget, basePath, provider: 'anthropic', @@ -177,8 +198,8 @@ function createAnthropicAdapter(env, deps = {}) { }, /** * 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. + * Merges in the anthropic-version default and required anthropic-beta + * values without dropping values already set by the client. * * @param {{ resolveHeaders: () => Record, req: import('http').IncomingMessage }} params * @returns {Record} @@ -191,22 +212,20 @@ function createAnthropicAdapter(env, deps = {}) { return {}; } const mergedHeaders = { ...headers }; + const authBeta = mergedHeaders['anthropic-beta']; + delete mergedHeaders['anthropic-beta']; if (!req.headers['anthropic-version']) { mergedHeaders['anthropic-version'] = '2023-06-01'; } - if (autoCache && EXTENDED_CACHE_BETA) { - const existing = req.headers['anthropic-beta']; - if (!existing) { - mergedHeaders['anthropic-beta'] = EXTENDED_CACHE_BETA; - } else { - const normalizedExisting = Array.isArray(existing) ? existing.join(',') : existing; - const existingBetas = normalizedExisting.split(',').map(s => s.trim()).filter(Boolean); - if (!existingBetas.includes(EXTENDED_CACHE_BETA)) { - mergedHeaders['anthropic-beta'] = `${normalizedExisting},${EXTENDED_CACHE_BETA}`; - } - } + const mergedBeta = mergeAnthropicBetas( + req.headers['anthropic-beta'], + authBeta, + autoCache ? EXTENDED_CACHE_BETA : undefined + ); + if (authBeta || (autoCache && EXTENDED_CACHE_BETA)) { + mergedHeaders['anthropic-beta'] = mergedBeta; } return mergedHeaders; diff --git a/containers/api-proxy/providers/cloud-oidc-init.js b/containers/api-proxy/providers/cloud-oidc-init.js index 617dfc542..f2908d72b 100644 --- a/containers/api-proxy/providers/cloud-oidc-init.js +++ b/containers/api-proxy/providers/cloud-oidc-init.js @@ -110,7 +110,7 @@ function resolveCloudOidcProviders(env, options = {}) { * oidcProvider: any, * awsOidcProvider: any, * oidcConfigured: boolean, - * runtimeMethods: { isEnabled: () => boolean, getOidcProvider: () => any, getAwsOidcProvider: () => any }, + * runtimeMethods: { isEnabled: () => boolean, getOidcProvider: () => any, getAwsOidcProvider: () => any, getRequestSigner: () => (Function|null) }, * validationSkip: () => ({ skip: true, reason: string }|null), * skipModelsFetch: () => boolean, * resolveAuthHeaders: (buildOidcHeaders: (token: string) => Record, staticHeaders: Record) => Record, @@ -225,7 +225,7 @@ function createProviderOidcHeaderResolver({ * oidcProvider: any, * awsOidcProvider: any, * oidcConfigured: boolean, - * runtimeMethods: { isEnabled: () => boolean, getOidcProvider: () => any, getAwsOidcProvider: () => any }, + * runtimeMethods: { isEnabled: () => boolean, getOidcProvider: () => any, getAwsOidcProvider: () => any, getRequestSigner: () => (Function|null) }, * validationSkip: () => ({ skip: true, reason: string }|null), * skipModelsFetch: () => boolean, * resolveAuthHeaders: (buildOidcHeaders: (token: string) => Record, staticHeaders: Record) => Record, diff --git a/containers/api-proxy/providers/index.js b/containers/api-proxy/providers/index.js index 926be2b29..c1575868b 100644 --- a/containers/api-proxy/providers/index.js +++ b/containers/api-proxy/providers/index.js @@ -78,6 +78,7 @@ const { createVertexAdapter } = require('./vertex'); * @property {(req?: import('http').IncomingMessage) => string} getTargetHost - Upstream hostname * @property {(req?: import('http').IncomingMessage) => string} getBasePath - Base path prefix * @property {(req: import('http').IncomingMessage) => Record} getAuthHeaders - Auth headers + * @property {() => (((request: object) => Record)|null)} [getRequestSigner] - Optional final-request signer * @property {((url: string) => string) | undefined} transformRequestUrl - Optional URL transform * @property {() => ((body: Buffer) => Buffer|null)|null} getBodyTransform - Optional body transform * diff --git a/containers/api-proxy/proxy-request.js b/containers/api-proxy/proxy-request.js index 914a5bacb..4a7563629 100644 --- a/containers/api-proxy/proxy-request.js +++ b/containers/api-proxy/proxy-request.js @@ -211,8 +211,9 @@ const sendUpstreamRequest = createSendUpstreamRequest({ * @param {string} provider - Provider name for logging and metrics * @param {string} [basePath=''] - Optional base-path prefix * @param {((body: Buffer) => (Buffer | null | Promise)) | null} [bodyTransform=null] + * @param {((request: object) => Record) | null} [requestSigner=null] */ -function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = '', bodyTransform = null) { +function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = '', bodyTransform = null, requestSigner = null) { const clientRequestId = req.headers['x-request-id']; const requestId = isValidRequestId(clientRequestId) ? clientRequestId : generateRequestId(); const startTime = Date.now(); @@ -274,7 +275,7 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = if (enforceGuards({ body, provider, req, res, requestId, startTime, span, inboundBytes })) return; sendUpstreamRequest(headers, { - body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, + body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, requestSigner, }); }); } diff --git a/containers/api-proxy/server-factory.js b/containers/api-proxy/server-factory.js index 15f3f6606..028910a13 100644 --- a/containers/api-proxy/server-factory.js +++ b/containers/api-proxy/server-factory.js @@ -33,7 +33,8 @@ function createProxyHandler(adapter, checkRateLimit, proxyRequest) { adapter.getAuthHeaders(req), adapter.name, adapter.getBasePath(req), - adapter.getBodyTransform() + adapter.getBodyTransform(), + adapter.getRequestSigner ? adapter.getRequestSigner() : null ); }; } @@ -87,6 +88,14 @@ function createWebSocketUpgradeHandler(adapter, proxyWebSocket) { return; } + // Bedrock SigV4 is implemented for buffered HTTP requests. Never allow an + // unsigned WebSocket upgrade to escape through an AWS-authenticated adapter. + if (adapter.getRequestSigner?.()) { + socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + if (adapter.transformRequestUrl) { req.url = adapter.transformRequestUrl(req.url); } diff --git a/containers/api-proxy/server-factory.test.js b/containers/api-proxy/server-factory.test.js index 5e20e589c..a903196c4 100644 --- a/containers/api-proxy/server-factory.test.js +++ b/containers/api-proxy/server-factory.test.js @@ -16,6 +16,71 @@ function makeTrackedSocket() { } describe('createProviderServer', () => { + test('passes the adapter request signer to the HTTP proxy pipeline', () => { + const requestSigner = jest.fn(); + const proxyRequest = jest.fn(); + const adapter = { + name: 'openai', + isManagementPort: false, + isEnabled: () => true, + getTargetHost: () => 'bedrock-runtime.us-east-1.amazonaws.com', + getAuthHeaders: () => ({}), + getBasePath: () => '', + getBodyTransform: () => null, + getRequestSigner: () => requestSigner, + }; + const server = createProviderServer(adapter, { + handleManagementEndpoint: () => false, + reflectEndpoints: () => [], + checkRateLimit: () => false, + proxyRequest, + proxyWebSocket: jest.fn(), + }); + const req = new EventEmitter(); + req.url = '/model/test/invoke'; + req.method = 'POST'; + req.headers = {}; + const res = {}; + + server.emit('request', req, res); + + expect(proxyRequest).toHaveBeenCalledWith( + req, + res, + 'bedrock-runtime.us-east-1.amazonaws.com', + {}, + 'openai', + '', + null, + requestSigner, + ); + }); + + test('fails closed for WebSocket upgrades when AWS request signing is configured', () => { + const clientSocket = makeTrackedSocket(); + const proxyWebSocket = jest.fn(); + const server = createProviderServer({ + name: 'copilot', + isEnabled: () => true, + getTargetHost: () => 'bedrock-runtime.us-east-1.amazonaws.com', + getAuthHeaders: () => ({}), + getBasePath: () => '', + getRequestSigner: () => jest.fn(), + }, { + handleManagementEndpoint: () => false, + reflectEndpoints: () => [], + checkRateLimit: () => false, + proxyRequest: jest.fn(), + proxyWebSocket, + }); + + server.emit('upgrade', { url: '/', headers: {} }, clientSocket, Buffer.alloc(0)); + + expect(proxyWebSocket).not.toHaveBeenCalled(); + expect(clientSocket.write).toHaveBeenCalledWith(expect.stringContaining('503 Service Unavailable')); + expect(clientSocket.destroy).toHaveBeenCalled(); + }); + test('shutdownConnections closes tracked upgraded sockets', async () => { const clientSocket = makeTrackedSocket(); const upstreamSocket = makeTrackedSocket(); diff --git a/containers/api-proxy/server.auth-matrix.test.js b/containers/api-proxy/server.auth-matrix.test.js index 0a20c12a5..358d78d8c 100644 --- a/containers/api-proxy/server.auth-matrix.test.js +++ b/containers/api-proxy/server.auth-matrix.test.js @@ -444,12 +444,30 @@ describe('Auth Matrix — Copilot', () => { ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'runtime-token', AWF_AUTH_AWS_ROLE_ARN: 'arn:aws:iam::123456:role/test', AWF_AUTH_AWS_REGION: 'us-east-1', + COPILOT_API_TARGET: 'bedrock-runtime.us-east-1.amazonaws.com', COPILOT_PROVIDER_BASE_URL: 'https://bedrock-runtime.us-east-1.amazonaws.com', }); const awsProvider = adapter.getAwsOidcProvider(); expect(awsProvider).toBeTruthy(); - // AWS uses SigV4 — no static auth header returned + expect(adapter.getRequestSigner()).toEqual(expect.any(Function)); + // AWS uses SigV4 at final dispatch, so no credential is exposed here. expect(adapter.getAuthHeaders(fakeReq())).toEqual({}); + awsProvider._cachedCredentials = { + accessKeyId: 'COPILOTKEY', + secretAccessKey: 'secret', + sessionToken: 'copilot-session', + }; + awsProvider._expiresAt = Math.floor(Date.now() / 1000) + 3600; + const signed = adapter.getRequestSigner()({ + method: 'POST', + path: '/model/test/invoke', + headers: {}, + body: Buffer.from('{}'), + targetHost: adapter.getTargetHost(), + now: new Date('2024-01-02T03:04:05.000Z'), + }); + expect(signed.Authorization).toContain('Credential=COPILOTKEY/'); + expect(signed['x-amz-security-token']).toBe('copilot-session'); awsProvider.shutdown(); }); }); diff --git a/containers/api-proxy/server.custom-auth-header.test.js b/containers/api-proxy/server.custom-auth-header.test.js index 2f8a7de79..b4be1ccda 100644 --- a/containers/api-proxy/server.custom-auth-header.test.js +++ b/containers/api-proxy/server.custom-auth-header.test.js @@ -77,6 +77,7 @@ describe('createAnthropicAdapter — custom auth header', () => { const headers = adapter.getAuthHeaders(fakeReq); expect(headers).toEqual({ Authorization: 'Bearer oidc-token', + 'anthropic-beta': 'oauth-2025-04-20', 'anthropic-version': '2023-06-01', }); expect(headers['api-key']).toBeUndefined(); diff --git a/containers/api-proxy/upstream-http.js b/containers/api-proxy/upstream-http.js index 9a2ec2a4b..46cd04397 100644 --- a/containers/api-proxy/upstream-http.js +++ b/containers/api-proxy/upstream-http.js @@ -8,6 +8,18 @@ const { parseBodyAsObject } = require('./body-utils'); */ const MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS = [1000, 2000]; +function rebuildBodyFramingHeaders(headers, bodyLength) { + const reframedHeaders = {}; + for (const [name, value] of Object.entries(headers)) { + const lowerName = name.toLowerCase(); + if (lowerName !== 'content-length' && lowerName !== 'transfer-encoding') { + reframedHeaders[name] = value; + } + } + reframedHeaders['content-length'] = String(bodyLength); + return reframedHeaders; +} + /** * Create and dispatch the upstream HTTPS request. * Sets up the proxyReq error handler, writes the body, and delegates response @@ -27,22 +39,47 @@ function createSendUpstreamRequest({ }) { return function sendUpstreamRequest(requestHeaders, { body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, + requestSigner = null, hasRetried = false, modelNotSupportedRetryCount = 0, }) { + let outboundHeaders = requestHeaders; + if (requestSigner) { + try { + outboundHeaders = requestSigner({ + method: req.method, + path: upstreamPath, + headers: requestHeaders, + body, + targetHost, + }); + } catch (err) { + otel.endSpanError(span, err, 503); + handleRequestError(err, { + res, requestId, provider, req, targetHost, startTime, + statusCode: 503, + clientMessage: 'AWS request signing unavailable', + extraMetrics: () => { + metrics.increment('requests_total', { provider, method: req.method, status_class: '5xx' }); + }, + }); + return; + } + } + const options = { hostname: targetHost, port: 443, path: upstreamPath, - method: req.method, headers: requestHeaders, + method: req.method, headers: outboundHeaders, agent: proxyAgent, }; const proxyReq = https.request(options, (proxyRes) => { - handleUpstreamResponse(proxyRes, requestHeaders, { + handleUpstreamResponse(proxyRes, outboundHeaders, { body, res, provider, requestId, req, targetHost, startTime, span, requestBytes, hasRetried, modelNotSupportedRetryCount, onRetry: (retryHeaders) => sendUpstreamRequest(retryHeaders, { - body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, + body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, requestSigner, hasRetried: true, modelNotSupportedRetryCount, }), @@ -50,7 +87,7 @@ function createSendUpstreamRequest({ const delayMs = MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS[modelNotSupportedRetryCount] ?? 2000; sleep(delayMs).then(() => { sendUpstreamRequest(requestHeaders, { - body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, + body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, requestSigner, hasRetried, modelNotSupportedRetryCount: modelNotSupportedRetryCount + 1, }); @@ -78,11 +115,13 @@ function createSendUpstreamRequest({ if (!newParsed) return false; newParsed.model = nextModel; const newBody = Buffer.from(JSON.stringify(newParsed), 'utf8'); + const retryHeaders = rebuildBodyFramingHeaders(requestHeaders, newBody.length); // Update the candidates list so if the next model also fails we can // continue falling back (by shifting the current index forward). - sendUpstreamRequest(requestHeaders, { - body: newBody, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, + sendUpstreamRequest(retryHeaders, { + body: newBody, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, + requestBytes: newBody.length, requestSigner, hasRetried, modelNotSupportedRetryCount, }); @@ -110,5 +149,6 @@ function createSendUpstreamRequest({ module.exports = { MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS, + rebuildBodyFramingHeaders, createSendUpstreamRequest, }; diff --git a/containers/api-proxy/upstream-http.test.js b/containers/api-proxy/upstream-http.test.js index 0de39046d..65c885641 100644 --- a/containers/api-proxy/upstream-http.test.js +++ b/containers/api-proxy/upstream-http.test.js @@ -1,6 +1,21 @@ -const { createSendUpstreamRequest, MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS } = require('./upstream-http'); +const { + createSendUpstreamRequest, + MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS, + rebuildBodyFramingHeaders, +} = require('./upstream-http'); describe('upstream-http', () => { + test('rebuilds body framing headers case-insensitively', () => { + expect(rebuildBodyFramingHeaders({ + 'Content-Length': '10', + 'Transfer-Encoding': 'chunked', + authorization: 'signed', + }, 42)).toEqual({ + authorization: 'signed', + 'content-length': '42', + }); + }); + function createContext(overrides = {}) { return { body: Buffer.from('{"ok":true}'), @@ -80,4 +95,119 @@ describe('upstream-http', () => { expect(sleep).toHaveBeenCalledWith(MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS[0]); expect(httpsRequest).toHaveBeenCalledTimes(2); }); + + test('signs every upstream attempt with the final body', async () => { + const proxyReq = { on: jest.fn(), write: jest.fn(), end: jest.fn() }; + const responseCallbacks = []; + const httpsRequest = jest.fn((_options, cb) => { + responseCallbacks.push(cb); + return proxyReq; + }); + const handleUpstreamResponse = jest.fn(); + const requestSigner = jest.fn(({ headers, body }) => ({ + ...headers, + authorization: `signed-${body.toString('utf8')}`, + })); + const sendUpstreamRequest = createSendUpstreamRequest({ + https: { request: httpsRequest }, + proxyAgent: {}, + handleUpstreamResponse, + sleep: jest.fn(() => Promise.resolve()), + otel: { endSpanError: jest.fn() }, + handleRequestError: jest.fn(), + metrics: { increment: jest.fn(), observe: jest.fn() }, + }); + + sendUpstreamRequest({}, createContext({ requestSigner })); + responseCallbacks[0]({ statusCode: 400, headers: {} }); + handleUpstreamResponse.mock.calls[0][2].onModelNotSupportedRetry(); + await Promise.resolve(); + + expect(requestSigner).toHaveBeenCalledTimes(2); + expect(httpsRequest.mock.calls[0][0].headers.authorization).toBe('signed-{"ok":true}'); + expect(httpsRequest.mock.calls[1][0].headers.authorization).toBe('signed-{"ok":true}'); + }); + + test('fails closed without opening an upstream request when signing fails', () => { + const httpsRequest = jest.fn(); + const handleRequestError = jest.fn(); + const endSpanError = jest.fn(); + const sendUpstreamRequest = createSendUpstreamRequest({ + https: { request: httpsRequest }, + proxyAgent: {}, + handleUpstreamResponse: jest.fn(), + sleep: jest.fn(), + otel: { endSpanError }, + handleRequestError, + metrics: { increment: jest.fn(), observe: jest.fn() }, + }); + const error = new Error('AWS temporary credentials are unavailable'); + + sendUpstreamRequest({}, createContext({ + requestSigner: () => { throw error; }, + })); + + expect(httpsRequest).not.toHaveBeenCalled(); + expect(endSpanError).toHaveBeenCalledWith(expect.anything(), error, 503); + expect(handleRequestError).toHaveBeenCalledWith(error, expect.objectContaining({ + statusCode: 503, + clientMessage: 'AWS request signing unavailable', + })); + }); + + test('reframes and re-signs endpoint-blocked fallback bodies', () => { + const proxyReq = { on: jest.fn(), write: jest.fn(), end: jest.fn() }; + const responseCallbacks = []; + const httpsRequest = jest.fn((_options, cb) => { + responseCallbacks.push(cb); + return proxyReq; + }); + const handleUpstreamResponse = jest.fn(); + const requestSigner = jest.fn(({ headers }) => ({ + ...headers, + authorization: 'fresh-signature', + })); + const sendUpstreamRequest = createSendUpstreamRequest({ + https: { request: httpsRequest }, + proxyAgent: {}, + handleUpstreamResponse, + sleep: jest.fn(), + otel: { endSpanError: jest.fn() }, + handleRequestError: jest.fn(), + metrics: { increment: jest.fn(), observe: jest.fn() }, + }); + const originalBody = Buffer.from('{"model":"a","messages":[]}'); + const req = { + method: 'POST', + awfModelCandidates: ['a', 'much-longer-model-name'], + }; + + sendUpstreamRequest({ + 'content-length': String(originalBody.length), + 'transfer-encoding': 'chunked', + }, createContext({ + body: originalBody, + requestBytes: originalBody.length, + req, + requestSigner, + })); + responseCallbacks[0]({ statusCode: 400, headers: {} }); + const retried = handleUpstreamResponse.mock.calls[0][2].onModelEndpointBlockedRetry(); + + const retryBody = Buffer.from('{"model":"much-longer-model-name","messages":[]}'); + expect(retried).toBe(true); + expect(httpsRequest).toHaveBeenCalledTimes(2); + responseCallbacks[1]({ statusCode: 200, headers: {} }); + expect(httpsRequest.mock.calls[1][0].headers).toEqual(expect.objectContaining({ + 'content-length': String(retryBody.length), + authorization: 'fresh-signature', + })); + expect(httpsRequest.mock.calls[1][0].headers).not.toHaveProperty('transfer-encoding'); + expect(proxyReq.write).toHaveBeenLastCalledWith(retryBody); + expect(handleUpstreamResponse.mock.calls[1][2].requestBytes).toBe(retryBody.length); + expect(requestSigner).toHaveBeenLastCalledWith(expect.objectContaining({ + body: retryBody, + headers: expect.objectContaining({ 'content-length': String(retryBody.length) }), + })); + }); }); diff --git a/docs/api-proxy-sidecar.md b/docs/api-proxy-sidecar.md index a4625d304..37cb342e5 100644 --- a/docs/api-proxy-sidecar.md +++ b/docs/api-proxy-sidecar.md @@ -3,7 +3,7 @@ title: API Proxy Sidecar description: Secure LLM API credential management using an isolated proxy sidecar container. --- -The AWF firewall supports an optional Node.js-based API proxy sidecar that securely holds LLM API credentials and automatically injects authentication headers while routing all traffic through Squid to respect domain whitelisting. +The AWF firewall includes a Node.js-based API proxy sidecar that securely holds LLM API credentials, automatically injects authentication headers, and routes outbound HTTP/HTTPS through Squid. The sidecar is a trusted component and is explicitly exempt from Squid's domain allowlist. :::note For a deep dive into how AWF handles authentication tokens and credential isolation, see the [Authentication Architecture](./authentication-architecture.md) guide. @@ -11,12 +11,16 @@ For a deep dive into how AWF handles authentication tokens and credential isolat ## Overview -When enabled, the API proxy sidecar: +The API proxy sidecar is **always enabled**. It: - **Isolates credentials**: API keys are never exposed to the agent container - **Auto-authentication**: Automatically injects Bearer tokens and API keys -- **Multi-provider support**: Supports OpenAI, Anthropic, Copilot, and Gemini APIs +- **Multi-provider support**: Supports OpenAI, Anthropic, Copilot, Gemini, and Google Vertex AI - **Transparent proxying**: Agent code uses standard SDK environment variables -- **Squid routing**: All traffic routes through Squid to respect domain whitelisting +- **Squid routing**: Outbound HTTP/HTTPS routes through Squid, with the trusted sidecar exempt from domain ACLs + +:::note[Implementation vs. provider documentation] +The `--enable-api-proxy` CLI flag is deprecated and ignored — it is kept only so existing command lines and workflows continue to work. `--no-enable-api-proxy` is rejected as a runtime error; the API proxy cannot be disabled. Do not add the deprecated flag to new commands. +::: ## Architecture @@ -39,7 +43,7 @@ When enabled, the API proxy sidecar: │ │ └──────────────────────────────┘ │ │ └─────────┼─────────────────────────────────────┘ - │ (Domain whitelist enforced) + │ (Trusted sidecar: domain ACL bypass) ↓ api.openai.com or api.anthropic.com ``` @@ -48,7 +52,7 @@ When enabled, the API proxy sidecar: 1. Agent makes a request to `172.30.0.30:10000` (OpenAI) or `172.30.0.30:10001` (Anthropic) 2. API proxy strips any client-supplied auth headers and injects the real credentials 3. API proxy routes the request through Squid via `HTTP_PROXY`/`HTTPS_PROXY` -4. Squid enforces the domain whitelist (only allowed domains pass) +4. Squid recognizes the trusted sidecar source IP and bypasses domain ACL evaluation 5. Request reaches `api.openai.com` or `api.anthropic.com` ## Usage @@ -60,8 +64,8 @@ When enabled, the API proxy sidecar: export OPENAI_API_KEY="sk-..." export ANTHROPIC_API_KEY="sk-ant-..." -# Enable API proxy sidecar -sudo awf --enable-api-proxy \ +# The API proxy sidecar is always active; no flag is needed to enable it +sudo awf \ --allow-domains api.openai.com,api.anthropic.com \ -- your-command ``` @@ -71,7 +75,7 @@ sudo awf --enable-api-proxy \ ```bash export OPENAI_API_KEY="sk-..." -sudo awf --enable-api-proxy \ +sudo awf \ --allow-domains api.openai.com \ -- npx @openai/codex -p "write a hello world function" ``` @@ -83,7 +87,7 @@ The agent container automatically uses `http://172.30.0.30:10000` as the OpenAI ```bash export ANTHROPIC_API_KEY="sk-ant-..." -sudo awf --enable-api-proxy \ +sudo awf \ --allow-domains api.anthropic.com \ -- claude-code "write a hello world function" ``` @@ -96,7 +100,7 @@ The agent container automatically uses `http://172.30.0.30:10001` as the Anthrop export OPENAI_API_KEY="sk-..." export ANTHROPIC_API_KEY="sk-ant-..." -sudo awf --enable-api-proxy \ +sudo awf \ --allow-domains api.openai.com,api.anthropic.com \ -- your-multi-llm-tool ``` @@ -120,15 +124,15 @@ The API proxy sidecar receives **real credentials** and routing configuration: | Variable | Value | When set | Description | |----------|-------|----------|-------------| -| `OPENAI_API_KEY` | Real API key | `--enable-api-proxy` and env set | OpenAI API key (injected into requests) | -| `ANTHROPIC_API_KEY` | Real API key | `--enable-api-proxy` and env set | Anthropic API key (injected into requests) | -| `COPILOT_GITHUB_TOKEN` | Real token | `--enable-api-proxy` and env set | GitHub Copilot token — sidecar uses it to talk to `api.githubcopilot.com` (CAPI BYOK / offline mode). Triggers Copilot sidecar routing. | -| `COPILOT_PROVIDER_API_KEY` | Real API key | `--enable-api-proxy` and env set | BYOK provider API key (e.g. Azure / OpenRouter) injected into upstream requests. **Independently** triggers Copilot sidecar routing (no `COPILOT_GITHUB_TOKEN` required); typically combined with `COPILOT_PROVIDER_BASE_URL` to point at an arbitrary upstream. | -| `COPILOT_PROVIDER_BASE_URL` | Real upstream URL | `--enable-api-proxy` and env set | User-supplied upstream URL for direct-BYOK mode; sidecar forwards Copilot CLI requests there instead of `api.githubcopilot.com`. | -| `GEMINI_API_KEY` | Real API key | `--enable-api-proxy` and env set | Google Gemini API key (injected into requests) | -| `GOOGLE_API_KEY` | Real API key | `--enable-api-proxy` and env set | Google Vertex AI API key (injected into `x-goog-api-key` header) | -| `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid for domain filtering | -| `HTTPS_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid for domain filtering | +| `OPENAI_API_KEY` | Real API key | env set on host | OpenAI API key (injected into requests) | +| `ANTHROPIC_API_KEY` | Real API key | env set on host | Anthropic API key (injected into requests) | +| `COPILOT_GITHUB_TOKEN` | Real token | env set on host | GitHub Copilot token — sidecar uses it to talk to `api.githubcopilot.com` (CAPI BYOK / offline mode). Triggers Copilot sidecar routing. | +| `COPILOT_PROVIDER_API_KEY` | Real API key | env set on host | BYOK provider API key (e.g. Azure / OpenRouter) injected into upstream requests. **Independently** triggers Copilot sidecar routing (no `COPILOT_GITHUB_TOKEN` required); typically combined with `COPILOT_PROVIDER_BASE_URL` to point at an arbitrary upstream. | +| `COPILOT_PROVIDER_BASE_URL` | Real upstream URL | env set on host | User-supplied upstream URL for direct-BYOK mode; sidecar forwards Copilot CLI requests there instead of `api.githubcopilot.com`. | +| `GEMINI_API_KEY` | Real API key | env set on host | Google Gemini API key (injected into requests) | +| `GOOGLE_API_KEY` | Real API key | env set on host | Google Vertex AI API key (injected into `x-goog-api-key` header) | +| `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid; sidecar traffic is exempt from domain ACLs | +| `HTTPS_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid; sidecar traffic is exempt from domain ACLs | :::danger[Real credentials in api-proxy] The api-proxy container holds **real, unredacted credentials**. These are used to authenticate requests to LLM providers. This container is isolated from the agent and has all capabilities dropped for security. @@ -142,7 +146,7 @@ The agent container receives **redacted placeholders** and proxy URLs: |----------|-------|----------|-------------| | `OPENAI_BASE_URL` | `http://172.30.0.30:10000` | `OPENAI_API_KEY` provided to host | Redirects OpenAI SDK to proxy | | `ANTHROPIC_BASE_URL` | `http://172.30.0.30:10001` | `ANTHROPIC_API_KEY` provided to host | Redirects Anthropic SDK to proxy | -| `ANTHROPIC_AUTH_TOKEN` | `placeholder-token-for-credential-isolation` | `ANTHROPIC_API_KEY` provided to host | Placeholder token (real auth via BASE_URL) | +| `ANTHROPIC_AUTH_TOKEN` | `sk-ant-placeholder-key-for-credential-isolation` | `ANTHROPIC_API_KEY` provided to host, or Anthropic WIF configured | Non-secret placeholder accepted by Claude clients (real auth via `ANTHROPIC_BASE_URL`) | | `CLAUDE_CODE_API_KEY_HELPER` | `/usr/local/bin/get-claude-key.sh` | `ANTHROPIC_API_KEY` provided to host | Helper script for Claude Code CLI | | `COPILOT_API_URL` | `http://172.30.0.30:10002` | `COPILOT_GITHUB_TOKEN` or `COPILOT_PROVIDER_API_KEY` provided to host | Redirects Copilot CLI to sidecar | | `COPILOT_TOKEN` | `ghu_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa` | `COPILOT_GITHUB_TOKEN` or `COPILOT_PROVIDER_API_KEY` provided to host | Placeholder token (real auth via API_URL) | @@ -155,12 +159,13 @@ The agent container receives **redacted placeholders** and proxy URLs: | `GEMINI_API_KEY` | `gemini-api-key-placeholder-for-credential-isolation` | `GEMINI_API_KEY` provided to host | Placeholder so Gemini CLI auth check passes (real key in sidecar) | | `GOOGLE_VERTEX_BASE_URL` | `http://172.30.0.30:10004` | `GOOGLE_API_KEY` provided to host | Redirects Vertex AI requests to proxy | | `GOOGLE_API_KEY` | `google-api-key-placeholder-for-credential-isolation` | `GOOGLE_API_KEY` provided to host | Placeholder so Vertex mode auth checks pass (real key in sidecar) | -| `OPENAI_API_KEY` | Not set | `--enable-api-proxy` | Excluded from agent (held in api-proxy) | -| `ANTHROPIC_API_KEY` | Not set | `--enable-api-proxy` | Excluded from agent (held in api-proxy) | +| `OPENAI_API_KEY` | `sk-placeholder-for-api-proxy` | `OPENAI_API_KEY` provided to host | Non-secret placeholder required by newer Codex clients; the real host value is excluded and held in the sidecar | +| `CODEX_API_KEY` | `sk-placeholder-for-api-proxy` | `OPENAI_API_KEY` provided to host | Non-secret placeholder for Codex routing; the sidecar replaces its auth header | +| `ANTHROPIC_API_KEY` | Not set | Always | Excluded from agent (held in api-proxy) | | `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid proxy | | `HTTPS_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid proxy | -| `NO_PROXY` | `localhost,127.0.0.1,172.30.0.30` | `--enable-api-proxy` | Bypass proxy for localhost and api-proxy | -| `AWF_API_PROXY_IP` | `172.30.0.30` | `--enable-api-proxy` | Used by iptables setup script | +| `NO_PROXY` | `localhost,127.0.0.1,172.30.0.30` | Always | Bypass proxy for localhost and api-proxy | +| `AWF_API_PROXY_IP` | `172.30.0.30` | Always | Used by iptables setup script | | `AWF_ONE_SHOT_TOKENS` | `COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,...` | Always | Tokens protected by one-shot-token library | :::note[Gemini setup is conditional] @@ -179,6 +184,10 @@ Token variables in the agent are set to placeholder values (for Copilot, `ghu_aa - The one-shot-token library protects placeholder values from being read more than once ::: +:::note[Implementation vs. provider documentation] +`ANTHROPIC_AUTH_TOKEN` is an official Anthropic SDK environment variable for bearer-token authentication. AWF does not currently consume it as a host-side source credential. AWF accepts `ANTHROPIC_API_KEY` for static auth, or Anthropic WIF configuration for short-lived bearer auth, and overwrites any host `ANTHROPIC_AUTH_TOKEN` with the non-secret agent placeholder shown above. +::: + These environment variables are recognized by: - OpenAI Python SDK (`openai`) - OpenAI Node.js SDK (`openai`) @@ -207,12 +216,17 @@ API keys are stored in the sidecar container's environment and in the Docker Com ### Network isolation -The proxy enforces domain-level egress control: +AWF separates agent egress control from trusted sidecar routing: - The agent can only reach the API proxy IP (`172.30.0.30`) for API calls - The sidecar routes all traffic through Squid proxy -- Squid enforces the domain whitelist (L7 filtering) +- Squid enforces domain ACLs for agent-originated traffic +- Squid explicitly allows all traffic from the trusted api-proxy source IP before domain ACL evaluation - iptables rules prevent the agent from bypassing the proxy +:::danger[The sidecar is allowlist-exempt] +The api-proxy holds live credentials and has unrestricted outbound HTTP/HTTPS access through Squid. It is part of AWF's trusted computing base. The agent domain allowlist does not contain a compromised sidecar. +::: + :::note[Squid allow rule for api-proxy IP] Squid includes an explicit `allow_api_proxy_ip` ACL that permits traffic to the api-proxy IP **before** the raw-IP deny rules. This is required because some HTTP clients (such as Node.js `fetch`/`undici` with a `ProxyAgent`) route requests to the api-proxy through `HTTP_PROXY` without honouring `NO_PROXY` for raw IP addresses. Without this rule, those requests would be rejected by Squid's raw-IP deny rules even though `NO_PROXY=172.30.0.30` is set in the agent container. ::: @@ -229,7 +243,7 @@ The sidecar has strict resource constraints: ### 1. Container startup -When you pass `--enable-api-proxy`: +The API proxy sidecar is always started: 1. AWF starts a Node.js API proxy at `172.30.0.30` 2. API keys are passed to the sidecar via environment variables 3. `HTTP_PROXY`/`HTTPS_PROXY` in the sidecar are configured to route through Squid @@ -245,7 +259,7 @@ Node.js API Proxy ↓ (injects Authorization: Bearer $OPENAI_API_KEY) ↓ (routes via HTTPS_PROXY to Squid) Squid Proxy - ↓ (enforces domain whitelist) + ↓ (trusted sidecar bypasses domain ACLs) ↓ (TLS connection to api.openai.com) OpenAI API ``` @@ -274,20 +288,22 @@ The proxy enforces a 10 MB request body size limit to prevent denial-of-service ### 4. Pre-flight health check Before running the user command, the agent container runs a health check script (`api-proxy-health-check.sh`) that verifies: -- API keys are **not** present in the agent environment (credential isolation working) +- Real API keys are **not** present in the agent environment; expected placeholders are allowed - The API proxy is reachable and responding (connectivity established) -If either check fails, the agent exits immediately without running the user command. +The script currently checks configured Anthropic, OpenAI, and Copilot routes. It does not pre-flight the Gemini or Vertex listeners. If a checked route fails credential-isolation or TCP-connectivity validation, the agent exits without running the user command. ## Configuration reference ### CLI options ```bash -sudo awf --enable-api-proxy [OPTIONS] -- COMMAND +sudo awf [OPTIONS] -- COMMAND ``` -**Required environment variables** (at least one): +`--enable-api-proxy` is accepted but deprecated (no-op) — see the note at the top of this document. + +**Provider credential environment variables** (configure at least one to use an LLM provider): - `OPENAI_API_KEY` — OpenAI API key - `ANTHROPIC_API_KEY` — Anthropic API key - `GEMINI_API_KEY` — Google Gemini API key @@ -302,7 +318,7 @@ When running AWF in a GitHub Actions workflow, API keys must be available as **r - name: Run agent env: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - run: sudo --preserve-env=GEMINI_API_KEY awf --enable-api-proxy ... + run: sudo --preserve-env=GEMINI_API_KEY awf ... ``` > **Note:** `sudo` strips most environment variables by default. Use `--preserve-env=VAR` (or `sudo -E` to preserve all) to ensure API keys are visible to the AWF process. @@ -310,10 +326,12 @@ When running AWF in a GitHub Actions workflow, API keys must be available as **r If the key is present only in `secrets.*` but not exported into the step's `env:`, AWF will warn that no Gemini key was found and the api-proxy Gemini listener will return `503`. ::: -**Recommended domain whitelist**: +**Recommended agent domain policy**: - `api.openai.com` — for OpenAI/Codex - `api.anthropic.com` — for Anthropic/Claude +These entries document and constrain agent-originated traffic. They do not constrain the trusted api-proxy, whose source IP bypasses Squid's domain ACLs. + **Optional flags for custom upstream endpoints**: | Flag | Default | Description | @@ -321,8 +339,12 @@ If the key is present only in `secrets.*` but not exported into the step's `env: | `--openai-api-target ` | `api.openai.com` | Custom upstream for OpenAI API requests (e.g. Azure OpenAI or an internal LLM router). Can also be set via `OPENAI_API_TARGET` env var (or `OPENAI_ENDPOINT_OVERRIDE` for runtime secret-backed endpoint injection). | | `--anthropic-api-target ` | `api.anthropic.com` | Custom upstream for Anthropic API requests (e.g. an internal Claude router). Can also be set via `ANTHROPIC_API_TARGET` env var. | | `--copilot-api-target ` | auto-derived | Custom upstream for GitHub Copilot API requests (useful for GHES). Can also be set via `COPILOT_API_TARGET` env var. | +| `--gemini-api-target ` | `generativelanguage.googleapis.com` | Custom upstream for Gemini API requests. Can also be set via `GEMINI_API_TARGET` env var. | +| `--gemini-api-base-path ` | empty | Base path prefix for Gemini API requests. Can also be set via `GEMINI_API_BASE_PATH` env var. | | `--vertex-api-target ` | `aiplatform.googleapis.com` | Custom upstream for Vertex API requests. Can also be set via `VERTEX_API_TARGET` env var. | | `--vertex-api-base-path ` | empty | Base path prefix for Vertex API requests. Can also be set via `VERTEX_API_BASE_PATH` env var. | +| `--openai-api-auth-header ` | `Authorization` (with `Bearer` prefix) | Custom auth header name for OpenAI requests — sends the raw key/token value with no prefix. Can also be set via `AWF_OPENAI_AUTH_HEADER` env var. | +| `--anthropic-api-auth-header ` | `x-api-key` | Custom auth header name for Anthropic requests — sends the raw key/token value with no prefix. Can also be set via `AWF_ANTHROPIC_AUTH_HEADER` env var. | > **Important**: When using a custom `--openai-api-target` or `--anthropic-api-target`, you must add the target domain to `--allow-domains` so the firewall permits outbound traffic. AWF will emit a warning if a custom target is set but not in the allowlist. @@ -337,7 +359,7 @@ Use `--anthropic-auto-cache` to enable automatic Anthropic prompt-caching in the This typically saves ~90% on Anthropic API input costs for repeated or long-running agentic sessions. ```bash -sudo awf --enable-api-proxy \ +sudo awf \ --anthropic-auto-cache \ --allow-domains api.anthropic.com \ -- claude --dangerously-skip-permissions @@ -352,7 +374,7 @@ Use `--anthropic-cache-tail-ttl` to control the TTL for the rolling-tail cache b ```bash # Long-running agentic task — use 1h TTL for maximum cache reuse -sudo awf --enable-api-proxy \ +sudo awf \ --anthropic-auto-cache \ --anthropic-cache-tail-ttl 1h \ --allow-domains api.anthropic.com \ @@ -602,7 +624,7 @@ and are normalized to dollars per million tokens inside the proxy. ### Gemini proxy returns 503 -When `--enable-api-proxy` is active **and `GEMINI_API_KEY` is provided to the AWF runner**, `GOOGLE_GEMINI_BASE_URL`, `GEMINI_API_BASE_URL`, and a placeholder `GEMINI_API_KEY` are injected into the agent container. If the real `GEMINI_API_KEY` was not set in the AWF runner environment, the Gemini routing vars are never set and the api-proxy Gemini listener (port 10003) responds with **503** to any requests that do reach it. +When `GEMINI_API_KEY` is provided to the AWF runner, `GOOGLE_GEMINI_BASE_URL`, `GEMINI_API_BASE_URL`, and a placeholder `GEMINI_API_KEY` are injected into the agent container. If the real `GEMINI_API_KEY` was not set in the AWF runner environment, the Gemini routing vars are never set and the api-proxy Gemini listener (port 10003) responds with **503** to any requests that do reach it. **Solution**: Export `GEMINI_API_KEY` in the runner environment before invoking AWF. In GitHub Actions, add it to the step's `env:` block and use `sudo --preserve-env`: @@ -612,12 +634,12 @@ When `--enable-api-proxy` is active **and `GEMINI_API_KEY` is provided to the AW GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} run: | sudo --preserve-env=GEMINI_API_KEY \ - awf --enable-api-proxy \ + awf \ --allow-domains generativelanguage.googleapis.com \ -- gemini ... ``` -> **Note:** Exit code 41 ("no auth method") should no longer occur with `--enable-api-proxy` since the placeholder key satisfies the CLI's pre-flight check. If you see exit 41, ensure `--enable-api-proxy` is active. +> **Note:** Exit code 41 ("no auth method") should no longer occur since the placeholder key satisfies the CLI's pre-flight check. If you see exit 41, verify `GEMINI_API_KEY` is exported in the AWF runner environment. ### Gemini requests blocked by Squid (connection refused / raw-IP denied) @@ -629,7 +651,7 @@ Some versions of the Gemini CLI use the Node.js `undici` HTTP client, which rout ``` ⚠️ API proxy enabled but no API keys found in environment - Set OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_PROVIDER_API_KEY, or GEMINI_API_KEY to use the proxy + Set OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_PROVIDER_API_KEY, GEMINI_API_KEY, or GOOGLE_API_KEY to use the proxy ``` **Solution**: Export API keys before running awf (use `sudo --preserve-env` in CI): @@ -659,7 +681,7 @@ docker logs awf-api-proxy Ensure the API domains are whitelisted: ```bash -sudo awf --enable-api-proxy \ +sudo awf \ --allow-domains api.openai.com,api.anthropic.com \ -- your-command ``` @@ -684,6 +706,12 @@ AWF supports OIDC-based credential exchange with multiple cloud providers via Gi | `ACTIONS_ID_TOKEN_REQUEST_URL` | ✅ | Provided automatically by the GitHub Actions runtime | | `ACTIONS_ID_TOKEN_REQUEST_TOKEN` | ✅ | Provided automatically by the GitHub Actions runtime | +:::note[OIDC request capability is sidecar-only] +AWF forwards `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` only to the api-proxy sidecar when `AWF_AUTH_TYPE=github-oidc`. The variables are excluded from the agent even when `--env-all`, `--env-file`, or explicit `--env` options request them. The minted GitHub JWT and exchanged provider credentials also remain inside the sidecar. + +GitHub Agentic Workflows handles HTTP MCP `auth.type: github-oidc` separately: the compiler-generated, runner-owned **Start MCP Gateway** step passes the Actions variables directly to the MCP gateway, which mints an audience-bound JWT for the remote server. AWF neither launches nor configures that gateway, and the variables do not need to pass through the agent. Recompile older workflow lock files that do not use this direct runner-to-gateway path; compatibility tracking is available in [github/gh-aw#50053](https://github.com/github/gh-aw/issues/50053). +::: + When `AWF_AUTH_TYPE=github-oidc` is set but `ACTIONS_ID_TOKEN_REQUEST_URL`/`ACTIONS_ID_TOKEN_REQUEST_TOKEN` are not available in the sidecar, Anthropic OIDC requests fail closed with: - `503 Anthropic OIDC requires ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN (permissions: id-token: write).` @@ -703,36 +731,13 @@ Exchanges the GitHub OIDC JWT for an Azure AD access token via workload identity Default OIDC audience: `api://AzureADTokenExchange` -#### GitHub Actions example (Azure) - -```yaml -jobs: - agent: - permissions: - id-token: write # required for OIDC token request - contents: read - steps: - - name: Run agent with Azure OpenAI - env: - AWF_AUTH_TYPE: github-oidc - AWF_AUTH_AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} - AWF_AUTH_AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} - OPENAI_API_TARGET: my-deployment.openai.azure.com - run: | - sudo --preserve-env=AWF_AUTH_TYPE,AWF_AUTH_AZURE_TENANT_ID,AWF_AUTH_AZURE_CLIENT_ID,OPENAI_API_TARGET \ - awf --enable-api-proxy \ - --openai-api-target my-deployment.openai.azure.com \ - --allow-domains my-deployment.openai.azure.com \ - -- your-agent-command -``` - -:::caution -Azure OpenAI deployments use a different base URL format from OpenAI. Set `--openai-api-target` to your Azure endpoint hostname and add it to `--allow-domains`. +:::caution[Agent routing is not automatically configured] +Azure OIDC can initialize in the sidecar, but OIDC configuration alone does not set `OPENAI_BASE_URL` or OpenAI compatibility placeholders in the agent. `buildOpenAiCredentialEnv()` currently enables agent routing only when `OPENAI_API_KEY` is configured. Until OIDC-aware OpenAI routing is implemented, AWF does not provide a complete keyless Azure OpenAI invocation path. ::: ### AWS Bedrock -Exchanges the GitHub OIDC JWT for temporary AWS credentials via STS `AssumeRoleWithWebIdentity`. The sidecar uses these credentials to sign requests to AWS Bedrock using SigV4. +Exchanges the GitHub OIDC JWT for temporary AWS credentials via STS `AssumeRoleWithWebIdentity`, caches/refreshes them, and signs outbound Bedrock Runtime requests with SigV4. #### AWS-specific environment variables @@ -744,32 +749,14 @@ Exchanges the GitHub OIDC JWT for temporary AWS credentials via STS `AssumeRoleW Default OIDC audience: `sts.amazonaws.com` -#### GitHub Actions example (AWS) +:::note[SigV4 signing] +The OpenAI and Copilot adapters sign each final outbound HTTP request with the temporary STS access key, secret key, and session token. Signing covers the method, canonical path/query, transformed body hash, regional target host, `AWF_AUTH_AWS_REGION`, and the `bedrock-runtime` service. Credentials remain inside the sidecar, retries are re-signed, and requests fail closed with `503` while credentials are unavailable. -```yaml -jobs: - agent: - permissions: - id-token: write - contents: read - steps: - - name: Run agent with AWS Bedrock - env: - AWF_AUTH_TYPE: github-oidc - AWF_AUTH_PROVIDER: aws - AWF_AUTH_AWS_ROLE_ARN: ${{ vars.AWS_ROLE_ARN }} - AWF_AUTH_AWS_REGION: us-east-1 - run: | - sudo --preserve-env=AWF_AUTH_TYPE,AWF_AUTH_PROVIDER,AWF_AUTH_AWS_ROLE_ARN,AWF_AUTH_AWS_REGION \ - awf --enable-api-proxy \ - --allow-domains bedrock-runtime.us-east-1.amazonaws.com,sts.us-east-1.amazonaws.com \ - -- your-agent-command -``` - -:::note -AWS Bedrock uses IAM/SigV4 request signing rather than Bearer tokens. The sidecar signs the complete request (method, path, headers, body hash) with the temporary credentials. +For credential-leak prevention, the target must exactly match `bedrock-runtime..amazonaws.com` (or `bedrock-runtime..amazonaws.com.cn` in China). Configure that host through `OPENAI_API_TARGET` or `COPILOT_PROVIDER_BASE_URL`. Adding it to the agent allowlist may document the intended policy, but does not constrain sidecar egress. ::: +SigV4 support applies to buffered HTTP requests, including streaming HTTP responses. WebSocket upgrades in AWS OIDC mode are rejected rather than forwarded unsigned. + ### GCP Vertex AI Exchanges the GitHub OIDC JWT for a GCP access token via the Security Token Service, optionally followed by service account impersonation. The resulting token is injected as a Bearer token. @@ -784,36 +771,24 @@ Exchanges the GitHub OIDC JWT for a GCP access token via the Security Token Serv Default OIDC audience: the `gcpWorkloadIdentityProvider` value -#### GitHub Actions example (GCP) - -```yaml -jobs: - agent: - permissions: - id-token: write - contents: read - steps: - - name: Run agent with GCP Vertex AI - env: - AWF_AUTH_TYPE: github-oidc - AWF_AUTH_PROVIDER: gcp - AWF_AUTH_GCP_WORKLOAD_IDENTITY_PROVIDER: projects/123456/locations/global/workloadIdentityPools/my-pool/providers/github - AWF_AUTH_GCP_SERVICE_ACCOUNT: my-sa@my-project.iam.gserviceaccount.com - run: | - sudo --preserve-env=AWF_AUTH_TYPE,AWF_AUTH_PROVIDER,AWF_AUTH_GCP_WORKLOAD_IDENTITY_PROVIDER,AWF_AUTH_GCP_SERVICE_ACCOUNT \ - awf --enable-api-proxy \ - --allow-domains sts.googleapis.com,iamcredentials.googleapis.com,us-central1-aiplatform.googleapis.com \ - -- your-agent-command -``` - :::note -`ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` are injected by the Actions runner automatically; AWF forwards them to the sidecar when `AWF_AUTH_TYPE=github-oidc`. +`ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` are injected by the Actions runner automatically. AWF forwards them to the sidecar when `AWF_AUTH_TYPE=github-oidc` and excludes them from the agent container. ::: :::tip When `gcpServiceAccount` is omitted, the federated token is used directly without service account impersonation. This requires that the federated principal has direct access grants on the target resource. ::: +:::note[Implementation vs. provider documentation] +GCP OIDC with Vertex-hosted models is served through the **OpenAI adapter** (`OPENAI_API_TARGET`/`--openai-api-target` pointed at a Vertex AI OpenAI-compatible endpoint), not through the native Vertex AI adapter (port 10004). The native Vertex adapter is static-`GOOGLE_API_KEY`-only and has no OIDC/WIF support today — see the [auth matrix](./auth-matrix.md#provider-google-vertex-ai) for details. + +The OpenAI-compatible Vertex endpoint also requires a resource-specific base path such as `/v1/projects/PROJECT_ID/locations/LOCATION/endpoints/openapi`. Set it with `OPENAI_API_BASE_PATH` or `--openai-api-base-path`; replace the example project and location with your own values. +::: + +:::caution[Agent routing is not automatically configured] +GCP OIDC can initialize in the sidecar, but OIDC configuration alone does not set `OPENAI_BASE_URL` or OpenAI compatibility placeholders in the agent. `buildOpenAiCredentialEnv()` currently enables agent routing only when `OPENAI_API_KEY` is configured. Until OIDC-aware OpenAI routing is implemented, AWF does not provide a complete keyless Vertex OpenAI-compatible invocation path. +::: + ### Anthropic API Exchanges the GitHub OIDC JWT for an Anthropic Workload Identity Federation access token, then injects it as an `Authorization` header on upstream Anthropic API requests. @@ -831,6 +806,10 @@ Exchanges the GitHub OIDC JWT for an Anthropic Workload Identity Federation acce Default OIDC audience: `https://api.anthropic.com` +For compatibility with Anthropic's official SDKs, AWF sends `anthropic-beta: oauth-2025-04-20,oidc-federation-2026-04-01` only on its JWT-bearer `POST /v1/oauth/token` exchange. Requests authenticated with the resulting bearer token send `oauth-2025-04-20`; they do not send the federation beta. Static `x-api-key` requests receive neither value, and forwarded refresh-token exchanges never receive the federation beta. AWF merges required values with client-supplied `anthropic-beta` values and the optional auto-cache beta without duplicates. + +**Official references:** [Anthropic WIF documentation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation) · [Anthropic TypeScript SDK federation exchange](https://github.com/anthropics/anthropic-sdk-typescript/blob/3b45cd3b69c956ac63384fdb09ce1d8109f3fa80/src/lib/credentials/oidc-federation.ts) · [credential beta constants](https://github.com/anthropics/anthropic-sdk-typescript/blob/3b45cd3b69c956ac63384fdb09ce1d8109f3fa80/src/lib/credentials/types.ts) + #### GitHub Actions example (Anthropic) ```yaml @@ -849,8 +828,8 @@ jobs: 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,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 \ + 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,ACTIONS_ID_TOKEN_REQUEST_URL,ACTIONS_ID_TOKEN_REQUEST_TOKEN \ + awf \ --allow-domains api.anthropic.com \ -- your-agent-command ``` @@ -1229,7 +1208,7 @@ When the variable is absent, the api-proxy uses a best-effort local NDJSON fallb | Mode | When | Behaviour | |------|------|-----------| -| **OTLP/HTTP export** | `OTEL_EXPORTER_OTLP_ENDPOINT` is set | Spans exported via HTTP POST routed through the Squid proxy (so the domain whitelist is respected). | +| **OTLP/HTTP export** | `OTEL_EXPORTER_OTLP_ENDPOINT` is set | Spans exported via HTTP POST routed through Squid; trusted sidecar traffic is exempt from domain ACLs. | | **File fallback** | Endpoint not set | Spans appended as NDJSON to `/var/log/api-proxy/otel.jsonl`. | ### Environment variables @@ -1282,8 +1261,9 @@ Each proxied request produces a single span: ### Proxy routing for OTLP export -OTLP/HTTP exports are routed through the Squid proxy (`HTTPS_PROXY` / `HTTP_PROXY`) so that -they respect the domain allowlist. Add the OTEL collector hostname to your allowlist: +OTLP/HTTP exports are routed through the Squid proxy (`HTTPS_PROXY` / `HTTP_PROXY`), but +the trusted sidecar bypasses domain ACLs. You can still list the collector hostname to +document the intended agent network policy: ```yaml # awf-config.yml @@ -1302,10 +1282,14 @@ into the api-proxy container, so no extra configuration is needed. - Keys must be set as environment variables (not file-based) - No request/response logging (by design, for security) +- **AWS Bedrock OIDC signs HTTP requests only**: WebSocket upgrades are rejected, and the signing target is restricted to the exact regional Bedrock Runtime hostname. See [OIDC Authentication > AWS Bedrock](#aws-bedrock). +- **Vertex AI adapter has no OIDC/WIF support**: the native Vertex adapter (port 10004) only accepts a static `GOOGLE_API_KEY`. To use GCP workload identity federation with Vertex-hosted models, point the OpenAI adapter (port 10000) at a Vertex OpenAI-compatible endpoint instead — see [OIDC Authentication > GCP Vertex AI](#gcp-vertex-ai). +- **GitHub Copilot Business tier target is never auto-derived**: set `COPILOT_API_TARGET=api.business.githubcopilot.com` explicitly (or `--copilot-api-target`); it is not inferred from `GITHUB_SERVER_URL`. ## Related documentation - [Authentication Architecture](./authentication-architecture.md) — detailed credential isolation internals +- [Auth Matrix](./auth-matrix.md) — per-provider auth combination reference (static keys, OIDC, custom headers) - [Security](./security.md) — overall security model - [Environment Variables](./environment.md) — environment variable configuration - [Troubleshooting](./troubleshooting.md) — common issues and solutions diff --git a/docs/auth-matrix.md b/docs/auth-matrix.md index 551ab2c0b..3d9e63ff8 100644 --- a/docs/auth-matrix.md +++ b/docs/auth-matrix.md @@ -1,4 +1,9 @@ -# Authentication Matrix +--- +title: Authentication matrix +description: Provider-by-provider reference for static keys, OIDC federation, headers, targets, and credential isolation in the AWF API proxy. +--- + +# Authentication matrix This document describes every authentication combination supported by AWF's api-proxy sidecar, including how each provider's auth works, what configuration is required, and how the proxy transforms credentials before forwarding to upstream APIs. @@ -9,6 +14,7 @@ This document describes every authentication combination supported by AWF's api- - [Provider: Anthropic](#provider-anthropic) - [Provider: GitHub Copilot](#provider-github-copilot) - [Provider: Google Gemini](#provider-google-gemini) +- [Provider: Google Vertex AI](#provider-google-vertex-ai) - [OIDC Providers](#oidc-providers) - [GitHub Instance Types](#github-instance-types) - [Custom Headers & Injection](#custom-headers--injection) @@ -22,7 +28,7 @@ Auth evaluation in the api-proxy is determined by the combination of these indep | # | Dimension | Controlled By | Values | |---|-----------|--------------|--------| -| 1 | Engine | Port binding (10000–10003) | openai, anthropic, copilot, gemini | +| 1 | Engine | Port binding (10000–10004) | openai, anthropic, copilot, gemini, vertex | | 2 | Auth Type | `AWF_AUTH_TYPE` | `api-key` (default), `github-oidc` | | 3 | OIDC Provider | `AWF_AUTH_PROVIDER` | `azure`, `aws`, `gcp`, `anthropic` | | 4 | Instance Type | `GITHUB_SERVER_URL` | github.com, GHEC (`*.ghe.com`), GHES | @@ -31,6 +37,10 @@ Auth evaluation in the api-proxy is determined by the combination of these indep | 7 | Custom Auth Header | `AWF_{PROVIDER}_AUTH_HEADER` | Any valid HTTP header name | | 8 | Extra Injection | `AWF_BYOK_EXTRA_HEADERS`, `AWF_BYOK_EXTRA_BODY_FIELDS` | JSON objects | +:::note +The OIDC Provider dimension (`AWF_AUTH_PROVIDER`) only applies to the OpenAI, Anthropic, and Copilot adapters. The Gemini and Vertex adapters are static-API-key only in the current implementation — see [Provider: Google Vertex AI](#provider-google-vertex-ai) for how GCP workload identity federation reaches Vertex-compatible endpoints today. +::: + --- ## Provider: OpenAI @@ -60,7 +70,7 @@ When `COPILOT_PROVIDER_TYPE=azure` and `COPILOT_PROVIDER_BASE_URL` is set: | Target | Derived from `COPILOT_PROVIDER_BASE_URL` | | Base path | Derived from URL path component | -**Official docs:** https://learn.microsoft.com/en-us/azure/ai-services/openai/reference +**Official docs:** https://learn.microsoft.com/en-us/azure/foundry/openai/reference ### Azure OIDC (Entra ID) @@ -68,14 +78,22 @@ When `AWF_AUTH_TYPE=github-oidc` and `AWF_AUTH_PROVIDER=azure`: | Setting | Value | |---------|-------| -| Header sent upstream | `Authorization: Bearer ` | +| Header sent upstream | `Authorization: Bearer ` | | Token exchange | GitHub JWT → Azure AD token endpoint | -| Scope | `https://cognitiveservices.azure.com/.default` (configurable via `AWF_AUTH_AZURE_SCOPE`) | +| Scope | `https://cognitiveservices.azure.com/.default` (default; configurable via `AWF_AUTH_AZURE_SCOPE`) | | OIDC audience | `api://AzureADTokenExchange` (configurable via `AWF_AUTH_OIDC_AUDIENCE`) | Note: When Azure OIDC is active, the header switches from `api-key:` back to `Authorization: Bearer`. -**Official docs:** https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/managed-identity +:::note[Implementation vs. provider documentation] +`https://cognitiveservices.azure.com/.default` is AWF's hardcoded default scope and remains valid for Azure OpenAI/Foundry resources. Some newer Microsoft Foundry how-to guides show `https://ai.azure.com/.default` for certain data-plane operations — this is not a universal replacement, and either value may be required depending on your resource and API surface. Set `AWF_AUTH_AZURE_SCOPE` (or `apiProxy.auth.azureScope`) explicitly if your deployment needs a different scope; AWF does not infer the correct scope for you. +::: + +**Official docs:** https://learn.microsoft.com/en-us/azure/foundry-classic/openai/how-to/managed-identity + +:::note[Implementation vs. provider documentation] +OpenAI's own API now offers [native workload identity federation](https://developers.openai.com/api/docs/guides/workload-identity-federation), which exchanges an external OIDC/JWT identity at `https://auth.openai.com/oauth/token` for a short-lived OpenAI access token. **AWF does not implement this.** The `openai.js` adapter's `AWF_AUTH_PROVIDER` OIDC support (`azure`, `aws`, `gcp`) is for reaching Azure OpenAI/Foundry or GCP-fronted OpenAI-compatible endpoints using that cloud's own identity tokens — it is unrelated to OpenAI's native federation feature. +::: ### Custom Auth Header @@ -99,7 +117,11 @@ Note: When Azure OIDC is active, the header switches from `api-key:` back to `Au Additional required headers: `anthropic-version: 2023-06-01` -**Official docs:** https://docs.anthropic.com/en/api/getting-started +**Official docs:** https://platform.claude.com/docs/en/api/overview + +:::note[Implementation vs. provider documentation] +Anthropic SDKs officially support `ANTHROPIC_AUTH_TOKEN` for bearer-token authentication. AWF does not currently accept that variable as a host-side source credential: static Anthropic auth is read from `ANTHROPIC_API_KEY` and sent as `x-api-key`. In the agent container, AWF reserves `ANTHROPIC_AUTH_TOKEN` for the non-secret placeholder `sk-ant-placeholder-key-for-credential-isolation`; the real credential remains in the sidecar. +::: ### Workload Identity Federation (WIF) @@ -117,7 +139,11 @@ When `AWF_AUTH_TYPE=github-oidc` and `AWF_AUTH_PROVIDER=anthropic`: **Key behavior change:** When OIDC is active, the auth header switches from `x-api-key` to `Authorization: Bearer`. -**Official docs:** https://docs.anthropic.com/en/docs/build-with-claude/workload-identity-federation +:::note[Anthropic beta headers] +AWF follows Anthropic's SDK behavior: JWT-bearer `POST /v1/oauth/token` exchanges send `oauth-2025-04-20,oidc-federation-2026-04-01`, while API requests authenticated with the resulting bearer token send `oauth-2025-04-20`. The federation beta is never added to static `x-api-key` requests or forwarded refresh-token exchanges. Client-supplied `anthropic-beta` values are preserved and deduplicated with AWF-required values and the optional auto-cache beta. +::: + +**Official references:** [Anthropic WIF documentation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation) · [Anthropic TypeScript SDK federation exchange](https://github.com/anthropics/anthropic-sdk-typescript/blob/3b45cd3b69c956ac63384fdb09ce1d8109f3fa80/src/lib/credentials/oidc-federation.ts) ### Custom Auth Header @@ -137,10 +163,13 @@ When `AWF_AUTH_TYPE=github-oidc` and `AWF_AUTH_PROVIDER=anthropic`: | github.com | `COPILOT_GITHUB_TOKEN` | `Authorization: Bearer ` | `api.githubcopilot.com` | | GHEC (`*.ghe.com`) | `COPILOT_GITHUB_TOKEN` | `Authorization: Bearer ` | `copilot-api..ghe.com` | | GHES (on-prem) | `COPILOT_GITHUB_TOKEN` | `Authorization: token ` ⚠️ | `api.enterprise.githubcopilot.com` | +| Business tier | `COPILOT_GITHUB_TOKEN` | `Authorization: token ` ⚠️ | `api.business.githubcopilot.com` (must be set explicitly via `COPILOT_API_TARGET`; never auto-derived) | -**⚠️ Critical:** GHES uses `token` prefix, NOT `Bearer`. This is the GitHub API v3 convention for OAuth tokens on Enterprise Server. +:::note[Implementation vs. provider documentation] +GitHub's [REST API authentication docs](https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api) state that `Authorization: Bearer` and `Authorization: token` are both generally accepted for PATs/OAuth tokens (only JWTs strictly require `Bearer`). The `token` prefix requirement documented here is **AWF-implementation-specific defensive behavior** for the Copilot chat-completions inference endpoint, not a general GitHub REST API rule: the enterprise and business Copilot targets (`api.enterprise.githubcopilot.com`, `api.business.githubcopilot.com`) return `400 Bad Request: Authorization header is badly formatted` when sent `Bearer` instead of `token` (see the regression covered by `copilot-adapter-enterprise.test.js`). AWF detects this via `copilotTargetRequiresGitHubTokenPrefix()` in `copilot-auth.js`, which matches on the specific target hostname or GHES-detection heuristics (`AWF_PLATFORM_TYPE=ghes`, or a `GITHUB_SERVER_URL` that isn't `github.com`/`*.ghe.com`). BYOK keys always use `Bearer` regardless of target. +::: -Additional header: `Copilot-Integration-Id: ` +All Copilot requests also include `Copilot-Integration-Id`. The default is `agentic-workflows`; set `COPILOT_INTEGRATION_ID` to override it. ### `/models` Endpoint (Special Case) @@ -154,12 +183,14 @@ The `/models` endpoint prefers `COPILOT_GITHUB_TOKEN` (GitHub OAuth) over BYOK k | Header | `Authorization: Bearer ` (always Bearer, even on GHES) | | Target | From `COPILOT_PROVIDER_BASE_URL` or `COPILOT_API_TARGET` | -### Azure BYOK +### Azure BYOK through the OpenAI adapter -When `COPILOT_PROVIDER_TYPE=azure`: +When `COPILOT_PROVIDER_TYPE=azure`, the OpenAI adapter on port 10000: - Header switches to `api-key: ` (Azure convention) - Unless OIDC is active, in which case it's `Authorization: Bearer` +The Copilot adapter on port 10002 does not emit `api-key`; its BYOK requests use `Authorization: Bearer`. + ### Copilot OIDC (Azure Entra / GCP / AWS) When `AWF_AUTH_TYPE=github-oidc` with Copilot: @@ -168,11 +199,19 @@ When `AWF_AUTH_TYPE=github-oidc` with Copilot: |----------|--------|-------| | Azure | `Authorization: Bearer ` | Via `oidc-token-provider.js` | | GCP | `Authorization: Bearer ` | Via `gcp-oidc-token-provider.js` | -| AWS | (SigV4 signing at request layer) | Via `aws-oidc-token-provider.js` | +| AWS | SigV4 `Authorization` plus `x-amz-*` signing headers | Via `aws-oidc-token-provider.js` and `aws-sigv4.js` | + +:::caution[Agent routing is credential-triggered] +Cloud OIDC configuration can initialize credentials in the sidecar, but the agent's OpenAI and Copilot base URLs/placeholders are currently configured only when the corresponding static OpenAI/Copilot credential is present. Anthropic WIF is the exception: its credential environment explicitly recognizes Anthropic OIDC. Treat Azure, GCP, and AWS OIDC support as sidecar authentication capability rather than a complete keyless agent-routing path until OIDC-aware agent routing is implemented. +::: + +:::note[AWS OIDC + Copilot] +Selecting `AWF_AUTH_PROVIDER=aws` signs Copilot-adapter HTTP requests at final dispatch with the cached temporary STS credentials. The target must be the exact regional Bedrock Runtime hostname; credentials are never returned by `getAuthHeaders()` or exposed to the agent. +::: **Official docs:** -- Copilot API: https://docs.github.com/en/rest/copilot -- GHES auth: https://docs.github.com/en/enterprise-server/rest/authentication/authenticating-to-the-rest-api +- Copilot API: https://docs.github.com/en/rest/copilot (documents management endpoints; the inference/chat-completions endpoint AWF proxies to is not covered by public GitHub REST docs) +- REST API authentication: https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api --- @@ -192,18 +231,54 @@ When `AWF_AUTH_TYPE=github-oidc` with Copilot: The proxy also strips `?key=`, `?apiKey=`, and `?api_key=` query parameters from requests to prevent duplicate-key errors. -**Note:** Gemini does NOT currently support OIDC in this proxy. For GCP WIF access to Gemini, use Vertex AI endpoints (which go through the OpenAI adapter with GCP OIDC). +:::note +The native Gemini API supports OAuth, but the AWF Gemini adapter does not — only a static `GEMINI_API_KEY` is supported. There are two distinct ways to reach Google infrastructure with GCP workload identity federation: +1. **Native Vertex AI adapter** (port 10004, static key only) — see [Provider: Google Vertex AI](#provider-google-vertex-ai). +2. **OpenAI adapter with GCP OIDC** — point `OPENAI_API_TARGET` at a Vertex AI OpenAI-compatible endpoint and set `AWF_AUTH_TYPE=github-oidc`, `AWF_AUTH_PROVIDER=gcp`. This is a separate code path (`openai.js`) from the native Vertex adapter and is the only way to use GCP OIDC/WIF with Vertex-hosted models today. +::: + +:::caution[Gemini API key migration] +Google says the Gemini API will reject standard API keys beginning in September 2026. Migrate `GEMINI_API_KEY` to Google's service-account-bound authorization key format before that deadline; AWF forwards either key type through the same `x-goog-api-key` header. +::: **Official docs:** https://ai.google.dev/gemini-api/docs/api-key --- +## Provider: Google Vertex AI + +**Port:** 10004 +**Implementation:** `containers/api-proxy/providers/vertex.js` (shares `createGoogleApiKeyAdapter` with the Gemini adapter via `google-adapter.js`) + +### Static API Key + +| Setting | Value | +|---------|-------| +| Env var | `GOOGLE_API_KEY` | +| Header sent upstream | `x-goog-api-key: ` | +| Default target | `aiplatform.googleapis.com` | +| Default base path | (none) | + +:::caution +Unlike the OpenAI, Anthropic, and Copilot adapters, the Vertex adapter does **not** call `createOidcAwareProviderAdapter` — it is always bound to port 10004 (returning `503` if `GOOGLE_API_KEY` is unconfigured) and supports only the static-key flow described above. There is no OIDC/WIF variant of this adapter. For GCP workload identity federation with Vertex-hosted models, use the OpenAI adapter pathway described in the [Google Gemini](#provider-google-gemini) section above instead. +::: + +:::note[Implementation vs. provider documentation] +This adapter exists to support the [Gemini CLI](https://geminicli.com/)'s `GOOGLE_GENAI_USE_VERTEXAI=true` mode: setting `GOOGLE_VERTEX_BASE_URL` to point at this sidecar lets AWF isolate whatever credential the CLI is configured to send. Google's general Vertex AI guidance recommends Application Default Credentials, a service account key, or workload identity federation for Vertex AI endpoints, and treats a bare API key as unsupported for most Vertex AI surfaces (API keys are the norm for the separate Gemini Developer API at `generativelanguage.googleapis.com`). AWF does not attempt to validate that `aiplatform.googleapis.com` accepts a given key for a given operation — it forwards `x-goog-api-key` unconditionally. Confirm your specific Vertex AI project/API supports API-key auth (for example, Vertex AI Express Mode) before relying on this adapter in production. +::: + +**Official docs:** https://geminicli.com/docs/get-started/authentication/ + +--- + ## OIDC Providers All OIDC flows require GitHub Actions runtime tokens: - `ACTIONS_ID_TOKEN_REQUEST_URL` — endpoint to mint OIDC JWTs - `ACTIONS_ID_TOKEN_REQUEST_TOKEN` — auth token for the OIDC endpoint +AWF forwards these variables only to the api-proxy sidecar in `github-oidc` mode and excludes them from the agent container. GitHub Agentic Workflows independently passes them from its runner-owned **Start MCP Gateway** step directly to the MCP gateway when a remote HTTP MCP server uses `auth.type: github-oidc`; AWF does not launch or configure that gateway. See [github/gh-aw#50053](https://github.com/github/gh-aw/issues/50053) for lock-file compatibility tracking. + ### Azure (Entra ID) | Config | Env Var | Required | @@ -211,7 +286,7 @@ All OIDC flows require GitHub Actions runtime tokens: | Tenant ID | `AWF_AUTH_AZURE_TENANT_ID` | ✅ | | Client ID | `AWF_AUTH_AZURE_CLIENT_ID` | ✅ | | Scope | `AWF_AUTH_AZURE_SCOPE` | ❌ (default: `https://cognitiveservices.azure.com/.default`) | -| Cloud | `AWF_AUTH_AZURE_CLOUD` | ❌ (default: public; options: `government`, `china`) | +| Cloud | `AWF_AUTH_AZURE_CLOUD` | ❌ (default: `public`; options: `public`, `usgovernment`, `china`) | | Audience | `AWF_AUTH_OIDC_AUDIENCE` | ❌ (default: `api://AzureADTokenExchange`) | **Token exchange endpoint:** `https://login.microsoftonline.com//oauth2/v2.0/token` @@ -228,10 +303,18 @@ All OIDC flows require GitHub Actions runtime tokens: | Audience | `AWF_AUTH_OIDC_AUDIENCE` | ❌ (default: `sts.amazonaws.com`) | **Token exchange:** `GET https://sts..amazonaws.com/?Action=AssumeRoleWithWebIdentity` -**Result:** Temporary (AccessKeyId, SecretAccessKey, SessionToken) for SigV4 signing -**Implementation:** `containers/api-proxy/aws-oidc-token-provider.js` +**Result:** Temporary credentials (AccessKeyId, SecretAccessKey, SessionToken), cached and refreshed by `AwsOidcTokenProvider` + +**Request signing:** SigV4 with service `bedrock-runtime`, applied after body transforms and repeated for retries + +**Implementation:** `containers/api-proxy/aws-oidc-token-provider.js`, `containers/api-proxy/aws-sigv4.js` + **Official docs:** https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_oidc.html +:::note[Signing boundary and fail-closed behavior] +The request layer signs the HTTP method, canonical path and sorted query, final body SHA-256, exact regional Bedrock Runtime host, region, and `bedrock-runtime` service. It includes the STS session token and re-signs retries. Missing/expired credentials return `503` without opening an upstream connection. To prevent credential disclosure, other target hosts and WebSocket upgrades are rejected. +::: + ### GCP (Workload Identity Federation) | Config | Env Var | Required | @@ -259,9 +342,13 @@ All OIDC flows require GitHub Actions runtime tokens: | Token URL | `AWF_AUTH_ANTHROPIC_TOKEN_URL` | ❌ (default: `https://api.anthropic.com/v1/oauth/token`) | | Audience | `AWF_AUTH_OIDC_AUDIENCE` | ❌ (default: `https://api.anthropic.com`) | -**Token exchange:** `POST https://api.anthropic.com/v1/oauth/token` (RFC 7523 jwt-bearer) -**Implementation:** `containers/api-proxy/anthropic-oidc-token-provider.js` -**Official docs:** https://docs.anthropic.com/en/docs/build-with-claude/workload-identity-federation +**Token exchange:** `POST https://api.anthropic.com/v1/oauth/token` (RFC 7523 jwt-bearer), with `anthropic-beta: oauth-2025-04-20,oidc-federation-2026-04-01` + +**Bearer API requests:** `anthropic-beta: oauth-2025-04-20` (merged with client and auto-cache beta values) + +**Implementation:** `containers/api-proxy/anthropic-oidc-token-provider.js` + +**Official references:** [Anthropic WIF documentation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation) · [Anthropic TypeScript SDK credential constants](https://github.com/anthropics/anthropic-sdk-typescript/blob/3b45cd3b69c956ac63384fdb09ce1d8109f3fa80/src/lib/credentials/types.ts) --- @@ -276,6 +363,8 @@ GITHUB_SERVER_URL → deriveCopilotApiTarget(): (other) → api.enterprise.githubcopilot.com ``` +`api.business.githubcopilot.com` (Business tier) is never auto-derived from `GITHUB_SERVER_URL` — it must be set explicitly via `COPILOT_API_TARGET`. + ### Auth Header Prefix Rules | Target | Credential Type | Auth Header Format | @@ -283,10 +372,11 @@ GITHUB_SERVER_URL → deriveCopilotApiTarget(): | `api.githubcopilot.com` | GitHub token | `Bearer ` | | `copilot-api.*.ghe.com` | GitHub token | `Bearer ` | | `api.enterprise.githubcopilot.com` | GitHub token | `token ` | +| `api.business.githubcopilot.com` | GitHub token | `token ` | | Any target | BYOK key | `Bearer ` (always) | | Any target | OIDC token | `Bearer ` (always) | -The `token` prefix is ONLY used for GitHub OAuth tokens on GHES. BYOK and OIDC always use `Bearer`. +The `token` prefix is used for GitHub OAuth tokens on the enterprise and business Copilot targets, or when GHES is otherwise detected (see `copilotTargetRequiresGitHubTokenPrefix()` in `copilot-auth.js`). BYOK and OIDC always use `Bearer`. As noted above, this is AWF-implementation-specific behavior driven by observed `400` errors from those two targets — not a general GitHub REST API requirement. --- @@ -322,22 +412,29 @@ Adds `x-session-id` header automatically in BYOK mode unless already present. | Engine | Auth Mode | Instance | Tested | Implementation | |--------|-----------|----------|--------|----------------| -| OpenAI | Static key | — | ✅ | `openai.js:47-63` | -| OpenAI | Azure BYOK | — | ✅ | `openai.js:64-76` | -| OpenAI | Azure OIDC | — | ✅ | `openai.js:79-161` | -| OpenAI | AWS OIDC | — | ✅ | `cloud-oidc-init.js:19-37` | -| OpenAI | GCP OIDC | — | ✅ | `cloud-oidc-init.js:38-50` | -| Anthropic | Static key | — | ✅ | `anthropic.js:45-52` | -| Anthropic | WIF | — | ✅ | `anthropic.js:53-78` | -| Anthropic | Custom header | — | ✅ | `anthropic.js:52` | -| Copilot | GitHub token | github.com | ✅ | `copilot.js:245-258` | -| Copilot | GitHub token | GHEC | ✅ | `copilot.js:245-258` | -| Copilot | GitHub token | GHES | ✅ | `copilot.js:245-258` | -| Copilot | BYOK key | — | ✅ | `copilot.js:278-284` | +| OpenAI | Static key | — | ✅ | `openai.js` | +| OpenAI | Azure BYOK | — | ✅ | `openai.js` | +| OpenAI | Azure OIDC | — | ✅ | `openai.js`, `oidc-token-provider.js` | +| OpenAI | AWS Bedrock OIDC + SigV4 | — | ✅ | `openai.js`, `aws-oidc-token-provider.js`, `aws-sigv4.test.js` | +| OpenAI | GCP OIDC | — | ✅ | `openai.js`, `gcp-oidc-token-provider.js` | +| Anthropic | Static key | — | ✅ | `anthropic.js` | +| Anthropic | WIF | — | ✅ | `anthropic.js`, `anthropic-oidc-token-provider.js` | +| Anthropic | Custom header | — | ✅ | `anthropic.js` | +| Copilot | GitHub token | github.com | ✅ | `copilot.js`, `copilot-auth.js` | +| Copilot | GitHub token | GHEC | ✅ | `copilot.js`, `copilot-auth.js` | +| Copilot | GitHub token | GHES | ✅ | `copilot.js`, `copilot-auth.js` | +| Copilot | GitHub token | Business tier | ✅ | `copilot-adapter-enterprise.test.js` | +| Copilot | BYOK key | — | ✅ | `copilot.js`, `copilot-byok.js` | | Copilot | Azure BYOK | — | ✅ | via OpenAI adapter | -| Copilot | Azure OIDC | — | ✅ | `copilot-adapter-enterprise.test.js:129+` | -| Copilot | AWS OIDC | — | ✅ | `cloud-oidc-init.js:19-37`, `server.auth-matrix.test.js` | -| Copilot | GCP OIDC | — | ✅ | `cloud-oidc-init.js:38-50`, `server.auth-matrix.test.js` | +| Copilot | Azure OIDC | — | ✅ | `copilot-adapter-enterprise.test.js` | +| Copilot | AWS Bedrock OIDC + SigV4 | — | ✅ | `aws-oidc-token-provider.js`, `server.auth-matrix.test.js` | +| Copilot | GCP OIDC | — | ✅ | `gcp-oidc-token-provider.js`, `server.auth-matrix.test.js` | | Copilot | GHES + BYOK | GHES | ✅ | `server.auth-matrix.test.js` | -| Gemini | Static key | — | ✅ | `gemini.js:25-45` | -| Gemini | GCP WIF | — | ❌ not impl | Would need Vertex AI | +| Gemini | Static key | — | ✅ | `gemini.js`, `google-adapter.js` | +| Gemini | GCP WIF | — | ❌ not impl | Use the OpenAI adapter with GCP OIDC pointed at a Vertex endpoint instead (see [Google Gemini](#provider-google-gemini)) | +| Vertex AI | Static key | — | ✅ | `vertex.js`, `google-adapter.js` | +| Vertex AI | GCP WIF | — | ❌ not impl | No OIDC support in `vertex.js`; see [Provider: Google Vertex AI](#provider-google-vertex-ai) | + +:::note +"Implementation" column lists source files, not line numbers — line references go stale quickly as the code evolves. Use your editor's search to locate the relevant logic within each file. +::: diff --git a/docs/authentication-architecture.md b/docs/authentication-architecture.md index 77a093acd..bc2c31931 100644 --- a/docs/authentication-architecture.md +++ b/docs/authentication-architecture.md @@ -3,22 +3,25 @@ title: Authentication Architecture description: How AWF isolates LLM API tokens using a multi-container credential separation architecture. --- -AWF implements a multi-layered security architecture to protect LLM API authentication tokens while providing transparent proxying for AI agent calls. This document explains the complete authentication flow, token isolation mechanisms, and network routing for both OpenAI/Codex and Anthropic/Claude APIs. +AWF implements a multi-layered security architecture to protect LLM API authentication tokens while providing transparent proxying for AI agent calls. This document explains credential isolation, token exchange, and network routing for every API-proxy provider. :::note -All LLM providers use identical credential isolation architecture. API keys are held exclusively in the api-proxy sidecar container (never in the agent container), and all providers route through the same Squid proxy for domain filtering. Providers are differentiated by port number and authentication header format: +All LLM providers use the same credential-isolation architecture. API keys are held exclusively in the api-proxy sidecar container (never in the agent container), and all providers route through Squid. The sidecar is a trusted component whose source IP is explicitly exempt from Squid's domain ACLs; the agent allowlist does not constrain sidecar-originated traffic. Providers are differentiated by port number and authentication header format: | Port | Provider | Auth header | |-------|--------------------|---------------------------------| -| 10000 | OpenAI | `Authorization: Bearer` | -| 10001 | Anthropic (Claude) | `x-api-key` (static) or `Authorization: Bearer` (OIDC) | -| 10002 | GitHub Copilot | `Authorization: Bearer` | -| 10003 | Google Gemini | `x-goog-api-key` | +| 10000 | OpenAI | `Authorization: Bearer` (static/Azure/GCP), or AWS SigV4 | +| 10001 | Anthropic (Claude) | `x-api-key` (static) or `Authorization: Bearer` (OIDC/WIF) | +| 10002 | GitHub Copilot | `Authorization: Bearer` or `token`, or AWS SigV4 | +| 10003 | Google Gemini | `x-goog-api-key` (static key only) | +| 10004 | Google Vertex AI | `x-goog-api-key` (static key only) | + +Only the OpenAI, Anthropic, and Copilot adapters support `AWF_AUTH_TYPE=github-oidc`. Gemini and Vertex AI are static-API-key only in the current implementation. See [`docs/auth-matrix.md`](./auth-matrix.md) for the full per-provider auth matrix, including the enterprise/business Copilot `token`-prefix requirement and AWS OIDC SigV4 support for Bedrock Runtime. ::: ## Architecture components -AWF uses a **3-container architecture** when API proxy mode is enabled: +AWF uses a **3-container architecture**. The API proxy sidecar is always enabled (see [Configuration requirements](#configuration-requirements) below): 1. **Squid Proxy Container** (`172.30.0.10`) — L7 HTTP/HTTPS domain filtering 2. **API Proxy Sidecar Container** (`172.30.0.30`) — credential injection and isolation @@ -44,7 +47,8 @@ AWF uses a **3-container architecture** when API proxy mode is enabled: │ │ │ │ │ Environment: │ │ Environment: │ │ ✓ OPENAI_API_KEY=sk-... │ │ ✗ No ANTHROPIC_API_KEY │ -│ ✓ ANTHROPIC_API_KEY=sk-ant-... │ │ ✗ No OPENAI_API_KEY │ +│ ✓ ANTHROPIC_API_KEY=sk-ant-... │ │ ✓ OPENAI_API_KEY= │ +│ │ │ sk-placeholder-for-api-proxy │ │ ✓ HTTP_PROXY=172.30.0.10:3128 │ │ ✓ ANTHROPIC_BASE_URL= │ │ ✓ HTTPS_PROXY=172.30.0.10:3128 │ │ http://172.30.0.30:10001 │ │ │ │ ✓ OPENAI_BASE_URL= │ @@ -53,6 +57,7 @@ AWF uses a **3-container architecture** when API proxy mode is enabled: │ - 10001 (Anthropic proxy) │ │ http://172.30.0.30:10002 │ │ - 10002 (Copilot proxy) │ │ ✗ GITHUB_TOKEN — excluded │ │ - 10003 (Gemini proxy) │ │ (not present in agent env) │ +│ - 10004 (Vertex AI proxy) │ │ │ │ Injects auth headers: │ │ User command execution: │ │ - x-api-key: sk-ant-... │ │ claude-code, copilot, etc. │ │ - Authorization: Bearer sk-... │ └──────────────────────────────────┘ @@ -63,10 +68,10 @@ AWF uses a **3-container architecture** when API proxy mode is enabled: │ Squid Proxy Container │ │ 172.30.0.10:3128 │ │ │ -│ Domain whitelist enforcement: │ -│ ✓ api.anthropic.com │ -│ ✓ api.openai.com │ -│ ✗ *.exfiltration.com (blocked) │ +│ Trusted api-proxy source: │ +│ ✓ Routed through Squid │ +│ ✓ Exempt from domain ACLs │ +│ (unrestricted outbound) │ │ │ └────────────────┬─────────────────┘ │ @@ -80,13 +85,13 @@ AWF uses a **3-container architecture** when API proxy mode is enabled: **Source:** `src/cli.ts` -When AWF is invoked with `--enable-api-proxy`: +The API proxy is always active. Set source credentials in the host environment before invoking AWF: ```bash export ANTHROPIC_API_KEY="sk-ant-..." export OPENAI_API_KEY="sk-..." -sudo awf --enable-api-proxy --allow-domains api.anthropic.com \ +sudo awf --allow-domains api.anthropic.com \ "claude-code --prompt 'write hello world'" ``` @@ -119,12 +124,15 @@ api-proxy: ```yaml agent: environment: - # NO API KEYS - only base URLs pointing to api-proxy + # No real API keys: only proxy URLs and non-secret compatibility placeholders - ANTHROPIC_BASE_URL=http://172.30.0.30:10001 - OPENAI_BASE_URL=http://172.30.0.30:10000 + - OPENAI_API_KEY=sk-placeholder-for-api-proxy + - CODEX_API_KEY=sk-placeholder-for-api-proxy - COPILOT_API_URL=http://172.30.0.30:10002 - GOOGLE_GEMINI_BASE_URL=http://172.30.0.30:10003 - GEMINI_API_BASE_URL=http://172.30.0.30:10003 + - GOOGLE_VERTEX_BASE_URL=http://172.30.0.30:10004 # GITHUB_TOKEN / GH_TOKEN are NOT present — excluded by the API-proxy # exclusion set to prevent credential extraction via /proc/self/environ networks: @@ -133,23 +141,26 @@ agent: ``` :::danger[Security design] -API credentials are intentionally excluded from the agent container environment. The API proxy is always enabled, and `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, and related credentials are added to the excluded environment variables list in `src/services/agent-environment/excluded-vars.ts`. +Real API credentials are intentionally excluded from the agent container environment. The API proxy is always enabled. Source values such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_AUTH_TOKEN` are excluded before AWF adds any non-secret compatibility placeholders required by client tools. For example, the agent receives `OPENAI_API_KEY=sk-placeholder-for-api-proxy` and `ANTHROPIC_AUTH_TOKEN=sk-ant-placeholder-key-for-credential-isolation`, never the host values. ::: ### 3. API proxy: credential injection layer -**Source:** `containers/api-proxy/server.js` +**Source:** `containers/api-proxy/server.js` (facade), `containers/api-proxy/server-factory.js` (shared HTTP handler logic), `containers/api-proxy/providers/*.js` (one adapter module per provider) -The api-proxy container runs five HTTP servers: +The api-proxy container runs five HTTP servers, one per provider adapter: #### Port 10000: OpenAI proxy +Simplified illustration of the request-handling logic (the real implementation lives in `providers/openai.js` and `server-factory.js`, and is provider-agnostic — this snippet is illustrative, not a literal excerpt): + ```javascript -// Stripped headers — never forwarded from client +// Stripped headers — never forwarded from client (containers/api-proxy/proxy-utils.js) const STRIPPED_HEADERS = new Set([ 'host', 'authorization', 'proxy-authorization', - 'x-api-key', 'forwarded', 'via', + 'x-api-key', 'x-goog-api-key', 'forwarded', 'via', ]); +// Header names starting with 'x-forwarded-' are also stripped. // OpenAI proxy handler http.createServer((req, res) => { @@ -181,7 +192,11 @@ Handles requests from the agent using `COPILOT_API_URL`. Injects the resolved Co #### Port 10003: Google Gemini proxy -Handles requests from the agent using `GOOGLE_GEMINI_BASE_URL` (read by the Gemini CLI) and `GEMINI_API_BASE_URL` (read by older SDK versions). Injects `x-goog-api-key` from `GEMINI_API_KEY`, forwarding to `generativelanguage.googleapis.com`. Returns `503` if `GEMINI_API_KEY` is not configured. +Handles requests from the agent using `GOOGLE_GEMINI_BASE_URL` (read by the Gemini CLI) and `GEMINI_API_BASE_URL` (read by older SDK versions). Injects `x-goog-api-key` from `GEMINI_API_KEY`, forwarding to `generativelanguage.googleapis.com`. Returns `503` if `GEMINI_API_KEY` is not configured. Static-key only — no OIDC/WIF support. + +#### Port 10004: Google Vertex AI proxy + +Handles requests from the agent using `GOOGLE_VERTEX_BASE_URL` (read by the Gemini CLI when `GOOGLE_GENAI_USE_VERTEXAI=true`). Injects `x-goog-api-key` from `GOOGLE_API_KEY`, forwarding to `aiplatform.googleapis.com`. Returns `503` if `GOOGLE_API_KEY` is not configured. Shares its adapter factory (`providers/google-adapter.js`) with the Gemini adapter, but is a distinct always-bound port with its own target and env vars. Static-key only — no OIDC/WIF support (see [`docs/auth-matrix.md`](./auth-matrix.md#provider-google-vertex-ai) for the implementation-vs-provider-docs caveat on API-key auth against Vertex AI). The `proxyRequest` function copies incoming headers, strips sensitive/proxy headers, injects the authentication headers, and forwards the request to the target API through Squid using `HttpsProxyAgent`. @@ -199,6 +214,7 @@ OPENAI_BASE_URL=http://172.30.0.30:10000 COPILOT_API_URL=http://172.30.0.30:10002 GOOGLE_GEMINI_BASE_URL=http://172.30.0.30:10003 GEMINI_API_BASE_URL=http://172.30.0.30:10003 +GOOGLE_VERTEX_BASE_URL=http://172.30.0.30:10004 ``` These are standard environment variables recognized by the official SDKs: @@ -209,7 +225,7 @@ These are standard environment variables recognized by the official SDKs: - Claude Code CLI - Codex CLI - GitHub Copilot CLI (`gh copilot`) -- Google Gemini CLI (reads `GOOGLE_GEMINI_BASE_URL`) +- Google Gemini CLI (reads `GOOGLE_GEMINI_BASE_URL`, or `GOOGLE_VERTEX_BASE_URL` when `GOOGLE_GENAI_USE_VERTEXAI=true`) When the agent code makes an API call: @@ -270,7 +286,7 @@ Without the NAT `RETURN` rule, traffic to `172.30.0.30` would be redirected to S 3. API proxy receives request on port 10001 4. API proxy injects `x-api-key: sk-ant-...` header 5. API proxy forwards to `api.anthropic.com` via Squid (using `HttpsProxyAgent`) -6. Squid enforces domain whitelist (only `api.anthropic.com` allowed) +6. Squid recognizes the trusted api-proxy source IP and bypasses domain ACL evaluation 7. Squid forwards to real API endpoint 8. Response flows back: API → Squid → api-proxy → agent @@ -281,11 +297,11 @@ Without the NAT `RETURN` rule, traffic to `172.30.0.30` would be redirected to S 3. API proxy receives request on port 10000 4. API proxy injects `Authorization: Bearer sk-...` header 5. API proxy forwards to `api.openai.com` via Squid (using `HttpsProxyAgent`) -6. Squid enforces domain whitelist (only `api.openai.com` allowed) +6. Squid recognizes the trusted api-proxy source IP and bypasses domain ACL evaluation 7. Squid forwards to real API endpoint 8. Response flows back: API → Squid → api-proxy → agent -### 6. Squid proxy: domain filtering +### 6. Squid proxy routing and trusted sidecar exemption The api-proxy container routes all outbound traffic through Squid via its `HTTP_PROXY`/`HTTPS_PROXY` environment variables: @@ -295,10 +311,10 @@ environment: HTTPS_PROXY: http://172.30.0.10:3128 ``` -Squid's domain whitelist ACLs control which API domains the sidecar can reach. For example, if only `api.anthropic.com` is whitelisted, the sidecar can only connect to that domain — even if a compromised sidecar tried to connect to a malicious domain, Squid would block it. +Squid routes the sidecar's outbound HTTP/HTTPS connections, but it does not apply the agent domain allowlist to them. `generateApiProxySection()` adds `http_access allow from_api_proxy` before domain ACL evaluation because OIDC exchanges and custom API targets may not appear in the agent allowlist. The api-proxy is therefore part of AWF's trusted computing base and has unrestricted outbound HTTP/HTTPS access through Squid. A compromised sidecar is not contained by the domain allowlist. :::note -The api-proxy connects to the real APIs (e.g., `api.openai.com`) over standard HTTPS (port 443) through Squid. Ports 10000–10003 are only used for internal agent-to-proxy communication within the Docker network. +The api-proxy connects to the real APIs (e.g., `api.openai.com`) over standard HTTPS (port 443) through Squid. Ports 10000–10004 are only used for internal agent-to-proxy communication within the Docker network. ::: ## Additional token protection mechanisms @@ -307,7 +323,7 @@ The api-proxy connects to the real APIs (e.g., `api.openai.com`) over standard H **Source:** `containers/agent/one-shot-token/` -While API keys don't exist in the agent container, other tokens may still be present. AWF uses an `LD_PRELOAD` library as defense-in-depth for any token that does reach the container: +While real provider keys do not exist in the agent container, non-secret compatibility placeholders and other tokens may still be present. AWF uses an `LD_PRELOAD` library as defense-in-depth for protected variable names: ```c // Intercept getenv() calls @@ -329,9 +345,9 @@ char* getenv(const char* name) { ``` **Protected tokens by default:** -- `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_API_KEY` (`ANTHROPIC_AUTH_TOKEN` is usually a placeholder in agent mode, but stays protected in case a real value is forwarded) +- `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_API_KEY` (`ANTHROPIC_AUTH_TOKEN` contains AWF's placeholder when Anthropic proxying is configured) - `OPENAI_API_KEY`, `OPENAI_KEY` -- `GITHUB_TOKEN`, `GH_TOKEN`, `COPILOT_GITHUB_TOKEN` (not passed to agent when api-proxy is enabled) +- `GITHUB_TOKEN`, `GH_TOKEN`, `COPILOT_GITHUB_TOKEN` (source values are excluded; Copilot may receive a placeholder) - `GITHUB_API_TOKEN`, `GITHUB_PAT`, `GH_ACCESS_TOKEN` - `CODEX_API_KEY` - `COPILOT_PROVIDER_API_KEY` (Copilot BYOK upstream provider key) @@ -392,8 +408,8 @@ This prevents tokens from being visible in `/proc/1/environ` after the agent sta 1. **Layer 1:** Agent cannot make direct internet connections (iptables blocks non-whitelisted traffic) 2. **Layer 2:** Agent can only reach api-proxy IP (`172.30.0.30`) for API calls -3. **Layer 3:** API proxy routes all traffic through Squid (enforced via `HTTP_PROXY` env) -4. **Layer 4:** Squid enforces the domain whitelist (only explicitly allowed domains, e.g., `api.anthropic.com`, `api.openai.com`, `api.githubcopilot.com`) +3. **Layer 3:** API proxy routes outbound HTTP/HTTPS through Squid (enforced via `HTTP_PROXY` env) +4. **Layer 4:** Squid enforces the domain allowlist for agent-originated traffic; the trusted api-proxy source IP is explicitly exempt 5. **Layer 5:** Host-level iptables provide additional egress control **Attack scenario: what if the agent tries to bypass the proxy?** @@ -442,14 +458,18 @@ Even if exploited, the api-proxy has no elevated privileges and limited resource ## Configuration requirements -### Enabling API proxy mode +### API proxy behavior + +:::note[Implementation vs. provider documentation] +The API proxy is **always enabled** — it cannot be turned off. The historical `--enable-api-proxy` CLI flag is deprecated and ignored (kept only for backward-compatible command lines), and `--no-enable-api-proxy` is rejected as a runtime error. The `apiProxy.enabled` config-file field is likewise deprecated and ignored. Do not add `--enable-api-proxy` to new commands. +::: **Example 1: Using with Claude Code** ```bash export ANTHROPIC_API_KEY="sk-ant-api03-..." -sudo awf --enable-api-proxy \ +sudo awf \ --allow-domains api.anthropic.com \ "claude-code --prompt 'Hello world'" ``` @@ -459,7 +479,7 @@ sudo awf --enable-api-proxy \ ```bash export OPENAI_API_KEY="sk-..." -sudo awf --enable-api-proxy \ +sudo awf \ --allow-domains api.openai.com \ "codex --prompt 'Hello world'" ``` @@ -470,20 +490,20 @@ sudo awf --enable-api-proxy \ export ANTHROPIC_API_KEY="sk-ant-api03-..." export OPENAI_API_KEY="sk-..." -sudo awf --enable-api-proxy \ +sudo awf \ --allow-domains api.anthropic.com,api.openai.com \ "your-multi-llm-agent" ``` -### Domain whitelist +### Provider domains and the agent allowlist -When using api-proxy, you must allow the API domains: +Provider domains may still be listed to express the intended network policy: ```bash --allow-domains api.anthropic.com,api.openai.com ``` -Without these, Squid blocks the api-proxy's outbound connections. +This allowlist constrains agent-originated traffic, not the api-proxy. The trusted sidecar source IP bypasses Squid's domain ACLs, so these entries are not an egress boundary for sidecar requests. ### NO_PROXY configuration @@ -500,9 +520,11 @@ This ensures: - The agent can reach api-proxy directly without going through Squid - Container-to-container communication works properly -## Comparison: with vs without API proxy +## Why credential isolation matters + +### Hypothetical direct authentication -### Without API proxy (direct authentication) +AWF does not provide this mode. The diagram shows the risk that the always-on sidecar avoids: ``` ┌─────────────────┐ @@ -525,7 +547,7 @@ This ensures: **Security risk:** If the agent is compromised, the attacker can read the API key from environment variables. -### With API proxy (credential isolation) +### AWF API proxy (credential isolation) ``` ┌─────────────────┐ ┌────────────────┐ @@ -548,7 +570,7 @@ This ensures: ## OIDC authentication (keyless credential exchange) -AWF also supports **keyless authentication** via GitHub Actions OIDC workload identity federation. Instead of static API keys, the api-proxy sidecar exchanges a short-lived GitHub-issued JWT for provider-specific credentials — without the agent ever seeing any secret. +AWF also supports **keyless authentication** via GitHub Actions OIDC workload identity federation. Instead of static API keys, the api-proxy sidecar exchanges a short-lived GitHub-issued JWT for provider-specific credentials. The Actions token-minting variables, minted JWT, and exchanged credentials remain outside the agent container. ### How native GitHub Actions OIDC works @@ -587,7 +609,7 @@ In a standard GitHub Actions workflow (without AWF), OIDC federation works like ### How AWF OIDC works (credential isolation) -AWF moves the entire OIDC exchange into the api-proxy sidecar, so the agent never sees any credential: +AWF keeps the Actions OIDC request capability, minted GitHub JWT, and exchanged cloud credential in the api-proxy sidecar: ``` ┌─────────────────────────────┐ ┌───────────────────────────────────────┐ @@ -618,14 +640,15 @@ AWF moves the entire OIDC exchange into the api-proxy sidecar, so the agent neve │ ▼ Cloud API endpoint - (Azure OpenAI / AWS Bedrock / GCP Vertex / Anthropic) + (Azure OpenAI, GCP-fronted OpenAI/Copilot targets, + Anthropic, and AWS Bedrock Runtime) ``` ### OIDC token flow: step by step #### Step 1: Configuration forwarding -The AWF CLI (`src/services/api-proxy-service.ts`) reads `AWF_AUTH_*` environment variables from the host and forwards them **only to the api-proxy sidecar**, not to the agent container: +The AWF CLI forwards `AWF_AUTH_*` configuration and the Actions runtime OIDC request URL and token only to the api-proxy sidecar. `buildOidcEnv()` conditionally adds the runtime variables to the sidecar in `github-oidc` mode, while `buildExclusionSet()` prevents every agent environment input path from adding them. ``` Host environment Sidecar container Agent container @@ -636,6 +659,12 @@ AWF_AUTH_AZURE_TENANT_ID=... ──► AWF_AUTH_AZURE_TENANT_ID ✓ ✗ (excl ACTIONS_ID_TOKEN_REQUEST_URL ──► forwarded when type=oidc ✓ ✗ (excluded) ``` +:::note[OIDC-authenticated MCP servers] +GitHub Agentic Workflows supports `auth.type: github-oidc` for remote HTTP MCP servers through its compiler-managed MCP gateway. The generated **Start MCP Gateway** workflow step runs on the Actions runner before the AWF agent, passes the Actions variables directly to the gateway, and supplies only the gateway endpoint to the agent. The gateway mints an audience-bound JWT and injects it into the remote MCP request. AWF does not launch or configure the gateway. + +Lock files generated by compiler versions that do not pass the variables directly from the runner to the gateway must be recompiled. See [github/gh-aw#50053](https://github.com/github/gh-aw/issues/50053) for compatibility validation and migration tracking. +::: + #### Step 2: GitHub OIDC token minting The sidecar's token provider (`github-oidc.js`) calls `ACTIONS_ID_TOKEN_REQUEST_URL` with a provider-appropriate audience claim: @@ -687,9 +716,12 @@ GitHub JWT ──► sts.googleapis.com/v1/token GitHub JWT ──► api.anthropic.com/v1/oauth/token grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer assertion={github_jwt} + anthropic-beta=oauth-2025-04-20,oidc-federation-2026-04-01 ◄── { access_token: "sk-ant-oat01-...", expires_in: 3600 } ``` +The federation beta is a routing switch used only for the JWT-bearer exchange. It is not added to static-key requests, forwarded refresh-token exchanges, or subsequent API calls. See Anthropic's [WIF documentation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation) and [TypeScript SDK exchange implementation](https://github.com/anthropics/anthropic-sdk-typescript/blob/3b45cd3b69c956ac63384fdb09ce1d8109f3fa80/src/lib/credentials/oidc-federation.ts). + #### Step 4: Credential caching and auto-refresh All token providers cache the exchanged credentials and schedule proactive refresh: @@ -705,10 +737,16 @@ When the agent sends a request to the sidecar, the provider adapter injects the | Provider | Auth injection method | |----------|----------------------| -| Azure | Authorization header | -| GCP | Authorization header | -| Anthropic | Authorization header | -| AWS | SigV4 request signing (method, path, headers, body hash) | +| Azure | `Authorization` header | +| GCP | `Authorization` header | +| Anthropic | `Authorization: Bearer` plus `anthropic-beta: oauth-2025-04-20` | +| AWS | SigV4 `Authorization`, `x-amz-date`, payload hash, and STS session token | + +For Anthropic bearer requests, AWF merges the OAuth beta with client-supplied `anthropic-beta` values and the optional auto-cache beta, deduplicating exact values. Static `x-api-key` requests do not receive OAuth or federation beta values. + +:::note[AWS OIDC requests are signed at final dispatch] +`AwsOidcTokenProvider` keeps `AccessKeyId`, `SecretAccessKey`, and `SessionToken` inside the sidecar. After all URL and body transforms, the request layer signs the method, canonical path/query, final body hash, regional Bedrock Runtime host, and `bedrock-runtime` service with Node's built-in cryptography. Retries are re-signed, expired or unavailable credentials produce `503` without contacting upstream, and signing is restricted to `bedrock-runtime..amazonaws.com` (or the corresponding China endpoint). +::: ### Comparison: static keys vs OIDC @@ -716,10 +754,10 @@ When the agent sends a request to the sidecar, the provider adapter injects the |----------|----------------|-----------------| | Credential type | Long-lived secret | Short-lived token (~1h) | | Rotation | Manual | Automatic (proactive refresh) | -| Agent sees secret | No (api-proxy only) | No (api-proxy only) | +| Agent sees credential material | No real provider key | No Actions OIDC request token, minted JWT, or exchanged provider credential | | GitHub Actions requirement | API key in secrets | `permissions: id-token: write` | | Cloud provider setup | Generate API key | Configure trust policy/federation | -| Supported providers | OpenAI, Anthropic, Copilot, Gemini | Azure OpenAI, AWS Bedrock, GCP Vertex AI, Anthropic WIF | +| Supported providers | OpenAI, Anthropic, Copilot, Gemini, Vertex AI | Azure (OpenAI/Copilot), GCP (OpenAI/Copilot adapters only — not the native Vertex/Gemini adapters), Anthropic WIF, AWS Bedrock Runtime via OpenAI/Copilot adapters | ### Configuration reference @@ -739,10 +777,13 @@ OIDC authentication is configured via `apiProxy.auth` in the AWF config file or | `containers/api-proxy/server.js` | API proxy implementation (credential injection, header stripping) | | `containers/api-proxy/github-oidc.js` | Shared GitHub Actions OIDC token minting utility | | `containers/api-proxy/oidc-token-provider.js` | Azure AD token exchange via workload identity federation | -| `containers/api-proxy/aws-oidc-token-provider.js` | AWS STS AssumeRoleWithWebIdentity credential exchange | +| `containers/api-proxy/aws-oidc-token-provider.js`, `aws-sigv4.js` | AWS STS AssumeRoleWithWebIdentity exchange and Bedrock Runtime SigV4 signing | | `containers/api-proxy/gcp-oidc-token-provider.js` | GCP STS token exchange + optional SA impersonation | | `containers/api-proxy/anthropic-oidc-token-provider.js` | Anthropic OAuth token exchange for workload identity federation | | `containers/api-proxy/providers/openai.js` | OpenAI adapter — selects OIDC provider based on `AWF_AUTH_PROVIDER` | +| `containers/api-proxy/providers/anthropic.js` | Anthropic adapter — static `x-api-key` or WIF `Authorization: Bearer` | +| `containers/api-proxy/providers/copilot.js`, `copilot-auth.js`, `copilot-byok.js` | Copilot adapter — GitHub token, BYOK, and OIDC handling, `token`/`Bearer` prefix logic | +| `containers/api-proxy/providers/gemini.js`, `vertex.js`, `google-adapter.js` | Gemini and Vertex AI adapters — static `x-goog-api-key` only, no OIDC | | `containers/agent/setup-iptables.sh` | iptables rules for api-proxy routing | | `containers/agent/entrypoint.sh` | Entrypoint token cleanup, capability drop | | `containers/agent/api-proxy-health-check.sh` | Pre-flight credential isolation verification | @@ -757,7 +798,7 @@ AWF implements **credential isolation** through architectural separation: 1. **API keys live in api-proxy container only** (never in agent environment) 2. **Agent uses standard SDK environment variables** (`*_BASE_URL`) to redirect traffic 3. **API proxy injects credentials** and routes through Squid -4. **Squid enforces the domain whitelist** (only allowed API domains) +4. **Squid routes sidecar traffic** (the trusted sidecar is exempt from domain ACLs) 5. **iptables enforces network isolation** (agent cannot bypass proxy) 6. **Multiple token cleanup mechanisms** protect other credentials (GitHub tokens, etc.) @@ -765,6 +806,7 @@ This architecture provides **transparent operation** (SDKs work without code cha ## Related documentation +- [Auth Matrix](./auth-matrix.md) — per-provider auth combination reference (static keys, OIDC, custom headers) - [API Proxy Sidecar](./api-proxy-sidecar.md) — user-facing guide for enabling the API proxy - [Security](./security.md) — overall security model - [Architecture](./architecture.md) — overall system architecture diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 981fa85fb..2a1a63229 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -298,7 +298,7 @@ passthrough. A conforming implementation MUST NOT inherit them from the host: |----------|-----------| | System | `PATH`, `PWD`, `OLDPWD`, `SHLVL`, `_`, `SUDO_COMMAND`, `SUDO_USER`, `SUDO_UID`, `SUDO_GID` | | Proxy | `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, `https_proxy`, `NO_PROXY`, `no_proxy`, `ALL_PROXY`, `all_proxy`, `FTP_PROXY`, `ftp_proxy` | -| Actions artifact tokens | `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RESULTS_URL` | +| Actions runtime credentials | `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RESULTS_URL`, `ACTIONS_ID_TOKEN_REQUEST_URL`, `ACTIONS_ID_TOKEN_REQUEST_TOKEN` | | AWF internal controls | `AWF_PREFLIGHT_BINARY`, `AWF_GEMINI_ENABLED` | > **Note:** Host proxy variables are read for upstream proxy auto-detection @@ -314,13 +314,16 @@ the following host variables into the agent container: |----------|-----------| | GitHub authentication | `GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_PERSONAL_ACCESS_TOKEN` | | GitHub enterprise | `GITHUB_SERVER_URL`, `GITHUB_API_URL` | -| Actions OIDC | `ACTIONS_ID_TOKEN_REQUEST_URL`, `ACTIONS_ID_TOKEN_REQUEST_TOKEN` | | Docker client | `DOCKER_HOST`, `DOCKER_TLS`, `DOCKER_TLS_VERIFY`, `DOCKER_CERT_PATH`, `DOCKER_CONFIG`, `DOCKER_CONTEXT`, `DOCKER_API_VERSION`, `DOCKER_DEFAULT_PLATFORM` | | User environment | `USER`, `XDG_CONFIG_HOME` | When `--env-all` IS active, all host variables not in the excluded set (§8.3) SHALL be forwarded, subject to credential isolation rules (§9). +Actions OIDC request variables MUST be forwarded directly to the api-proxy +sidecar when `apiProxy.auth.type` is `github-oidc` and MUST NOT be forwarded +to the agent through any environment input path. + ### 8.5 Explicit Overrides Variables passed via `-e` / `--env` MUST override values from `--env-all` diff --git a/docs/environment.md b/docs/environment.md index c88317688..0dd3f6ff6 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -44,7 +44,7 @@ Using `--env-all` passes all host environment variables to the container, which 3. **Unnecessary Access**: Extra variables increase attack surface (violates least privilege) 4. **Accidental Sharing**: Easy to forget what's in your environment when sharing commands -**Excluded variables** (even with `--env-all`): `PATH`, `PWD`, `OLDPWD`, `SHLVL`, `_`, `SUDO_*` +**Excluded variables** (even with `--env-all`): `PATH`, `PWD`, `OLDPWD`, `SHLVL`, `_`, `SUDO_*`, `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RESULTS_URL`, `ACTIONS_ID_TOKEN_REQUEST_URL`, and `ACTIONS_ID_TOKEN_REQUEST_TOKEN`. Actions OIDC variables are forwarded directly to the api-proxy sidecar in `github-oidc` mode, never to the agent. **Proxy variables:** `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, `https_proxy`, `NO_PROXY`, `no_proxy`, `ALL_PROXY`, and `FTP_PROXY` (all case variants) from the host are **excluded from container passthrough** when using `--env-all`. The firewall sets its own proxy variables pointing to Squid inside the container. However, host proxy variables **are read** for upstream proxy auto-detection — if the host has `https_proxy`/`http_proxy` set, AWF configures Squid to chain outbound traffic through that corporate proxy (see [Upstream Proxy Support](#upstream-corporate-proxy-support)). @@ -64,7 +64,7 @@ Using `--env-all` passes all host environment variables to the container, which 3. `--env-file` variables 4. `--env` / `-e` explicit variables (highest priority) -**Excluded variables** in `--env-file` (same list as `--env-all`): `PATH`, `PWD`, `HOME`, `SUDO_*`, etc. +**Excluded variables** in `--env-file` (same list as `--env-all`): `PATH`, `PWD`, `HOME`, `SUDO_*`, Actions runtime credentials, etc. Explicit `--env` cannot override credential exclusions. **Example use case — Safe Outputs MCP:** ```bash diff --git a/docs/sbx-integration.md b/docs/sbx-integration.md index a30b1b567..abe13e125 100644 --- a/docs/sbx-integration.md +++ b/docs/sbx-integration.md @@ -219,9 +219,9 @@ What `createSandbox()` shares, in order: `accessTokens.json`, `service_principal_entries.json`) are treated as credential stores and scrubbed before sandbox creation (sbx) or masked with `/dev/null` overlays (compose). Agents cannot read host Azure auth tokens - directly. Azure authentication must be obtained at runtime via OIDC - (`ACTIONS_ID_TOKEN_REQUEST_URL`/`TOKEN`, already forwarded) or via the - `ADO_MCP_AUTH_TOKEN` environment variable. + directly. Azure API authentication must be handled by the api-proxy's + sidecar-only OIDC exchange or by an external trusted service. The separate + `ADO_MCP_AUTH_TOKEN` environment variable remains available for ADO MCP. ::: **Scrubbing nested credential stores.** Several whitelisted dirs legitimately @@ -238,9 +238,11 @@ them after the sandbox is torn down** (`scrubHomeCredentials` / the secrets are absent from the VM while the benign tool state stays available. This is the sbx analog of compose mode's `/dev/null` credential overlays, and the central credential list in `sandbox-mount-policy.json` is shared between backends -to prevent drift. The agent receives whatever credentials it needs through the -api-proxy or environment (e.g. `ADO_MCP_AUTH_TOKEN`, OIDC tokens), not by reading -the host's on-disk auth store, so removing these paths is safe. +to prevent drift. The agent accesses OIDC-backed providers through requests +routed to the api-proxy, or receives separately allowed environment credentials +such as `ADO_MCP_AUTH_TOKEN`, not by reading the host's on-disk auth store. +Provider credentials and Actions OIDC request variables remain in the api-proxy +or another trusted external service, so removing these paths is safe. A `seenPaths` set deduplicates so no path is mounted twice, and `execInSandbox(..., { workDir })` passes `--workdir` so commands run inside the diff --git a/package-lock.json b/package-lock.json index 08a880b4a..a54d43a18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4569,9 +4569,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/config/sandbox-mount-policy.json b/src/config/sandbox-mount-policy.json index 8a999c776..d5cc6e67c 100644 --- a/src/config/sandbox-mount-policy.json +++ b/src/config/sandbox-mount-policy.json @@ -9,7 +9,7 @@ "etc": ["/etc/ssl", "/etc/ca-certificates", "/etc/pki/ca-trust/extracted", "/etc/pki/tls/certs", "/etc/alternatives", "/etc/ld.so.cache", "/etc/nsswitch.conf"] }, "home": { - "$comment": "Agent $HOME exposure. `toolSubdirs` is the ALLOW list: tool caches, language toolchains and agent state the agent legitimately needs. `forbiddenSubdirs` is a DENY guard: dirs whose primary purpose is storing credentials and which must NEVER be added to the allow list. Compose mounts an empty home + binds toolSubdirs on top; sbx mounts toolSubdirs wholesale instead of the whole $HOME. EXCEPTION: `.azure` is credential-bearing — it is intentionally mounted to provide Azure CLI config and account metadata, but its live token caches (msal_token_cache.bin, msal_token_cache.json, accessTokens.json, service_principal_entries.json) are masked by the credentials deny list so agents cannot read host auth tokens directly. Azure auth must come via OIDC (ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN) or the ADO_MCP_AUTH_TOKEN env var.", + "$comment": "Agent $HOME exposure. `toolSubdirs` is the ALLOW list: tool caches, language toolchains and agent state the agent legitimately needs. `forbiddenSubdirs` is a DENY guard: dirs whose primary purpose is storing credentials and which must NEVER be added to the allow list. Compose mounts an empty home + binds toolSubdirs on top; sbx mounts toolSubdirs wholesale instead of the whole $HOME. EXCEPTION: `.azure` is credential-bearing — it is intentionally mounted to provide Azure CLI config and account metadata, but its live token caches (msal_token_cache.bin, msal_token_cache.json, accessTokens.json, service_principal_entries.json) are masked by the credentials deny list so agents cannot read host auth tokens directly. Azure API auth must use the api-proxy's sidecar-only OIDC exchange or another trusted external service; ADO MCP may use its separate ADO_MCP_AUTH_TOKEN env var.", "toolSubdirs": [ ".cache", ".config", diff --git a/src/services/agent-environment-credentials.test.ts b/src/services/agent-environment-credentials.test.ts index 2d890f2c8..eeaad5ca1 100644 --- a/src/services/agent-environment-credentials.test.ts +++ b/src/services/agent-environment-credentials.test.ts @@ -1,3 +1,6 @@ +import fs from 'fs'; +import path from 'path'; + import { generateDockerCompose, WrapperConfig, baseConfig, mockNetworkConfig, useTempWorkDir } from './service-test-setup.test-utils'; // Create mock functions (must remain per-file — jest.mock() is hoisted before imports) @@ -147,48 +150,39 @@ describe('agent environment: credentials', () => { expect(env.GH_TOKEN).not.toBe('ghp_real_secret_token_12345'); }); - it('should pass through ACTIONS_ID_TOKEN_REQUEST_URL when present in environment', () => { - const originalEnv = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + it('should never pass Actions OIDC minting variables to the agent', () => { + const origUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + const origToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://token.actions.githubusercontent.com/abc'; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'test-oidc-token-value'; try { const result = generateDockerCompose(mockConfig, mockNetworkConfig); const env = result.services.agent.environment as Record; - expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBe('https://token.actions.githubusercontent.com/abc'); + expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined(); + expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined(); } finally { - if (originalEnv !== undefined) { - process.env.ACTIONS_ID_TOKEN_REQUEST_URL = originalEnv; + if (origUrl !== undefined) { + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = origUrl; } else { delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; } - } - }); - - it('should pass through ACTIONS_ID_TOKEN_REQUEST_TOKEN when present in environment', () => { - const originalEnv = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; - process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'test-oidc-token-value'; - - try { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); - const env = result.services.agent.environment as Record; - expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBe('test-oidc-token-value'); - } finally { - if (originalEnv !== undefined) { - process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = originalEnv; + if (origToken !== undefined) { + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = origToken; } else { delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; } } }); - it('should not pass through OIDC variables when not in environment', () => { + it('should exclude Actions OIDC minting variables from --env-all', () => { const origUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; const origToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; - delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; - delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://token.actions.githubusercontent.com/abc'; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'test-oidc-token-value'; try { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose({ ...mockConfig, envAll: true }, mockNetworkConfig); const env = result.services.agent.environment as Record; expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined(); expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined(); @@ -206,6 +200,32 @@ describe('agent environment: credentials', () => { } }); + it('should reject explicit Actions OIDC minting variables in additionalEnv', () => { + const result = generateDockerCompose({ + ...mockConfig, + additionalEnv: { + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://token.actions.githubusercontent.com/abc', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'test-oidc-token-value', + }, + }, mockNetworkConfig); + const env = result.services.agent.environment as Record; + expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined(); + expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined(); + }); + + it('should exclude Actions OIDC minting variables from env files', () => { + const envFile = path.join(mockConfig.workDir, 'oidc.env'); + fs.writeFileSync(envFile, [ + 'ACTIONS_ID_TOKEN_REQUEST_URL=https://token.actions.githubusercontent.com/abc', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN=test-oidc-token-value', + ].join('\n')); + + const result = generateDockerCompose({ ...mockConfig, envFile }, mockNetworkConfig); + const env = result.services.agent.environment as Record; + expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined(); + expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined(); + }); + it('should never pass ACTIONS_RUNTIME_TOKEN to agent container', () => { const originalToken = process.env.ACTIONS_RUNTIME_TOKEN; process.env.ACTIONS_RUNTIME_TOKEN = 'test-runtime-token-value'; diff --git a/src/services/agent-environment/env-passthrough.ts b/src/services/agent-environment/env-passthrough.ts index 018c45395..0f249b10b 100644 --- a/src/services/agent-environment/env-passthrough.ts +++ b/src/services/agent-environment/env-passthrough.ts @@ -41,8 +41,6 @@ export function passthroughHostEnvironment(params: EnvPassthroughParams): void { 'XDG_CONFIG_HOME', 'GITHUB_SERVER_URL', 'GITHUB_API_URL', - 'ACTIONS_ID_TOKEN_REQUEST_URL', - 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', 'AZURE_CONFIG_DIR', 'ADO_MCP_AUTH_TOKEN', 'DOCKER_HOST', diff --git a/src/services/agent-environment/excluded-vars.ts b/src/services/agent-environment/excluded-vars.ts index 9df3adde5..73ef060b7 100644 --- a/src/services/agent-environment/excluded-vars.ts +++ b/src/services/agent-environment/excluded-vars.ts @@ -14,6 +14,8 @@ export function buildExclusionSet(config: WrapperConfig): Set { 'SUDO_GID', 'ACTIONS_RUNTIME_TOKEN', 'ACTIONS_RESULTS_URL', + 'ACTIONS_ID_TOKEN_REQUEST_URL', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', ...PROXY_ENV_VARS, 'AWF_PREFLIGHT_BINARY', 'AWF_STAGED_RUNNER_BINARY_NAME', diff --git a/src/services/api-proxy-service-oidc.test.ts b/src/services/api-proxy-service-oidc.test.ts index 22f2d9a5f..16407b827 100644 --- a/src/services/api-proxy-service-oidc.test.ts +++ b/src/services/api-proxy-service-oidc.test.ts @@ -37,8 +37,11 @@ describe('API proxy sidecar: OIDC env forwarding', () => { const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' }; const result = generateDockerCompose(config, mockNetworkConfigWithProxy); const env = result.services['api-proxy'].environment as Record; + const agentEnv = result.services.agent.environment as Record; expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBe('https://actions.local/token'); expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBe('runtime-token'); + expect(agentEnv.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined(); + expect(agentEnv.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined(); }); it('should forward ACTIONS_ID_TOKEN_REQUEST_* when config.authType is github-oidc (config-file path)', () => {