NodeJS 在Google Cloud Run中使用默认凭据进行全域委派

zpgglvta  于 2023-04-05  发布在  Node.js
关注(0)|答案(3)|浏览(113)

我使用的是一个自定义服务帐户(在deploy命令中使用--service-account参数)。该服务帐户启用了域范围的委托,并安装在G Apps管理面板中。
我试了这个代码:

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()
})

如果我在我的机器上运行它(将GOOGLE_APPLICATION_CREDENTIALS env var设置为分配给Cloud Run服务的同一服务帐户的路径),它就可以工作,但当它在Cloud Run中运行时,我得到以下响应:

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

我看到this解决方案解决了这个问题,但它是针对Python的,我不知道如何用Node库复制这种行为。

jecbmhm3

jecbmhm31#

经过几天的研究,我终于得到了一个工作解决方案(移植Python实现):

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

vecaoik12#

在这里,您可以将yaml文件中的ENV变量定义为described in this documentation,以将GOOGLE_APPLICATION_CREDENTIALS设置为JSON键的路径。
然后使用这里提到的代码。

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]
};

或者遵循与这里提到的方法类似的方法。

'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);
inb24sb2

inb24sb23#

这是维克托的答案的镜像,除了它使用IAMCredentialsClient而不是弃用的iam.projects.serviceAccounts.signBlob来签名令牌。此代码是一个完整的TS文件,因此您可以将其放入项目并使用它,如下所示:

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

请注意,只有当您需要通过域范围的委派访问Workspace API时,才需要电子邮件。换句话说,即使您没有提供电子邮件,该类也可以在您需要生成令牌以访问Google资源的任何地方工作。如果您通过Google库访问资源,则不需要该类,但如果您使用REST API并需要获取访问令牌,则很方便。

/**
 * 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(/=*$/, '')
    }
}

相关问题