1

I'm using a custom service account (using --service-account parameter in the deploy command). That service account has domain-wide delegation enabled and it's installed in the G Apps Admin panel.

I tried this code:

app.get('/test', async (req, res) => {
    const auth = new google.auth.GoogleAuth()
    const gmailClient = google.gmail({ version: 'v1' })
    const { data } = await gmailClient.users.labels.list({ auth, userId: 'user@domain.com' })
    return res.json(data).end()
})

It works if I run it on my machine (having the GOOGLE_APPLICATION_CREDENTIALS env var setted to the path of the same service account that is assigned to the Cloud Run service) but when it's running in Cloud Run, I get this response:

{
  "code" : 400,
  "errors" : [ {
    "domain" : "global",
    "message" : "Bad Request",
    "reason" : "failedPrecondition"
  } ],
  "message" : "Bad Request"
}

I saw this solution for this same issue, but it's for Python and I don't know how to replicate that behaviour with the Node library.

TheMaster
  • 45,448
  • 6
  • 62
  • 85
victor141516
  • 71
  • 1
  • 5
  • You need to use delegated credentials. Add this line `auth.subject = "username@example.com"`. Use the email address of the user you want to impersonate. – John Hanley Feb 29 '20 at 02:41

3 Answers3

5

After some days of research, I finally got a working solution (porting the Python implementation):

