Search code examples
node.jsservice-accountsgoogle-cloud-rungoogle-auth-library-nodejs

Domain-wide delegation using default credentials in Google Cloud Run


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


Solution

  • 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')
        }
    }