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:
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:
The SAML HTTP-POST binding (“SAMLP”) is the binding typically used for browser-based single sign-on. The basic idea is:
- The application (“service provider” in SAML lingo) redirects the user to the IdP, passing a SAML request in the URL.
- The user interacts with the IdP to authenticate.
- 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. - 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:
- Enabling IWA for intranet authentication
- Ensuring that AD FS has the right SPN
- Extending the user agent whitelist for Integrated Windows Authentication
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:
- In the AD FS MMC snap-in, navigate to Relying party trusts and double-click the relying party trust.
- Open the Endpoints tab and click Add SAML.
- 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.
- Click OK.
Authenticating using PowerShell
Now let’s log in to a domain-joined Windows machine and create a PowerShell script that:
- Creates a SAML Request and sends it to AD FS, just like a SAML service provider would do in a browser-based scenario
- Authenticates to AD FS using Integrated Windows Authentication
- 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
invokesRequestAccessTokenAsync
, 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:
- Create a SAML Request and sends it to AD FS
- Authenticate to AD FS using Integrated Windows Authentication
- Extract the SAML response from the response page
- Use the STS API to exchange the SAML response against an STS token.
- 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
});