Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion containers/api-proxy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
32 changes: 32 additions & 0 deletions containers/api-proxy/aws-oidc-token-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string>}
*/
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).
Expand Down
142 changes: 142 additions & 0 deletions containers/api-proxy/aws-oidc-token-provider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
156 changes: 156 additions & 0 deletions containers/api-proxy/aws-sigv4.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading
Loading