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:
- Grant the service account domain-wide delegation for a certain scope, say,
../auth/cloud-identity
. - Download a service account key and store it at a location where our application can access it.
- 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 theiam.serviceAccounts.signBlob
permission on the respective service account. Similarly, to attach a service account to a compute resource, we must have theiam.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:
- Create a service account (
[email protected]
) and attach it to the compute resource that runs our application. - Create a second service account (
[email protected]
) and
configure it for domain-wide delegation. - Grant the
app@
service account the Service Account Token Creator role on thedwd@
service account so that it can sign JWTs usingdwd@
’s Google-managed service account key.
With that setup the place, the process to use domain-wide delegation is:
- Call
GoogleCredential.GetApplicationDefault()
to authenticate asapp@
. - Create a JWT bearer token with
sub
set to[email protected]
. - Call signJwt
to sign the JWT using
dwd@
’s Google-managed service account key. - Post the JWT to
https://oauth2.googleapis.com/token
using the grant typeurn: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;
}
}
}