googleapis / google-auth-library-nodejs

🔑 Google Auth Library for Node.js
Apache License 2.0
1.74k stars 382 forks source link

User Impersonation with Google Service Account #916

Open hsw48 opened 4 years ago

hsw48 commented 4 years ago

Hi. I'm trying to implement user impersonation with a google service account and have been having problems for a while. After adding the user to be impersonated as the subject, a token gets created but when calling an API like Google Calendar I get a 401 Invalid Credentials error as if the token that was just created has expired or is invalid.

Do you have any samples of user impersonation? I don't see any in the samples or using Node in Google's documentation. Here is their documentation on the subject: https://developers.google.com/identity/protocols/oauth2/service-account#delegate-domain-wide-authority

Thanks a lot.

shierro commented 4 years ago

i am unable to impersonate successfully as well.

Error, code 401

unauthorized_client: Client is unauthorized to retrieve access tokens using this method, or client not authorized for any of the scopes requested.
const googleService = {};

const authClient = google.auth.fromJSON(serviceJson);
authClient.scopes = [
  'https://www.googleapis.com/auth/calendar',
  'https://www.googleapis.com/auth/calendar.events',
];
authClient.subject = 'email@domain.com.au';
googleService.authClient = authClient;

async function addGuestAndSendEmail(eventId, calendarId, newGuest) {
  const {
    data: { attendees = [] },
  } = await googleService.event.get(eventId, calendarId);
  attendees.push({ email: newGuest });
  return calendar.events.patch({
    calendarId,
    eventId,
    auth: googleService.authClient,
    requestBody: {
      sendUpdates: 'all',
      attendees,
    },
  });
}

service account where I got serviceJson Enable G Suite Domain-wide Delegation - check

service account granted access on google admin Calendar (Read-Write) https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events

