GitHub Token Exchanger

In modern world almost everything is moving towards "federated" credentials, when you services are authenticating against each other with OpenId Connect, without the need to have shared secret and as the result without the need to hide it, rotate, revoke, etc

But as usual there are some caveats, as example, at moment Azure has limit (I do not remember 20 or 60) number of federated credentials

Think of this - if you want to build and publish docker image from within github action, you gonna need to authenticate against Azure Container Registry, and to do so, you gonna need either create bazillion azure apps per each repostiroy with their own federated credentials or have single app with 20 or 60 credentials max, so if you have bigger number of repositories - you are out of luck

And exactly this scenario is good one to introduce "exchanger"

How it works

Inside github action we have access to github oidc provider, and can ask it to sign new token

name: demo

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]
  workflow_dispatch:

permissions:
  contents: read
  # this one is required
  id-token: write

jobs:
  demo:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/github-script@v6
        with:
          script: |
            core.getIDToken('HelloWorld').then(token => {
              // just for demo purposes we are printing it as hex, otherwise github will mask the token
              console.log(Buffer.from(token, 'utf8').toString('hex'))
            })

Such github actions will print token in hex representation, we may decode it like so:

const hex = '65794a30655841694f694...'
const token = Buffer.from(hex, 'hex').toString('utf8')
console.log(token)

and decoded token will look something like this

{
  "jti": "c142c404-07e7-45df-9a76-0567bcd861ab",
  "sub": "repo:mac2000/demo:pull_request",
  "aud": "HelloWorld",
  "ref": "refs/pull/1/merge",
  "sha": "fcc8fc13f82c8b1be9192a75088cb4e7f0f61160",
  "repository": "mac2000/demo",
  "repository_owner": "mac2000",
  "repository_owner_id": "123",
  "run_id": "5354606088",
  "run_number": "232",
  "run_attempt": "11",
  "repository_visibility": "private",
  "repository_id": "456",
  "actor_id": "88868",
  "actor": "mac2000",
  "workflow": "main",
  "head_ref": "demo",
  "base_ref": "main",
  "event_name": "pull_request",
  "ref_type": "branch",
  "workflow_ref": "mac2000/demo/.github/workflows/demo.yml@refs/pull/1/merge",
  "workflow_sha": "fcc8fc13f82c8b1be9192a75088cb4e7f0f61160",
  "job_workflow_ref": "mac2000/demo/.github/workflows/demo.yml@refs/pull/1/merge",
  "job_workflow_sha": "fcc8fc13f82c8b1be9192a75088cb4e7f0f61160",
  "runner_environment": "github-hosted",
  "iss": "https://token.actions.githubusercontent.com",
  "nbf": 1687512875,
  "exp": 1687513775,
  "iat": 1687513475
}

The most important things here are audience aud and issuer iss with them in place, we can do something as simple as

Exchange github token to azure token with dotnet

mkdir exchange
cd exchange
dotnet new web --no-https --exclude-launch-settings
dotnet add package Microsoft.Identity.Web
dotnet add package Azure.Identity
using Azure.Core;
using Azure.Identity;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;

var credentials = new DefaultAzureCredential();

var builder = WebApplication.CreateBuilder(args);

// all you need, for your app to authenticate github tokens
builder.Services.AddAuthorization().AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options => {
        options.Authority = "https://token.actions.githubusercontent.com";
        options.Audience = "HelloWorld";
    });


var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/", async ([FromQuery]string scope, CancellationToken cancellationToken) => {
    // just for demo, we are responding with azure token for requests with github tokens
    var result = await credentials.GetTokenAsync(new TokenRequestContext(new [] { scope }), cancellationToken);
    return result.Token;
}).RequireAuthorization();

app.Run();

and usage example will be

# exchange github token to azure token
curl -s -i 'http://localhost:5000/?scope=https://management.azure.com/.default' -H "Authorization: Bearer $(node token.js)"

# use token
access_token=$(curl -s 'http://localhost:5000/?scope=https://management.azure.com/.default' -H "Authorization: Bearer $(node token.js)")
curl -s -H "Authorization: Bearer $access_token" 'https://management.azure.com/subscriptions?api-version=2022-09-01' | jq ".value"

