Google Cloud Using domain-wide-delegation on Google Cloud without service account keys

Before we can call a Google Cloud API, we have to obtain an OAuth access token. We can obtain access tokens for a user (by following the OAuth authorize flow) or for a service account, and most APIs will happily accept both kinds of tokens.

But there are some APIs that won’t accept access tokens issued to service accounts. Examples for such APIs include the Cloud Identity API, parts of the Directory API, and several non-Cloud APIs. If we want to use these APIs in an unattended scenario, we have to use domain-wide delegation.

Domain-wide delegation

Domain-wide delegation lets a service account obtain access tokens for a Cloud Identity or Workspace user, effectively impersonating that user.

But domain-wide delegation isn’t limited to any particular user – once we grant a service account domain-wide delegation, that service account can impersonate any user in our Cloud Identity or Workspace account. So domain-wide delegation is a very powerful capability.

Does that mean a service account with domain-wide delegation can do everything that any user in the Cloud Identity or Workspace can do, making it as powerful as a super-admin? Not exactly, because if we grant domain-wide delegation, we only do so for certain OAuth scopes. For example, if we grant a service account domain-wide delegation for the scope ../auth/bigquery.readonly, the service account can impersonate any user, but is still limited to performing read-only operations on BigQuery. Even impersonating a super-user won’t change that.

Using a downloaded service account key

A common way to use domain-wide delegation is to use a service account key. Suppose we have a service account [email protected] and we want to impersonate a user [email protected]. Then the process looks like this:

  1. Grant the service account domain-wide delegation for a certain scope, say, ../auth/cloud-identity.
  2. Download a service account key and store it at a location where our application can access it.
  3. Let the application use the service account key to authenticate and impersonate [email protected] in a single step.

In C#, the CreateWithUser method lets us to (3):

private static async Task<GoogleCredential> ImpersonateUserAsync(
  string serviceAccountKeyFile,
  string userToImpersonate,
  IEnumerable<string> scopes)
{
  return (await GoogleCredential.FromFileAsync(serviceAccountKeyFile, CancellationToken.None))
    .CreateWithUser(userToImpersonate) // <-- domain-wide delegation
    .CreateScoped(scopes);
}

...

var bobsCredential = await ImpersonateUsingServiceAccountKeyAsync(
  @"path\to\service-account-key.json",
  "[email protected]",
  new[] { "https://www.googleapis.com/auth/cloud-identity" })

What happens under the hood is that the library creates a JWT bearer token with the following claims:

{
    "scope": "https://www.googleapis.com/auth/cloud-identity",
    "email_verified": false,
    "iss": "[email protected]",
    "sub": "[email protected]",
    "aud": "https://oauth2.googleapis.com/token",
    "exp": 1618576074,
    "iat": 1618572474
}

Note the sub claim, which indicates that we’re requesting a token for [email protected], not [email protected].

The library then signs the JWT with the service account key and posts it to https://oauth2.googleapis.com/token using the grant type urn:ietf:params:oauth:grant-type:jwt-bearer. The result is a (short-lived) access token for Bob.

The challenge with service account keys is that they are difficult to secure: Like passwords and other private keys, service account keys are a form of secret and must be treated as such. And that’s often easier said than done.

Luckily, there’s a way to use domain-wide delegation without having to worry about downloaded service account keys. To see how that works, let’s take a step back and revisit how service account keys work.

Types of service account keys

Service account keys come in two types, Google-managed and user-managed. Google-managed keys and user-managed keys differ in where their private key is stored, how they can be used for authentication, and how they must be secured:

  • Google-managed keys are automatically generated and fully managed by Cloud IAM. IAM maintains Google-managed key pairs for all service accounts, and rotates them periodically. We can download the public key of a Google-managed key pair, but we can’t access its private key.

    To use the private key of a Google-managed key pair, we must request IAM to perform operations for us. For example, we can invoke the signBlob or signJwt operations to let IAM use a service account’s Google-managed private key to sign a piece of data for us. Similarly, by attaching a service account to a compute resource, we can authorize IAM to use the Google-managed key whenever the compute resource requests service account credentials.

    Requesting IAM to use a service account’s Google-managed private key for us requires special permissions. For example, to invoke signBlob, we must have the iam.serviceAccounts.signBlob permission on the respective service account. Similarly, to attach a service account to a compute resource, we must have the iam.serviceAccounts.actAs permission. To secure the service account, and protect against privilege-escalation threats, we must ensure that only authorized users are granted roles that include these permissions.

  • User-managed keys are additional key pairs that we can associate with a service account, either by uploading a public key or by letting IAM generate a key pair for us. In both cases, IAM only stores the public key while we own the private key.

    The private key, along with some metadata is called a service account key and typically stored in JSON format.

    Because we own the private key, we are responsible for keeping it confidential, storing it securely, and rotating it periodically. Anybody who can access the private key can use it to create a JWT bearer token and authenticate as that service account.

