Google Cloud Authenticating to Google Cloud using Integrated Windows Authentication, workload identity federation, and SAML-POST

Previously, we explored two ways of authenticating to Google Cloud using Kerberos and NTLM credentials. Both ways involved authenticating to AD FS using Integrated Windows Authentication, obtaining either an OAuth access token or a SAML assertion, and then using workload identity federation:

Process

We saw that one advantage of SAML over OIDC is that it lets us propagate additional claims to workload identity federation, including the user’s UPN, SID, and group memberships. But does that justify using WS-Trust, a rather outdated and cumbersome protocol?

Maybe. But there is another alternative: Instead of using WS-Trust, we can also obtain a SAML assertion by using the SAML HTTP-POST binding, and exchange that against Google credentials by using workload identity federation:

Process

The SAML HTTP-POST binding (“SAMLP”) is the binding typically used for browser-based single sign-on. The basic idea is:

  1. The application (“service provider” in SAML lingo) redirects the user to the IdP, passing a SAML request in the URL.
  2. The user interacts with the IdP to authenticate.
  3. After successful authentication, the IdP returns a HTML page that contains a form with a (hidden) field named SAMLResponse. This field contains a SAML response, which in turn contains a SAML assertion.
  4. A JavaScript automatically posts the form to the service provider’s Assertion Consumer Service (ACS) URL, which then uses the SAML assertion to authenticate the user.

Workload identity federation isn’t intended for browser-based scenarios – instead, it’s meant to be used for unattended, workload-to-workload communication. Such unattended scenarios rarely involve a browser, so having to deal with HTTP redirects and HTML forms is far from ideal. WS-Trust is just much better suited in this regard.

But not ideal doesn’t mean it’s not possible to use SAMLP in conjunction with Windows Integrated Authentication and workload identity federation. Let’s see how that can work.

Preparing AD FS

First, we need to prepare AD FS for using Windows Integrated Authentication. This involves:

We also need to create a relying party trust that corresponds to the workload identity pool. Once we’ve created this relying party trust, we can enable the SAML HTTP-POST binding:

  1. In the AD FS MMC snap-in, navigate to Relying party trusts and double-click the relying party trust.
  2. Open the Endpoints tab and click Add SAML.
  3. Enter the following settings:
    • Binding: POST
    • Trusted URL: Enter a pseudo-URL such as https://myapp.example/. The URL must be unique, but doesn’t need to point anywhere.
  4. Click OK.

Authenticating using PowerShell

Now let’s log in to a domain-joined Windows machine and create a PowerShell script that:

  1. Creates a SAML Request and sends it to AD FS, just like a SAML service provider would do in a browser-based scenario
  2. Authenticates to AD FS using Integrated Windows Authentication
  3. Loads the response page and extracts the SAML response from the embedded HTML form

That looks like:

$ErrorActionPreference = "Stop" 
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12

#
# Configuration
#

$AdfsBaseUrl = "https://example.net/adfs"
$RelyingPartyId = "https://iam.googleapis.com/projects/PROJECT-ID/locations/global/workloadIdentityPools/POOL-ID/providers/PROVIDER-ID"
$DummyAcsUrl = "https://powershell.local/"

Write-Host "Obtaining SAML assertion for $(whoami)..."

#
# Create SAML request
#

$AuthnRequest = "<?xml version='1.0' encoding='utf-8'?>
    <samlp:AuthnRequest 
        xmlns:samlp='urn:oasis:names:tc:SAML:2.0:protocol' 
        ID='_$([Guid]::NewGuid())' 
        Version='2.0' 
        IssueInstant='$(Get-Date -Format "o")' 
        ProtocolBinding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' 
        ProviderName='PowerShell' 
        IsPassive='false' 
        AssertionConsumerServiceURL='$DummyAcsUrl'>
      <saml:Issuer xmlns:saml='urn:oasis:names:tc:SAML:2.0:assertion'>$RelyingPartyId</saml:Issuer>
      <samlp:NameIDPolicy AllowCreate='true' Format='urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified' />
    </samlp:AuthnRequest>"

$Buffer = New-Object IO.MemoryStream
$Deflate = New-Object IO.Compression.DeflateStream ($Buffer, [IO.Compression.CompressionMode]::Compress)
$Writer = New-Object IO.StreamWriter ($Deflate, [Text.Encoding]::ASCII)
$Writer.Write($AuthnRequest)
$Writer.Flush()
$Deflate.Dispose()
$SamlRequest = [Convert]::ToBase64String($Buffer.ToArray())

#
# Send SAML request to AD FS, and authenticate using IWA.
#

