Another deep dive into Azure AD Workload Identity Federation using GitHub actions

I just had a previous blogpost about Workload Identity Federation, where I went into the details of how authentication works. This time, I want to use GitHub actions, which is the currently supported method. There are some limitations to the documentation today, but hopefully we will be able to do things like accessing KeyVaults and other services, not only using Azure CLI.

Let us first follow this and this documentation, to configure an Azure CLI session from GitHub Actions, and build from there.

We start by registering a new app registration in Azure AD, and adding our GitHub federated credential:

I am choosing a random value in the GitHub environment name property, as I have no idea what it is yet, but we’ll figure it out:

From the information we have inserted, we find that the issuer is always https://token.actions.githubusercontent.com, and subject identifier (OIDC sub claim) has the different values we used – repo:mariussm/action-testing:environment:Development.

This will automatically create a service principal, that we will now grant the Reader role for our subscription:

We can now use the provided action example, adjusting a few things:

name: Test

# Controls when the workflow will run
on:
  push:
    branches: [ main ]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# Allow the action to get the required token
permissions:
  id-token: write
  contents: read

# Our Azure environment that we want to sign into
env:
  CLIENT_ID: 8e80a94d-b9bc-48ed-8c68-7ebcf3f1e26a
  TENANT_ID: 95877aab-f66b-4cd0-98a0-1218634664fa
  SUBSCRIPTION_ID: cf52a112-fbee-4377-807b-cca5820bc0af

jobs: 
  Test:
      runs-on: windows-latest
      steps:
        # Step that uses our federated workflow identity and creates an Azure CLI session
        - name: OIDC Login to Azure Public Cloud with AzPowershell (enableAzPSSession true)
          uses: azure/login@v1
          with:
            client-id: ${{env.CLIENT_ID}}
            tenant-id: ${{env.TENANT_ID}}
            subscription-id: ${{env.SUBSCRIPTION_ID}}
            enable-AzPSSession: true

        # Get resource group using the previously created session
        - name: 'Get resource group with PowerShell action'
          uses: azure/powershell@v1
          with:
            inlineScript: |
              Get-AzResourceGroup
            azPSVersion: "latest"

And of course it fails on first try, but the error message is super easy to follow:

Error: : AADSTS70021: No matching federated identity record found for presented assertion. Assertion Issuer: 'https://token.actions.githubusercontent.com'. Assertion Subject: 'repo:mariussm/action-testing:ref:refs/heads/main'. Assertion Audience: 'api://AzureADTokenExchange'

What we can see here, is as I suspected, an error because I have not provided “Environment” anywhere. I used the value “Development”, but I have not provided it to the action anywhere. Therefore, the azure/login@v1 step got a JWT with repo:mariussm/action-testing:ref:refs/heads/main as the subject, which is not what Azure AD expects.

Luckily, the GitHub documentation is very good, and using the environment documentation, but that is not available for my private repo. So let’s just update our federated credential. This means we can only run from the main branch, but for my testing this is fine:

And we can now see that it succeeds:

Ok, that is cool and all, but currently this is very limited. I have Azure CLI available, so I can do things like Get-AzAccessToken to get access tokens to services, but this cmdlet does not support things like scopes.

Adding some API permissions to our app and updating our action shows that this works:

name: Test

# Controls when the workflow will run
on:
  push:
    branches: [ main ]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# Allow the action to get the required token
permissions:
  id-token: write
  contents: read

# Our Azure environment that we want to sign into
env:
  CLIENT_ID: 8e80a94d-b9bc-48ed-8c68-7ebcf3f1e26a
  TENANT_ID: 95877aab-f66b-4cd0-98a0-1218634664fa
  SUBSCRIPTION_ID: cf52a112-fbee-4377-807b-cca5820bc0af

jobs: 
  Test:
      runs-on: windows-latest
      steps:
        # Step that uses our federated workflow identity and creates an Azure CLI session
        - name: OIDC Login to Azure Public Cloud with AzPowershell (enableAzPSSession true)
          uses: azure/login@v1
          with:
            client-id: ${{env.CLIENT_ID}}
            tenant-id: ${{env.TENANT_ID}}
            subscription-id: ${{env.SUBSCRIPTION_ID}}
            enable-AzPSSession: true

        # Get users from Azure AD using access token provided by Azure CLI
        - name: 'Get graph access token'
          uses: azure/powershell@v1
          with:
            inlineScript: |
              $token = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com/"
              Invoke-RestMethod "https://graph.microsoft.com/v1.0/users" -Headers @{Authorization = ("Bearer {0}" -f $token.Token)} | Select-Object -ExpandProperty value | Measure-Object
            azPSVersion: "latest"