Badly need help :((

shierro commented 4 years ago

without authClient.subject, i get error code 403

Service accounts cannot invite attendees without Domain-Wide Delegation of Authority.
shierro commented 4 years ago

also tried

    const authClient = new google.auth.JWT({
      email: serviceJson.client_email,
      key: serviceJson.private_key,
      scopes: [
        'https://www.googleapis.com/auth/calendar',
        'https://www.googleapis.com/auth/calendar.events',
        'https://www.googleapis.com/auth/cloud-platform',
      ],
      subject: 'email@domain.com.au',
    });
    googleService.authClient = authClient;

with error

unauthorized_client: Client is unauthorized to retrieve access tokens using this method, or client not authorized for any of the scopes requested
shierro commented 4 years ago

I was able to do impersonation with official python client library, so I suspect there's something wrong with this NodeJS client

bcoe commented 4 years ago

There's an outstanding PR, #779, to add support for impersonation. Perhaps try this as a starting point, and see if the approach would work for you?

charlesjacobsonrarebirds commented 4 years ago

Hey @bcoe thanks for pointing out the PR, I wish to have seen this a few days ago 😄 We've finished the feature in the python client so I think I'll be delayed in testing the PR

shierro commented 4 years ago

i tried to impersonate a user under the domain but it didn't work. I got error

Error: Error: Unable to refresh sourceCredential: Error: Error: Unable to impersonate: Error: Requested entity was not found.

sample code:

    const saclient = new JWT(
      serviceJson.client_email,
      null,
      serviceJson.private_key,
      scopes,
    );

    // Use that to impersonate the targetPrincipal
    const targetClient = new Impersonated({
      sourceClient: saclient,
      targetPrincipal: 'someuser@domain.com',
      lifetime: 30,
      delegates: [],
      targetScopes: scopes,
    });
    const authHeaders = await targetClient.getRequestHeaders();
    console.log('authHeaders', authHeaders);

I guess this impersonation applies only to a service account, not to domain users

marksantoso commented 4 years ago

There's an outstanding PR, #779, to add support for impersonation. Perhaps try this as a starting point, and see if the approach would work for you?

https://stackoverflow.com/a/61571003/3539640

any service accounts made after March 2, 2020 will no longer be able to invite guests to events without using impersonation.

@bcoe Has impersonation been implemented in this package?

migo1 commented 4 years ago

https://stackoverflow.com/questions/61473708/creating-events-using-the-google-calendar-api-and-service-account

VanathiMK commented 4 years ago

Is this working fine

dr-aiuta commented 4 years ago

maybe this could help

const auth = new google.auth.GoogleAuth({
    keyFile: './service.json',
    scopes: [
      'https://www.googleapis.com/auth/contacts',...
    ],
    clientOptions: {
      subject: 'email@email.com'
    },
  });
fabiomig commented 3 years ago

@dr-aiuta you save my life!! After hours of searching... Not sure why Google don't put this on docs...

bcoe commented 3 years ago

@fabiomig is @dr-aiuta's example working for you? It sounds like we definitely should document this approach.

john-ballon commented 3 years ago

@bcoe, @dr-aiuta's approach worked for me. Also working for me is the following (Calendar API specific example):

const { google } = require('googleapis');
const moment = require('moment');
const googleKey = require('../service-account.json');

const SUBJECT = 'email.to.impersonate@email.com'

const auth = new google.auth.JWT({
  email: googleKey.client_email,
  key: googleKey.private_key,
  scopes: ['https://www.googleapis.com/auth/calendar'],
  subject: SUBJECT
});

const calendar = google.calendar({ version: 'v3', auth });

calendar.events.insert({
  calendarId: SUBJECT,
  sendUpdates: 'all',
  requestBody: {
    summary: 'This is a summary',
    description: 'This is a description',
    start: { dateTime: moment().add(1, 'day'), timeZone: 'PST' },
    end: { dateTime: moment().add(1, 'day').add(45, 'minutes'), timeZone: 'PST' },
    attendees: [{ email: SUBJECT }, { email:  'other.attendee.email@email.com' }]
  }
}, (err, res) => {
  if (err) return console.log('The API returned an error: ' + err);
  // handle res
});

Bear in mind it impersonates a user, not a separate Service Account. I was only able to find this info on SO/here/a couple of blogs. It would be helpful if it were documented if it isn't already.

Jose-DM commented 3 years ago

maybe this could help

const auth = new google.auth.GoogleAuth({
    keyFile: './service.json',
    scopes: [
      'https://www.googleapis.com/auth/contacts',...
    ],
    clientOptions: {
      subject: 'email@email.com'
    },
  });

Thank you much.

ncabelin commented 3 years ago

can this "clientOptions" parameter be put on one of the Quickstarts pages as one of the examples? I spent hours trying to find a solution and I'm so relieved to find this thread. This should be standard in the Directory API docs.

increos commented 2 years ago

Hi .. I am trying to use the service account using Google Cloud Functions to access the Workspace Directory API. I am trying to use Application Default Credentials approach. Since the documentation doesn't mention any additional steps to be done in the Google Cloud Console side, I assume that Application Default Credentials (ADC) to the function is automatically passed to the cloud function from Google Cloud's Metadata server. The following code works perfectly in my local, emulated environment where I have set GOOGLE_APPLICATION_CREDENTIALS environment variable to point to the JSON credential file. However when I deploy the function to Google Cloud, I am getting the error "Not Authorized to access this resource/api". I have been searching and trying for days without any success. As an aside, before I stumbled upon this thread and recommendation from @dr-aiuta, I was using getCredentials() method of GoogleAuth to get the private_key to create JWT auth (for the "subject" property) and passing that auth to admin API call. Which again worked perfectly in my local environment but fails in the cloud environment because getCredentials() private_key is null, which is probably expected behavior. Any help is deeply appreciated. If this request needs to be posted somewhere else please advise as well.

export const listUsers = functions.https.onCall((data, context) => {
  return new Promise(async (resolve, reject) => {
    const envAuth = new GoogleAuth({
      scopes: ["https://www.googleapis.com/auth/admin.directory.user.readonly"],
      clientOptions: {
        subject: "admin@mydomain.com",
      },
    });

    const client = await envAuth.getClient();
    const service = google.admin({version: "directory_v1", auth: client});
    try {
      const response = await service.users.list({
        customer: "MYCUSTOMER_ID",
      });
      resolve(response);
    } catch (err) {
      reject(err);
    }
  });
});
jmkrimm commented 2 years ago

I am having the same issue as @increos and would like to replicate the solution provided for Python: https://github.com/GoogleCloudPlatform/professional-services/blob/master/examples/gce-to-adminsdk/main.py

increos commented 2 years ago

@jmkrimm My conclusion through trial and error (correctly or incorrectly ) is the ADC strategy works for "newer?" Google Cloud Platform service e.g. Cloud Storage, Secrets Manager etc. But if you are reaching beyond to other (legacy?) Google Products like Workspace then you need other approaches. I got it to work by using using the Secret Manager product. Storing my keys there and reading those in and explicitly using the JWT token to get access to the Google Admin Directory API.

I am pasting excerpts of my code below (in typescript) if it helps in anyway :

import {SecretManagerServiceClient} from '@google-cloud/secret-manager';
import {JWT} from 'google-auth-library';

... ...

const client = new SecretManagerServiceClient();
const name = 'projects/<project id >/secrets/private_key/versions/1';
export function googleAuthorize(scopes: Array<string>, subject: string): Promise<JWT> {
  return new Promise(async (resolve) => {
    const [version] = await client.accessSecretVersion({
      name: name,
    });
    const secret = JSON.parse(version.payload?.data?.toString() as string);
    const jwt = new google.auth.JWT({
      scopes: scopes,
      subject: subject,
      email: secret.client_email,
      key: secret.private_key,
    });
    resolve(jwt);
  });
}

and the finally

 etc. etc.

    const scopes = ['https://www.googleapis.com/auth/admin.directory.user.readonly'];
    const jwtAuth = await googleAuthorize(scopes, ADMIN_EMAIL);
    const service = google.admin({version: 'directory_v1', auth: jwtAuth});

    try {
      const response = await service.users.list({
        customer: "MYCUSTOMER_ID",
      });
      resolve(response);
    } catch (err) {
      reject(err);
    }

etc etc 
jmkrimm commented 2 years ago

@increos sorry but that solution will not work because I am trying not to create a KEY file at all. When code runs on GCP and the default service account has the necessary authorization, it should just work. Similar to authenticaing to GCP resources like Cloud Storage. The issue is not with the Google API but the nodejs library. Google Workspace API in most cases expects impersonation of a Google Workspace account and the client library does not support this without providing a key file. It is very frustrating for enterprise Google Workspace customers like myself.

RizqiSyahrendra commented 2 years ago

maybe this could help

const auth = new google.auth.GoogleAuth({
    keyFile: './service.json',
    scopes: [
      'https://www.googleapis.com/auth/contacts',...
    ],
    clientOptions: {
      subject: 'email@email.com'
    },
  });

Wow great, thank you so much 😄 , I've been looking for this all day long

patrykkarny commented 2 years ago

for me the solution with JWT worked:

const auth = new google.auth.JWT({
  keyFile: 'path-to-service-account.json',
  scopes: [
    'https://www.googleapis.com/auth/calendar',
    'https://www.googleapis.com/auth/calendar.events',
  ],
  subject: 'impersonated@email.com',
})
linusromland commented 2 years ago

maybe this could help

const auth = new google.auth.GoogleAuth({
    keyFile: './service.json',
    scopes: [
      'https://www.googleapis.com/auth/contacts',...
    ],
    clientOptions: {
      subject: 'email@email.com'
    },
  });

THANK YOU!

svante-jonsson commented 2 years ago

@dr-aiuta

maybe this could help

const auth = new google.auth.GoogleAuth({
    keyFile: './service.json',
    scopes: [
      'https://www.googleapis.com/auth/contacts',...
    ],
    clientOptions: {
      subject: 'email@email.com'
    },
  });

Thanks! This solved everything! ❤️

kkam-hca commented 2 years ago

maybe this could help

const auth = new google.auth.GoogleAuth({
    keyFile: './service.json',
    scopes: [
      'https://www.googleapis.com/auth/contacts',...
    ],
    clientOptions: {
      subject: 'email@email.com'
    },
  });

Wow great, thank you so much 😄 , I've been looking for this all day long

OMG THANK YOU SO MUCH

yohanna17 commented 1 year ago

also tried

    const authClient = new google.auth.JWT({
      email: serviceJson.client_email,
      key: serviceJson.private_key,
      scopes: [
        'https://www.googleapis.com/auth/calendar',
        'https://www.googleapis.com/auth/calendar.events',
        'https://www.googleapis.com/auth/cloud-platform',
      ],
      subject: 'email@domain.com.au',
    });
    googleService.authClient = authClient;

with error

unauthorized_client: Client is unauthorized to retrieve access tokens using this method, or client not authorized for any of the scopes requested

For those of you who still encounter this problem and hasn't found light, try redownloading json credentials. If you add scope or domain-wide delegation to the service account, it seems the private key will also change, and thus you need to download a new one

Lonolf commented 1 year ago

@increos sorry but that solution will not work because I am trying not to create a KEY file at all. When code runs on GCP and the default service account has the necessary authorization, it should just work. Similar to authenticaing to GCP resources like Cloud Storage. The issue is not with the Google API but the nodejs library. Google Workspace API in most cases expects impersonation of a Google Workspace account and the client library does not support this without providing a key file. It is very frustrating for enterprise Google Workspace customers like myself.

I'm trying too to use the Application Default Credentials to create a JWT token to impersonate the users. This will be very useful to deploy both in production and in test enviroment without a cumbersone json file to every time manage

BoscoDomingo commented 1 year ago

Wow, so to this date, the Node.js library has no way of just using the ADC? Instead, we have to explicitly create a key for the Service Account (which is a bad practice as per Google's documentation) image and using that generated JSON to manually create a JWT with subject: "impersonated_user@domain"????

@bcoe @danielbankhead can you please shed some light on this. I believe both Python and Java libraries have this functionality already as expressed in this tutorial of yours and it seems crazy that I have to increase security risks at my organisation due to a missing feature...


Edit: FYI manually creating a new Impersonated auth as per this guide doesn't work either as that's only meant for impersonating other SAs.

Tests ```typescript const auth = new GoogleAuth({ clientOptions: { // these seem to be ignored altogether subject: hostEmail, }, scopes: [ // added just in case, but I've tried without this and same thing 'https://www.googleapis.com/auth/calendar', 'https://www.googleapis.com/auth/calendar.events', ], }); const targetClient = new Impersonated({ sourceClient: authClient, targetPrincipal: targetUserToImpersonateWithServiceAccount, lifetime: 30, delegates: [], targetScopes: [ 'https://www.googleapis.com/auth/calendar', 'https://www.googleapis.com/auth/calendar.events', ], }); const authHeaders = await targetClient.getRequestHeaders(); ``` results in a `GaxiosError: Could not refresh access token: PERMISSION_DENIED: unable to impersonate: Request had insufficient authentication scopes.` even with DWD set up. Doing a Frankenstein-worthy workaround: ```ts const authClient: { sourceClient: AuthClient } = (await auth.getClient()) as unknown as { sourceClient: AuthClient; }; const targetClient = new Impersonated({ sourceClient: authClient.sourceClient, // ... ``` I get a `GaxiosError: Could not refresh access token: NOT_FOUND: unable to impersonate: Not found; Gaia id not found for email email@domain`

There's no way to use an impersonated Service Account to impersonate a Workspace user via ADC for Node as far as I can tell, and the maintainers seem not to mind it. I really want to be wrong, but nothing points me to think otherwise.

I guess it's either the JWT way or logging in directly as the SA, without impersonation (is that even possible?) :/

jars commented 10 months ago

After combining knowledge from this thread, and countless other sources online (Google and not), I was finally able to get NodeJS to do Service Account impersonation using my Google Account ADC, without having to manage service account keys.

1) optional: destroy your ~/.config/gcloud directory to ensure no other pre-existing login credentials conflict with the authentication: rm -rf ~/.config/gcloud 2) provide ADC to gcloud: gcloud auth application-default login and follow the login flow in the browser, logging in with your Google Account. 3) provide your ADC Google Account the Service Account Token Creator Role on the Service Account you are trying to impersonate. 4) The IAM Service Account Credentials API must be enable in your GCP Project. I thought this was the same as the IAM API. It is not. Goto GCP -> APIs & Services -> Enable APIs & Services -> search for it, and Enable it. 5) install the necessary dependencies, and run the code below (example uses BigQuery):

