Coding Stephan

How do Federated credentials in GitHub Actions actually work

I’m super enthusiastic about managed identities, because it allows you to deploy your application without having to worry about credentials. Federated credentials are a way to accomplish the same for none Azure resources. You can use federated credentials to authenticate several tasks inside Github Actions, and thus securely deploy your app to Azure without the need of a secret configured in GitHub.

As the regular readers might expect this post will explain how federated credentials actually work inside GitHub Actions, a deep dive into the techniques that are actually driving this feature.

Get a federation token from GitHub

Federated credentials in a nutshell

  1. Configure an Azure Application registration with federated credentials and the correct permissions
  2. Get an access token from a federated identity provider (e.g. Github Actions server)
  3. Use the “federated” access token as the client assertion in the client credentials flow. See this manual

It is comparable to using a certificate to authenticate to an identity provider (Entra ID), but instead of having the certificate and creating a signed jwt with it you tell the identity provider who will issue the federation token that will be used as a client assertion.

Federated credentials in GitHub Actions

In GitHub Actions you used to have unlimited access to everything, but recently it’s changed to a more permissive security system. If you want “special” things, you need to specify those permissions in the workflow file.

Required permissions

To enable federated credentials in GitHub Actions you need to add the correct permission to your workflow file.

Note, this is blocked for pull request (on public repositories), as described in the documentation.

...
permissions:
  id-token: write # This is required for requesting the JWT
  contents: read  # This is required for actions/checkout

Requesting the federation token

This change enables the token endpoint for that workflow. Yes, yet another token endpoint, this time it’s only available to the workflow runner. So you’re actually using a token (in ACTIONS_ID_TOKEN_REQUEST_TOKEN) to request another token (from the endpoint in ACTIONS_ID_TOKEN_REQUEST_URL), and that is used in the next step to get the actual access token for the resource we want to manage.

The token endpoint is available in the ACTIONS_ID_TOKEN_REQUEST_URL environment variable. It needs yet another token to request an access token which is available in the ACTIONS_ID_TOKEN_REQUEST_TOKEN environment variable. You also need an audience, which will appear in the token as the aud claim. The audience can be anything you want (I guess uri compatible), and by default for Azure is set to api://AzureADTokenExchange.

The following example shows how to request an access token from the token endpoint with curl:

# Get a federation token from GitHub
curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange"

Requesting the access token from Entra ID

To request the actual access token from Entra ID, you’ll need additional information, the client id and the tenant are not known to GitHub, so you’ll need to provide those.

  • The Tenant ID (in the url) for which you wish to get a token
  • client_id - The client id of the application registration (in Entra, this is obviously not available in GitHub Actions)
  • client_assertion - the JWT token from the previous step
  • scope - To which application do you want access?
  • grant_type - This is always client_credentials since that is the flow we are using
  • client_assertion_type - This is always urn:ietf:params:oauth:client-assertion-type:jwt-bearer since that is the type of client assertion we are using
POST /{tenant}/oauth2/v2.0/token HTTP/1.1
Host: login.microsoftonline.com:443
Content-Type: application/x-www-form-urlencoded

scope=https%3A%2F%2Fgraph.microsoft.com%2F.default
&client_id=97e0a5b7-d745-40b6-94fe-5f77d35c6e05
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIsIng1dCI6Imd4OHRHeXN5amNScUtqRlBuZDdSRnd2d1pJMCJ9.eyJ{a lot of characters here}M8U3bSUKKJDEg
&grant_type=client_credentials

Bonus: GithubActionsTokenCredential

Previously I explained how DefaultAzureCredential works, it’s a chain of credentials where applications using it just pick the first one that works. In my opinion it should also support Federated Credentials in GitHub Actions out-of-the-box if you set the correct environment variables. Eventually I’ll be creating a pull request to get this code into the DefaultAzureCredential chain inside Azure.Identity, but until then you have to add this code yourself.

To use the new GithubActionsTokenCredential, be sure to set the following environment variables:

  • AZURE_TENANT_ID - The tenant id of the tenant you want to authenticate against
  • AZURE_CLIENT_ID - The client id of the application registration you want to use

And in your C# code you can replace the DefaultAzureCredential with the GithubActionsTokenCredential:

// Replace this line
var credentials = new DefaultAzureCredential();
// with this line
var credentials = new GithubActionsTokenCredential();
// or with this line if you still want to support other credentials
var credentials = new ChainedTokenCredential(new GithubActionsTokenCredential(), new DefaultAzureCredential());

// And then use those credentials to get a token for the Graph API (or any other resource)
var tokenResult = await credentials.GetTokenAsync(new Azure.Core.TokenRequestContext(new[] { "https://graph.microsoft.com/.default" }));
Console.WriteLine(tokenResult.Token);

GithubActionsTokenCredential code

The latest code is available at GitHub

using Azure.Core;
using System.Net.Http.Headers;
using System.Net.Http.Json;

namespace Azure.Identity.Federation;

