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