Golang OIDC verifier for Azure and Github Actions

Challenge: imagine you have some small rest api which you want to protect by requiring some kind of auth

aka as simple as:

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	fmt.Println("curl localhost:8080")
	log.Fatal(http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		secret := retrieveSecret()
		fmt.Fprint(w, secret)
	})))
}

func retrieveSecret() string {
	return "I'm some secret we want to protect"
}

The simples possible way is to add some check for secret being passed in header

But it will require us to remember about it, rotate it, hide it, ...

Instead we are going to protect this api with oidc

For providers we are going to use Azure and GitHub

In both cases we do not want to register any apps or doing something fancy, everything should just work out of the box without any specifics or configurations

Azure

For Azure our service should accept user access token that may be received like so:

az account get-access-token --query accessToken -o tsv

Token will be something like this:

{
  "aud": "https://management.core.windows.net/",
  "iss": "https://sts.windows.net/695e0000-0000-0000-0000-000000000c41/",
  "iat": 1710251613,
  "nbf": 1710251613,
  "exp": 1710256394,
  "acr": "1",
  "aio": "AVQAq/...",
  "amr": ["pwd", "mfa"],
  "appid": "04b07795-8ddb-461a-bbee-02f9e1bf7b46",
  "appidacr": "0",
  "family_name": "Marchenko",
  "given_name": "Alexandr",
  "groups": ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000003"],
  "idtyp": "user",
  "name": "Alexandr Marchenko",
  "oid": "57400000-0000-0000-0000-00000000c607",
  "onprem_sid": "S-1-5-21-1282360863-452469976-1639649100-2204",
  "puid": "10037FFEAE2D019E",
  "rh": "0.AR8AtWReaRMtqE67Eab9otYMQUZIf3kAutdPukPawfj2MBOFAJw.",
  "scp": "user_impersonation",
  "sub": "8_1Al2XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXEqa8",
  "tid": "695e0000-0000-0000-0000-000000000c41",
  "unique_name": "[email protected]",
  "upn": "[email protected]",
  "uti": "VqWW-xxxxxxxx_hhhhJ2AA",
  "ver": "1.0",
  "wids": ["62e90000-0000-0000-0000-000000005e10", "b79f0000-0000-0000-0000-000000005509"],
  "xms_cae": "1",
  "xms_cc": ["CP1"],
  "xms_filter_index": ["31"],
  "xms_rd": "0.42LlYBRilAcA",
  "xms_ssm": "1",
  "xms_tcdt": 1528879851
}

What is important here:

  • iss - issuer - who signed the token, will contain our tenant id - so if we were able to validate token we can be sure it is our user
  • groups - claim containing active directory groups user belongs to - can be used to restrict access even further

Everything else in this concrete example does not matter

Here is an example of verifying such token

package main

import (
	"context"
	"fmt"
	"log"
	"os"

	oidc "github.com/coreos/go-oidc"
)

func main() {
	tenantID := "695e0000-0000-0000-0000-000000000c41"
	ctx := context.Background()
	token := os.Getenv("token")
	if token == "" {
		log.Fatal("No token provided")
	}

	provider, err := oidc.NewProvider(ctx, "https://sts.windows.net/"+tenantID+"/")
	if err != nil {
		log.Fatal("Error creating OIDC provider:", err)
	}

	verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true})
	idToken, err := verifier.Verify(ctx, token)
	if err != nil {
		log.Fatal("Error verifying ID token:", err)
	}

	fmt.Println("Verified ID token: ", idToken.Subject)
	var claims struct {
		Groups []string `json:"groups"`
	}
	if err := idToken.Claims(&claims); err != nil {
		log.Fatal(err)
	}
	wanted := "00000000-0000-0000-0000-000000000001"
	found := false
	for _, group := range claims.Groups {
		if group == wanted {
			fmt.Println("User is in group", wanted)
			found = true
			break
		}
	}
	if found {
		fmt.Println("OK")
	} else {
		fmt.Println("User is not in group", wanted)
	}
}

Just replace tenantID and wanted identifiers and run it like so:

token=$(az account get-access-token --query accessToken -o tsv) go run azure/main.go

And if everything fine you will see an output like this:

