Pipeline Identities, Part 3: The Job's ID Token

In this part you'll have a job ask GitHub for an ID token, and print what it says. The call you'll cover:

  • call 4, job → GitHub: the job asks for an ID token.

The token says which repository and branch the job runs for, signed by GitHub.

  • It allows nothing by itself: Part 4 shows it to AWS, which decides what to grant.
  • It is the ID token of the OIDC page, with a job where alice was.
  • A job gets one in four moves:
    1. permissions: id-token: write lets the job ask. Without it, nothing else in this list happens.
    2. The runner sets two environment variables in the job:
      • ACTIONS_ID_TOKEN_REQUEST_URL: the address of GitHub's token service;
      • ACTIONS_ID_TOKEN_REQUEST_TOKEN: a short-lived token proving the request comes from this job.
    3. The job calls that URL with the request token, naming an audience: the service the ID token is for.
    4. GitHub answers with a JWT signed with its private key.

No browser, no sign-in, no consent: GitHub started the job, so it knows which job is asking. This part talks to GitHub only.

Add a workflow that prints the token

Create a second workflow, run by hand, so the pipeline is left as it is. It starts without id-token: write, so its first run shows what the permission does.

.github/workflows/show-token.yml:

 1name: show-token
 2
 3on: workflow_dispatch
 4
 5permissions:
 6  contents: read
 7
 8jobs:
 9  token:
10    runs-on: ubuntu-latest
11    steps:
12      - name: GitHub's discovery document and public keys
13        run: |
14          curl -sS https://token.actions.githubusercontent.com/.well-known/openid-configuration | jq '{issuer, jwks_uri}'
15          curl -sS https://token.actions.githubusercontent.com/.well-known/jwks | jq '.keys[] | {kid, kty, alg}'
16
17      - name: Ask for an ID token addressed to AWS, and print its header and claims
18        run: |
19          # The runner sets the request URL and token only when the job holds id-token: write.
20          if [ -z "$ACTIONS_ID_TOKEN_REQUEST_URL" ]; then
21            echo "No ID token request URL: this job may not ask for an ID token"
22            exit 1
23          fi
24          TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
25                    "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
26          # Print parts 1 and 2 only. The signature, and the token as a whole, stay out of the log.
27          for part in 1 2; do
28            p=$(echo "$TOKEN" | cut -d. -f"$part" | tr '_-' '/+')
29            while [ $(( ${#p} % 4 )) -ne 0 ]; do p="$p="; done # base64url drops the padding
30            echo "$p" | base64 -d | jq .
31          done
  • The first step prints the two public documents any service reads to check GitHub's tokens: the discovery document and the keys.
  • The second step asks for a token addressed to AWS, and prints its header and claims.
    • The whole token stays out of the log: for a few minutes it is a real credential.

Run it, and read the refusal

  1. Commit and push.
  2. Open Actions → show-token → Run workflow.
  3. Read the run:
    • The discovery document and public keys print: they are public, and need no credential.

    • Ask for an ID token fails:

      1No ID token request URL: this job may not ask for an ID token
      

      The job holds no id-token: write, so the runner set neither variable.

Grant the job the right to ask

  1. Add the permission to show-token.yml:

    1permissions:
    2  contents: read
    3  id-token: write # lets the job ask GitHub for an ID token
    
  2. Commit and push, then Run workflow again. The step prints the token's header and claims.

Read the token's claims

The claims say who the job is; the header says which key signed them.

ClaimValueMeaning
isshttps://token.actions.githubusercontent.comGitHub issued it
audsts.amazonaws.comthe audience the job asked for
subrepo:<you>/pipeline-identities:ref:refs/heads/mainthis repository, this branch
repository, ref, sha, workflow, actor…more detail a service may check
iat, expa few minutes apartit expires almost at once
  • sub is the claim Part 4's AWS role checks.
  • kid in the header matches one of the keys the first step printed: the key AWS will check the signature with.

Change the request

  1. Ask for another audience. Change audience=sts.amazonaws.com to audience=example.com, push, and run again.
    • aud is now example.com; AWS would refuse this token, since it is addressed to someone else.
  2. Run it on another branch. Create a branch try, push it, and run show-token on it from Run workflow.
    • sub ends in refs/heads/try.
  3. Put the audience back to sts.amazonaws.com, and push. Part 4 does not use this workflow, but it stays useful for a look at the claims.