$Result = Invoke-RestMethod `
    -Method POST `
    -Uri "$AdfsBaseUrl/ls?SAMLRequest=$([Web.HttpUtility]::UrlEncode($SamlRequest))" `
    -UseDefaultCredentials

#
# Extract SAML response from HTML page
# (assuming authentication was successful).
#

$Result.SelectSingleNode("//input[@name='SAMLResponse']").value

Assuming we’ve configured AD FS properly, running the script prints a Base64-encoded SAML response:


Obtaining SAML assertion for example\myuser...

PHNhbWxwOlJlc3BvbnNl…

We can use this assertion and feed it to the client libraries or gcloud.

Notice however that the script contains no real error handling. If AD FS authentication fails, AD FS returns an HTML page with an error message. The structure of the page might depend on the version and configuration of AD FS, making it all but impossible to reliably handle error cases.

But that’s the price we have to pay when using SAML HTTP-POST instead of WS-Trust.

Authenticating using C

Anyway, let’s go one step further and creating a custom credential class in C# that:

  • Performs the entire process described above, including both credential exchanges.
  • 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. Create a SAML Request and sends it to AD FS
  2. Authenticate to AD FS using Integrated Windows Authentication
  3. Extract the SAML response from the response page
  4. Use the STS API to exchange the SAML response against an STS token.
  5. Use the STS token to impersonate a service account and get a Google access token.

And this look like:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Net;
using System.Text;
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;
using System.IO;
using System.Xml;
using System.IO.Compression;
using System.Net.Http;

namespace CredentialUtils
{
  /// <summary>
  /// Credential that uses SAMLP (SAML POST binding) and workload identity
  /// federation to authenticate.
  /// </summary>
  public class AdfsSamlpWorkloadIdentityCredential : ServiceCredential
  {
    /// <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; }

    /// <summary>
    /// ACS URL. This can be a dummy URL such as 'https://app.example/', but
    /// it must be configured in ADFS.
    /// </summary>
    public string AssertionConsumerServiceUrl { get; }

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

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

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

    /// <summary>
    /// Create a SAML AuthnRequest.
    /// </summary>
    private string CreateSamlAuthnRequest()
    {
      using (var output = new MemoryStream())
      {
        using (var zip = new DeflateStream(output, CompressionMode.Compress))
        using (var writer = new StreamWriter(zip, new UTF8Encoding(false)))
        using (var xmlWriter = XmlWriter.Create(writer))
        {
          var samlpNamespace = "urn:oasis:names:tc:SAML:2.0:protocol";
          xmlWriter.WriteStartDocument();
          xmlWriter.WriteStartElement("samlp", "AuthnRequest", samlpNamespace);
          xmlWriter.WriteAttributeString("xmlns", "samlp", null, samlpNamespace);
          xmlWriter.WriteAttributeString("ID", "_" + Guid.NewGuid().ToString());
          xmlWriter.WriteAttributeString("Version", "2.0");
          xmlWriter.WriteAttributeString("IssueInstant", XmlConvert.ToString(
            DateTime.UtcNow, 
            XmlDateTimeSerializationMode.Utc));

          xmlWriter.WriteAttributeString("ProtocolBinding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST");
          xmlWriter.WriteAttributeString("IsPassive", "false");
          xmlWriter.WriteAttributeString("AssertionConsumerServiceURL", this.AssertionConsumerServiceUrl);
          
          xmlWriter.WriteStartElement("saml", "Issuer", "urn:oasis:names:tc:SAML:2.0:assertion");
          xmlWriter.WriteString(this.RelyingPartyId);
          xmlWriter.WriteEndElement();
          
          xmlWriter.WriteStartElement("samlp", "NameIDPolicy", samlpNamespace);
          xmlWriter.WriteAttributeString("AllowCreate", XmlConvert.ToString(true));
          xmlWriter.WriteAttributeString("Format", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified");
          xmlWriter.WriteEndElement();
          
          xmlWriter.WriteEndElement();
          xmlWriter.WriteEndDocument();
          xmlWriter.Flush();
        }

        return Convert.ToBase64String(output.ToArray());
      }
    }

    /// <summary>
    /// Use SAML-POST binding and IWA to obtain a SAML Response.
    /// </summary>
    private async Task<string> GetSamlResponseAsync(string samlRequest)
    {
      using (var handler = new HttpClientHandler 
      { 
        Credentials = CredentialCache.DefaultNetworkCredentials
      })
      using (var client = new HttpClient(handler))
      {
        //
        // Set a characteristic user agent. This use agent must
        // be enabled for IWA in AD FS.
        //
        client.DefaultRequestHeaders.UserAgent.ParseAdd(
          "Mozilla/5.0 WorkloadIdentityFederationClient");

        using (var response = await client.PostAsync(
            $"{this.AdfsBaseUrl}/ls?SAMLRequest={WebUtility.UrlEncode(samlRequest)}",
            null)
          .ConfigureAwait(false))
        {
          //
          // If the sign-in succeeded, the resulting HTML page must
          // have a form that includes a field named 'SAMLResponse'. Instead
          // of POST-ing the form to the ACS, extract the field's value.
          //
          try
          {
            var responseString = await response
              .Content
              .ReadAsStringAsync()
              .ConfigureAwait(false);

            var responseDoc = new XmlDocument();
            responseDoc.LoadXml(responseString);

            var node = responseDoc.SelectSingleNode("//input[@name='SAMLResponse']/@value");
            if (node != null)
            {
              return node.InnerText;
            }
            else
            {
              throw new WebException(
                "Authentication failed",
                new WebException(responseString));
            }
          }
          catch (Exception e)
          {
            throw new WebException("Failed to parse AD FS response, authentication failed", e);
          }
        }
      }
    }

    /// <summary>
    /// Exchange SAML assertion against a Google STS token.
    /// </summary>
    private async Task<StsData.GoogleIdentityStsV1ExchangeTokenResponse> ExchangeTokenAsync(
      string subjectToken,
      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: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)
    {
      //
      // Authenticate to ADFS and get a SAML assertion.
      //
      var samlResponse = await GetSamlResponseAsync(CreateSamlAuthnRequest())
        .ConfigureAwait(false);

      //
      // Exchange the SAML assertion against a Google STS token.
      //
      var stsToken = await ExchangeTokenAsync(
          samlResponse,
          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 now 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 acsUrl = "https://myapp.example/";

var credential = new AdfsSamlpWorkloadIdentityCredential(
  new Uri(adfsBaseUrl),
  providerUrl,
  acsUrl,
  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
  });

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