Verified ID token:  8_1Al2....
User is in group 00000000-0000-0000-0000-000000000001
OK

Things to note:

  • We do not need to register any apps, for app to work we need to pass SkipClientIDCheck flag
  • We can perform as complex checks as we want agains this token - main job ob verifying signature is done with help of go-oidc
  • This snippet can be easily copy pasted into our web service

So ideally in future we will do something like

token=$(az account get-access-token --query accessToken -o tsv)
curl http://localhost:8000 -H "Authorization: Bearer $token"

GitHub

From GitHub side we may want to call our service from withing GitHub actions

While there is a way to authorize against Azure in Github actions we may want to stick with its tokens, once again to not register any apps and to not configure anything

Here is an sample github action yaml that you may use to generate github token:

name: sample
on:
  workflow_dispatch:
permissions:
  contents: read
  # this one is required
  id-token: write
jobs:
  sample:
    runs-on: ubuntu-latest
    steps:
      - 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'))
            })
            //.then(token => {
            //  core.setSecret(token)
            //  core.setOutput('token', token)
            //})

For demo purposes we are printing the token in hex format so we can copy it from logs, and decode with following snippet:

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

And the token will be something like:

{
  "jti": "8b780000-0000-0000-0000-00000000efa2",
  "sub": "repo:mac2000/mactemp:ref:refs/heads/main",
  "aud": "HelloWorld",
  "ref": "refs/heads/main",
  "sha": "185b51481a2a3e50f96a504a7e957bec7b1ec471",
  "repository": "mac2000/mactemp",
  "repository_owner": "mac2000",
  "repository_owner_id": "20000080",
  "run_id": "8250335905",
  "run_number": "2",
  "run_attempt": "1",
  "repository_visibility": "private",
  "repository_id": "73000094",
  "actor_id": "800008",
  "actor": "mac2000",
  "workflow": "sample",
  "head_ref": "",
  "base_ref": "",
  "event_name": "workflow_dispatch",
  "ref_protected": "true",
  "ref_type": "branch",
  "workflow_ref": "mac2000/mactemp/.github/workflows/sample.yml@refs/heads/main",
  "workflow_sha": "185b51481a2a3e50f96a504a7e957bec7b1ec471",
  "job_workflow_ref": "mac2000/mactemp/.github/workflows/sample.yml@refs/heads/main",
  "job_workflow_sha": "185b51481a2a3e50f96a504a7e957bec7b1ec471",
  "runner_environment": "github-hosted",
  "iss": "https://token.actions.githubusercontent.com",
  "nbf": 1710252203,
  "exp": 1710253103,
  "iat": 1710252803
}

Previously I already did something similar for token exchanger here

So our goal now to do something similar we did for Azure, e.g.:

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"strings"

	oidc "github.com/coreos/go-oidc"
)

func main() {
	token := os.Getenv("token")
	if token == "" {
		log.Fatal("No token provided")
	}
	ctx := context.Background()
	provider, err := oidc.NewProvider(ctx, "https://token.actions.githubusercontent.com")
	if err != nil {
		log.Fatal("Error creating OIDC provider:", err)
	}

	verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true})
	idToken, err := verifier.Verify(ctx, token)
	if err != nil {
		log.Fatal("Error verifying ID token:", err)
	}

	fmt.Println("Verified ID token: ", idToken.Subject)
	if !strings.HasPrefix(idToken.Subject, "repo:mac2000/") {
		log.Fatal("Not a repo:mac2000/* token")
	}
	found := false
	for _, audience := range idToken.Audience {
		if audience == "HelloWorld" {
			found = true
			break
		}
	}
	if found {
		fmt.Println("OK")
	} else {
		log.Fatal("Invalid audience")
	}
}

To run this app - first we need to create an github action, receive hex encoded token, decode it, expose it as environment variable and only then run our app:

token='xxxxxxx'
go run github/main.go

output will be

Verified ID token:  repo:mac2000/mactemp:ref:refs/heads/main
OK

Things to note:

  • We use HelloWorld audience as a first level check, but everyone can create such token
  • So as a second level check we are checking subject that is sticked to repository and restricts access to only us
  • If everything passes we can be sure it is our repo and is signed by Github

