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