@@ -18,6 +18,40 @@ import utils from "./utils.js";
1818
1919const 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 */
4276function 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 */
115151function _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() {
132168function 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+
174433export default {
434+ Now,
435+ fetchCredentials,
175436 readCredentials,
176437 sessionToken,
177438 writeCredentials
0 commit comments