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/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/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/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 543450b84..92c007a91 100644 --- a/docs/api-proxy-sidecar.md +++ b/docs/api-proxy-sidecar.md @@ -747,7 +747,7 @@ Azure OpenAI deployments use a different base URL format from OpenAI. Set `--ope ### AWS Bedrock -Exchanges the GitHub OIDC JWT for temporary AWS credentials via STS `AssumeRoleWithWebIdentity`, and caches/refreshes them like the other providers. +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 @@ -759,11 +759,13 @@ Exchanges the GitHub OIDC JWT for temporary AWS credentials via STS `AssumeRoleW Default OIDC audience: `sts.amazonaws.com` -:::danger[SigV4 request signing is not implemented] -AWS Bedrock requires every request to be signed with SigV4 (using the `bedrock-runtime` service name), not a bearer token. **The current implementation does not do this.** `AwsOidcTokenProvider` mints and caches temporary STS credentials, but no code in the request pipeline (`proxy-request.js`, `http-client.js`, `upstream-http.js`) signs outgoing requests with them, and this project has no SigV4/AWS-SDK signing dependency. Requests sent through this path go upstream with no `Authorization` header and will be rejected by AWS. Treat AWS OIDC as **credential-exchange-only** — it is not currently a working path to Bedrock. See the [Limitations](#limitations) section below. +:::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. + +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` and include it in the AWF domain allowlist. ::: -No Bedrock invocation example is provided because the current request path cannot authenticate successfully. +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 @@ -1310,7 +1312,7 @@ 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 is credential-exchange only**: the sidecar mints and caches temporary AWS credentials via STS but does not sign requests with SigV4. See [OIDC Authentication > AWS Bedrock](#aws-bedrock) for details. +- **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`. diff --git a/docs/auth-matrix.md b/docs/auth-matrix.md index 0499cdbe6..cc262f105 100644 --- a/docs/auth-matrix.md +++ b/docs/auth-matrix.md @@ -197,10 +197,10 @@ 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 | *(none — see caution below)* | 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[AWS OIDC + Copilot: no SigV4 signing implemented] -Selecting `AWF_AUTH_PROVIDER=aws` for the Copilot adapter mints and caches temporary STS credentials, but **no code path signs the outgoing request with them**. This mirrors the same gap documented in [AWS (STS)](#aws-sts) below — see that section for details. Requests sent this way have no upstream `Authorization` header and will be rejected by any AWS-fronted target. +:::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:** @@ -296,13 +296,15 @@ All OIDC flows require GitHub Actions runtime tokens: **Token exchange:** `GET https://sts..amazonaws.com/?Action=AssumeRoleWithWebIdentity` **Result:** Temporary credentials (AccessKeyId, SecretAccessKey, SessionToken), cached and refreshed by `AwsOidcTokenProvider` -**Implementation:** `containers/api-proxy/aws-oidc-token-provider.js` -**Official docs:** https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_oidc.html -:::danger[SigV4 request signing is not implemented] -AWS Bedrock requires every request to be signed with [SigV4](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv4.html) using the `bedrock-runtime` service name. The current implementation mints and caches STS credentials via `AwsOidcTokenProvider`, but **no code in the request pipeline (`proxy-request.js`, `http-client.js`, `upstream-http.js`) signs outgoing requests with them**, and the project has no SigV4/AWS-SDK signing dependency. `resolveOidcAuthHeaders()` returns an empty header set for the AWS provider with a comment stating signing happens "later" — that later step does not exist yet. +**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 -**Practical effect:** requests routed through this AWS OIDC path go upstream with no `Authorization` header and will be rejected by AWS. Treat AWS OIDC support as **credential-lifecycle-only** (useful for testing STS role assumption) until request signing is implemented. Do not rely on it to reach AWS Bedrock today. +:::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) @@ -405,7 +407,7 @@ Adds `x-session-id` header automatically in BYOK mode unless already present. | OpenAI | Static key | — | ✅ | `openai.js` | | OpenAI | Azure BYOK | — | ✅ | `openai.js` | | OpenAI | Azure OIDC | — | ✅ | `openai.js`, `oidc-token-provider.js` | -| OpenAI | AWS OIDC (credential exchange only, no request signing) | — | ✅ (exchange) / ❌ (signing) | `openai.js`, `aws-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` | @@ -417,7 +419,7 @@ Adds `x-session-id` header automatically in BYOK mode unless already present. | Copilot | BYOK key | — | ✅ | `copilot.js`, `copilot-byok.js` | | Copilot | Azure BYOK | — | ✅ | via OpenAI adapter | | Copilot | Azure OIDC | — | ✅ | `copilot-adapter-enterprise.test.js` | -| Copilot | AWS OIDC (credential exchange only, no request signing) | — | ✅ (exchange) / ❌ (signing) | `aws-oidc-token-provider.js`, `server.auth-matrix.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`, `google-adapter.js` | diff --git a/docs/authentication-architecture.md b/docs/authentication-architecture.md index 4b94fbc3b..753bc876f 100644 --- a/docs/authentication-architecture.md +++ b/docs/authentication-architecture.md @@ -10,13 +10,13 @@ All LLM providers use identical credential isolation architecture. API keys are | Port | Provider | Auth header | |-------|--------------------|---------------------------------| -| 10000 | OpenAI | `Authorization: Bearer` (static or OIDC) | +| 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` (see note below), or `api-key` (Azure BYOK) | +| 10002 | GitHub Copilot | `Authorization: Bearer` or `token`, `api-key` (Azure BYOK), 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 known gaps (for example, AWS OIDC for Bedrock currently mints credentials but does not sign outgoing requests). +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 @@ -640,9 +640,8 @@ AWF moves the entire OIDC exchange into the api-proxy sidecar, so the agent neve │ ▼ Cloud API endpoint - (Azure OpenAI, GCP-fronted OpenAI/Copilot targets, Anthropic; - AWS Bedrock credential exchange works, but see the AWS - SigV4 caveat in Step 5 below — requests are not yet signed) + (Azure OpenAI, GCP-fronted OpenAI/Copilot targets, + Anthropic, and AWS Bedrock Runtime) ``` ### OIDC token flow: step by step @@ -735,12 +734,12 @@ When the agent sends a request to the sidecar, the provider adapter injects the | Azure | `Authorization` header | | GCP | `Authorization` header | | Anthropic | `Authorization: Bearer` plus `anthropic-beta: oauth-2025-04-20` | -| AWS | *(none — see caution below)* | +| 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. -:::danger[AWS OIDC: credentials are minted but never used to sign requests] -`AwsOidcTokenProvider` exchanges the GitHub JWT for temporary STS credentials (`AccessKeyId`/`SecretAccessKey`/`SessionToken`) and caches/refreshes them like the other providers, but **no code in the request pipeline signs outgoing requests with SigV4**. There is no AWS SDK or `aws4`-style signing dependency in `containers/api-proxy/package.json`, and `resolveOidcAuthHeaders()` returns an empty header object for the AWS provider. In practice, selecting `AWF_AUTH_PROVIDER=aws` currently produces STS credentials that are never applied to any request — AWS Bedrock (which requires SigV4 with the `bedrock-runtime` service) would reject a request sent this way for lack of an `Authorization` header. Treat this as a credential-lifecycle-only capability until request signing is implemented. +:::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 @@ -752,7 +751,7 @@ For Anthropic bearer requests, AWF merges the OAuth beta with client-supplied `a | Agent sees secret | No (api-proxy only) | No (api-proxy only) | | 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, Vertex AI | Azure (OpenAI/Copilot), GCP (OpenAI/Copilot adapters only — not the native Vertex/Gemini adapters), Anthropic WIF, AWS (credential exchange only; request signing not implemented) | +| 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 @@ -772,7 +771,7 @@ 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` |