GitHub App generating JWT

Some of GitHub API like status checks requires GitHub App

In general it is almost the same as using REST API but there is one caveat - generating an access token - it is like a night mare

Things becomes worse because it is needed not often and each time become pain

That's why going to put some notes after yet another time going thru seven rounds of hell trying to figure out hot it should be done

Let's pretend he have created and installend an app, as well as granted required permissions to it

We should have following info (in all next examples we expect this is accessible as environment variables)

  • APP_ID - can be found at url like this: https://github.com/organizations/{ORG}/settings/apps/{APP_NAME}
  • INSTALLATION_ID - can be found at installations page https://github.com/organizations/{ORG}/settings/apps/{APP_NAME}/installations, just navigate to intallation and grab your id from an url
  • PRIVATE_KEY - path to downloaded private key, it will have name like my-awesome-app.2023-12-14.private-key.pem and conctent like -----BEGIN RSA PRIVATE KEY-----.......

Example: Using Python to generate a JWT

Before doing anything else we need an baseline to check if things works at all

So I am taking an example from docs for python

Here is an script:

import jwt
import time
import os
import requests

private_key = jwt.jwk_from_pem(open(os.getenv("PRIVATE_KEY"), 'rb').read())
app_id = os.getenv("APP_ID")
installation_id = os.getenv("INSTALLATION_ID")

payload = {
    'iat': int(time.time()) - 10,
    'exp': int(time.time()) + 600,
    'iss': app_id
}

encoder = jwt.JWT()
encoded = encoder.encode(payload, private_key, alg='RS256')
print('jwt:', encoded)

token = requests.post(f"https://api.github.com/app/installations/{installation_id}/access_tokens", headers={"Authorization": f"Bearer {encoded}"}).json()['token']
print('token:', token)

meta = requests.get("https://api.github.com/meta", headers={"Authorization": f"Bearer {token}"})
print('meta:', meta.status_code)

python3 -m venv
source ./venv/bin/activate
pip install jwt
python sample.py
APP_ID=123 INSTALLATION_ID=456 PRIVATE_KEY=privatekey.pem python sample.py

If everything fine, output should be:

jwt: <token signed by private key>
token: <exchanged access token>
meta: 200

Note that https://api.github.com/meta is accessible by anonymous users, but if you will try to access it with wrong token you will receive an bad credentials error

Example: Using JavaScript to generate a JWT

Not it become little bit easy, here is an notjs example of the same test

import crypto from 'crypto'
import fs from 'fs'

var app_id = process.env.APP_ID
var installation_id = process.env.INSTALLATION_ID
var private_key = process.env.PRIVATE_KEY

var header = Buffer.from(
  JSON.stringify({
    typ: 'JWT',
    alg: 'RS256',
  })
).toString('base64url')

var payload = Buffer.from(
  JSON.stringify({
    iat: Math.floor(Date.now() / 1000) - 10,
    exp: Math.floor(Date.now() / 1000) + 600,
    iss: app_id,
  })
).toString('base64url')

const signature = crypto.createSign('RSA-SHA256').update(`${header}.${payload}`).sign(fs.readFileSync(private_key, 'utf8'), 'base64url')
var jwt = `${header}.${payload}.${signature}`
console.log('jwt:', jwt)

var { token } = await fetch(`https://api.github.com/app/installations/${installation_id}/access_tokens`, { method: 'POST', headers: { authorization: `Bearer ${jwt}` } }).then(r => r.json())
console.log('token:', token)

const meta = await fetch('https://api.github.com/meta', { headers: { authorization: `Bearer ${token}` } })
console.log('meta:', meta.status)

And its usage:

APP_ID=123 INSTALLATION_ID=456 PRIVATE_KEY=privatekey.pem node sample.mjs

Note: there is no need to install any 3rd party dependencies so it should work out of the box

Example: Using C# to generate a JWT

Before proceeding to next example, here is one more for C#

using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

var app_id = Environment.GetEnvironmentVariable("APP_ID");
var private_key = Environment.GetEnvironmentVariable("PRIVATE_KEY");
var installation_id = Environment.GetEnvironmentVariable("INSTALLATION_ID");

var head = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
{
    alg = "RS256",
    typ = "JWT"
}))).TrimEnd('=').Replace('+', '-').Replace('/', '_');

var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
{
    iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
    exp = DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds(),
    iss = app_id
}))).TrimEnd('=').Replace('+', '-').Replace('/', '_');

var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText(private_key));

var signature = Convert.ToBase64String(rsa.SignData(Encoding.UTF8.GetBytes($"{head}.{payload}"), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
var jwt = $"{head}.{payload}.{signature}";
Console.WriteLine($"jwt: {jwt}");

var client = new HttpClient();

client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt);
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (compatible; AcmeInc/1.0)");
var response = await client.PostAsync($"https://api.github.com/app/installations/{installation_id}/access_tokens", null);
var token = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.GetProperty("token").GetString();
Console.WriteLine($"token: {token}");

client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
response = await client.GetAsync("https://api.github.com/meta");
Console.WriteLine($"meta: {response.StatusCode}");

Usage:

APP_ID=123 INSTALLATION_ID=456 PRIVATE_KEY=privatekey.pem dotnet run

and finaly

Example: Using PowerShell to generate a JWT

$app_id = $env:APP_ID
$installation_id = $env:INSTALLATION_ID
$private_key = $env:PRIVATE_KEY

$header = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{
        alg = "RS256"
        typ = "JWT"
      }))).TrimEnd('=').Replace('+', '-').Replace('/', '_');

$payload = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{
        iat = [System.DateTimeOffset]::UtcNow.AddSeconds(-10).ToUnixTimeSeconds()
        exp = [System.DateTimeOffset]::UtcNow.AddMinutes(10).ToUnixTimeSeconds()
        iss = $app_id
      }))).TrimEnd('=').Replace('+', '-').Replace('/', '_');

$rsa = [System.Security.Cryptography.RSA]::Create()
$rsa.ImportFromPem((Get-Content $private_key -Raw))

$signature = [Convert]::ToBase64String($rsa.SignData([System.Text.Encoding]::UTF8.GetBytes("$header.$payload"), [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)).TrimEnd('=').Replace('+', '-').Replace('/', '_')
$jwt = "$header.$payload.$signature"
Write-Host "jwt: $jwt"

$token = Invoke-RestMethod -Method Post "https://api.github.com/app/installations/$installation_id/access_tokens" -Headers @{ Authorization = "Bearer $jwt" } | Select-Object -ExpandProperty token
Write-Host "token: $token"

$meta = Invoke-WebRequest "https://api.github.com/meta" -Headers @{ Authorization = "Bearer $token" }
Write-Host "meta: $($meta.StatusCode)"

With this in place, not it should be pretty straight forward to move further

Wondering if powershell example will be good enought to be added to dcos, meanwhile saving for myself in future