Skip to content

Commit 3c8c3ff

Browse files
shawnhankimdekobon
authored andcommitted
feat: payload hash func, default host name var, common var for AWS credentials and debug
- Move reusable functions of fectching credentials into awscredentials.js - Replace name of env variables of AWS credentials and debug to support common AWS signature lib for multiple services in AWS - Add a NJS var of default host name to be used for Role Session Name at multiple services in AWS - Add a payload hash function to support multiple services in AWS such as S3 and Lambda, and it can be used for not only GET but also POST method.
1 parent 2fd4fa4 commit 3c8c3ff

20 files changed

+573
-396
lines changed

common/docker-entrypoint.d/00-check-for-required-env.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ required=("S3_BUCKET_NAME" "S3_SERVER" "S3_SERVER_PORT" "S3_SERVER_PROTO"
3434
if [[ -v AWS_CONTAINER_CREDENTIALS_RELATIVE_URI ]]; then
3535
echo "Running inside an ECS task, using container credentials"
3636

37-
elif [[ -v S3_SESSION_TOKEN ]]; then
37+
elif [[ -v AWS_SESSION_TOKEN ]]; then
3838
echo "S3 Session token specified - not using IMDS for credentials"
3939

4040
# b) Using Instance Metadata Service (IMDS) credentials, if IMDS is present at http://169.254.169.254.
@@ -52,7 +52,7 @@ elif [[ -v AWS_WEB_IDENTITY_TOKEN_FILE ]]; then
5252
# If none of the options above is used, require static credentials.
5353
# See https://docs.aws.amazon.com/sdkref/latest/guide/feature-static-credentials.html.
5454
else
55-
required+=("S3_ACCESS_KEY_ID" "S3_SECRET_KEY")
55+
required+=("AWS_ACCESS_KEY_ID" "AWS_SECRET_ACCESS_KEY")
5656
fi
5757

5858
for name in ${required[@]}; do
@@ -101,7 +101,7 @@ if [ $failed -gt 0 ]; then
101101
fi
102102

103103
echo "S3 Backend Environment"
104-
echo "Access Key ID: ${S3_ACCESS_KEY_ID}"
104+
echo "Access Key ID: ${AWS_ACCESS_KEY_ID}"
105105
echo "Origin: ${S3_SERVER_PROTO}://${S3_BUCKET_NAME}.${S3_SERVER}:${S3_SERVER_PORT}"
106106
echo "Region: ${S3_REGION}"
107107
echo "Addressing Style: ${S3_STYLE}"

common/etc/nginx/include/awscredentials.js

Lines changed: 271 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,40 @@ import utils from "./utils.js";
1818

1919
const fs = require('fs');
2020

21+
/**
22+
* The current moment as a timestamp. This timestamp will be used across
23+
* functions in order for there to be no variations in signatures.
24+
* @type {Date}
25+
*/
26+
const NOW = new Date();
27+
28+
/**
29+
* Constant base URI to fetch credentials together with the credentials relative URI, see
30+
* https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html for more details.
31+
* @type {string}
32+
*/
33+
const ECS_CREDENTIAL_BASE_URI = 'http://169.254.170.2';
34+
35+
/**
36+
* @type {string}
37+
*/
38+
const EC2_IMDS_TOKEN_ENDPOINT = 'http://169.254.169.254/latest/api/token';
39+
40+
const EC2_IMDS_SECURITY_CREDENTIALS_ENDPOINT = 'http://169.254.169.254/latest/meta-data/iam/security-credentials/';
41+
42+
/**
43+
* Offset to the expiration of credentials, when they should be considered expired and refreshed. The maximum
44+
* time here can be 5 minutes, the IMDS and ECS credentials endpoint will make sure that each returned set of credentials
45+
* is valid for at least another 5 minutes.
46+
*
47+
* To make sure we always refresh the credentials instead of retrieving the same again, keep credentials until 4:30 minutes
48+
* before they really expire.
49+
*
50+
* @type {number}
51+
*/
52+
const maxValidityOffsetMs = 4.5 * 60 * 1000;
53+
54+
2155
/**
2256
* Get the current session token from either the instance profile credential
2357
* cache or environment variables.
@@ -34,24 +68,26 @@ function sessionToken(r) {
3468
}
3569

3670
/**
37-
* Get the instance profile credentials needed to authenticated against S3 from
71+
* Get the instance profile credentials needed to authenticate against S3 from
3872
* a backend cache. If the credentials cannot be found, then return undefined.
3973
* @param r {Request} HTTP request object (not used, but required for NGINX configuration)
4074
* @returns {undefined|{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string|null), expiration: (string|null)}} AWS instance profile credentials or undefined
4175
*/
4276
function readCredentials(r) {
4377
// TODO: Change the generic constants naming for multiple AWS services.
44-
if ('S3_ACCESS_KEY_ID' in process.env && 'S3_SECRET_KEY' in process.env) {
45-
const sessionToken = 'S3_SESSION_TOKEN' in process.env ?
46-
process.env['S3_SESSION_TOKEN'] : null;
78+
if ('AWS_ACCESS_KEY_ID' in process.env && 'AWS_SECRET_ACCESS_KEY' in process.env) {
79+
let sessionToken = 'AWS_SESSION_TOKEN' in process.env ?
80+
process.env['AWS_SESSION_TOKEN'] : null;
81+
if (sessionToken !== null && sessionToken.length === 0) {
82+
sessionToken = null;
83+
}
4784
return {
48-
accessKeyId: process.env['S3_ACCESS_KEY_ID'],
49-
secretAccessKey: process.env['S3_SECRET_KEY'],
85+
accessKeyId: process.env['AWS_ACCESS_KEY_ID'],
86+
secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY'],
5087
sessionToken: sessionToken,
5188
expiration: null
5289
};
5390
}
54-
5591
if ("variables" in r && r.variables.cache_instance_credentials_enabled == 1) {
5692
return _readCredentialsFromKeyValStore(r);
5793
} else {
@@ -113,8 +149,8 @@ function _readCredentialsFromFile() {
113149
* @private
114150
*/
115151
function _credentialsTempFile() {
116-
if (process.env['S3_CREDENTIALS_TEMP_FILE']) {
117-
return process.env['S3_CREDENTIALS_TEMP_FILE'];
152+
if (process.env['AWS_CREDENTIALS_TEMP_FILE']) {
153+
return process.env['AWS_CREDENTIALS_TEMP_FILE'];
118154
}
119155
if (process.env['TMPDIR']) {
120156
return `${process.env['TMPDIR']}/credentials.json`
@@ -132,7 +168,7 @@ function _credentialsTempFile() {
132168
function writeCredentials(r, credentials) {
133169
/* Do not bother writing credentials if we are running in a mode where we
134170
do not need instance credentials. */
135-
if (process.env['S3_ACCESS_KEY_ID'] && process.env['S3_SECRET_KEY']) {
171+
if (process.env['AWS_ACCESS_KEY_ID'] && process.env['AWS_SECRET_ACCESS_KEY']) {
136172
return;
137173
}
138174

@@ -171,7 +207,232 @@ function _writeCredentialsToFile(credentials) {
171207
fs.writeFileSync(_credentialsTempFile(), JSON.stringify(credentials));
172208
}
173209

210+
/**
211+
* Get the credentials needed to create AWS signatures in order to authenticate
212+
* to AWS service. If the gateway is being provided credentials via a instance
213+
* profile credential as provided over the metadata endpoint, this function will:
214+
* 1. Try to read the credentials from cache
215+
* 2. Determine if the credentials are stale
216+
* 3. If the cached credentials are missing or stale, it gets new credentials
217+
* from the metadata endpoint.
218+
* 4. If new credentials were pulled, it writes the credentials back to the
219+
* cache.
220+
*
221+
* If the gateway is not using instance profile credentials, then this function
222+
* quickly exits.
223+
*
224+
* @param r {Request} HTTP request object
225+
* @returns {Promise<void>}
226+
*/
227+
async function fetchCredentials(r) {
228+
/* If we are not using an AWS instance profile to set our credentials we
229+
exit quickly and don't write a credentials file. */
230+
if (utils.areAllEnvVarsSet(['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'])) {
231+
r.return(200);
232+
return;
233+
}
234+
235+
let current;
236+
237+
try {
238+
current = readCredentials(r);
239+
} catch (e) {
240+
utils.debug_log(r, `Could not read credentials: ${e}`);
241+
r.return(500);
242+
return;
243+
}
244+
245+
if (current) {
246+
// If AWS returns a Unix timestamp it will be in seconds, but in Date constructor we should provide timestamp in milliseconds
247+
// In some situations (including EC2 and Fargate) current.expiration will be an RFC 3339 string - see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#instance-metadata-security-credentials
248+
const expireAt = typeof current.expiration == 'number' ? current.expiration * 1000 : current.expiration
249+
const exp = new Date(expireAt).getTime() - maxValidityOffsetMs;
250+
if (NOW.getTime() < exp) {
251+
r.return(200);
252+
return;
253+
}
254+
}
255+
256+
let credentials;
257+
258+
utils.debug_log(r, 'Cached credentials are expired or not present, requesting new ones');
259+
260+
if (utils.areAllEnvVarsSet('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI')) {
261+
const relative_uri = process.env['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'] || '';
262+
const uri = ECS_CREDENTIAL_BASE_URI + relative_uri;
263+
try {
264+
credentials = await _fetchEcsRoleCredentials(uri);
265+
} catch (e) {
266+
utils.debug_log(r, 'Could not load ECS task role credentials: ' + JSON.stringify(e));
267+
r.return(500);
268+
return;
269+
}
270+
}
271+
else if (utils.areAllEnvVarsSet('AWS_WEB_IDENTITY_TOKEN_FILE')) {
272+
try {
273+
credentials = await _fetchWebIdentityCredentials(r)
274+
} catch (e) {
275+
utils.debug_log(r, 'Could not assume role using web identity: ' + JSON.stringify(e));
276+
r.return(500);
277+
return;
278+
}
279+
} else {
280+
try {
281+
credentials = await _fetchEC2RoleCredentials();
282+
} catch (e) {
283+
utils.debug_log(r, 'Could not load EC2 task role credentials: ' + JSON.stringify(e));
284+
r.return(500);
285+
return;
286+
}
287+
}
288+
try {
289+
writeCredentials(r, credentials);
290+
} catch (e) {
291+
utils.debug_log(r, `Could not write credentials: ${e}`);
292+
r.return(500);
293+
return;
294+
}
295+
r.return(200);
296+
}
297+
298+
/**
299+
* Get the credentials needed to generate AWS signatures from the ECS
300+
* (Elastic Container Service) metadata endpoint.
301+
*
302+
* @param credentialsUri {string} endpoint to get credentials from
303+
* @returns {Promise<{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}>}
304+
* @private
305+
*/
306+
async function _fetchEcsRoleCredentials(credentialsUri) {
307+
const resp = await ngx.fetch(credentialsUri);
308+
if (!resp.ok) {
309+
throw 'Credentials endpoint response was not ok.';
310+
}
311+
const creds = await resp.json();
312+
313+
return {
314+
accessKeyId: creds.AccessKeyId,
315+
secretAccessKey: creds.SecretAccessKey,
316+
sessionToken: creds.Token,
317+
expiration: creds.Expiration,
318+
};
319+
}
320+
321+
/**
322+
* Get the credentials needed to generate AWS signatures from the EC2
323+
* metadata endpoint.
324+
*
325+
* @returns {Promise<{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}>}
326+
* @private
327+
*/
328+
async function _fetchEC2RoleCredentials() {
329+
const tokenResp = await ngx.fetch(EC2_IMDS_TOKEN_ENDPOINT, {
330+
headers: {
331+
'x-aws-ec2-metadata-token-ttl-seconds': '21600',
332+
},
333+
method: 'PUT',
334+
});
335+
const token = await tokenResp.text();
336+
let resp = await ngx.fetch(EC2_IMDS_SECURITY_CREDENTIALS_ENDPOINT, {
337+
headers: {
338+
'x-aws-ec2-metadata-token': token,
339+
},
340+
});
341+
/* This _might_ get multiple possible roles in other scenarios, however,
342+
EC2 supports attaching one role only.It should therefore be safe to take
343+
the whole output, even given IMDS _might_ (?) be able to return multiple
344+
roles. */
345+
const credName = await resp.text();
346+
if (credName === "") {
347+
throw 'No credentials available for EC2 instance';
348+
}
349+
resp = await ngx.fetch(EC2_IMDS_SECURITY_CREDENTIALS_ENDPOINT + credName, {
350+
headers: {
351+
'x-aws-ec2-metadata-token': token,
352+
},
353+
});
354+
const creds = await resp.json();
355+
356+
return {
357+
accessKeyId: creds.AccessKeyId,
358+
secretAccessKey: creds.SecretAccessKey,
359+
sessionToken: creds.Token,
360+
expiration: creds.Expiration,
361+
};
362+
}
363+
364+
/**
365+
* Get the credentials by assuming calling AssumeRoleWithWebIdentity with the environment variable
366+
* values ROLE_ARN, AWS_WEB_IDENTITY_TOKEN_FILE and HOSTNAME
367+
*
368+
* @returns {Promise<{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}>}
369+
* @private
370+
*/
371+
async function _fetchWebIdentityCredentials(r) {
372+
const arn = process.env['AWS_ROLE_ARN'];
373+
const name = process.env['HOSTNAME'] || r.variables.defaultHostName;
374+
375+
let sts_endpoint = process.env['STS_ENDPOINT'];
376+
if (!sts_endpoint) {
377+
/* On EKS, the ServiceAccount can be annotated with
378+
'eks.amazonaws.com/sts-regional-endpoints' to control
379+
the usage of regional endpoints. We are using the same standard
380+
environment variable here as the AWS SDK. This is with the exception
381+
of replacing the value `legacy` with `global` to match what EKS sets
382+
the variable to.
383+
See: https://docs.aws.amazon.com/sdkref/latest/guide/feature-sts-regionalized-endpoints.html
384+
See: https://docs.aws.amazon.com/eks/latest/userguide/configure-sts-endpoint.html */
385+
const sts_regional = process.env['AWS_STS_REGIONAL_ENDPOINTS'] || 'global';
386+
if (sts_regional === 'regional') {
387+
/* STS regional endpoints can be derived from the region's name.
388+
See: https://docs.aws.amazon.com/general/latest/gr/sts.html */
389+
const region = process.env['AWS_REGION'];
390+
if (region) {
391+
sts_endpoint = `https://sts.${region}.amazonaws.com`;
392+
} else {
393+
throw 'Missing required AWS_REGION env variable';
394+
}
395+
} else {
396+
// This is the default global endpoint
397+
sts_endpoint = 'https://sts.amazonaws.com';
398+
}
399+
}
400+
401+
const token = fs.readFileSync(process.env['AWS_WEB_IDENTITY_TOKEN_FILE']);
402+
403+
const params = `Version=2011-06-15&Action=AssumeRoleWithWebIdentity&RoleArn=${arn}&RoleSessionName=${name}&WebIdentityToken=${token}`;
404+
405+
const response = await ngx.fetch(sts_endpoint + "?" + params, {
406+
headers: {
407+
"Accept": "application/json"
408+
},
409+
method: 'GET',
410+
});
411+
412+
const resp = await response.json();
413+
const creds = resp.AssumeRoleWithWebIdentityResponse.AssumeRoleWithWebIdentityResult.Credentials;
414+
415+
return {
416+
accessKeyId: creds.AccessKeyId,
417+
secretAccessKey: creds.SecretAccessKey,
418+
sessionToken: creds.SessionToken,
419+
expiration: creds.Expiration,
420+
};
421+
}
422+
423+
/**
424+
* Get the current timestamp. This timestamp will be used across functions in
425+
* order for there to be no variations in signatures.
426+
*
427+
* @returns {Date} The current moment as a timestamp
428+
*/
429+
function Now() {
430+
return NOW;
431+
}
432+
174433
export default {
434+
Now,
435+
fetchCredentials,
175436
readCredentials,
176437
sessionToken,
177438
writeCredentials

0 commit comments

Comments
 (0)