Google Cloud Authenticating to Google Cloud when all we have are Kerberos or NTLM credentials

When an application needs to access Google Cloud APIs, it needs credentials. On Google Cloud, we can attach a service account to the underlying compute resource to let the application obtain credentials. On AWS and Azure, we can achieve something to the same effect by using workload identity federation. But what about on-premises?

What Google Cloud, AWS, and Azure all have in common is that they give VMs access to a metadata server, which serves as a conduit between data plane and control plane: It lets applications running on the VM interact with the control plane – to query information about the VM itself, its environment, and to fetch credentials for the control plane.

On premises, there is no metadata server. If we want to avoid using persistent credentials (such as service account keys) to authenticate to Google Cloud, we have to look for different sources of “ambient” credentials.

One such source is Active Directory: When a Windows service is configured to run as a domain user, then its process has ambient access to Kerberos and NTLM credentials. We can’t use these to authenticate to Google Cloud directly, but we can use them indirectly by chaining two token-exchanges:

  1. Exchange Kerberos or NTLM credentials against an OAuth access token or SAML assertion.

    For this, we need an identity provider that lets clients authenticate by using Integrated Windows Authentication (IWA). AD FS is an obvious choice, but many other identity providers support IWA too.

  2. Exchange the OAuth access token or SAML assertion against Google credentials by using workload identity federation.

Chaining these two token exchanges creates a certain complexity – but frees us from having to manage and secure any persistent credentials like service account keys.

So let’s see how we can actually do that.

Combining IWA, OpenID Connect, and workload identity federation

In a previous post, we looked at how we can obtain access tokens from AD FS using the client credentials grant and Integrated Windows Authentication. So we already know how to do the first part of the 2-part token exchange.

We also saw that workload identity federation recently added support for AD FS access tokens, which covers the second part of the 2-part token exchange.

All that’s left to do is to put the pieces together. Let’s do that by creating a custom credential class in C# that:

  • Performs the two token exchanges 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.

All we really have to implement is RequestAccessTokenAsync. In this method, we need to automate the steps described in the workload identity federation documentation for authenticating by using the REST API. These are:

  1. Get a new access token from AD FS.
  2. Use the STS API to exchange the AD FS access token against an STS token.
  3. Use the STS token to impersonate a service account and get a Google access token.