And we can now see that it can find 43 users:

So, we are now at a place where GitHub actions can authenticate, but only with Azure CLI. How can we customize a bit more?

I checked out the code behind the azure/login@v1 step, and found this line:

This uses the getIDToken method of core, which is defined in @actions/core. There should be no reasy why we should not be able to call this from any other action step. What I have done previously in Azure DevOps, is adding something like this to print all environment variables:

dir ENV: | out-string | write-host

Using this I find this:

So now I have something to Google, and we can find this doc that documents the ACTIONS_ID_TOKEN_REQUEST_URL variable.

Based on one of the examples here, I believe the following will nicely get a JWT:

$githubjwt = Invoke-RestMethod $ENV:ACTIONS_ID_TOKEN_REQUEST_URL -Headers @{Authorization = ("bearer {0}" -f $ENV:ACTIONS_ID_TOKEN_REQUEST_TOKEN)}

Testing this I found that it returns a dictionary with Value, which is a jwt:

And here we can find that the audience is not what we need (though, that can be changed in Azure AD):

We can now get the JWT and display the contents using an action like this:

name: Test

# Controls when the workflow will run
on:
  push:
    branches: [ main ]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# Allow the action to get the required token
permissions:
  id-token: write
  contents: read

# Our Azure environment that we want to sign into
env:
  CLIENT_ID: 8e80a94d-b9bc-48ed-8c68-7ebcf3f1e26a
  TENANT_ID: 95877aab-f66b-4cd0-98a0-1218634664fa
  SUBSCRIPTION_ID: cf52a112-fbee-4377-807b-cca5820bc0af

jobs: 
  Test:
      runs-on: windows-latest
      steps:
        # Get users from Azure AD using access token provided by Azure CLI
        - name: 'Get graph access token'
          uses: azure/powershell@v1
          with:
            inlineScript: |
              # Get JWT from GitHub
              $url = $ENV:ACTIONS_ID_TOKEN_REQUEST_URL
              $githubjwt = Invoke-RestMethod $url -Headers @{Authorization = ("bearer {0}" -f $ENV:ACTIONS_ID_TOKEN_REQUEST_TOKEN)}
              
              Write-Host "GitHub JWT url: $url"

              Write-Host "GitHub JWT payload:"
              # Fix padding characters
              $payload = ($githubjwt.Value -split "\.")[1]
              if(($payload.Length % 4) -ne 0) {
                $payload = $payload.PadRight($payload.Length + 4 - ($payload.Length % 4), "=")
              }

              # Print pretty
              [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload)) | convertfrom-json | convertto-json
            azPSVersion: "latest"

Now, I have no idea which property to provide to the idtoken endpoint in order to change the audience, but i tried resource which is common in OIDC – no luck – tried aud, and then finally audience – which worked!

So now I believe we have the JWT we need to authenticate to Azure AD. Here is a full working pipeline!

name: Test

# Controls when the workflow will run
on:
  push:
    branches: [ main ]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# Allow the action to get the required token
permissions:
  id-token: write
  contents: read

# Our Azure environment that we want to sign into
env:
  CLIENT_ID: 8e80a94d-b9bc-48ed-8c68-7ebcf3f1e26a
  TENANT_ID: 95877aab-f66b-4cd0-98a0-1218634664fa

