Can GKE Workload Identity be used with Domain Wide Delegation?
Asked Answered
N

1

6

We've been using the Google Directory API to get the profile of our users, on an internal app. When we authenticate, we've been using a json keyfile for a service and the google-auth-library JWT class. The service account has Domain Wide Delegation to use this endpoint. Thus, the access token needs to have it's subject set to a workspace admin.

const jwt = new JWT({
  email: key.client_email,
  key: key.private_key,
  subject: '<admin-email-address>',
  scopes: 'https://www.googleapis.com/auth/admin.directory.user.readonly',
})

const accessToken = await jwt.getAccessToken()

Our organisation is trying to shift away from using keyfiles for service accounts, and move towards GKE Workload Identity. We can authenticate using application default credentials, setting the subject in clientOptions.

const auth = new GoogleAuth({
  scopes: 'https://www.googleapis.com/auth/admin.directory.user.readonly',
  clientOptions: {
    subject: '<admin-email-address>'
  }
})

const accessToken = await auth.getAccessToken()

However, the access token created doesn't have the subject set. This means the token can't access the Directory API.

Is there a way to create a token that could, using Workload Identity?

Nigritude answered 21/2, 2022 at 10:21 Comment(5)
Have you checked on the guide Configure applications to use Workload Identity? Also, can you provide more details on the steps you have made?Dunton
Sure. We followed that guide when setting it up. We assigned a k8s service account to the application; created an IAM service account; added an IAM policy binding; added the annotation; and the workload runs as the service account. We verified the binding - if we connect in an interactive session we have access to the IAM account. If we use google-auth library as above, with application default credentials, we can get an access token with the scopes we need. However, we can't set the subject of this access token. Because of domain wide delegation, we need to set the subject for our use case.Nigritude
Have you found a solution? We are having the same problem here :SExarchate
Hi, I am struggling to get it working. Did you guys manage it?Guntar
Hi, have you found solution yet?Sentry
E
0

I had a similar problem, where I needed to have the application default credentials of a Cloud Run service, impersonate another service account and then this second service account will have the rights for domain wide delegation and impersonate a specific google workspace account. The nodejs library doesn't have this out of the box so I thought to share as figuring the solution was tough.

In essence, you've already managed to create an auth client in your nodejs app that will work with workload identity. I guess it's as simple as that:

const auth = new GoogleAuth({
      projectId: 'my-gcp-project-id'
    })
const sourceClient = await auth.getClient()

The next step is to create an ID token with the desired scopes and it's subject set as the email address of the google workspace user to impersonate.

While composing the payload of such a token is straightforward, you need to get it signed. For this purpose there is the signJWT method in the iam credentials api.

  const payload = {
    iss: serviceAccount,// service account e-mail
    scope, // space separated list of scopes
    aud: 'https://oauth2.googleapis.com/token',
    iat,
    exp: iat + lifetime,// max 3600
    sub: subject // google workspace email
  }

  const name = `projects/-/serviceAccounts/${serviceAccount}`
  const singJwtTokenUrl = `https://iamcredentials.googleapis.com/v1/${name}:signJwt`
  // sign JWT token.
  const { data: { signedJwt } } = await sourceClient.request({
    url: singJwtTokenUrl,
    data: {
      delegates: [],
      payload: JSON.stringify(payload)
    },
    method: 'POST'
  })

Now the next step is to use this signedJWT and get an accessToken, and then create a new oauth client with this access token as credentials. This auth client can then be used for about an hour, as this is the max lifetime of the access token.

See what worked for me in this helper function:

const getImpersonatedClientForScopes = async ({
  serviceAccount, // service account to impersonate
  targetScopes,
  subject,
  sourceClient,
  lifetime = 3600 // the Google default for the access token lifetime is max 1 hour
}) => {
  if (!subject) {
    return new Impersonated({
      sourceClient,
      targetPrincipal: serviceAccount,
      lifetime,
      delegates: [],
      targetScopes
    })
  }
  // Domain-wide-Delegation
  const iat = parseInt(Date.now() / 1000)
  const scope = targetScopes.join(' ')
  const payload = {
    iss: serviceAccount,
    scope,
    aud: 'https://oauth2.googleapis.com/token',
    iat,
    exp: iat + lifetime,
    sub: subject
  }

  const name = `projects/-/serviceAccounts/${serviceAccount}`
  const singJwtTokenUrl = `https://iamcredentials.googleapis.com/v1/${name}:signJwt`
  // sign JWT token.
  const { data: { signedJwt } } = await sourceClient.request({
    url: singJwtTokenUrl,
    data: {
      delegates: [],
      payload: JSON.stringify(payload)
    },
    method: 'POST'
  })

  const {
    data: {
      access_token,
      expires_in,
      token_type
    }
  } = await sourceClient.request({
    method: 'POST',
    url: 'https://www.googleapis.com/oauth2/v4/token',
    data: {
      grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
      assertion: signedJwt
    },
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    responseType: 'json'
  })

  const credentials = {
    // The time in ms at which this token is thought to expire.
    expiry_date: expires_in === null || expires_in === undefined ? undefined : (iat + expires_in) * 1000,
    // A token that can be sent to a Google API.
    access_token,
    // Identifies the type of token returned. At this time, this field always has the value Bearer.
    token_type,
    // A JWT that contains identity information about the user that is digitally signed by Google.
    id_token: signedJwt,
    // The scopes of access granted by the access_token expressed as a list of space-delimited, case-sensitive strings.
    scope: targetScopes.join(' ')
  }

  const impersonatedClient = new OAuth2Client()
  impersonatedClient.setCredentials(credentials)

  return impersonatedClient
}

Now the disadvantages of this approach is that it won't perform, as you call two more http endpoints, while when using credential files, the signing happens locally because we have the private key in hand. It might be a good idea to cache the impersonated access token for it's lifetime, but this might not scale if there is a requirement to impersonate multiple users frequently due to rate limits.

Exhalant answered 3/6, 2024 at 15:27 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.