async function getGoogleCredentials(subject: string, scopes: string[]): Promise<JWT | OAuth2Client> {
    const auth = new google.auth.GoogleAuth({
        scopes: ['https://www.googleapis.com/auth/cloud-platform'],
    })
    const authClient = await auth.getClient()

    if (authClient instanceof JWT) {
        return (await new google.auth.GoogleAuth({ scopes, clientOptions: { subject } }).getClient()) as JWT
    } else if (authClient instanceof Compute) {
        const serviceAccountEmail = (await auth.getCredentials()).client_email
        const unpaddedB64encode = (input: string) =>
            Buffer.from(input)
                .toString('base64')
                .replace(/=*$/, '')
        const now = Math.floor(new Date().getTime() / 1000)
        const expiry = now + 3600
        const payload = JSON.stringify({
            aud: 'https://accounts.google.com/o/oauth2/token',
            exp: expiry,
            iat: now,
            iss: serviceAccountEmail,
            scope: scopes.join(' '),
            sub: subject,
        })

        const header = JSON.stringify({
            alg: 'RS256',
            typ: 'JWT',
        })

        const iamPayload = `${unpaddedB64encode(header)}.${unpaddedB64encode(payload)}`

        const iam = google.iam('v1')
        const { data } = await iam.projects.serviceAccounts.signBlob({
            auth: authClient,
            name: `projects/-/serviceAccounts/${serviceAccountEmail}`,
            requestBody: {
                bytesToSign: unpaddedB64encode(iamPayload),
            },
        })
        const assertion = `${iamPayload}.${data.signature!.replace(/=*$/, '')}`

        const headers = { 'content-type': 'application/x-www-form-urlencoded' }
        const body = querystring.encode({ assertion, grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer' })
        const response = await fetch('https://accounts.google.com/o/oauth2/token', { method: 'POST', headers, body }).then(r => r.json())

        const newCredentials = new OAuth2Client()
        newCredentials.setCredentials({ access_token: response.access_token })
        return newCredentials
    } else {
        throw new Error('Unexpected authentication type')
    }
}
victor141516
  • 71
  • 1
  • 5
  • Had the goal of using a Firebase Function associated Service Account to access a Domain Wide Delegated Google API without using a standalone credential json file. This solved that problem perfectly. – John Gasper Jan 18 '22 at 06:13
  • Thank you! This technique worked perfectly and I was able to access to the Google AdminSDK in production with nothing more than specifying the service account for Cloud Run to use. It seems that iam.projects.serviceAccounts.signBlob is deprecated, so I included an updated solution below. – Mike M Apr 01 '23 at 17:07
1

This is a mirror of Victor's answer, with the exception that it uses IAMCredentialsClient to sign the token instead of the deprecated iam.projects.serviceAccounts.signBlob. This code is a complete TS file so you can drop it in to your project and use it as follows:

const prov = new ApplicationDefaultCredentials(workspaceAdminEmail@mydomain.com)
return prov.getAccessToken(scopes)

Note that the email is only needed if you need to access Workspace APIs via domain-wide-delegation. In other words, this class will work anywhere you need to generate a token to access Google resources, even if you do not provide an email. This class would not needed if you are accessing the resources through Google libraries, but is handy if you are using REST APIs and need to get an access token.

/**
 * This class mimics the Application Default Credentials flow and works both in devel and production.
 * * It exposes a getAccessToken() method that can be used to get a Bearer token for Authentication and Authorization.
 * * The generated token will also work for google workspace APIs calls that are authorized with domain wide delegation.
 *   When requiring access to Workspace APIs with domain wide delegation, an emailToImpersonate must be provided to the constructor.
 * * In Development mode, you will need to get set the GOOGLE_APPLICATION_CREDENTIALS to a key file that
 *   you created for the service account
 * * In production, you do not need to use a service account key at all because this code will generate the key dynamically
 *   based on the service account specified with the Application Default Credentials.  It does this by generating a JWT token,
 *   using the service account to sign it, and then requesting a token based on that.
 * * To use this class, Create an instance via:
 *       const provider = new ApplicationDefaultCredentials(emailToImpersonate?: string))
 *   Then use it like this:
 *       const bearerToken = provider.getAccessToken(scopes)
 * */
import { Compute, GoogleAuth, JWT } from 'google-auth-library'
import { IAMCredentialsClient } from '@google-cloud/iam-credentials'
const querystring = require('querystring')

export class ApplicationDefaultCredentials {
    scopes: string[] = []
    emailToImpersonate?: string
    token?: any
    tokenExpiry?: Date

    constructor(emailToImpersonate?: string) {
        this.emailToImpersonate = emailToImpersonate
        this.token = undefined
        this.tokenExpiry = undefined
    }
    async getAccessToken(scopes: string[]): Promise<any> {
        // Use this hack because "this" in the context of the Promise's anonymous function does not refer to this class.
        const self = this
        return new Promise(async (resolve, reject) => {
            if (self.scopes !== scopes) {
                self.token = undefined
                self.tokenExpiry = undefined
            }
            self.scopes = scopes
            if (self.token && self.tokenExpiry && self.tokenExpiry > new Date()) {
                console.log('Reusing token')
                return resolve(self.token)
            }

            try {
                let clientOptions: any = {}
                if (self.emailToImpersonate) clientOptions = { subject: self.emailToImpersonate }

                const auth = new GoogleAuth({
                    scopes: scopes,
                    clientOptions
                })
                const client = await auth.getClient()

                if (client instanceof JWT) {
                    const json = await client.getAccessToken()
                    self.token = json.token
                    // the token expiry does not seem to be returned.  If it is undefined, the token will simply not be cached
                    self.tokenExpiry = json.res?.data.tokenExpiry
                    resolve(self.token)
                    return
                }
                if (!(client instanceof Compute))
                    throw new Error(`Unexpected authentication type: ${client.constructor!.name}`)

                try {
                    // Create a JWT Token signed with the service account
                    // Translated this code from https://stackoverflow.com/questions/60435998/domain-wide-delegation-using-default-credentials-in-google-cloud-run?rq=2
                    // Also took advice from https://developers.google.com/identity/protocols/oauth2/service-account#delegatingauthority

                    const serviceAccountEmail = (await auth.getCredentials()).client_email

                    // Create the JWT Payload
                    const now = Math.floor(new Date().getTime() / 1000)
                    const expiry = now + 3600
                    const payload = JSON.stringify({
                        aud: 'https://oauth2.googleapis.com/token',
                        exp: expiry,
                        iat: now,
                        iss: serviceAccountEmail,
                        scope: scopes.join(' '),
                        sub: self.emailToImpersonate
                    })
                    const header = JSON.stringify({
                        alg: 'RS256',
                        typ: 'JWT'
                    })
                    const iamPayload = `${this.unpaddedB64encode(header)}.${this.unpaddedB64encode(payload)}`

                    // get the JWT Payload signature
                    const credentialsClient = new IAMCredentialsClient()
                    const request = {
                        name: `projects/-/serviceAccounts/${serviceAccountEmail}`,
                        payload: this.unpaddedB64encode(iamPayload)
                    }
                    const [data] = await credentialsClient.signBlob(request)

                    if (!data.signedBlob) return reject('Could not sign blob')

                    // send the signed JWT token
                    const blob64 = this.unpaddedB64encode(data.signedBlob)
                    const assertion = `${iamPayload}.${blob64}`
                    const headers = { 'content-type': 'application/x-www-form-urlencoded' }
                    const body = querystring.encode({
                        assertion,
                        grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer'
                    })
                    const res = await fetch('https://oauth2.googleapis.com/token', {
                        method: 'POST',
                        headers,
                        body
                    })
                    let json = await res.json()
                    if (json.error) throw new Error(`${json.error}: ${json.error_description}`)
                    if (!json.access_token) throw new Error(`No Access token returned: ${JSON.stringify(json)}`)

                    self.token = json.access_token
                    let expiresAt = json.expires_at
                        ? json.expires_at * 1000
                        : json.expires_in
                        ? json.expires_in * 1000 + Date.now()
                        : undefined
                    if (expiresAt) self.tokenExpiry = new Date(expiresAt)
                    resolve(json.access_token)
                } catch (error: any) {
                    console.error('Error generating JWT Token:', error)
                    reject(error.message ?? error)
                }
            } catch (err: any) {
                console.error('Error in getAccessToken:', err)
                reject(err.message ?? err)
            }
        })
    }

    unpaddedB64encode(input: string | Uint8Array) {
        return Buffer.from(input).toString('base64').replace(/=*$/, '')
    }
}
Mike M
  • 71
  • 1
  • 3
0

What you can do here is define ENV variables in your yaml file as described in this documentation to set the GOOGLE_APPLICATION_CREDENTIALS to the path of the JSON key.

Then use a code such as the one mentioned here.

const authCloudExplicit = async ({projectId, keyFilename}) => {
  // [START auth_cloud_explicit]
  // Imports the Google Cloud client library.
  const {Storage} = require('@google-cloud/storage');

  // Instantiates a client. Explicitly use service account credentials by
  // specifying the private key file. All clients in google-cloud-node have this
  // helper, see https://github.com/GoogleCloudPlatform/google-cloud-node/blob/master/docs/authentication.md
  // const projectId = 'project-id'
  // const keyFilename = '/path/to/keyfile.json'
  const storage = new Storage({projectId, keyFilename});

  // Makes an authenticated API request.
  try {
    const [buckets] = await storage.getBuckets();

    console.log('Buckets:');
    buckets.forEach(bucket => {
      console.log(bucket.name);
    });
  } catch (err) {
    console.error('ERROR:', err);
  }
  // [END auth_cloud_explicit]
};

Or follow an approach similar to the one mentioned here.

'use strict';

const {auth, Compute} = require('google-auth-library');


async function main() {
  const client = new Compute({
    serviceAccountEmail: 'some-service-account@example.com',
  });
  const projectId = await auth.getProjectId();
  const url = `https://dns.googleapis.com/dns/v1/projects/${projectId}`;
  const res = await client.request({url});
  console.log(res.data);
}

main().catch(console.error);
Waelmas
  • 1,894
  • 1
  • 9
  • 19