public class GithubActionsTokenCredential : TokenCredential
{
    private const string ActionsRequestTokenKey = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
    private const string ActionsRequestUrlKey = "ACTIONS_ID_TOKEN_REQUEST_URL";
    private const string DefaultIdTokenAudience = "api://AzureADTokenExchange";

    private const string AzureTenantIdKey = "AZURE_TENANT_ID";
    private const string AzureClientIdKey = "AZURE_CLIENT_ID";

    private readonly string? _requestToken;
    private readonly string? _requestUrl;
    private readonly string IdTokenAudience;
    private readonly string TenantId;
    private readonly string ClientId;
    private readonly HttpClient httpClient;
    private ClientAssertionCredential? clientAssertionCredential;

    public GithubActionsTokenCredential(string? tenantId = null, string? clientId = null, string? idTokenAudience = DefaultIdTokenAudience, HttpClient? httpClient = null)
    {
        IdTokenAudience = idTokenAudience ?? DefaultIdTokenAudience;
        _requestToken = Environment.GetEnvironmentVariable(ActionsRequestTokenKey);
        _requestUrl = Environment.GetEnvironmentVariable(ActionsRequestUrlKey);
        this.httpClient = httpClient ?? new HttpClient();
        this.httpClient.DefaultRequestHeaders.UserAgent.Clear();
        this.httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Azure.Identity.Federation", "1.0"));
        // Not sure if this is needed, see https://github.com/actions/toolkit/blob/c5c786523e095ca3fabfc4d345e16242da34e108/packages/core/src/oidc-utils.ts#L22
        this.httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("actions/oidc-client", "1.0"));
        TenantId = tenantId ?? Environment.GetEnvironmentVariable(AzureTenantIdKey);
        ClientId = clientId ?? Environment.GetEnvironmentVariable(AzureClientIdKey);
    }

    public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
    {
        ValidateSettings();
        clientAssertionCredential ??= new ClientAssertionCredential(TenantId, ClientId, (cancallationToken) => GetIdToken(cancellationToken));

        return clientAssertionCredential.GetToken(requestContext, cancellationToken);
    }

    public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
    {
        ValidateSettings();
        clientAssertionCredential ??= new ClientAssertionCredential(TenantId, ClientId, (cancallationToken) => GetIdToken(cancellationToken));
        return clientAssertionCredential.GetTokenAsync(requestContext, cancellationToken);
    }

    private void ValidateSettings()
    {
        if (string.IsNullOrWhiteSpace(_requestToken) || string.IsNullOrWhiteSpace(_requestUrl) || !Uri.TryCreate(_requestUrl, UriKind.Absolute, out _))
        {
            throw new CredentialUnavailableException($"Environment variables '{ActionsRequestTokenKey}' and/or '{ActionsRequestUrlKey}' are not set.");
        }

        if (string.IsNullOrWhiteSpace(IdTokenAudience))
        {
            throw new ArgumentException("Audience must be set.", nameof(IdTokenAudience));
        }

        if (string.IsNullOrWhiteSpace(TenantId))
        {
            throw new ArgumentException("Tenant ID must be set", nameof(TenantId));
        }

        if (string.IsNullOrWhiteSpace(ClientId))
        {
            throw new ArgumentException("Client ID must be set", nameof(ClientId));
        }
    }

    private async Task<string> GetIdToken(CancellationToken cancellationToken)
    {
        var uri = new Uri($"{_requestUrl!}&audience={System.Web.HttpUtility.UrlEncode(IdTokenAudience!)}");
        var request = new HttpRequestMessage(HttpMethod.Get, uri);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _requestToken!);
        var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
        if (!response.IsSuccessStatusCode)
            throw new CredentialUnavailableException($"Request to '{uri}' failed with status code '{response.StatusCode}'.");
        var result = await response.Content.ReadFromJsonAsync<GithubTokenResponse>(cancellationToken: cancellationToken).ConfigureAwait(false);

        return result!.value;
    }
}

public class GithubTokenResponse
{
    public string value { get; set; }
}

This code is inspired by the other credentials, in that it automatically discovers the needed environment variables. And returns a CredentialUnavailableException if the environment variables are not set. This means it will work the same as any other credential, and you can chain it if desired.

Conclusion

Federated credentials are a great way to authenticate to Azure without having to store a secret in GitHub. This post explained how federated credentials work in GitHub Actions, and how you can use them in your own code. By directly supporting federated credentials, you don’t have to call the Azure CLI login before your task (DefaultAzureCredential uses AzureCliCredential as part of the credential chain). This also means the actual access token or the credentials are not (temporarily) stored on disk during the workflow run, making this new approach just a little bit safer.

Whichever way you use, using federated credentials instead of a secret is a great way to improve the security of your GitHub Actions. Eliminating passwords on all levels should be top priority for any organization.

I hope you enjoyed this post, and if you have any questions or comments, send me a message on any of the socials below.

Twitter or LinkedIn