Note that whenever somebody says service account key, they’re typically referring to a user-managed service account key.

Using a Google-managed service account key

As we saw before, using domain-wide delegation involves creating a JWT bearer token and signing it with a service account key. But instead of using a user-managed service account key, we can just as well use a Google-managed service account key! That way, we don’t have to download a service account key, and don’t have to worry about keeping it secure.

The idea is:

  1. Create a service account ([email protected]) and attach it to the compute resource that runs our application.
  2. Create a second service account ([email protected]) and
    configure it for domain-wide delegation.
  3. Grant the app@ service account the Service Account Token Creator role on the dwd@ service account so that it can sign JWTs using dwd@’s Google-managed service account key.

Domain-wide delegation

With that setup the place, the process to use domain-wide delegation is:

  1. Call GoogleCredential.GetApplicationDefault() to authenticate as app@.
  2. Create a JWT bearer token with sub set to [email protected].
  3. Call signJwt to sign the JWT using dwd@’s Google-managed service account key.
  4. Post the JWT to https://oauth2.googleapis.com/token using the grant type urn:ietf:params:oauth:grant-type:jwt-bearer.

The result is a (short-lived) access token for Bob.

DomainWideDelegationCredential

Let’s automate this process by creating a DomainWideDelegationCredential class that:

  • derives from ICredential so that we can use it just like other credentials,
  • takes an input credential and performs steps 2-4 above.

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.

So all we really have to implement is RequestAccessTokenAsync. With much of the error handling stripped, this looks like:

using Google.Apis.Auth.OAuth2;
using Google.Apis.Auth.OAuth2.Requests;
using Google.Apis.Auth.OAuth2.Responses;
using Google.Apis.IAMCredentials.v1;
using Google.Apis.IAMCredentials.v1.Data;
using Google.Apis.Services;
using Google.Apis.Util;
using Newtonsoft.Json;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace CredentialUtils
{
  public class DomainWideDelegationCredential : ServiceCredential
  {
    private static readonly DateTime UnixEpoch = 
      new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);

    private readonly ServiceCredential serviceAccountCredential;
    private readonly IEnumerable<string> scopes;

    public string DelegationUser { get; }
    public string Id { get; }

    public DomainWideDelegationCredential(
      ServiceCredential serviceAccountCredential,
      string serviceAccountstring,
      string delegationUser,
      IEnumerable<string> scopes)
      : base(new Initializer(GoogleAuthConsts.TokenUrl))
    {
      this.serviceAccountCredential = serviceAccountCredential;
      this.Id = serviceAccountstring;
      this.DelegationUser = delegationUser;
      this.scopes = scopes;
    }

    public override async Task<bool> RequestAccessTokenAsync(
      CancellationToken cancellationToken)
    {
      //
      // Create a JWT bearer for the delegated user.
      //
      var issuedAt = DateTime.UtcNow;
      var claims = new Hashtable
      {
        { "iss", this.Id },
        { "iat", ((long)(issuedAt - UnixEpoch).TotalSeconds) },
        { "exp", ((long)(issuedAt.AddMinutes(10) - UnixEpoch).TotalSeconds) },
        { "aud", GoogleAuthConsts.TokenUrl },
        { "sub", this.DelegationUser },
        { "scope", string.Join(" ", this.scopes) }
      };

      //
      // Sign it using the service account.
      //
      var credentialService = new IAMCredentialsService(
        new BaseClientService.Initializer()
        {
          HttpClientInitializer = this.serviceAccountCredential
        });

      var response = await credentialService.Projects.ServiceAccounts
        .SignJwt(
          new SignJwtRequest()
          {
            Payload = JsonConvert.SerializeObject(claims)
          },
          $"projects/-/serviceAccounts/{this.Id}")
        .ExecuteAsync(cancellationToken)
        .ConfigureAwait(false);

      var tokenRequest = new GoogleAssertionTokenRequest()
      {
        GrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer",
        Assertion = response.SignedJwt
      };

      //
      // Exchange against an access token.
      //
      this.Token = await tokenRequest.ExecuteAsync(
        base.HttpClient,
        base.TokenServerUrl,
        cancellationToken,
        base.Clock);

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