const { BigQuery }                 = require('@google-cloud/bigquery');
const { GoogleAuth, Impersonated } = require('google-auth-library');

const projectId           = 'your-project';
const serviceAccountEmail = `your-sa@${projectId}.iam.gserviceaccount.com`;
const scopes              = ['https://www.googleapis.com/auth/cloud-platform'];
const delegates           = [];
const lifetime            = 3600;

(async () => {

  const auth         = new GoogleAuth({ scopes });
  const sourceClient = await auth.getClient();

  const authClient = new Impersonated({
      sourceClient,
      targetPrincipal: serviceAccountEmail,
      lifetime,
      delegates,
      targetScopes: scopes
  });

  const bigquery = new BigQuery({ projectId, authClient });

  const response = await bigquery.query('select 1 as x ');

  /* should print [ [ { x: 1 } ] ] */
  console.log(response);

})()

While this works, it leaves much to be desired. I would prefer if all of this impersonation & authclient code could be stored locally and referenced as ADC by the Node SDK... so, I found: gcloud auth application-default login --impersonate-service-account SERVICE_ACCT_EMAIL. But, it fails for reasons I'm still not sure. Substituting (2) above with this command and cleaning up the BigQuery constructor to just simply const bigquery = new BigQuery({ projectId }); fails with:

