Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User Impersonation with Google Service Account #916

Open
hsw48 opened this issue Mar 19, 2020 · 44 comments
Open

User Impersonation with Google Service Account #916

hsw48 opened this issue Mar 19, 2020 · 44 comments
Labels
priority: p2 Moderately-important priority. Fix may not be included in next release. type: feature request ‘Nice-to-have’ improvement, new feature or different behavior or design.

Comments

@hsw48
Copy link

hsw48 commented Mar 19, 2020

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.

@bcoe bcoe added the type: question Request for information or clarification. Not an issue. label Mar 19, 2020
@shierro
Copy link

shierro commented Apr 2, 2020

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 protected]';
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
Copy link

shierro commented Apr 2, 2020

without authClient.subject, i get error code 403

Service accounts cannot invite attendees without Domain-Wide Delegation of Authority.

@shierro
Copy link

shierro commented Apr 3, 2020

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 protected]',
    });
    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
Copy link

shierro commented Apr 7, 2020

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

@bcoe bcoe added type: feature request ‘Nice-to-have’ improvement, new feature or different behavior or design. and removed type: question Request for information or clarification. Not an issue. labels Apr 7, 2020
@bcoe
Copy link
Contributor

bcoe commented Apr 7, 2020

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

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
Copy link

shierro commented Apr 24, 2020

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: '[email protected]',
      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
Copy link

marksantoso commented May 19, 2020

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
Copy link

migo1 commented May 26, 2020

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

@VanathiMK
Copy link

Is this working fine

@dr-aiuta
Copy link

dr-aiuta commented Oct 19, 2020

maybe this could help

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

@fabiomig
Copy link

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

@bcoe
Copy link
Contributor

bcoe commented Dec 21, 2020

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

@john-ballon
Copy link

@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 protected]'

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:  '[email protected]' }]
  }
}, (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
Copy link

Jose-DM commented Apr 21, 2021

maybe this could help

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

Thank you much.

@ncabelin
Copy link

ncabelin commented Sep 2, 2021

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
Copy link

increos commented Dec 16, 2021

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: "[email protected]",
      },
    });

    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
Copy link

jmkrimm commented Jan 31, 2022

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
Copy link

increos commented Jan 31, 2022

@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
Copy link

jmkrimm commented Mar 28, 2022

@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
Copy link

maybe this could help

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

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

@patrykkarny
Copy link

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: '[email protected]',
})

@linusromland
Copy link

maybe this could help

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

THANK YOU!

@svante-jonsson
Copy link

svante-jonsson commented Jul 18, 2022

@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 protected]'
    },
  });

Thanks! This solved everything! ❤️

@kkam-hca
Copy link

kkam-hca commented Sep 3, 2022

maybe this could help

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

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

OMG THANK YOU SO MUCH

@danielbankhead danielbankhead self-assigned this Feb 16, 2023
@yohanna17
Copy link

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 protected]',
    });
    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
Copy link

Lonolf commented Jun 16, 2023

@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

@danielbankhead danielbankhead removed their assignment Jul 13, 2023
@BoscoDomingo
Copy link

BoscoDomingo commented Oct 11, 2023

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
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:

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?) :/

@danielbankhead danielbankhead added the priority: p2 Moderately-important priority. Fix may not be included in next release. label Oct 13, 2023
@jars

This comment was marked as off-topic.

@BoscoDomingo
Copy link

BoscoDomingo commented Jan 8, 2024

@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
Copy link

jars commented Jan 8, 2024

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
Copy link

@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
Copy link

@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
Copy link

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
Copy link

tcvall86 commented Apr 22, 2024

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
Copy link

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
Copy link

@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
Copy link

alitto commented Jun 13, 2024

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(
  '[email protected]',
  '[email protected]',
  ['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
Copy link

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
Copy link

tzappia commented Jul 16, 2024

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
Copy link

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
Copy link

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 protected]";

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

@logemann
Copy link

logemann commented Aug 29, 2024

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.

@JKHari
Copy link

JKHari commented Jan 6, 2025

async function createGoogleMeet() {
  const credentialsPath = path.resolve(__dirname, "../cred.json"); 

  try {
    const auth = new google.auth.GoogleAuth({
      keyFile: credentialsPath,
      scopes: [
        "https://www.googleapis.com/auth/calendar",
        "https://www.googleapis.com/auth/calendar.events"
      ],
      // clientOptions: {
      //   subject: "[email protected]",
      // }
    });

    const client = await auth.getClient();
    const calendar = google.calendar({ version: "v3", auth :  client});

    const now = new Date();
    const startTime = new Date(now.getTime() + 5 * 60000); // 5 minutes from now
    const endTime = new Date(startTime.getTime() + 60 * 60000); // 1 hour duration

    const event = {
      summary: "Test Meeting",
      description: "This is a test meeting with Google Meet link.",
      start: {
        dateTime: startTime.toISOString(),
        timeZone: "America/Los_Angeles",
      },
      end: {
        dateTime: endTime.toISOString(),
        timeZone: "America/Los_Angeles",
      },
      conferenceProperties: {
        allowedConferenceSolutionTypes: ["hangoutsMeet"],
      },
      attendees: [{ email: "[email protected]" }],

      
    };

    const response = await calendar.events.insert({
      auth: auth,
      calendarId: "primary",
      resource: event,
      conferenceDataVersion: 1,
    });

    console.log("Event created successfully:", response);
    // console.log("Google Meet link:", response.config.data.conferenceProperties);
  } catch (error) {
    console.error("Error creating event:", error.message);
    if (error.response) {
      console.error(
        "Error Details:",
        JSON.stringify(error.response.data, null, 2)
      );
    }
  }
}

Error creating event: Service accounts cannot invite attendees without Domain-Wide Delegation of Authority.
Error Details: {
"error": {
"errors": [
{
"domain": "calendar",
"reason": "forbiddenForServiceAccounts",
"message": "Service accounts cannot invite attendees without Domain-Wide Delegation of Authority."
}
],
"code": 403,
"message": "Service accounts cannot invite attendees without Domain-Wide Delegation of Authority."
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
priority: p2 Moderately-important priority. Fix may not be included in next release. type: feature request ‘Nice-to-have’ improvement, new feature or different behavior or design.
Projects
None yet
Development

No branches or pull requests