Authenticating to Google Cloud by using an Azure managed identity and workload identity federation

On Google Cloud, we can attach a service account to compute resources such as VM instances to let applications obtain service account credentials. Using these credentials, applications can then access the Google APIs they need.

When an application running on Azure needs access to Google APIs, attaching a service account obviously won’t work – but we can use workload identity federation to let the application use its Azure credentials to authenticate to Google APIs.

The idea behind Workload identity federation is to set up a one-way trust relationship between Google Cloud and Azure AD that lets applications exchange their Azure credentials against Google credentials by following a three-step process:

  1. Obtain an Azure access token, ideally by using a managed identity
  2. Use the Security Token Service (STS) API to exchange the credential against a short-lived Google STS token.
  3. Use the STS token to authenticate to the IAM Service Account Credentials API and obtain short-lived Google access tokens for a service account.

The following diagram illustrates the process:

Sequence

Using the resulting short-lived Google access tokens, the application can then access any Google Cloud resources that the service account has been granted access to.

Client Libraries

In many cases, we don’t actually have to implement this 3-step process ourselves. Instead, we can let the Cloud Client Libraries do it for us by:

  1. Creating a create a credential configuration file that defines where to obtain external credentials from (IMDS, in this case), which workload identity federation pool and provider to use, and which service account to impersonate
  2. Setting the environment variable GOOGLE_APPLICATION_CREDENTIALS to point to this file
  3. Using the library to obtain default credentials.

In Python, this looks like:

#!/usr/bin/python3
import google.auth
from google.auth.transport import requests

credentials, project_id = google.auth.default(scopes = "https://www.googleapis.com/auth/cloud-platform")

http_request = requests.Request()
credentials.refresh(http_request)

print("Token: " + credentials.token)

That’s exactly the same code we’d write for an application running on Google Cloud.

Using workload identity federation in .NET

Unfortunately, the C# library lags behind and doesn’t support workload identity federation and credential configuration files yet. But we can fill that gap by writing a class that:

  • Performs the 3-step process described above
  • Derives from ICredential. so that we can use it just like other credentials.

We can save ourselves some work by inheriting from ServiceCredential instead of directly implementing ICredential. ServiceCredential is an abstract base class that implements an access token cache:

  • To request an access token, ServiceCredential invokes RequestAccessTokenAsync which deriving classes must implement.
  • ServiceCredential then caches the token and keeps using the token until it’s close to expiry.

So all we really have to implement is RequestAccessTokenAsync. In this method, we have to:

  1. Call the Azure Instance Metadata Server to get an access token.
  2. Exchange the access token against an STS token.
  3. Impersonate the service account by using the STS token.

To interact the STS and IAM Credentials APIs, we can use the NuGet packages Google.Apis.CloudSecurityToken.v1 and Google.Apis.IAMCredentials.v1.

With that out of the way, here’s the code:

using Google.Apis.Auth.OAuth2;
using Google.Apis.Auth.OAuth2.Responses;
using Google.Apis.Util;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Sts = Google.Apis.CloudSecurityToken.v1;
using StsData = Google.Apis.CloudSecurityToken.v1.Data;
using Iam = Google.Apis.IAMCredentials.v1;
using IamData = Google.Apis.IAMCredentials.v1.Data;
using Google.Apis.Services;

namespace CredentialUtils
{
  public class AzureManagedIdentityCredential : ServiceCredential
  {
    /// <summary>
    /// Application ID Uri. Typed as string (and not Uri) to prevent
    /// canonicaliuation, which might break the STS token exchange.
    /// </summary>
    public string ApplicationIdUri { get; }

    /// <summary>
    /// URL of Workload identity federation provider.
    /// </summary>
    public Uri WorkloadIdentityProvider { get; }

    /// <summary>
    /// Scope(s) to acquire access token for.
    /// </summary>
    public IList<string> Scopes { get; }

    /// <summary>
    /// Service account to impersonate.
    /// </summary>
    public string ServiceAccountEmail { get; }

    public AzureManagedIdentityCredential(
      string applicationIdUri,
      Uri workloadIdentityProvider,
      string serviceAccountEmail,
      IEnumerable<string> scopes)
      : base(new ServiceCredential.Initializer(GoogleAuthConsts.TokenUrl))
    {
      Utilities.ThrowIfNull(applicationIdUri, nameof(applicationIdUri));
      Utilities.ThrowIfNull(workloadIdentityProvider, nameof(workloadIdentityProvider));
      Utilities.ThrowIfNull(serviceAccountEmail, nameof(serviceAccountEmail));
      Utilities.ThrowIfNull(scopes, nameof(scopes));

      this.ApplicationIdUri = applicationIdUri;
      this.WorkloadIdentityProvider = workloadIdentityProvider;
      this.ServiceAccountEmail = serviceAccountEmail;
      this.Scopes = scopes.ToList();
    }

    public AzureManagedIdentityCredential(
      string applicationIdUri,
      ulong projectNumber,
      string poolId,
      string providerId,
      string serviceAccountEmail,
      IEnumerable<string> scopes)
      : this(
          applicationIdUri,
          new Uri($"https://iam.googleapis.com/projects/{projectNumber}/locations/" + 
              $"global/workloadIdentityPools/{poolId}/providers/{providerId}"),
          serviceAccountEmail,
          scopes)
    {
    }