And this look like:

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 AdfsOidcWorkloadIdentityCredential : ServiceCredential
  {
    /// <summary>
    /// AD FS Client ID.
    /// </summary>
    public string ClientId { get; }

    /// <summary>
    /// AD FS Relying Party ID. Typed as string (and not Uri) to prevent
    /// canonicaliuation, which might break the STS token exchange.
    /// </summary>
    public string RelyingPartyId { get; }

    /// <summary>
    /// URL of AD FS token endpoint.
    /// </summary>
    public Uri AdfsTokenEndpoint { 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 AdfsOidcWorkloadIdentityCredential(
      Uri adfsTokenEndpoint,
      string clientId,
      string relyingPartyId,
      Uri workloadIdentityProvider,
      string serviceAccountEmail,
      IEnumerable<string> scopes)
      : base(new ServiceCredential.Initializer(GoogleAuthConsts.TokenUrl))
    {
      Utilities.ThrowIfNull(adfsTokenEndpoint, nameof(adfsTokenEndpoint));
      Utilities.ThrowIfNull(clientId, nameof(clientId));
      Utilities.ThrowIfNull(relyingPartyId, nameof(relyingPartyId));
      Utilities.ThrowIfNull(workloadIdentityProvider, nameof(workloadIdentityProvider));
      Utilities.ThrowIfNull(serviceAccountEmail, nameof(serviceAccountEmail));
      Utilities.ThrowIfNull(scopes, nameof(scopes));

      this.AdfsTokenEndpoint = adfsTokenEndpoint;
      this.ClientId = clientId;
      this.RelyingPartyId = relyingPartyId;
      this.WorkloadIdentityProvider = workloadIdentityProvider;
      this.ServiceAccountEmail = serviceAccountEmail;
      this.Scopes = scopes.ToList();
    }

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

    /// <summary>
    /// Use Integrated Windows Authentication (IWA) to obtain an access
    /// token from AD FS.
    /// </summary>
    private async Task<TokenResponse> GetAdfsTokenAsync(
      CancellationToken cancellationToken)
    {
      using (var handler = new HttpClientHandler())
      {
        //
        // Use current (NTLM or Kerberos) credentials.
        //
        handler.UseDefaultCredentials = true;

        //
        // Send request to token endpoint, expecting an
        // access token in return.
        //
        using (var client = new HttpClient(handler))
        {
          var parameters = new Dictionary<string, string>
          {
            { "client_id", this.ClientId },
            { "resource", this.RelyingPartyId },
            { "grant_type", "client_credentials" },
            { "use_windows_client_authentication", "true" },
            { "scope", "openid" }
          };

          var request = new HttpRequestMessage(HttpMethod.Post, this.AdfsTokenEndpoint)
          {
            Content = new FormUrlEncodedContent(parameters)
          };

          using (var response = await client.SendAsync(
              request,
              HttpCompletionOption.ResponseHeadersRead,
              cancellationToken)
            .ConfigureAwait(false))
          {
            //
            // 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 access token against a Google STS token.
    /// </summary>
    private async Task<StsData.GoogleIdentityStsV1ExchangeTokenResponse> ExchangeTokenAsync(
      string accessToken,
      CancellationToken cancellationToken)
    {
      using (var service = new Sts.CloudSecurityTokenService())
      {
        return await service.V1
          .Token(
            new StsData.GoogleIdentityStsV1ExchangeTokenRequest()
            {
              Audience = this.WorkloadIdentityProvider
                .ToString()
                .Replace("https:", string.Empty),
              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 = accessToken
            })
          .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 AD FS access token..
      //
      var adfsToken = await GetAdfsTokenAsync(
          cancellationToken)
        .ConfigureAwait(false);

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

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

      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 this code, we have to prepare AD FS and configure workload identity federation. After that, we can use the credential class like any other ICredential implementation. For example:

// Create a federated Google credential
var adfsBaseUrl = "https://login.example.org/adfs/";
var adfsClientId = "adfs-client-id";
var providerUrl = "https://iam.googleapis.com/projects/PROJECT-NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER";
var serviceAccountToImpersonate = "[email protected]";

var credential = new AdfsOidcWorkloadIdentityCredential(
  new Uri(adfsBaseUrl),
  adfsClientId,
  providerUrl,
  new Uri(providerUrl),
  serviceAccountToImpersonate,
  new[] { "https://www.googleapis.com/auth/cloud-platform" }))
    
// Use the credential to access Google APIs    
var service = new ComputeService(
  new BaseClientService.Initializer()
  {
    HttpClientInitializer = credential
  });

OIDC limitations

By combining IWA, OpenID Connect, and workload identity federation, we can authenticate to Google Cloud without any persistent credentials. That’s pretty useful – but there are some caveats:

  1. AD FS must be publicly accessible over the internet so that workload identity can fetch the OIDC metadata and JSON Web Key Set.
  2. During the two token exchanges, information about the Windows user, its identity, and group memberships is lost.

The second item sounds dramatic, so let’s look at that in more detail.

After authenticating to AD FS using IWA, the access token we get contains the following claims:

{
  "aud": "https://iam.googleapis.com/projects/PROJECT-ID/locations/global/workloadIdentityPools/POOL/providers/PROVIDER",
  "iss": "https://login.example.com/adfs/services/trust",
  "iat": 1645432497,
  "nbf": 1645432497,
  "exp": 1645436097,
  "apptype": "Confidential",
  "appid": "oidc-clientid",
  "authmethod": "http://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/windows",
  "auth_time": "2022-02-21T08:34:57.635Z",
  "ver": "1.0",
  "scp": "openid"
}

Notice that the appid claim identifies the OIDC client, and that none of the claim indicates which Windows user we used for IWA.

Let’s see if we can change that by adding a custom issuance transform rule that passes thru all incoming claims:

c:[ ] => issue(claim = c);

With this rule in place, the access token is a bit more verbose:

{
  "aud": "https://iam.googleapis.com/projects/PROJECT-ID/locations/global/workloadIdentityPools/POOL/providers/PROVIDER",
  "iss": "https://login.example.com/adfs/services/trust",
  "iat": 1645432771,
  "nbf": 1645432771,
  "exp": 1645436371,
  "auth_time": "2022-02-21T08:39:31.320Z",
  "authmethod": "http://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/windows",
  "sub": "oidc-clientid",
  "anchor": "sub",
  "appid": "oidc-clientid",
  "apptype": "Confidential",
  "clientuseragent": "Mozilla/5.0 ...",
  "endpointpath": "/adfs/oauth2/token/",
  "insidecorpnetwork": "true",
  "clientreqid": "6559b6f5-d73a-4577-0c00-0080000000cd",
  "clientip": "35....",
  "userip": "35....",
  "forwardedclientip": [
    "35...."
  ],
  "ver": "1.0",
  "scp": "openid"
}

Still, no information about the Windows user, much less about their group memberships!

The lack of information about the Windows user seems surprising at first – but ultimately makes sense given that the entire point of using the client_credentials grant is to authenticate a client, not a user.

In most cases, we can live with these two caveats: Most AD FS instances are publicly available and whether the token contains information about the Windows identity or not won’t matter too much.

But what if our case isn’t like most cases? Then there’s a plan B, which is to use SAML instead of OIDC.

Using SAML instead of OIDC

AD FS’s support for Windows Integrated Authentication isn’t limited to OIDC. We can also use it to obtain SAML assertions if we’re not afraid of using WS-Trust. And as it turns out, using workload identity federation in combination with SAML lets us overcome the two limitations that we saw with OIDC:

  1. To validate SAML assertions, workload identity federation uses the metadata and signing certificate that we upload during setup. So there’s no need for workload identity federation to ever contact AD FS – all communication is unidirectional, from on-premises to Google Cloud.
  2. WS-Trust doesn’t authenticate a client, but the user. As a result, a SAML assertion obtained by using WS-Trust and Integrated Windows Authentication contains information about the Windows user.

Again, let’s look at (2) in more detail: If we configure the claims issuance policy of the AD FS relying party to pass thru all input claims and authenticate by using WS-Trust and IWA, an assertion looks like this:

<Assertion ID="_6219f6c3-bd96-4d15-80a8-ea2ff85cb675" IssueInstant="2022-02-21T08:53:33.967Z" Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion">
  <Issuer>https://login.cloud.ntdev.net/adfs/services/trust</Issuer>
  <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
    ...
  </ds:Signature>
  <Subject>
    <NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">[email protected]</NameID>
    <SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
      <SubjectConfirmationData NotOnOrAfter="2022-02-21T08:58:33.967Z" />
    </SubjectConfirmation>
  </Subject>
  <Conditions NotBefore="2022-02-21T08:53:33.954Z" NotOnOrAfter="2022-02-21T09:53:33.954Z">
    <AudienceRestriction>
      <Audience>https://iam.googleapis.com/projects/671641973636/locations/global/workloadIdentityPools/wif-adfs-saml-1/providers/wif-adfs-saml-1</Audience>
    </AudienceRestriction>
  </Conditions>
  <AttributeStatement>
    <Attribute Name="http://schemas.microsoft.com/ws/2014/01/identity/claims/anchorclaimtype">
      <AttributeValue>http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname</AttributeValue>
    </Attribute>
    <Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/implicitupn">
      <AttributeValue>>[email protected]</AttributeValue>
    </Attribute>
    <Attribute Name="http://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-forwarded-client-ip" a:OriginalIssuer="CLIENT CONTEXT" xmlns:a="...">
      <AttributeValue>34......</AttributeValue>
      <AttributeValue>35......</AttributeValue>
    </Attribute>
    <Attribute Name="http://schemas.microsoft.com/2014/09/requestcontext/claims/userip" a:OriginalIssuer="CLIENT CONTEXT" xmlns:a="...">
      <AttributeValue>130.211.2.131</AttributeValue>
    </Attribute>
    <Attribute Name="http://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-client-ip" a:OriginalIssuer="CLIENT CONTEXT" xmlns:a="...">
      <AttributeValue>130.211.2.131</AttributeValue>
    </Attribute>
    <Attribute Name="http://schemas.microsoft.com/2012/01/requestcontext/claims/client-request-id" a:OriginalIssuer="CLIENT CONTEXT" xmlns:a="...">
      <AttributeValue>49201c78-0fff-44a2-a41d-a68a1273223a</AttributeValue>
    </Attribute>
    <Attribute Name="http://schemas.microsoft.com/ws/2012/01/insidecorporatenetwork" a:OriginalIssuer="CLIENT CONTEXT" xmlns:a="...">
      <AttributeValue b:type="tn:boolean" xmlns:b="http://www.w3.org/2001/XMLSchema-instance" xmlns:tn="http://www.w3.org/2001/XMLSchema">true</AttributeValue>
    </Attribute>
    <Attribute Name="http://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-endpoint-absolute-path" a:OriginalIssuer="CLIENT CONTEXT" xmlns:a="...">
      <AttributeValue>/adfs/services/trust/13/windowsmixed</AttributeValue>
    </Attribute>
    <Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn">
      <AttributeValue>[email protected]</AttributeValue>
    </Attribute>
    <Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/denyonlysid">
      <AttributeValue>S-1-5-32-544</AttributeValue>
      <AttributeValue>S-1-5-21-0120120120-1231231231-456456456-512</AttributeValue>
    </Attribute>
    <Attribute Name="http://schemas.microsoft.com/ws/2008/06/identity/claims/primarygroupsid">
      <AttributeValue>S-1-5-21-0120120120-1231231231-456456456-513</AttributeValue>
    </Attribute>
    <Attribute Name="http://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid">
      <AttributeValue>S-1-5-21-0120120120-1231231231-456456456-513</AttributeValue>
      <AttributeValue>S-1-1-0</AttributeValue>
      <AttributeValue>S-1-5-32-545</AttributeValue>
      <AttributeValue>S-1-5-32-555</AttributeValue>
      <AttributeValue>S-1-5-14</AttributeValue>
      <AttributeValue>S-1-5-4</AttributeValue>
      <AttributeValue>S-1-5-11</AttributeValue>
      <AttributeValue>S-1-5-15</AttributeValue>
      <AttributeValue>S-1-2-0</AttributeValue>
      <AttributeValue>S-1-18-1</AttributeValue>
      <AttributeValue>S-1-5-21-0120120120-1231231231-456456456-1110</AttributeValue>
      <AttributeValue>S-1-5-21-0120120120-1231231231-456456456-572</AttributeValue>
    </Attribute>
    <Attribute Name="http://schemas.microsoft.com/ws/2008/06/identity/claims/primarysid">
      <AttributeValue>S-1-5-21-0120120120-1231231231-456456456-1104</AttributeValue>
    </Attribute>
    <Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name">
      <AttributeValue>EXAMPLE\dadmin</AttributeValue>
    </Attribute>
    <Attribute Name="http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname">
      <AttributeValue>EXAMPLE\dadmin</AttributeValue>
    </Attribute>
  </AttributeStatement>
  <AuthnStatement AuthnInstant="2022-02-21T08:53:33.529Z">
    <AuthnContext>
      <AuthnContextClassRef>urn:federation:authentication:windows</AuthnContextClassRef>
    </AuthnContext>
  </AuthnStatement>
</Assertion>

Notice that this assertion not only contains the UPN and SID of the Windows user, but also lists group memberships! We can now set up attribute mappings in workload identity federation so that:

  • google.subject is mapped to the UPN or SID of the Windows user.
  • google.groups is mapped to the list of group memberships.

That’s arguably nicer and more powerful than what we were able to do with OIDC.

Now let’s create another custom credential class in C#, one that uses SAML and WS-Trust:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Net;
using System.Text;
using System.IdentityModel.Protocols.WSTrust;
using System.IdentityModel.Tokens;
using System.ServiceModel;
using System.ServiceModel.Security;
using Google.Apis.Services;
using Google.Apis.Auth.OAuth2;
using Google.Apis.Auth.OAuth2.Responses;
using Google.Apis.Util;
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;

namespace CredentialUtils
{

  public class AdfsSamlWorkloadIdentityCredential : ServiceCredential
  {
    /// <summary>
    /// Windows credential for authenticating to ADFS.
    /// If null, default network credentials will be used.
    /// </summary>
    public NetworkCredential WindowsCredential { get; }

    /// <summary>
    /// AD FS Relying Party ID . Typed as string (and not Uri) to prevent
    /// canonicaliuation, which might break the STS token exchange.
    /// </summary>
    public string RelyingPartyId { get; }

    /// <summary>
    /// Base URL of AD FS, typically ending in '/adfs/'.
    /// </summary>
    public Uri AdfsBaseUrl { 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 AdfsSamlWorkloadIdentityCredential(
      Uri adfsBaseUrl,
      string relyingPartyId,
      Uri workloadIdentityProvider,
      string serviceAccountEmail,
      IEnumerable<string> scopes,
      NetworkCredential windowsCredential = null)
      : base(new ServiceCredential.Initializer(GoogleAuthConsts.TokenUrl))
    {
      Utilities.ThrowIfNull(adfsBaseUrl, nameof(adfsBaseUrl));
      Utilities.ThrowIfNull(relyingPartyId, nameof(relyingPartyId));
      Utilities.ThrowIfNull(workloadIdentityProvider, nameof(workloadIdentityProvider));
      Utilities.ThrowIfNull(serviceAccountEmail, nameof(serviceAccountEmail));
      Utilities.ThrowIfNull(scopes, nameof(scopes));

      this.WindowsCredential = windowsCredential;
      this.AdfsBaseUrl = adfsBaseUrl;
      this.RelyingPartyId = relyingPartyId;
      this.WorkloadIdentityProvider = workloadIdentityProvider;
      this.ServiceAccountEmail = serviceAccountEmail;
      this.Scopes = scopes.ToList();
    }

    public AdfsSamlWorkloadIdentityCredential(
      Uri adfsBaseUrl,
      string relyingPartyId,
      ulong projectNumber,
      string poolId,
      string providerId,
      string serviceAccountEmail,
      IEnumerable<string> scopes,
      NetworkCredential windowsCredential = null)
      : this(
          adfsBaseUrl,
          relyingPartyId,
          new Uri($"https://iam.googleapis.com/projects/{projectNumber}/locations/" +
              $"global/workloadIdentityPools/{poolId}/providers/{providerId}"),
          serviceAccountEmail,
          scopes,
          windowsCredential)
    {
    }

    /// <summary>
    /// Create a WS-Trust channel factory that uses the given credential
    /// to authenticate to AD FS.
    /// </summary>
    private WSTrustChannelFactory CreateChannelFactory(
      MessageCredentialType credentialType,
      NetworkCredential credential)
    {
      var endpoint = this.AdfsBaseUrl.ToString();
      endpoint = endpoint.EndsWith("/") ? endpoint : endpoint + "/";

      var binding = new WS2007HttpBinding(SecurityMode.TransportWithMessageCredential);
      binding.Security.Message.EstablishSecurityContext = false;
      binding.Security.Message.ClientCredentialType = credentialType;
      binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.None;

      //
      // Select the right binding based on the type of credential we have.
      //
      switch (credentialType)
      {
        case MessageCredentialType.Windows:
          {
            //
            // Use Integrated Windows Authentication (IWA).
            //
            var factory = new WSTrustChannelFactory(
              binding,
              new EndpointAddress(
                new Uri(new Uri(endpoint), "services/trust/13/windowsmixed")));

            factory.TrustVersion = TrustVersion.WSTrust13;
            factory.Credentials.Windows.ClientCredential = credential;

            return factory;
          }

        case MessageCredentialType.UserName:
          {
            //
            // Use username/password.
            //
            var factory = new WSTrustChannelFactory(
              binding,
              new EndpointAddress(
                new Uri(new Uri(endpoint), "services/trust/13/usernamemixed")));

            factory.TrustVersion = TrustVersion.WSTrust13;
            factory.Credentials.UserName.UserName = string.IsNullOrEmpty(credential.Domain)
              ? credential.UserName
              : $"{credential.Domain}\\{credential.UserName}";
            factory.Credentials.UserName.Password = credential.Password;
            return factory;
          }

        default:
          throw new ArgumentException(nameof(credentialType));
      }
    }

    /// <summary>
    /// Use WS-Trust to obtain a SAML assertion from AD FS.
    /// </summary>
    private Task<GenericXmlSecurityToken> GetSamlSecurityTokenAsync(
      WSTrustChannelFactory factory)
    {
      //
      // Request a SAML 2.0 assertion (as opposed to SAML 1.1).
      //
      var tokenRequest = new RequestSecurityToken
      {
        RequestType = RequestTypes.Issue,
        AppliesTo = new EndpointReference(this.RelyingPartyId),
        KeyType = KeyTypes.Bearer,
        TokenType = "urn:oasis:names:tc:SAML:2.0:assertion"
      };

      var channel = factory.CreateChannel();

      return Task.Factory.FromAsync(
        channel.BeginIssue(tokenRequest, null, null),
        ar => (GenericXmlSecurityToken)channel.EndIssue(ar, out var _));
    }

    /// <summary>
    /// Exchange SAML assertion against a Google STS token.
    /// </summary>
    private async Task<StsData.GoogleIdentityStsV1ExchangeTokenResponse> ExchangeTokenAsync(
      GenericXmlSecurityToken samlAssertion,
      CancellationToken cancellationToken)
    {
      //
      // Convert the SAML assertion to a Base64 string.
      //
      var subjectToken = Convert.ToBase64String(
        Encoding.UTF8.GetBytes(samlAssertion.TokenXml.OuterXml));

      using (var service = new Sts.CloudSecurityTokenService())
      {
        return await service.V1
          .Token(
            new StsData.GoogleIdentityStsV1ExchangeTokenRequest()
            {
              Audience = this.WorkloadIdentityProvider
                .ToString()
                .Replace("https:", string.Empty),
              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:saml2",
              SubjectToken = subjectToken,
            })
          .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)
    {
      //
      // Create a WS-Trust channel to AD FS.
      //
      var channelFactory = this.WindowsCredential == null
        ? CreateChannelFactory(
          MessageCredentialType.Windows, 
          CredentialCache.DefaultNetworkCredentials)
        : CreateChannelFactory(
          MessageCredentialType.UserName, 
          this.WindowsCredential);

      //
      // Authenticate to ADFS and get a SAML assertion.
      //
      var assertion = await GetSamlSecurityTokenAsync(channelFactory)
        .ConfigureAwait(false);

      //
      // Exchange the SAML assertion against a Google STS token.
      //
      var stsToken = await ExchangeTokenAsync(
          assertion,
          cancellationToken)
        .ConfigureAwait(false);

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

      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;
    }
  }
}

Assuming that we’ve prepared AD FS and configured workload identity federation correctly, we can use the credential class like so:

// Create a federated Google credential
var adfsBaseUrl = "https://login.example.org/adfs/";
var providerUrl = "https://iam.googleapis.com/projects/PROJECT-NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER";
var serviceAccountToImpersonate = "[email protected]";

var credential = new AdfsSamlWorkloadIdentityCredential(
  new Uri(adfsBaseUrl),
  providerUrl,
  providerUrl,
  serviceAccountToImpersonate,
  new[] { "https://www.googleapis.com/auth/cloud-platform" });
    
// Use the credential to access Google APIs    
var service = new ComputeService(
  new BaseClientService.Initializer()
  {
    HttpClientInitializer = credential
  });

Does that mean we should generally prefer SAML over OIDC when using workload identity federation with AD FS? Absolutely not. But it’s good to know that there is an alternative when we’re hitting the limitations of OIDC.

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