Error: The incoming JSON object does not contain a client_email field

I guess it has something to do with the credential file that is stored in ~/.config/gcloud/application_default_credentials.json. The schemas are not aligned when running the command with or without the --impersonate-service-account option.

I will go ahead with what I have now, but would appreciate if anyone could shed light on it.

BoscoDomingo commented 10 months ago

@jars you're doing the opposite of what we're trying to achieve, which we know works ;).

This issue is about using a Service Account to impersonate a user, not the other way around. For example, I want to be able to send a calendar invitation or an email on behalf of colleagues in my Domain (with their prior consent, obviously) via Domain-Wide Delegation (DWD). Hope that clears it up!

Note: Mind you, this is already possible in other client libraries, but not for the Node one for some reason

jars commented 10 months ago

Ahh -- Got it, @BoscoDomingo. I don't want to derail convo. Will search for a more suitable place online to post my findings. Update: I used the Hide function above and found a SO Question to answer instead.

akshaychopra5207 commented 10 months ago

@charlesjacobsonrarebirds

Hey @bcoe thanks for pointing out the PR, I wish to have seen this a few days ago 😄 We've finished the feature in the python client so I think I'll be delayed in testing the PR

Can you please direct me how to do this in python client . Also does this method need Domain wide delegation? I came from here https://stackoverflow.com/questions/67396417/error-invalid-conference-type-value-while-creating-event-with-google-meet-link

Its difficult for us to enable domain wide delegation because I dont need to invite users or access their calenders. the only thing I need is to create google meeting where everybody can join

PraiseTy commented 8 months ago

@akshaychopra5207, Did you ever figure this out? I am also trying to created a google meeting where everyone can join and getting this error in python?

BoscoDomingo commented 8 months ago