    /// <summary>
    /// Fetch token from Azure instance metadata server (IMDS). This
    /// requires the instance to have a managed identity assined.
    /// </summary>
    private async Task<TokenResponse> GetTokenFromMetadataServerAsync(
      CancellationToken cancellationToken)
    {
      var metadataRequest = new HttpRequestMessage(
        HttpMethod.Get,
        "http://169.254.169.254/metadata/identity/oauth2/token?" +
          $"api-version=2018-02-01&resource={this.ApplicationIdUri}");
      metadataRequest.Headers.Add("Metadata", "true");

      using (var response = await this.HttpClient
        .SendAsync(
          metadataRequest,
          cancellationToken)
        .ConfigureAwait(false))
      {
        //
        // The response contains some Azure-specific fields
        // in addition to the standard OAuth fields - but
        // we don't care about those. 
        //
        // Use the existing TokenResponse class to parse
        // the response (and handle errors).
        //
        return await TokenResponse.FromHttpResponseAsync(
            response,
            this.Clock,
            Logger)
          .ConfigureAwait(false);
      }
    }

    /// <summary>
    /// Exchange an Azure access token against a Google STS token.
    /// </summary>
    private async Task<StsData.GoogleIdentityStsV1ExchangeTokenResponse> ExchangeTokenAsync(
      string azureAccessToken,
      CancellationToken cancellationToken)
    {
      using (var service = new Sts.CloudSecurityTokenService())
      {
        return await service.V1
          .Token(
            new StsData.GoogleIdentityStsV1ExchangeTokenRequest()
            {
              Audience = this.WorkloadIdentityProvider
                .ToString()
                .Replace("https:", string.Empty, StringComparison.OrdinalIgnoreCase),
              GrantType = "urn:ietf:params:oauth:grant-type:token-exchange",
              RequestedTokenType = "urn:ietf:params:oauth:token-type:access_token",
              Scope = string.Join(" ", this.Scopes),
              SubjectTokenType = "urn:ietf:params:oauth:token-type:jwt",
              SubjectToken = azureAccessToken
            })
          .ExecuteAsync(cancellationToken)
          .ConfigureAwait(false);
      }
    }

    /// <summary>
    /// Obtain a (short-lived) access token by impersonating a
    /// service account.
    /// </summary>
    private async Task<IamData.GenerateAccessTokenResponse> ImpersonateServiceAccountAsync(
      string stsToken,
      CancellationToken cancellationToken)
    {
      using (var service = new Iam.IAMCredentialsService(
        new BaseClientService.Initializer()
        {
          //
          // Use the STS token like an access token to authenticate
          // requests.
          //
          HttpClientInitializer = GoogleCredential.FromAccessToken(stsToken)
        }))
      {
        return await service.Projects.ServiceAccounts.GenerateAccessToken(
            new IamData.GenerateAccessTokenRequest()
            {
              Scope = this.Scopes
            },
            $"projects/-/serviceAccounts/{this.ServiceAccountEmail}")
          .ExecuteAsync(cancellationToken)
          .ConfigureAwait(false);
      }
    }

    /// <summary>
    /// Request a new token. The base class invokes this method when
    /// the previous token has expired.
    /// </summary>
    public override async Task<bool> RequestAccessTokenAsync(
      CancellationToken cancellationToken)
    {
      //
      // Get access token for managed identity.
      //
      var azureToken = await GetTokenFromMetadataServerAsync(
          cancellationToken)
        .ConfigureAwait(false);

      //
      // Exchange Azure access token against a Google STS token.
      //
      var stsToken = await ExchangeTokenAsync(
          azureToken.AccessToken, 
          cancellationToken)
        .ConfigureAwait(false);

      //
      // Use STS token to impersonate a service account.
      //
      var serviceAccountToken = await ImpersonateServiceAccountAsync(
        stsToken.AccessToken,
        cancellationToken);

      var timeTillExpiry = ((DateTime)serviceAccountToken.ExpireTime) - this.Clock.UtcNow;

      //
      // Assign resulting access token to property so that it's used until
      // it expires. 
      //
      this.Token = new TokenResponse()
      {
        AccessToken = serviceAccountToken.AccessToken,
        ExpiresInSeconds = (long)timeTillExpiry.TotalSeconds
      };

      return true;
    }
  }
}

Before we can use the code, we have to configure workload identity federation and we need to make sure that the managed identity can access the Azure application used for workload identity federation.

Once these prerequisites are met, we can use the credential like any other ICredential-based object. For example:

var credential = new AzureManagedIdentityCredential(
  "api://my-azure-app-for-workload-identity",
  1234567890,
  "my-wi-pool",
  "my-wi-provider",
  "[email protected],
  new[] { "https://www.googleapis.com/auth/cloud-platform" });
  
var service = var service = new ComputeService(
  new BaseClientService.Initializer()
  {
    HttpClientInitializer = credential
  });
Any opinions expressed on this blog are Johannes' own. Refer to the respective vendor’s product documentation for authoritative information.
« Back to home