Google Cloud Using domain-wide delegation without service account keys, Java edition

Before we can call a Google API, we have to obtain an OAuth access token. As we learned before, there are different types of access tokens, and service account access tokens are a little different from user access tokens.

The vast majority of Google Cloud APIs don’t discriminate between these two types of token and work with both. But beyond Google Cloud, there are still many Google APIs that don’t accept service account access tokens. If we want to use these APIs in an unattended scenario, we have to use domain-wide delegation.

A common way to use domain-wide delegation is to use a downloaded service account key. But by now, we all know that using service account keys is risky and best avoided.

A while ago, we therefore looked at using domain-wide-delegation on Google Cloud without service account keys, and how that can be done in a .NET application.

Simplified a bit, the idea is:

  1. Create a service account and attach it to the compute resource that runs our application.
  2. Grant the service account permission to use domain-wide delegation.
  3. Grant the service account the Service Account Token Creator role on itself.

The last step is key, because it allows the service account to sign JWTs using its Google-managed service account key.

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

  1. In the application, call GoogleCredentials.getApplicationDefault() to obtain credentials for the attached service account.
  2. Prepare a JWT bearer token with sub set to the Cloud Identity/Workspace user that we want to impersonate, for example [email protected].
  3. Use the Service Account Credentials API to sign the JWT.
  4. Post the signed 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 [email protected].

To use this process in Java, we can create a custom credential class. The easiest way to do this is to derive from Credentials and override the refreshAccessToken() method like so:

/**
 * Delegated credentials for a user account, obtained using
 * domain-wide delegation.
 */
public class DelegatedCredentials extends GoogleCredentials {

 @Override
 public @NotNull AccessToken refreshAccessToken() throws IOException {
  //
  // Prepare a JWT payload.
  //
  var payload = new JsonWebToken.Payload()
   .setIssuer(this.serviceAccountEmail)
   .set("scope", String.join(" ", this.scopes))
   .setAudience(TOKEN_ENDPOINT)
   .setExpirationTimeSeconds(this.clock.currentTimeMillis() / 1000 + Long.valueOf(this.lifetime))
   .setIssuedAtTimeSeconds(this.clock.currentTimeMillis() / 1000)
   .set("sub", this.targetPrincipal);

  //
  // Provide a GSON factory so that we can obtain the encoded
  // payload by calling toString later.
  //
  payload.setFactory(JSON_FACTORY);

  //
  // Let the service account sign the JWT.
  //
  String jwtAssertion;
  try {
   var clientBuilder = new IAMCredentials.Builder(
    createTransport(),
    new GsonFactory(),
    new HttpCredentialsAdapter(this.sourceCredentials));

   var signResponse = clientBuilder
    .build()
    .projects()
    .serviceAccounts()
    .signJwt(
     "projects/-/serviceAccounts/" + this.serviceAccountEmail,
     new SignJwtRequest().setPayload(payload.toString()))
    .execute();

   jwtAssertion = signResponse.getSignedJwt();
  }
  catch (GoogleJsonResponseException e) {
   throw new IOException(
    String.format(
     "Using the service account '%s' to sign a JWT assertion failed",
     this.serviceAccountEmail),
    e);
  }

  //
  // Use the JWT assertion to obtain delegated credentials.
  //
  try {
   var tokenRequest = new GenericData()
    .set("grant_type", JWT_BEARER_GRANT_TYPE)
    .set("assertion", jwtAssertion);

   var tokenResponse = createTransport()
    .createRequestFactory()
    .buildPostRequest(
     new GenericUrl(TOKEN_ENDPOINT),
     new UrlEncodedContent(tokenRequest))
    .setParser(new JsonObjectParser(JSON_FACTORY))
    .execute()
    .parseAs(GenericJson.class);

   var accessToken = (String)tokenResponse.get("access_token");
   var expiresIn = ((Number)tokenResponse.get("expires_in")).longValue();

   var delegatedToken = AccessToken.newBuilder()
    .setTokenValue(accessToken)
    .setExpirationTime(new Date(
     clock.currentTimeMillis() + expiresIn))
    .setScopes(String.join(" ", this.scopes))
    .build();

   return delegatedToken;
  }
  catch (HttpResponseException e) {
   throw new IOException(
    String.format(
     "Obtaining delegated credentials for the principal '%s' failed",
     this.targetPrincipal),
    e);
  }
 }
 
 // Some more boilerplate code ...
 
}

You can find the complete code in this Gist.

A key benefit of this approach is that we don’t have to worry about expiry: When the access token is about to expire (after one hour), the base class automatically invokes refreshAccessToken() again and caches the resulting access token.

That means we can use the DelegatedCredentials as a drop-in replacement for GoogleCredentials when initializing an API client. For example:

final String SERVICE_ACCOUNT = "[email protected]";
final String DWD_USER = "[email protected]"

// Get the service account credential.
var sourceCredentials = GoogleCredentials.getApplicationDefault();

// Wrap it to add our domain-wide delegation logic.
var delegatedCredentials = DelegatedCredentials.create(
 sourceCredentials,
 SERVICE_ACCOUNT,
 DWD_USER,
 List.of("https://www.googleapis.com/auth/devstorage.read_only"),
 3600);

// Initialize an API client.
var clientBuilder = new SomeGoogleApi.Builder(
  GoogleNetHttpTransport.newTrustedTransport(),
  new GsonFactory(),
  new HttpCredentialsAdapter(delegatedCredentials)); // pass DWD credential
var client = clientBuilder.build();

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