Just FYI this is still an issue, 4 years later. Python and Java already have this functionality, so it's a matter of translating code unless I'm missing something (I haven't been able to look into the code)

tcvall86 commented 7 months ago

Hello,

I am not sure this is helpful or not for people who like us wants to use WIF with temporary access credentials generated from the client library config file and impersonation with SA for domain wide delegation.

We rebuilt the functionality used in this Github Action (https://github.com/google-github-actions/auth) so all credit to that source for figuring out the boilerplate. We reuse this in Lambdas and other types of AWS Services.

Full disclaimer I don't consider myself to be a node / typescript coder so don't take the code here for granted. There are probably a lot of things that could be neater

import axios from 'axios'; // we need this to build the DWD
import { ExternalAccountClient, OAuth2Client } from 'google-auth-library';
import { SecretService } from './secret'; // deals with fetching secrets from aws secretsmanager
import { SignedJWT } from '../domain/google';
/*
export interface SignedJWT {
  kid: string;
  signedJwt: string;
}
*/
import { GOOGLE_API_SCOPES } from '../constants'; // Just definition of the API Scopes requested
import { logger } from '../logging'; // This is a lambda powertools configuration

const {
  ENVIRONMENT,
  GCP_PROJECT_ID,
  SERVICE_ACCOUNT_EMAIL
} = process.env;

// userAgent is the default user agent.
const userAgent = `someuser-agent-you-want-to-use`;

export async function generateOauth2Client(secretService: SecretService, impersonationEmail: string): Promise<OAuth2Client> {
  const creds = await secretService.getGoogleSecret(); // get SA config for WIF
  const auth_client = ExternalAccountClient.fromJSON(creds);
  if (!auth_client) { throw Error('no client created') }
  auth_client.scopes = ['https://www.googleapis.com/auth/cloud-platform'];

  if (!GCP_PROJECT_ID || !SERVICE_ACCOUNT_EMAIL) { throw Error('missing required gcp env vars') }

  if (!auth_client) { throw Error('no client created') }
  logger.debug("requesting dwd credentials");

  const unsignedJwt = buildDomainWideDelegationJWT(
    SERVICE_ACCOUNT_EMAIL,
    impersonationEmail,
    GOOGLE_API_SCOPES,
    3600, // 1 hour, longer duration is unsupported with DWD
  );

  // build dwd request
  const url = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${SERVICE_ACCOUNT_EMAIL}:signJwt`;
  const headers = await auth_client.getRequestHeaders();
  const body = {
    payload: unsignedJwt
  }

  const signJwtResponse = (await auth_client.request({
    url: url,
    body: JSON.stringify(body),
    headers: headers,
    method: 'POST'
  })).data as SignedJWT;

  if (!signJwtResponse.signedJwt) {
    throw new Error("No data in response")
  }

  const access_token = await generateDomainWideDelegationAccessToken(signJwtResponse.signedJwt);

  // We get an OAuth2 access token, we need to convert this into a client to be able to initiate the other google api clients such as admin or gmail
  const oauth2Client = new OAuth2Client();
  oauth2Client.setCredentials({ access_token: access_token });
  return oauth2Client

  function buildDomainWideDelegationJWT(
    serviceAccount: string,
    subject: string | undefined | null,
    scopes: Array<string> | undefined | null,
    lifetime: number,
  ): string {
    const now = Math.floor(new Date().getTime() / 1000);

    const body: Record<string, string | number> = {
      iss: serviceAccount,
      aud: 'https://oauth2.googleapis.com/token',
      iat: now,
      exp: now + lifetime,
    };
    if (subject && subject.trim().length > 0) {
      body.sub = subject;
    }
    if (scopes && scopes.length > 0) {
      // Yes, this is a space delimited list.
      // Not a typo, the API expects the field to be "scope" (singular).
      body.scope = scopes.join(' ');
    }

    return JSON.stringify(body);
  }

  /*
    We need to send this request with axios (or other http client) because we want to use a JWT (JSON Web Token) for Domain-Wide Delegation of Authority. 
    The Google Auth Library for Node.js does not provide a built-in method for this specific use case as far as we have found
  */
  async function generateDomainWideDelegationAccessToken(
    signedJwt: string,
  ): Promise<string> {
    const url = 'https://oauth2.googleapis.com/token';
    const headers = {
      'Accept': 'application/json',
      'Content-Type': 'application/x-www-form-urlencoded',
      'User-Agent': userAgent
    };
    const body = new URLSearchParams();
    body.append('grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer');
    body.append('assertion', signedJwt);

    try {
      const resp = await axios.post(url, body.toString(), { headers });
      if (resp.status < 200 || resp.status > 299) {
        throw new Error(`Failed to call ${url}: HTTP ${resp.status}: ${resp.data || '[no body]'}`);
      }
      logger.debug("Got oauth2 access token")
      return resp.data.access_token;
    } catch (err) {
      throw new Error(`Failed to generate Google Cloud Domain Wide Delegation OAuth 2.0 Access Token: ${err}`);
    }
  }
}

Apart from this the service account needs to have service account token creator permission.

You can then start other clients such as admin or gmail clients ie.

import { gmail_v1 } from '@googleapis/gmail';

const oauth2Client = await generateOauth2Client(secretService, impersonationEmail);
gmailClient = new gmail_v1.Gmail({ auth: oauth2Client });
lopezvit commented 6 months ago

I want to build in top of @tcvall86 answer, but using the application default credentials, as the original OP requested (and I also needed), so here is the improved version (moved back to vanilla JS):

const axios = require("axios"); // we need this to build the DWD
const { GoogleAuth } = require("google-auth-library");
const { OAuth2Client } = require("google-auth-library");

logger = console;

// userAgent is the default user agent.
const userAgent = `someuser-agent-you-want-to-use`;

async function generateOauth2Client(impersonationEmail, gcpProjectId, serviceAccountEmail, googleApiScopes) {
  const auth_client = new GoogleAuth({
    scopes: ["https://www.googleapis.com/auth/cloud-platform"],
  });
  if (!auth_client) {
    throw Error("no client created");
  }

  if (!gcpProjectId || !serviceAccountEmail) {
    throw Error("missing required gcp env vars");
  }

  if (!auth_client) {
    throw Error("no client created");
  }
  logger.debug("requesting dwd credentials");

  const unsignedJwt = buildDomainWideDelegationJWT(
    serviceAccountEmail,
    impersonationEmail,
    googleApiScopes,
    3600 // 1 hour, longer duration is unsupported with DWD
  );

  // build dwd request
  const url = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccountEmail}:signJwt`;
  const headers = await auth_client.getRequestHeaders();
  const body = {
    payload: unsignedJwt,
  };

  const signJwtResponse = (
    await auth_client.request({
      url: url,
      body: JSON.stringify(body),
      headers: headers,
      method: "POST",
    })
  ).data;

  if (!signJwtResponse.signedJwt) {
    throw new Error("No data in response");
  }

  const access_token = await generateDomainWideDelegationAccessToken(
    signJwtResponse.signedJwt
  );

  // We get an OAuth2 access token, we need to convert this into a client to be able to initiate the other google api clients such as admin or gmail
  const oauth2Client = new OAuth2Client();
  oauth2Client.setCredentials({ access_token: access_token });
  return oauth2Client;

  function buildDomainWideDelegationJWT(
    serviceAccount,
    subject,
    scopes,
    lifetime
  ) {
    const now = Math.floor(new Date().getTime() / 1000);

    const body = {
      iss: serviceAccount,
      aud: "https://oauth2.googleapis.com/token",
      iat: now,
      exp: now + lifetime,
    };
    if (subject && subject.trim().length > 0) {
      body.sub = subject;
    }
    if (scopes && scopes.length > 0) {
      // Yes, this is a space delimited list.
      // Not a typo, the API expects the field to be "scope" (singular).
      body.scope = scopes.join(" ");
    }

    return JSON.stringify(body);
  }

  /*
    We need to send this request with axios (or other http client) because we want to use a JWT (JSON Web Token) for Domain-Wide Delegation of Authority. 
    The Google Auth Library for Node.js does not provide a built-in method for this specific use case as far as we have found
  */
  async function generateDomainWideDelegationAccessToken(signedJwt) {
    const url = "https://oauth2.googleapis.com/token";
    const headers = {
      Accept: "application/json",
      "Content-Type": "application/x-www-form-urlencoded",
      "User-Agent": userAgent,
    };
    const body = new URLSearchParams();
    body.append("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer");
    body.append("assertion", signedJwt);

    try {
      const resp = await axios.post(url, body.toString(), { headers });
      if (resp.status < 200 || resp.status > 299) {
        throw new Error(
          `Failed to call ${url}: HTTP ${resp.status}: ${
            resp.data || "[no body]"
          }`
        );
      }
      logger.debug("Got oauth2 access token");
      return resp.data.access_token;
    } catch (err) {
      throw new Error(
        `Failed to generate Google Cloud Domain Wide Delegation OAuth 2.0 Access Token: ${err}`
      );
    }
  }
}

module.exports = {
  generateOauth2Client
};

And this is the way that it is used:

[...]
const { generateOauth2Client } = require("./impersonation.js");
[...]
  const auth = await generateOauth2Client(
    "<USE-OWN-IMPERSONATED-USER>",
    "<USE-YOUR-OWN-PROJECT>",
    "<USE-YOUR-OWN-IMPERSONATING-SA",
    [
      "https://www.googleapis.com/auth/drive.readonly",
    ]
  );
  const service = google.drive({ version: "v3", auth });

  resAbout = await service.about.get({
    fields: "*",
  });
  console.log(resAbout.data.user);

The result of said code is printing the information about the impersonated user, meaning that we have managed to really impersonate it!

BoscoDomingo commented 6 months ago

@lopezvit Nice one! I will give it a go whenever I have time for it, and let you know if it worked for me too. Can't promise it'll be soon though (it's for work and we've more pressing matters atm)

alitto commented 5 months ago

In case anyone else is interested in the TypeScript version of @tcvall86 and @lopezvit snippets:

import { GoogleAuth } from 'google-auth-library';

const GOOGLE_OAUTH2_TOKEN_API_URL = 'https://oauth2.googleapis.com/token';

/**
 * This function generates an OAuth2 Access Token with scopes obtained via domain-wide delegation
 * without requiring a JSON key file from a Service Account.
 *
 * Resources:
 *  - https://github.com/googleapis/google-auth-library-nodejs/issues/916
 *  - https://cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys?hl=es-419#domain-wide-delegation
 *
 * @param impersonationEmail Email address of the Google account that has granted domain-wide scopes to the service account
 * @param serviceAccountEmail Service account which received the scopes using domain-wide delegation
 * @param googleApiScopes Scopes to request for the generated Access Token
 * @param lifetime Lifetime, in seconds, of the generated access token. It can't be greater than 1h
 * @returns the generated access token
 */
export async function getDomainWideDelegationAccessToken(
  impersonationEmail: string,
  serviceAccountEmail: string,
  googleApiScopes: string[],
  lifetime: number,
): Promise<string> {
  if (!impersonationEmail) {
    throw Error('impersonationEmail is required');
  }
  if (!serviceAccountEmail) {
    throw Error('serviceAccountEmail is required');
  }
  if (lifetime > 3600) {
    throw Error('lifetime cannot be greater than 3600 seconds (1 hour)');
  }

  // Build client using Application Default Credentials
  const auth_client = new GoogleAuth({
    scopes: ['https://www.googleapis.com/auth/cloud-platform'],
  });

  // Build JWT token for domain-wide delegation
  const unsignedJwt = buildDomainWideDelegationJWT(serviceAccountEmail, impersonationEmail, googleApiScopes, lifetime);

  // Sign JWT token using a system-managed private key of the given service account
  const signJwtResponse = (
    await auth_client.request({
      url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccountEmail}:signJwt`,
      body: JSON.stringify({
        payload: unsignedJwt,
      }),
      headers: await auth_client.getRequestHeaders(),
      method: 'POST',
    })
  ).data;

  if (!signJwtResponse.signedJwt) {
    throw new Error('Failed to sign JWT token using the Service Account key');
  }

  return await generateDomainWideDelegationAccessToken(signJwtResponse.signedJwt);
}

/**
 * Builds the payload to request a JWT token using domain-wide delegation.
 * See: https://cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys?hl=es-419#domain-wide-delegation
 */
function buildDomainWideDelegationJWT(
  serviceAccount: string,
  subject: string,
  scopes: string[],
  lifetime: number,
): string {
  const now = Math.floor(new Date().getTime() / 1000);

  const body: Record<string, string | number | undefined> = {
    iss: serviceAccount,
    aud: GOOGLE_OAUTH2_TOKEN_API_URL,
    iat: now,
    exp: now + lifetime,
    sub: subject ?? undefined,
    // Yes, this is a space delimited list.
    // Not a typo, the API expects the field to be "scope" (singular).
    scope: scopes && scopes.length > 0 ? scopes.join(' ') : undefined,
  };

  return JSON.stringify(body);
}

/**
 * We need to send this request using an alternative http client because we want to use a JWT (JSON Web Token) for Domain-Wide Delegation of Authority.
 * The Google Auth Library for Node.js does not provide a built-in method for this specific use case.
 * See: https://cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys?hl=es-419#domain-wide-delegation
 */
async function generateDomainWideDelegationAccessToken(signedJwt: string): Promise<string> {
  const url = GOOGLE_OAUTH2_TOKEN_API_URL;
  const headers = {
    Accept: 'application/json',
    'Content-Type': 'application/x-www-form-urlencoded',
  };
  const body = new URLSearchParams();
  body.append('grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer');
  body.append('assertion', signedJwt);

  try {
    const resp = await fetch(url, {
      method: 'POST',
      body: body.toString(),
      headers,
    });
    if (resp.status < 200 || resp.status > 299) {
      throw new Error(`Failed to call ${url}: HTTP ${resp.status}: ${await resp.text()}`);
    }
    const data = (await resp.json()) as { access_token: string };
    return data.access_token;
  } catch (err) {
    throw new Error(`Failed to generate Google Cloud Domain Wide Delegation OAuth 2.0 Access Token: ${err}`);
  }
}

This can be used as follows:

// Generate access token using Domain-wide delegation
const accessToken = await getDomainWideDelegationAccessToken(
  'impersonated-user@example.com',
  'impersonating-service-account@example.com',
  ['scope1', 'scope2'], // Requested scopes
  15 * 60, // Lifetime of the access token, it cannot be greater than 1h
)

const oauth2Client = new OAuth2Client();
oauth2Client.setCredentials({ access_token: accessToken });
antnat96 commented 5 months ago

I would also really like this feature. Here is what I am currently using - I adjusted the above code a little bit to make use of the signJwt method that Google exposes in their IAMCredentialsClient, here it is:

import { IAMCredentialsClient } from '@google-cloud/iam-credentials'

const GOOGLE_OAUTH2_TOKEN_API_URL = 'https://oauth2.googleapis.com/token';

const buildUnsignedJwt = (serviceAccountEmail: string, impersonatedWorkspaceUserEmail: string, scopes: string[]): string => {
    const now = Math.floor(new Date().getTime() / 1000);
    const body: Record<string, string | number | undefined> = {
        iss: serviceAccountEmail,
        aud: GOOGLE_OAUTH2_TOKEN_API_URL,
        iat: now,
        exp: now + 3600,
        sub: impersonatedWorkspaceUserEmail,
        scope: scopes.join(' '),
    }
    return JSON.stringify(body)
}

const generateDomainWideDelegationAccessToken = async (signedJwt: string): Promise<string> => {
    const headers = {
        Accept: 'application/json',
        'Content-Type': 'application/x-www-form-urlencoded',
    };
    const body = new URLSearchParams();
    body.append('grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer');
    body.append('assertion', signedJwt);

    try {
        const resp = await fetch(GOOGLE_OAUTH2_TOKEN_API_URL, {
            method: 'POST',
            body: body.toString(),
            headers,
        });
        if (resp.status < 200 || resp.status > 299) {
            throw new Error(`Failed to call ${GOOGLE_OAUTH2_TOKEN_API_URL}: HTTP ${resp.status}: ${await resp.text()}`);
        }
        const data = (await resp.json()) as { access_token: string };
        return data.access_token;
    } catch (err) {
        throw new Error(`Failed to generate Google Cloud Domain Wide Delegation OAuth 2.0 Access Token: ${err}`);
    }
}

export const getDomainWideDelegationAccessToken = async (impersonatedWorkspaceUserEmail: string, serviceAccountEmail: string, scopes: string[]): Promise<string> => {
    if (!impersonatedWorkspaceUserEmail) throw Error('impersonationEmail is required');
    if (!serviceAccountEmail) throw Error('serviceAccountEmail is required');
    if (scopes.length === 0) throw Error('No scopes were provided');

    // Build unsigned JWT
    const unsignedJwt = buildUnsignedJwt(serviceAccountEmail, impersonatedWorkspaceUserEmail, scopes);

    // Sign JWT token using a system-managed private key of the given service account
    const [signedJwtResponse] = await new IAMCredentialsClient().signJwt({
        name: `projects/-/serviceAccounts/${serviceAccountEmail}`,
        payload: unsignedJwt,
    })
    const { signedJwt } = signedJwtResponse
    if (!signedJwt) throw new Error('Failed to sign JWT token using the Service Account key');
    return await generateDomainWideDelegationAccessToken(signedJwt);
}
tzappia commented 4 months ago

Thanks for your code contributions @tcvall86 @lopezvit @alitto @antnat96 ! So helpful! For anyone else trying out one of those solutions, make sure to add the Service Account Token Creator role to your service account in IAM. (I needed it with @alitto 's version.) It's so annoying this is still a limitation in the library, but this workaround is great!

anantakrishna commented 4 months ago

method that Google exposes in their IAMCredentialsClient

Let me add that googleapis package has this as well. This is how I call it:

    // Sign JWT token using a system-managed private key of the given service account
    await google.iamcredentials('v1').projects.serviceAccounts.signJwt({
      name: `projects/-/serviceAccounts/${serviceAccountEmail}`,
      requestBody: {
        // Build JWT token for domain-wide delegation
        payload: unsignedJWT,
      },
      auth,
    })
aaronjonesii commented 3 months ago

I am not sure if this helps others but I was able to provide a service account using credentials:

export function getGoogleAuth(serviceAccount: string): Auth.GoogleAuth {
  const SCOPES = [...];

    const impersonationEmail = "email@domain.com";

    return new google.auth.GoogleAuth({
      scopes: SCOPES,
      clientOptions: {subject: impersonationEmail},
      credentials: JSON.parse(serviceAccount),
    });
}
logemann commented 3 months ago

Thank god i found this thread because for me it was totally unclear how to deal with the " Service accounts cannot invite attendees without Domain-Wide Delegation of Authority." error even though i did the DWD setup correctly.

Problem is, you see pretty good tutotials on the web for other programming languages, like this one for C# (https://medium.com/iceapple-tech-talks/integration-with-google-calendar-api-using-service-account-1471e6e102c8) and then you realize that the c# client obviously behaves differently than my target language JS/TS and the node lib.

What i still dont get, that while i cant add attendees to a calendar event without clientOptions->subject, the mentioned subject has nothing to do with my attendees emails or the service user i am using. I just added one of my admin email address from my workspace users. I dont get the idea of "subject" at all. I always thought the service account is the one which does the calls.