Repository: aws-actions/configure-aws-credentials
Pull request #240 (2021-07-23 19:27:08)

feat: Add the ability to use a web identity token file
*Issue #, if available:* : 

https://github.com/aws-actions/configure-aws-credentials/issues/124

*Description of changes:*

I would like to be able to use a github action to consume a web identity token file on an EKS worker node.  I have a self-hosted runner which does not run as `root` and thus it does not automatically get IRSA credentials, so my current workaround is to use a bash script which handles the assume role portions, but it doesn't neatly export the credentials the way this workflow does.  This pull request would allow users of this workflow to consume the web identity token file when assuming a role in a much cleaner fashion.


paragbhingre: @nesta219 Thank you for contributing this feature. We will take a look at it and get back to you soon.
nesta219: Very reasonable suggestions, I will update the docs and I'll test using the action from my fork on a real EKS node as well
nesta219: @allisaurus updated based on your feedback, and I also tested this with a real self-hosted github actions runner in EKS and was able to assume the web identity role using the token file
nesta219: ![Screen Shot 2021-07-29 at 3 26 28 PM](https://user-images.githubusercontent.com/1932244/127553715-0ec23768-4516-48c3-97d1-389a4918af6a.png)

nesta219: Note in the screen shot that the decisions to use `role-duration-seconds: 3600` and `role-skip-session-tagging: true` were made because of some restrictions that our runner role has, and are not related to the actual GH action itself
nesta219: One further comment: 
The nodejs [assumeRoleWithWebIdentity](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/STS.html#assumeRoleWithWebIdentity-property) function doesn't support the `Tags` parameter (the tags come from the JWT [stored in the web identity token file](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_session-tags.html#id_session-tags_adding-assume-role-idp) as opposed to the request body), so I adjusted the logic to exclude this parameter from the request if we are using a web identity token file.

Patch:
diff --git a/README.md b/README.md
index 864f9c9..1b5b108 100644
--- a/README.md
+++ b/README.md
@@ -189,6 +189,17 @@ with:
 ```
 In this case, your runner's credentials must have permissions to assume the role.
 
+You can also assume a role using a web identity token file, such as if using [Amazon EKS IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts-technical-overview.html).  Pods running in EKS worker nodes that do not run as root can use this file to assume a role with a web identity.
+
+You can configure your workflow as follows in order to use this file:
+```yaml
+uses: aws-actions/configure-aws-credentials@v1
+with:
+  aws-region: us-east-2
+  role-to-assume: my-github-actions-role
+  web-identity-token-file: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
+```
+
 ### Use with the AWS CLI
 
 This workflow does _not_ install the [AWS CLI](https://aws.amazon.com/cli/) into your environment. Self-hosted runners that intend to run this action prior to executing `aws` commands need to have the AWS CLI [installed](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-install.html) if it's not already present. 
diff --git a/action.yml b/action.yml
index fafa25b..4e41aeb 100644
--- a/action.yml
+++ b/action.yml
@@ -34,6 +34,11 @@ inputs:
       environment with the assumed role credentials rather than with the provided
       credentials
     required: false
+  web-identity-token-file:
+    description: >-
+      Use the web identity token file from the provided file system path in order to
+      assume an IAM role using a web identity. E.g., from within an Amazon EKS worker node
+    required: false
   role-duration-seconds:
     description: "Role duration in seconds (default: 6 hours)"
     required: false
diff --git a/index.js b/index.js
index 018aac3..5b6262e 100644
--- a/index.js
+++ b/index.js
@@ -1,6 +1,8 @@
 const core = require('@actions/core');
 const aws = require('aws-sdk');
 const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
 
 // The max time that a GitHub action is allowed to run is 6 hours.
 // That seems like a reasonable default to use if no role duration is defined.
@@ -22,7 +24,8 @@ async function assumeRole(params) {
     roleDurationSeconds,
     roleSessionName,
     region,
-    roleSkipSessionTagging
+    roleSkipSessionTagging,
+    webIdentityTokenFile
   } = params;
   assert(
       [sourceAccountId, roleToAssume, roleDurationSeconds, roleSessionName, region].every(isDefined),
@@ -42,6 +45,7 @@ async function assumeRole(params) {
     // Supports only 'aws' partition. Customers in other partitions ('aws-cn') will need to provide full ARN
     roleArn = `arn:aws:iam::${sourceAccountId}:role/${roleArn}`;
   }
+
   const tagArray = [
     {Key: 'GitHub', Value: 'Actions'},
     {Key: 'Repository', Value: GITHUB_REPOSITORY},
@@ -74,15 +78,38 @@ async function assumeRole(params) {
     assumeRoleRequest.ExternalId = roleExternalId;
   }
 
-  return sts.assumeRole(assumeRoleRequest)
-  .promise()
-  .then(function (data) {
-    return {
-      accessKeyId: data.Credentials.AccessKeyId,
-      secretAccessKey: data.Credentials.SecretAccessKey,
-      sessionToken: data.Credentials.SessionToken,
-    };
-  });
+  let assumeFunction = sts.assumeRole.bind(sts);
+
+  if(isDefined(webIdentityTokenFile)) {
+    core.debug("webIdentityTokenFile provided. Will call sts:AssumeRoleWithWebIdentity and take session tags from token contents.")
+    delete assumeRoleRequest.Tags;
+
+    const webIdentityTokenFilePath = path.isAbsolute(webIdentityTokenFile) ?
+      webIdentityTokenFile :
+      path.join(process.env.GITHUB_WORKSPACE, webIdentityTokenFile);
+
+    if (!fs.existsSync(webIdentityTokenFilePath)) {
+      throw new Error(`Web identity token file does not exist: ${webIdentityTokenFilePath}`);
+    }
+
+    try {
+      assumeRoleRequest.WebIdentityToken = await fs.promises.readFile(webIdentityTokenFilePath, 'utf8');
+      assumeFunction = sts.assumeRoleWithWebIdentity.bind(sts);
+    } catch(error) {
+      throw new Error(`Web identity token file could not be read: ${error.message}`);
+    }
+    
+  } 
+
+  return assumeFunction(assumeRoleRequest)
+    .promise()
+    .then(function (data) {
+      return {
+        accessKeyId: data.Credentials.AccessKeyId,
+        secretAccessKey: data.Credentials.SecretAccessKey,
+        sessionToken: data.Credentials.SessionToken,
+      };
+    });
 }
 
 function sanitizeGithubActor(actor) {
@@ -211,6 +238,7 @@ async function run() {
     const roleSessionName = core.getInput('role-session-name', { required: false }) || ROLE_SESSION_NAME;
     const roleSkipSessionTaggingInput = core.getInput('role-skip-session-tagging', { required: false })|| 'false';
     const roleSkipSessionTagging = roleSkipSessionTaggingInput.toLowerCase() === 'true';
+    const webIdentityTokenFile = core.getInput('web-identity-token-file', { required: false })
 
     if (!region.match(REGION_REGEX)) {
       throw new Error(`Region is not valid: ${region}`);
@@ -249,7 +277,8 @@ async function run() {
         roleExternalId,
         roleDurationSeconds,
         roleSessionName,
-        roleSkipSessionTagging
+        roleSkipSessionTagging,
+        webIdentityTokenFile
       });
       exportCredentials(roleCredentials);
       await validateCredentials(roleCredentials.accessKeyId);


Test patch:
diff --git a/index.test.js b/index.test.js
index f2295c9..af9d4a6 100644
--- a/index.test.js
+++ b/index.test.js
@@ -24,6 +24,7 @@ const ENVIRONMENT_VARIABLE_OVERRIDES = {
     GITHUB_ACTOR: 'MY-USERNAME[bot]',
     GITHUB_SHA: 'MY-COMMIT-ID',
     GITHUB_REF: 'MY-BRANCH',
+    GITHUB_WORKSPACE: '/home/github'
 };
 const GITHUB_ACTOR_SANITIZED = 'MY-USERNAME_bot_'
 
@@ -46,6 +47,7 @@ const ASSUME_ROLE_INPUTS = {...CREDS_INPUTS, 'role-to-assume': ROLE_ARN, 'aws-re
 
 const mockStsCallerIdentity = jest.fn();
 const mockStsAssumeRole = jest.fn();
+const mockStsAssumeRoleWithWebIdentity = jest.fn();
 
 jest.mock('aws-sdk', () => {
     return {
@@ -55,10 +57,20 @@ jest.mock('aws-sdk', () => {
         STS: jest.fn(() => ({
             getCallerIdentity: mockStsCallerIdentity,
             assumeRole: mockStsAssumeRole,
+            assumeRoleWithWebIdentity: mockStsAssumeRoleWithWebIdentity
         }))
     };
 });
 
+jest.mock('fs', () => {
+    return {
+        promises: {
+            readFile: jest.fn(() => Promise.resolve('testpayload')),
+        },
+        existsSync: jest.fn(() => true)
+    };
+});
+
 describe('Configure AWS Credentials', () => {
     const OLD_ENV = process.env;
 
@@ -119,6 +131,20 @@ describe('Configure AWS Credentials', () => {
                 }
             }
         });
+
+        mockStsAssumeRoleWithWebIdentity.mockImplementation(() => {
+            return {
+                promise() {
+                    return Promise.resolve({
+                        Credentials: {
+                            AccessKeyId: FAKE_STS_ACCESS_KEY_ID,
+                            SecretAccessKey: FAKE_STS_SECRET_ACCESS_KEY,
+                            SessionToken: FAKE_STS_SESSION_TOKEN
+                        }
+                    });
+                }
+            }
+        });
     });
 
     afterEach(() => {
@@ -507,6 +533,34 @@ describe('Configure AWS Credentials', () => {
         })
     });
 
+    test('web identity token file provided with absolute path', async () => {
+        core.getInput = jest
+            .fn()
+            .mockImplementation(mockGetInput({'role-to-assume': ROLE_ARN, 'aws-region': FAKE_REGION, 'web-identity-token-file': '/fake/token/file'}));
+
+        await run();
+        expect(mockStsAssumeRoleWithWebIdentity).toHaveBeenCalledWith({
+            RoleArn: 'arn:aws:iam::111111111111:role/MY-ROLE',
+            RoleSessionName: 'GitHubActions',
+            DurationSeconds: 6 * 3600,
+            WebIdentityToken: 'testpayload'
+        })
+    });
+
+    test('web identity token file provided with relative path', async () => {
+        core.getInput = jest
+            .fn()
+            .mockImplementation(mockGetInput({'role-to-assume': ROLE_ARN, 'aws-region': FAKE_REGION, 'web-identity-token-file': 'fake/token/file'}));
+
+        await run();
+        expect(mockStsAssumeRoleWithWebIdentity).toHaveBeenCalledWith({
+            RoleArn: 'arn:aws:iam::111111111111:role/MY-ROLE',
+            RoleSessionName: 'GitHubActions',
+            DurationSeconds: 6 * 3600,
+            WebIdentityToken: 'testpayload'
+        })
+    });
+
     test('role external ID provided', async () => {
         core.getInput = jest
             .fn()