jobs: 
  Test:
      runs-on: windows-latest
      steps:
        # Get users from Azure AD using access token provided by Azure CLI
        - name: 'Get graph access token'
          uses: azure/powershell@v1
          with:
            inlineScript: |
              # 1 - Get JWT from GitHub
              $audience = "api://AzureADTokenExchange"
              $url = "{0}&audience={1}" -f $ENV:ACTIONS_ID_TOKEN_REQUEST_URL, $audience
              $githubjwt = Invoke-RestMethod $url -Headers @{Authorization = ("bearer {0}" -f $ENV:ACTIONS_ID_TOKEN_REQUEST_TOKEN)}
              
              # 2 - Print GitHub url and payload to screen for debuging
              Write-Host "GitHub JWT url: $url"
              Write-Host "GitHub JWT payload:"              
              $payload = ($githubjwt.Value -split "\.")[1]
              if(($payload.Length % 4) -ne 0) {
                $payload = $payload.PadRight($payload.Length + 4 - ($payload.Length % 4), "=")
              }
              [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload)) | convertfrom-json | convertto-json # Pretty print
              
              # 3 - Use the GitHub JWT as proof for authenticating as the app defined in env.CLIENT_ID:
              $uri = "https://login.microsoftonline.com/{0}/oauth2/v2.0/token" -f "${{env.TENANT_ID}}"
              $body = "scope=https://graph.microsoft.com/.default&client_id=${{env.CLIENT_ID}}&grant_type=client_credentials&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion={0}" -f [System.Net.WebUtility]::UrlEncode($githubjwt.Value)
              $aadtoken = Invoke-RestMethod $uri -Body $body -ContentType "application/x-www-form-urlencoded" -ErrorAction SilentlyContinue

              # 4 - Print AAD url and payload to screen for debuging
              Write-Host "AAD JWT url: $uri"
              Write-Host "AAD JWT payload:"

              # Fix padding characters
              $payload = ($aadtoken.access_token -split "\.")[1]
              if(($payload.Length % 4) -ne 0) {
                $payload = $payload.PadRight($payload.Length + 4 - ($payload.Length % 4), "=")
              }
              [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload)) | convertfrom-json | convertto-json

              # 5 - Use the received token to get users from Graph
              Write-Host "Getting users from AAD:"
              Invoke-RestMethod "https://graph.microsoft.com/v1.0/users" -Headers @{Authorization = ("Bearer {0}" -f $aadtoken.access_token)} | Select-Object -ExpandProperty value | Measure-Object | Select-Object -ExpandProperty Count
            azPSVersion: "latest"

To comment a bit on the PowerShell code:

1Gets a JWT from the GitHub OIDC issuer, with api://AzureADTokenExchange as audience (Matching the federated credential on the app registration)
2Debug output only, usefull for troubleshooting. Prints the GitHub token endpoint url and recieved JWT payload to screen.
3In this section we use the GitHub JWT as proof for authenticating as our application to Azure AD, providing the JWT in the client_assertion parameter, in a client credential flow.
4Debug output only, usefull for troubleshooting. Prints the Azure AD token endpoint url and recieved JWT payload to screen.
5Here we actually use the app registration’s JWT to get users from Azure AD

This then results in the following fully functioning action:

You want to get data from a keyvault using the same approach? Sure no problem!

We start by creating our KeyVault:

We add an access policy that allows our app to get secrets:

And we create a secret:

Now we can take the working action, modify which resource we are getting the AAD signed token for (https://vault.azure.net) and add a call to the KeyVault API:

name: KeyVaultDemo

# Controls when the workflow will run
on:
  push:
    branches: [ main ]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# Allow the action to get the required token
permissions:
  id-token: write
  contents: read

# Our Azure environment that we want to sign into
env:
  CLIENT_ID: 8e80a94d-b9bc-48ed-8c68-7ebcf3f1e26a
  TENANT_ID: 95877aab-f66b-4cd0-98a0-1218634664fa
  KEYVAULT: testkv991

jobs: 
  Test:
      runs-on: windows-latest
      steps:
        # Get users from Azure AD using access token provided by Azure CLI
        - name: 'Work with KeyVault'
          uses: azure/powershell@v1
          with:
            inlineScript: |
              # Get JWT from GitHub
              $audience = "api://AzureADTokenExchange"
              $url = "{0}&audience={1}" -f $ENV:ACTIONS_ID_TOKEN_REQUEST_URL, $audience
              $githubjwt = Invoke-RestMethod $url -Headers @{Authorization = ("bearer {0}" -f $ENV:ACTIONS_ID_TOKEN_REQUEST_TOKEN)}
              
              # Use the GitHub JWT as proof for authenticating as the app defined in env.CLIENT_ID:
              $uri = "https://login.microsoftonline.com/{0}/oauth2/token" -f "${{env.TENANT_ID}}"
              $body = "resource=https://vault.azure.net&client_id=${{env.CLIENT_ID}}&grant_type=client_credentials&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion={0}" -f [System.Net.WebUtility]::UrlEncode($githubjwt.Value)
              $kvtoken = Invoke-RestMethod $uri -Body $body -ContentType "application/x-www-form-urlencoded" -ErrorAction SilentlyContinue

              # Use the received token to get the secret from KeyVault
              Write-Host "Getting secret from KeyVault:"
              $secret = Invoke-RestMethod "https://${{env.KEYVAULT}}.vault.azure.net/secrets/somesecret?api-version=7.2" -Headers @{Authorization = ("Bearer {0}" -f $kvtoken.access_token)}
              $secret.value
            azPSVersion: "latest"

And would you look at that, there is our secret!

And that’s it. Because we can authenticate as the Azure AD app (service principal), we can now utilize this method for accessing any Azure AD protected resouce, without worrying about a single secret or certificate.

Azure AD Federated Workload Identities really opens up great functionality in GitHub Actions!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s