Later, it may be used in github something like so:

core
  .getIDToken('acr')
  .then(token => fetch('https://our-secret-service.mac-blog.org.ua', { headers: { Authorization: `Bearer ${token}` } }))
  .then(res => res.text())
  .then(secret => {
    core.setSecret(secret)
    core.setOutput('secret', secret)
  })

So now the last part - put everthing together which should not be challenging at all, just extract functions and try to check incomming token against both providers, aka:

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"strings"

	oidc "github.com/coreos/go-oidc"
)

// this is our interface - aka token is valid or not
type TokenVerifier interface {
	Verify(ctx context.Context, token string) error
}

// azure impl
type AzureVerifier struct {
	provider       *oidc.Provider
	allowedGroupID string
}

func NewAzureVerifier(ctx context.Context, tenantID string, allowedGroupID string) (*AzureVerifier, error) {
	provider, err := oidc.NewProvider(ctx, "https://login.microsoftonline.com/"+tenantID+"/v2.0")
	if err != nil {
		return nil, err
	}
	return &AzureVerifier{provider: provider, allowedGroupID: allowedGroupID}, nil
}

func (p *AzureVerifier) Verify(ctx context.Context, token string) error {
	verifier := p.provider.Verifier(&oidc.Config{SkipClientIDCheck: true})
	idToken, err := verifier.Verify(ctx, token)
	if err != nil {
		return err
	}
	var claims struct {
		Groups []string `json:"groups"`
	}
	if err := idToken.Claims(&claims); err != nil {
		return err
	}
	for _, group := range claims.Groups {
		if group == p.allowedGroupID {
			return nil
		}
	}
	return errors.New("not allowed")
}

// github impl
type GithubVerifier struct {
	provider *oidc.Provider
	owner    string
	audience string
}

func NewGithubVerifier(ctx context.Context, owner string, audience string) (*GithubVerifier, error) {
	provider, err := oidc.NewProvider(ctx, "https://token.actions.githubusercontent.com")
	if err != nil {
		return nil, err
	}
	return &GithubVerifier{provider: provider, owner: owner, audience: audience}, nil
}

func (p *GithubVerifier) Verify(ctx context.Context, token string) error {
	verifier := p.provider.Verifier(&oidc.Config{SkipClientIDCheck: true})
	idToken, err := verifier.Verify(ctx, token)
	if err != nil {
		return err
	}
	if !strings.HasPrefix(idToken.Subject, "repo:"+p.owner+"/") {
		return fmt.Errorf("not a repo:%s/* token", p.owner)
	}
	for _, audience := range idToken.Audience {
		if audience == p.audience {
			return nil
		}
	}
	return errors.New("invalid audience")
}

func getVerifiers(ctx context.Context) ([]TokenVerifier, error) {
	azure, err := NewAzureVerifier(ctx, "695e0000-0000-0000-0000-000000000c41", "00000000-0000-0000-0000-000000000001")
	if err != nil {
		return nil, err
	}
	github, err := NewGithubVerifier(ctx, "mac2000", "HelloWorld")
	if err != nil {
		return nil, err
	}
	return []TokenVerifier{azure, github}, nil
}

// our service
func main() {
	ctx := context.Background()
	verifiers, err := getVerifiers(ctx)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("curl localhost:8080")
	log.Fatal(http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// before responding with secret - check auth
		authorization := r.Header.Get("Authorization")
		if len(authorization) < 7 {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
		verified := false
		for _, verifier := range verifiers {
			err = verifier.Verify(ctx, authorization[7:])
			if err == nil {
				verified = true
				break
			}
		}
		if !verified {
			http.Error(w, "Forbidden", http.StatusForbidden)
			return
		}

		secret := retrieveSecret()
		fmt.Fprint(w, secret)
	})))
}

func retrieveSecret() string {
	return "I'm some secret we want to protect"
}

Obviously this one is not ideal and is not ment to be copy pasted but rather as an example for further ideas

e.g. you may put it into dedicated packages, create dedicated verifier struct on top of it, hidding iteration, use wait groups to verify tokens simultaneosly and etc

In my case main function takes almost half a minute to respond, that's why I do not care at all and leaving this for future.