Using a Google Cloud service account to authenticate to AD FS
Let’s assume that we have a number of backend services running in our on-premises environment, and that we’re using AD FS as our identity provider. The backend services expose APIs and expect clients and frontend apps to authenticate using tokens from AD FS.
Now we’re developing another frontend app, but this time we deploy it on Google Cloud. How can this app authenticate to the existing backend services? Or more precisely, how can this application authenticate to AD FS and obtain access tokens for the backend services?
This situation is similar to the one we looked at last time, just that it involves AD FS instead of KeyCloak. Let’s see if we can apply the same solution.
Certificate-based authentication
Like KeyCloak, AD FS allows clients to authenticate by using a certificate instead of using a client secret. To do that, the documentation instructs us to pass the following parameters in the token request:
grant_type = client_credentials
client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion = ...
But what exactly do we need to pass as client assertion? The docs are rather vague:
An assertion (a JSON web token) that you need to create and sign with the certificate you registered as credentials for your application.
To find out more, let’s turn to RFC 7521 which defines the Assertion Framework for OAuth 2.0 Client Authentication and Grants and:
provides a general framework for the use of assertions as authorization grants with OAuth 2.0. It also provides a framework for assertions to be used for client authentication.
In the section Common Scenarios, the RFC describes our use case:
Client Acting on Behalf of Itself
When a client is accessing resources on behalf of itself, it does so in a manner analogous to the Client Credentials Grant defined in Section 4.4 of OAuth 2.0 [RFC6749]. This is a special case that combines both the authentication and authorization grant usage patterns. In this case, the interactions with the authorization server should be treated as using an assertion for Client Authentication according to Section 4.2, while using the "grant_type" parameter with the value "client_credentials" to indicate that the client is requesting an access token using only its client credentials.
The following example demonstrates an assertion being used for a client credentials access token request, as defined in Section 4.4.2 of OAuth 2.0 [RFC6749] (with extra line breaks for display purposes only):
POST /token HTTP/1.1
Host: server.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
client_assertion_type=urn%3Aietf%3Aparams%3Aoauth
%3Aclient-assertion-type%3Asaml2-bearer&
client_assertion=PHNhbW...[omitted for brevity]...ZT
Great, but we still don’t know how the assertion needs to look like. For this, we need to look at yet another RFC, RFC 7523, which describes the JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants and:
profiles the OAuth Assertion Framework [RFC7521] to define an extension grant type that uses a JWT Bearer Token to request an OAuth 2.0 access token as well as for use as client credentials.
In the JWT Format and Processing Requirements section, this RFC gets more specific and defines which claims a client assertion needs to contain:
1. The JWT MUST contain an "iss" (issuer) claim that contains a unique identifier for the entity that issued the JWT. [...]
2. The JWT MUST contain a "sub" (subject) claim identifying the principal that is the subject of the JWT. [...]
For client authentication, the subject MUST be the "client_id" of the OAuth client.
3. The JWT MUST contain an "aud" (audience) claim containing a value that identifies the authorization server as an intended audience. The token endpoint URL of the authorization server MAY be used as a value for an "aud" element to identify the authorization server as an intended audience of the JWT. [...]
4. The JWT MUST contain an "exp" (expiration time) claim that limits the time window during which the JWT can be used. [...]
5. The JWT MAY contain an "nbf" (not before) claim that identifies the time before which the token MUST NOT be accepted for Processing.
6. The JWT MAY contain an "iat" (issued at) claim that identifies the time at which the JWT was issued. [...]
7. The JWT MAY contain a "jti" (JWT ID) claim that provides a unique identifier for the token. [...]
8. The JWT MAY contain other claims.
9. The JWT MUST be digitally signed or have a Message Authentication Code (MAC) applied by the issuer. The authorization server MUST reject JWTs with an invalid signature or MAC.
10. The authorization server MUST reject a JWT that is not valid in all other respects per "JSON Web Token (JWT)" [...].
That’s something we can work with.
Using a service account to sign a client assertion
Now that we know how a client assertion needs to look like, let’s follow the same approach as we did with KeyCloak:
- In GCP, we create a service account and attach it to the compute resource (VM, Cloud Run instance, Cloud Function, …) that runs our app.
- We grant the service account the Service Account Token Creator role on itself, so that the service account can use its own signing key to sign data.
- In AD FS, we create a client of type server application and configure it to use signed JWTs for authentication. We then configure the client to periodically download the JWKS from the service account’s JSON Web Key Set (JWKS) endpoint.
With that setup in place, an app can do the following to authenticate to AD FS:
- Create a client assertion (containing the list of claims described above).
- Obtain an access token from the metadata server (for example, by using the client libraries).
- Use the access token to authenticate to the IAM Credentials API and call the signJwt method to sign the assertion.
- Authenticate to AD FS by using the
client_credentials
grant and the signed assertion.
AD FS will then:
- Parse the assertion and extract the client ID.
- Look up the JWKS endpoint for the client.
- Download the latest signing keys from the JWKS endpoint.
- Validate the assertion’s signature using the signing keys from the JWKS.
The following diagram illustrates the process:
Now let’s go through the steps in more detail.
Creating a client in AD FS
First, we need to create the client in AD FS. This client corresponds to a specific Google Cloud service account:
- In the AD FS management console, go to Application Groups and click Add application group.
- Enter a name and select Server application. Then click Next.
- On the Server application page, enter the following settings
- Client Identifier: Email address of the service account, or another unique ID.
- Redirect URI:
http://invalid
or some other URL. Because we’re only using theclient_credentials
grant, the redirect URI won’t be used.
- Click Next.
- On the Configure application credentials page, set Register a key used to sign JSON Web Tokens for authentication to enabled and click Configure.
- Under Periodically download certificates or public keys from this JWKS URL, paste the following URL:
https://www.googleapis.com/service_accounts/v1/jwk/[email protected]
where[email protected]
is the email addres of the service account. - Click Download Now.
- Click OK.
- Follow the remaining steps of the wizard.
Note: To validate the client assertion, AD FS must be able to download the JWKS. If AD FS doesn’t have direct outbound internet access, we can configure it to use a proxy by running
netsh winhttp set proxy "proxy.example.com:3128"
Authenticating
With that setup in place, let’s test the process with PowerShell:
Initialize a few variables:
$ClientId = 'ADFS-CLIENT-ID' $ServiceAccountEmail = '[email protected]'
Obtain a Google access token:
$AccessToken = (gcloud auth print-access-token)
Create a client assertion:
$Assertion = @{ "sub" = $ClientId "iss" = $ClientId "aud" = "https://login.example.com/adfs/oauth2/token/" "jti" = [Guid]::NewGuid().ToString() "iat" = [DateTimeOffset]::Now.ToUnixTimeSeconds() "exp" = [DateTimeOffset]::Now.AddMinutes(5).ToUnixTimeSeconds() } | ConvertTo-Json
Call
signJwt
to sign the assertion:$SignedAssertion = (Invoke-RestMethod ` -Method POST ` -Uri "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/$($ServiceAccountEmail):signJwt" ` -ContentType "application/x-www-form-urlencoded" ` -Header @{ "Authorization" = "Bearer $AccessToken" } ` -Body @{ "payload" = $Assertion }).signedJwt
When decoded, the resulting assertion (JWT) looks like this:
{ "alg": "RS256", "kid": "5b355eeab9544b65d41cee238e43677ed5314bb4", "typ": "JWT" }.{ "exp": 1636198728, "sub": "ADFS-CLIENT-ID", "jti": "2119ca6e-8266-4329-9a56-a3e1c5acabf2", "aud": "https://login.example.com/adfs/oauth2/token/", "iss": "ADFS-CLIENT-ID", "iat": 1636198428 }.[Signature]
Notice the
kid
claim in the header, which lets AD FS find the right signing key in the JWKS.Authenticate to AD FS by using the signed assertion:
Invoke-RestMethod ` -Method POST ` -Uri "https://login.example.com/adfs/oauth2/token/" ` -ContentType "application/x-www-form-urlencoded" ` -Body "grant_type=client_credentials&client_assertion=$SignedAssertion&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
Notice the two extra parameters:
client_assertion
contains the signed assertion.client_assertion_type
indicates that the client assertion is a signed JWT.
In return, we get an access token:
access_token ------------ eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6I...