Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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