Windows 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:

  1. In GCP, we create a service account and attach it to the compute resource (VM, Cloud Run instance, Cloud Function, …) that runs our app.
  2. 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.
  3. 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:

  1. Create a client assertion (containing the list of claims described above).
  2. Obtain an access token from the metadata server (for example, by using the client libraries).
  3. Use the access token to authenticate to the IAM Credentials API and call the signJwt method to sign the assertion.
  4. Authenticate to AD FS by using the client_credentials grant and the signed assertion.

AD FS will then:

  1. Parse the assertion and extract the client ID.
  2. Look up the JWKS endpoint for the client.
  3. Download the latest signing keys from the JWKS endpoint.
  4. Validate the assertion’s signature using the signing keys from the JWKS.

The following diagram illustrates the process:

Sequence

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:

  1. In the AD FS management console, go to Application Groups and click Add application group.
  2. Enter a name and select Server application. Then click Next.
  3. On the Server application page, enter the following settings
    1. Client Identifier: Email address of the service account, or another unique ID.
    2. Redirect URI: http://invalid or some other URL. Because we’re only using the client_credentials grant, the redirect URI won’t be used.
  4. Click Next.
  5. On the Configure application credentials page, set Register a key used to sign JSON Web Tokens for authentication to enabled and click Configure.
  6. 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.
  7. Click Download Now.
  8. Click OK.
  9. 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:

  1. Initialize a few variables:

    $ClientId = 'ADFS-CLIENT-ID'
    $ServiceAccountEmail = '[email protected]'
    
  2. Obtain a Google access token:

    $AccessToken = (gcloud auth print-access-token)
    
  3. 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
    
  4. 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.

  5. 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...
    
Any opinions expressed on this blog are Johannes' own. Refer to the respective vendor’s product documentation for authoritative information.
« Back to home