But can it be done even simpler

Exchange github token to azure container registry credentials

Technically speaking I need just container registry credentials, at the very end, no matter what, after many exchanges we are receiving them, so the idea behind this - just make it directly

Part of may github action:

# exchange github token to azure container registry credentials
# output: steps.acr.outputs.password
- uses: actions/github-script@v6
  id: acr
  with:
    script: |
      core.getIDToken('acr')
        .then(token => fetch('https://acr.mac-blog.org.ua', {headers: {Authorization: `Bearer ${token}`}}))
        .then(res => res.text())
        .then(password => {
          core.setSecret(password)
          core.setOutput('password', password)
        })

# docker login, aka: az acr login -n mac
- uses: docker/login-action@v2
  with:
    registry: mac.azurecr.io
    username: mac
    password: ${{ steps.acr.outputs.password }}

# now you can push images like mac.azurecr.io/whatever:1.2

And here is actual deployment (note: it is terraform but should be straight forward)

resource "kubernetes_deployment_v1" "acr" {
  metadata {
    name      = "acr"
    labels = {
      app = "acr"
    }
  }
  spec {
    selector {
      match_labels = {
        app = "acr"
      }
    }
    template {
      metadata {
        labels = {
          app = "acr"
        }
      }
      spec {
        volume {
          name = "acr"
          config_map {
            name = kubernetes_config_map_v1.acr.metadata[0].name
          }
        }
        container {
          name              = "acr"
          image             = "bitnami/oauth2-proxy"
          args = [
            # restrictions (repository_owner=mac2000, repository=mac2000/demo, ref=refs/heads/main, sha=616c..., repository_visibility=private, actor=mac2000, workflow=main)
            "--oidc-groups-claim=repository_owner",
            "--allowed-group=mac2000",
            # allowed audience
            "--client-id=acr",
            # allowed issuer
            "--oidc-issuer-url=https://token.actions.githubusercontent.com",
            # act as jwt verifier
            "--skip-jwt-bearer-tokens=true",
            # respond on success - the trick
            "--upstream=file://index.html",
            # default is 127.0.0.1:4180
            "--http-address=0.0.0.0:4180",
            # rest is required but values does not matter
            "--standard-logging=true",
            "--auth-logging=true",
            "--request-logging=true",
            "--provider=oidc",
            "--client-secret=whatever",
            "--email-domain=*",
            # whatever, random string
            "--cookie-secret=A1m8QYoKkpNdOylNUP8lW85Vc10ysCf-kuangYkYDUY=",
          ]
          port {
            container_port = 4180
          }
          volume_mount {
            name       = "acr"
            # mount credentials from config map as index.html - the trick
            mount_path = "/opt/bitnami/oauth2-proxy/index.html"
            sub_path   = "acr"
            read_only  = true
          }
        }
      }
    }
  }
}

Having container registry credentials we store them in config map or secret, and mount them as index.html file into our deployment, aka if it was simple nginx image that its home page will returns us credentials

Now we need to protect it, so only authenticated users can access it, thankfully there is oauth2-proxy for this

We can ask oauth2-proxy to serve static file for authenticated users by passing --upstream=file://index.html

Also we need to pass --skip-jwt-bearer-tokens=true to ask oauth2-proxy to act as jwt verifier

And rest of settings are pretty usual and stratight forward for configuring proxy

Also we can add little bit more of restrictions by passign oidc-groups-claim and allowed-group

So from github side we are doing:

core.getIDToken('acr')
  // at this point "token" will be github token
  .then(token => fetch('https://acr.mac-blog.org.ua', {headers: {Authorization: `Bearer ${token}`}})).then(res => res.text())
  // at this point we have sent request to our exchanger, it validated it, and if everything fine, responded with index.html that contains password for our container registry
  .then(password => {
    core.setSecret(password)
    core.setOutput('password', password)
  })

The beauty of this approach is that it can be used for anything else, not only github, also we do not need to build any custom apps, just configuring the thing from building pieces