Digital Garden Home

Setting up OpenID Connect authentication for GitHub Actions with AWS CDK

As of November 2021, GitHub Actions now provides support for OpenID Connect authentication when reaching out to third party services. In the past you'd need to generate access tokens for the platform of your choosing, but now with a handshake done by OpenID Connect, you only need to provide identification strings of saying who you are authenticating as rather than access tokens. GitHub worked with GCP, Azure, AWS, as well as Hashicorp Vault to start (Visit the GitHub Docs to learn more for the various providers). With this walkthrough we are going to use AWS and set up the majority of the infrastructure for this with AWS CDK.

Configuring our CDK Stack

Start out with a sample CDK stack written in JavaScript.

mkdir aws-cdk-oidc
cd aws-cdk-oidc
npx aws-cdk init app --language javascript

Then in the lib folder of our repo there will be the boilerplate code for our CDK stack, let's create a S3 bucket that our GitHub Actions workflow will be able to access.

class AwsCdkOidcStack extends cdk.Stack {
constructor(scope, id, props) {
super(scope, id, props)
const bucket = s3.Bucket(this, 'newBucket', {
bucketName: 'test-bucket',
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
})
}
}

I'm setting the blockPublicAccess prop on the bucket to BLOCK_ALL so it is private by default.

Next, I headed over to the AWS Console to set up an identity provider which tells AWS when it receives OIDC requests how to handle them and generate short-lived tokens for the workflow. There can only be one GitHub provider per account so I did this in the console directly. Visit the AWS Docs to learn how to set up an OpenID Connect identity provider. You'll want to set up the provider url to be https://token.actions.githubusercontent.com and the "Audience" field to be sts.amazonaws.com.

Afterwards, we want to create a role that has the permissions we want for this use case. We could manually write out the CDK code to create a new iam.Role, but a community member on GitHub wrote a CDK construct that manages the main low-level IAM settings for you: aripalo/aws-cdk-github-oidc. Install this package from npm:

npm install aws-cdk-github-oidc

And in our code, we want to use two exports from this package, GitHubActionsIdentityProvider and GitHubActionsRole.

import {
GitHubActionsIdentityProvider,
GitHubActionsRole,
} from 'aws-cdk-github-oidc'

GitHubActionsIdentityProvider would allow us to create that identity provider as before, but since we created one through the console, I'm going to use the fromAccount method to grab the provider from my AWS account to use within my CDK stack.

const provider = GitHubActionsIdentityProvider.fromAccount(
this,
'GitHubProvider'
)

Next, GitHubActionsRole will set up a new IAM role,

const uploadRole = new GithubActionsRole(this, 'S3UploadRole', {
provider,
owner: 'lannonbr',
repo: 'sample-repo',
filter: 'ref:refs/heads/main',
})

This doesn't give any permissions yet, but sets up a trust policy so if anyone tries to assume this role, it will be rejected unless the GitHub owner and repo name match. As well, the filter prop allows further restriction, so if you only want the workflow to run on a specific branch, or on a tag, etc.

Finally, we can use the CDK pattern of the .grant* methods on AWS resources which you can pass an IAM resource as the argument to set up the binding that a role or user should have some access to this AWS resource. For our use case, we want to grant our new role write permissions to the bucket:

bucket.grantWrite(uploadRole)

In summary, here's the full code for our new CDK stack:

class GitHubActionsCDKStack extends cdk.Stack {
constructor(scope, id, props) {
super(scope, id, props)
// Create a new bucket
const bucket = s3.Bucket(this, 'newBucket', {
bucketName: 'test-bucket',
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
})
// Fetch the GitHub Actions Identity Provider from our AWS Account
const provider = GitHubActionsIdentityProvider.fromAccount(
this,
'GitHubProvider'
)
// Set up the initial role
const uploadRole = new GithubActionsRole(this, 'S3UploadRole', {
provider,
owner: 'lannonbr',
repo: 'sample-repo',
filter: 'ref:refs/heads/main',
})
// Grant the role write permissions to our bucket
bucket.grantWrite(uploadRole)
}
}

Then, deploy the various resources to AWS with the deploy command

npx aws-cdk deploy

Configuring our GitHub Actions workflow

Next, we can start writing our Actions workflow. To begin, we want to define a few permissions for the entire workflow in the permissions field. For this workflow, I am going to trigger it on pushes to the main branch, but any other workflow trigger will also work.

name: Save Screenshot to S3 bucket
on:
push:
branches: [main]
permissions:
id-token: write
contents: write

id-token is to write the github token that will be sent up in the OIDC request, and contents is to allow the workflow to access the repo the workflow is in.

After this, let's run some code to create an image. I'm going to use my puppeteer-screenshot-action.

jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Screenshot
uses: 'lannonbr/puppeteer-screenshot-action@1.3.1'
with:
url: https://google.com/
width: 1440
height: 1024

Following this, we want to now authorize our workflow to act as the role we created in the previous section. As of v1.6.0 of the configure-aws-credentials action, it will now properly use the OIDC workflow, so if you are using the @v1 tag, it will automatically work with some params:

- uses: aws-actions/configure-aws-credentials@v1
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
role-session-name: S3Session
role-duration-seconds: 900

The main field here is the role-to-assume which is a secret with the ARN for the role created. You can either grab that role by visiting the IAM section in the AWS console or updating the CDK Stack to output the role ARN using the CFNOutput Construct.

In previous versions of this action, you would have to provide it an access key id and secret, but now we just tell it the role we want to use and if the conditions are properly set up, it will generate credentials for the workflow and will clean them up once the workflow completes.

Now we are properly authenticated so we can use the AWS CLI or any of the SDK clients in your language of choice. Since we are just uploading an image to an S3 bucket without any processing, let's use the CLI:

- name: Push to S3
env:
BUCKET_NAME: ${{ secrets.BUCKET_NAME }}
run: aws s3 cp $GITHUB_WORKSPACE/screenshots/ s3://$BUCKET_NAME/ --recursive

We store the bucket name as a secret and then run a cp command to take the output of the puppeteer screenshot action which is stored in $GITHUB_WORKSPACE/screenshots/ by default and put them into the root of the bucket.

As a recap, here's the full workflow file:

name: Save Screenshot to S3 bucket
on:
push:
branches: [main]
permissions:
id-token: write
contents: write
jobs:
run_puppeteer:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Screenshot
uses: 'lannonbr/puppeteer-screenshot-action@1.3.1'
with:
url: https://google.com/
width: 1440
height: 1024
- uses: aws-actions/configure-aws-credentials@v1
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
role-session-name: S3Session
role-duration-seconds: 900
- name: Push to S3
env:
BUCKET_NAME: ${{ secrets.BUCKET_NAME }}
run: aws s3 cp $GITHUB_WORKSPACE/screenshots/ s3://$BUCKET_NAME/ --recursive