Skip to content

Instantly share code, notes, and snippets.

@kurrik
Last active June 24, 2023 19:16
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kurrik/85e68c2322b335e7c8d0b83bbbd5ad38 to your computer and use it in GitHub Desktop.
Save kurrik/85e68c2322b335e7c8d0b83bbbd5ad38 to your computer and use it in GitHub Desktop.
Call Google services from a service account using account delegation on Cloudflare Workers

Call Google services from a service account using account delegation on Cloudflare Workers

I had to figure this out for the nyt-connections project but wound up not using it because even with this auth scheme you can't list messages from chat rooms which allow non-domain users to be added.

Credentials

Create a service account in https://console.cloud.google.com/iam-admin/serviceaccounts?supportedpurview=project

Delegating domain-wide authority to the service account (needs a pro workspace):

  • From your Google Workspace domain's Admin console, go to Main menu menu > Security > Access and data control > API Controls.
  • In the Domain wide delegation pane, select Manage Domain Wide Delegation.
  • Click Add new.
  • In the Client ID field, enter the service account's Client ID. You can find your service account's client ID in the Service accounts page.
  • In the OAuth scopes (comma-delimited) field, enter the list of scopes that your application should be granted access to. Add:
    • https://www.googleapis.com/auth/chat.messages.readonly
  • Click Authorize.

Put the service account credentials json file contents into a secret:

cat ~/Downloads/nyt-connections-2a220302a411.json | jq -c | pbcopy
wrangler secret put GCP_SERVICE_ACCOUNT

Put the spaces/xxx ID into a secret:

wrangler secret put SPACE_ID

Put the account you want to delegate access to the service account from:

wrangler secret put GCP_DELEGATED_ACCESS_ACCOUNT

For local development, make .dev.vars:

GCP_SERVICE_ACCOUNT=...
SPACE_ID=...
GCP_DELEGATED_ACCESS_ACCOUNT=...
// Taken from https://github.com/Schachte/cloudflare-google-auth/blob/master/index.ts
// I added the code which adds `sub` to the claimset, which is the mechanism used to activate delegated auth.
const PEM_HEADER: string = '-----BEGIN PRIVATE KEY-----'
const PEM_FOOTER: string = '-----END PRIVATE KEY-----'
// Simplify binding the env var to a typed object
export interface GoogleKey {
type: string;
project_id: string;
private_key_id: string;
private_key: string;
client_email: string;
client_id: string;
auth_uri: string;
token_uri: string;
auth_provider_x509_cert_url: string;
client_x509_cert_url: string;
}
interface TokenResponse {
access_token: string;
}
// Inspiration: https://gist.github.com/markelliot/6627143be1fc8209c9662c504d0ff205
//
// GoogleOAuth encapsulates the logic required to retrieve an access token
// for the OAuth flow.
export default class GoogleOAuth {
constructor(public googleKey: GoogleKey, public scopes: string[], public delegated_account: string | null) { }
public async getGoogleAuthToken(
): Promise<string | undefined> {
const { client_email: user, private_key: key } = this.googleKey
const scope = this.formatScopes(this.scopes)
const jwtHeader = this.objectToBase64url({ alg: 'RS256', typ: 'JWT' })
try {
const assertiontime = Math.round(Date.now() / 1000)
const expirytime = assertiontime + 3600
let claimobj = {
iss: user,
scope,
aud: 'https://oauth2.googleapis.com/token',
exp: expirytime,
iat: assertiontime,
...this.delegated_account && { sub: this.delegated_account }
}
const claimset = this.objectToBase64url(claimobj);
const jwtUnsigned = `${jwtHeader}.${claimset}`
const signedJwt = `${jwtUnsigned}.${await this.sign(jwtUnsigned, key)}`
const body = `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${signedJwt}`
const response = await fetch(this.googleKey.token_uri, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Cache-Control': 'no-cache',
Host: 'oauth2.googleapis.com',
},
body,
})
const resp = await response.json<TokenResponse>()
return resp.access_token
} catch (err) {
console.error(err)
return undefined
}
}
private objectToBase64url(object: object): string {
return this.arrayBufferToBase64Url(new TextEncoder().encode(JSON.stringify(object)))
}
private arrayBufferToBase64Url(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
}
private str2ab(str: string): ArrayBuffer {
const buf = new ArrayBuffer(str.length)
const bufView = new Uint8Array(buf)
for (let i = 0, strLen = str.length; i < strLen; i += 1) {
bufView[i] = str.charCodeAt(i)
}
return buf
}
private async sign(content: string, signingKey: string): Promise<string> {
const buf = this.str2ab(content)
const plainKey = signingKey
.replace(/(\r\n|\n|\r)/gm, '')
.replace(PEM_HEADER, '')
.replace(PEM_FOOTER, '')
.trim()
const binaryKey = this.str2ab(atob(plainKey))
const signer = await crypto.subtle.importKey(
'pkcs8',
binaryKey,
{
name: 'RSASSA-PKCS1-V1_5',
hash: { name: 'SHA-256' },
},
false,
['sign'],
)
const binarySignature = await crypto.subtle.sign(
{ name: 'RSASSA-PKCS1-V1_5' },
signer,
buf,
)
return this.arrayBufferToBase64Url(binarySignature)
}
// formatScopes will create a scopes string that is formatted for the Google API
private formatScopes(scopes: string[]): string {
return scopes.join(' ')
}
}
// This code uses googleauth to make an authenticated call.
// Web framework is Hono: https://hono.dev/
import { Hono } from 'hono';
import GoogleAuth, { GoogleKey } from './googleauth';
const app = new Hono();
app.get('/test', async (c) => {
const scopes: string[] = ['https://www.googleapis.com/auth/chat.messages.readonly'];
const env = c.env;
if (!env) {
return c.json({
error: 'Invalid environment'
});
}
const googleAuth: GoogleKey = JSON.parse(env.GCP_SERVICE_ACCOUNT as string)
const oauth = new GoogleAuth(googleAuth, scopes, env.GCP_DELEGATED_ACCESS_ACCOUNT as string)
const token = await oauth.getGoogleAuthToken()
const res = await fetch(`https://chat.googleapis.com/v1/${env.SPACE_ID as string}/messages`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
})
const body = await res.json();
console.log(`Body: ${JSON.stringify(body)}`)
return c.json(body)
});
export default app
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment