Azure Authenticating to Google Cloud from Azure App Services

Using workload identity federation, we can let Azure-hosted applications authenticate to Google Cloud using their managed identity, no service account keys required.

More specifically, we can configure the Google Cloud client libraries so that they don’t look for a service account key, but instead obtain an Azure access token and exchange it against a Google Cloud access token.

Setting up workload identity federation with Azure typically involves 4 steps:

  1. Creating an app registration in Entra ID and assigning it an Application URI.
  2. Setting up a workload identity pool and provider.
  3. Creating a credential configuration file that captures all necessary parameters for the client library to perform a token exchange.
  4. Configuring the Azure-hosted application to use the credential configuration file.

However, for Azure App Services, we have to deviate from this process a little.

Azure VMs vs App Services

Like Azure VMs, Azure App Services support managed identities, but the process how applications obtain tokens for their managed identity differs between the two environments:

The Google Cloud client libraries know how to fetch an access token from a static URL, but they don’t know how to assemble URLs dynamically.

So we need to write some extra code to make workload identity federation work on Azure App Services.

The following code snippet shows how that can look like: The CreateWorkloadIdentityCredential method reads the IDENTITY_ENDPOINT and IDENTITY_HEADER environment variables and dynamically creates the equivalent of a configuration file, which it then uses to initialize a GoogleCredential.

static GoogleCredential CreateWorkloadIdentityCredential()
{
  //
  // Read endpoint and XSRF header from the App Services environment.
  // Cf. https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity
  //
  var identityHeader = Environment.GetEnvironmentVariable("IDENTITY_HEADER");
  var identityEndpoint = Environment.GetEnvironmentVariable("IDENTITY_ENDPOINT");

  if (string.IsNullOrEmpty(identityHeader) || string.IsNullOrEmpty(identityEndpoint))
  {
    throw new InvalidOperationException(
      "The environment variables IDENTITY_HEADER and IDENTITY_ENDPOINT " +
      "have not been initialized. This indicates that the application is " +
      "not running on Azure App Services or that you haven't assigned " +
      "a managed identity to the application yet");
  }

  //
  // Read workload identity configuration from environment.
  //
  var audience = 
    Environment.GetEnvironmentVariable("GOOGLE_WORKLOADIDENTITY_AUDIENCE");
  if (string.IsNullOrEmpty(audience) || !audience.StartsWith("//"))
  {
    throw new InvalidOperationException(
      "The environment variable GOOGLE_WORKLOADIDENTITY_AUDIENCE " +
      "has not been initialized. The variable must contain a valid " +
      "workload identity pool provider URL (without https: prefix)");
  }

  var appId = Environment.GetEnvironmentVariable("GOOGLE_WORKLOADIDENTITY_APPID");
  if (string.IsNullOrEmpty(appId))
  {
    throw new InvalidOperationException(
      "The environment variable GOOGLE_WORKLOADIDENTITY_APPID " +
      "has not been initialized. The variable must contain the App ID URI " +
      "of the Entra ID application registration trusted by your workload " +
      "identity pool provider");
  }

  var serviceAccountToImpersonate = 
    Environment.GetEnvironmentVariable("GOOGLE_WORKLOADIDENTITY_SERVICEACCOUNT");

  var identityEndpointRequestUrl = new UriBuilder(identityEndpoint)
  {
    Query = new QueryString()
      .Add("resource", appId)
      .Add("api-version", "2019-08-01")
      .ToString()
  }.Uri;

  //
  // Create the equivalent of a credential configuration file that
  // incorporates the identity endpoint request URL and header.
  //
  var credentialConfiguration = new JsonCredentialParameters
  {
    Type = "external_account",
    Audience = audience,
    SubjectTokenType = "urn:ietf:params:oauth:token-type:jwt",
    ServiceAccountImpersonationUrl = serviceAccountToImpersonate == null 
      ? null  // No impersonation
      : "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/" +
        $"{serviceAccountToImpersonate}:generateAccessToken",
    TokenUrl = "https://sts.googleapis.com/v1/token",
    CredentialSourceConfig = new JsonCredentialParameters.CredentialSource
    {
      Url = identityEndpointRequestUrl.AbsoluteUri,
      Headers = new Dictionary<string, string>
        {
          { "X-IDENTITY-HEADER", identityHeader }
        },
      Format = new JsonCredentialParameters.CredentialSource.SubjectTokenFormat
      {
        SubjectTokenFieldName = "access_token",
        Type = "json"
      }
    }
  };

  //
  // Create a workload identity federation credential that automatically
  // fetches and refreshes access tokens as necessary.
  //
  return GoogleCredential
    .FromJsonParameters(credentialConfiguration)
    .CreateScoped("https://www.googleapis.com/auth/cloud-platform");
}

The method essentially replaces GoogleCredential.GetApplicationDefault(), which we’d use normally.

Revised setup process

Equipped with the method above, the process to set up workload identity federation for Azure App Services now looks like this:

  1. Creating an app registration in Entra ID and assigning it an Application URI.
  2. Setting up a workload identity pool and provider.
  3. Creating a credential configuration file that captures all necessary parameters for the client library to authenticate using workload identity.

    Changing the code to use CreateWorkloadIdentityCredential().

  4. Configuring the Azure-hosted application to use the credential configuration file.

    Configuring an extra set of environment variables for the Azure App Services app that capture the necessary parameters for using workload identity:

    Environment variables

You can find a complete, working example of an Azure App Services app that uses workload identity federation, in the jpassing/workloadidentity-examples repository.

Any opinions expressed on this blog are Johannes' own. Refer to the respective vendor’s product documentation for authoritative information.
« Back to home