oauth.ts
Language TypeScript14.6 KB362 lines
import { errorMessage } from '@meridian/shared'/*** Service-account auth: one key, every mailbox.** One credential rather than seven. A service account with domain-wide* delegation is authorized once in Google Admin and can then impersonate any* @meridiandisplay.example mailbox, so there is no consent screen, no browser* flow per mailbox, and (the reason this matters) nothing to re-mint when a* role account changes hands, which is exactly the event per-mailbox refresh* tokens break on. Refresh tokens are supported too (see RefreshCredential).** No SDK, and no Node. `googleapis` and `google-auth-library` both pull a* Node-shaped dependency tree the Worker cannot bundle. The whole flow is: build* a JWT, sign it with WebCrypto, POST it. WebCrypto is in Workers natively, so* this file is runtime-neutral (rule 7).*/const TOKEN_URL = 'https://oauth2.googleapis.com/token'/*** The scopes, and why there are two.** Reading is `gmail.readonly`. Replying from the Inbox tab needs `gmail.send`,* which permits exactly `users.messages.send`; it cannot read a message,* cannot touch a label, cannot create a draft. `gmail.compose` would add full* drafts CRUD the board never calls, and `gmail.modify` is avoided by keeping* read marks board-side.** A refresh token carries the scopes it was consented with. Adding the send* scope to the consent screen changes nothing about a token already minted, so* every mailbox must be re-minted with `pnpm inbox:auth` before it can send.* That is not a state the board may guess at: `readMailbox` reports what* Google actually returned (`canSend`), and the compose window renders a* read-only token as read-only rather than offering a button that fails.*/export const GMAIL_READ_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'export const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send'export const GMAIL_SCOPES = [GMAIL_READ_SCOPE, GMAIL_SEND_SCOPE] as const/** Space-joined, the form the OAuth `scope` parameter and the JWT claim take. */export const GMAIL_SCOPE = GMAIL_SCOPES.join(' ')/*** Did a grant come back with everything asked for?** As a set, not a string compare. Google returns granted scopes in its own* order and may add ones it considers implied, so `granted !== requested` is* true on perfectly good grants and would cry wolf on every re-mint.* It lives here rather than in `scripts/inbox-auth.ts` because that script has* no test harness and this is the part worth pinning.*/export function scopesGranted(granted: string,wanted: readonly string[] = GMAIL_SCOPES,): { ok: boolean; missing: string[] } {const have = new Set(granted.trim().split(/\s+/).filter(Boolean))const missing = wanted.filter((w) => !have.has(w))return { ok: missing.length === 0, missing }}export interface ServiceAccount {/** `...@....iam.gserviceaccount.com`, the `client_email` in the key JSON. */email: string/** The `private_key` from the key JSON, PEM, PKCS#8. */privateKey: string}export interface TokenResult {accessToken: stringexpiresIn: number/*** The scopes this access token actually carries, as Google reported them.** Null is not "none". It means the token response carried no `scope` field,* so we do not know, and the surface must render that as its own third* state rather than collapsing it into "cannot send" (the prime invariant:* absent and false do not look the same). Observed for free on every sync;* it costs no extra request.*/scopes: string[] | null}/** `body.scope` -> a list, or null when Google reported none. */const scopesFrom = (body: Record<string, unknown>): string[] | null =>typeof body.scope === 'string' ? body.scope.trim().split(/\s+/).filter(Boolean) : null/*** Per-mailbox OAuth refresh token, for when domain-wide delegation is not* available: the delegation screen needs a Workspace super-admin.** Both credential shapes stay supported, with refresh tokens taking precedence* per mailbox. If a super-admin ever adds the delegation entry, the service* account silently covers any mailbox without a token, and the tokens can be* deleted one by one, with no code change and no flag day.*/export interface RefreshCredential {clientId: stringclientSecret: stringrefreshToken: string}export type GmailCredential = ServiceAccount | RefreshCredentialexport const isRefreshCredential = (c: GmailCredential): c is RefreshCredential =>'refreshToken' in c/** Refresh token -> access token. One POST, no SDK, same reasons as the JWT path. */export async function accessTokenForRefresh(mailbox: string,cred: RefreshCredential,fetchImpl: typeof fetch = fetch,): Promise<TokenResult> {const res = await fetchImpl(TOKEN_URL, {method: 'POST',headers: { 'content-type': 'application/x-www-form-urlencoded' },body: new URLSearchParams({client_id: cred.clientId,client_secret: cred.clientSecret,refresh_token: cred.refreshToken,grant_type: 'refresh_token',}),})const body = (await res.json().catch(() => ({}))) as Record<string, unknown>if (!res.ok) {throw new GmailAuthError(mailbox, String(body.error ?? 'unknown'), res.status)}const token = body.access_tokenif (typeof token !== 'string' || !token) {throw new GmailAuthError(mailbox, 'no access_token in a 200 response', res.status)}return { accessToken: token, expiresIn: Number(body.expires_in) || 0, scopes: scopesFrom(body) }}/** One entry point for either credential shape. */export async function accessTokenForCredential(mailbox: string,cred: GmailCredential,fetchImpl: typeof fetch = fetch,): Promise<TokenResult> {return isRefreshCredential(cred)? accessTokenForRefresh(mailbox, cred, fetchImpl): accessTokenFor(mailbox, cred, fetchImpl)}/*** Errors worth telling apart, because they need opposite responses.** `unauthorized_client` is the one that will actually happen during setup: the* service account exists and the key is valid, but nobody has authorized its* client ID for this scope in Google Admin, or the scope string there does not* match byte for byte. Google says only `unauthorized_client`, so this names the* candidates rather than picking one (rule 1).*/export class GmailAuthError extends Error {constructor(readonly mailbox: string,readonly code: string,readonly status: number,detail?: string,) {super(GmailAuthError.explain(mailbox, code, status, detail))this.name = 'GmailAuthError'}private static explain(mailbox: string, code: string, status: number, detail?: string): string {if (code === 'unauthorized_client') {return (`${mailbox}: the service account is not authorized to impersonate this mailbox ` +`(unauthorized_client). Either its client ID is not in Google Admin under ` +`Security → API controls → Domain-wide delegation, or the scopes there are not ` +`exactly "${GMAIL_SCOPES.join(',')}" (that field takes them comma-separated). ` +`Admin propagation can also take a few minutes.`)}if (code === 'invalid_grant') {return (`${mailbox}: Google rejected the credential (invalid_grant). For a service account: ` +`the mailbox does not exist or is suspended, or the clock is off by minutes. For a ` +`refresh token: it was revoked, the mailbox was suspended, or the OAuth consent ` +`screen is in Testing, which expires refresh tokens after 7 days.`)}if (code === 'invalid_key') {return `${mailbox}: the private key could not be read: ${detail ?? 'unparseable PEM'}.`}return `${mailbox}: token request failed (${code}, HTTP ${status})${detail ? `: ${detail}` : ''}.`}}/*** Read a service-account secret however it was pasted.** The secret often holds something other than a bare key, most often the* whole downloaded JSON file, and "the PEM body is not valid base64" is all* the key parser can say about it. That is a completely reasonable thing to* paste: the file is what Google hands you, the prompt says* "enter a secret value", and the person pasting is doing it once under time* pressure with no way to see what landed. So the code meets the paste where it* is:** - the full key-file JSON -> take private_key, and client_email if present* - a bare PEM -> as-is* - either, wrapped in stray quotes or with \n flattened -> unwrapped** The email in the JSON wins over a separately-set GMAIL_SA_EMAIL only when the* caller passes none: one pasted file should be a complete configuration.*/export function parseServiceAccountSecret(raw: string): { email: string | null; privateKey: string } {let v = raw.trim()// Strip one layer of accidental quoting before sniffing the format.if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {const inner = v.slice(1, -1)// Only unwrap if it does not break JSON. A JSON document legitimately// starts with '{', not a quote, so this only affects quoted PEMs.if (!inner.trimStart().startsWith('{')) v = inner.trim()}// A common mistake: the OAuth client secret, pasted because it was on screen// when the secret prompt appeared. It is a real credential (just not this// one), and "not valid base64" says nothing. Name it.if (/^GOCSPX-/.test(v)) {throw new Error('this is an OAuth client secret (GOCSPX-…), not a service-account key. The inbox ' +'needs the JSON key file from IAM & Admin → Service accounts → Keys. Set it with: ' +'npx wrangler secret put GMAIL_SA_KEY < key.json',)}if (v.startsWith('{')) {let parsed: { client_email?: string; private_key?: string }try {parsed = JSON.parse(v) as typeof parsed} catch {// JSON that does not parse is usually a paste that captured only part of// the file. Say that, because "not valid base64" points nowhere.throw new Error('the secret looks like the key JSON file but does not parse; the paste probably ' +'captured only part of it. Re-set it from the file itself: ' +'npx wrangler secret put GMAIL_SA_KEY < key.json',)}if (!parsed.private_key) {throw new Error('the secret is JSON but has no private_key field. Is it the service account key file?')}return { email: parsed.client_email ?? null, privateKey: parsed.private_key }}return { email: null, privateKey: v }}const b64url = (bytes: Uint8Array): string => {let bin = ''for (const b of bytes) bin += String.fromCharCode(b)return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}/*** UTF-8 text -> base64url. Exported so `send.ts` encodes the outgoing MIME with* this one rather than a second copy: the TextEncoder step is why `btoa` cannot* throw on an accented subject or an emoji, and two implementations of that is* one implementation that eventually loses it.*/export const b64urlText = (s: string): string => b64url(new TextEncoder().encode(s))// Derived rather than named: `CryptoKey` is not a global in this project's lib// set (ES2022 + @types/node), and pulling in DOM types to name one thing would// widen every file's globals.type SigningKey = Awaited<ReturnType<typeof crypto.subtle.importKey>>/*** PEM to a signing key.** The `\n` trap. The `private_key` in Google's key JSON contains real newlines.* Once it has been through a shell, an env var or a secrets UI it very often* arrives with literal backslash-n instead, and the base64 body then fails to* decode with an error that says nothing useful. Both forms are accepted here,* because the alternative is an afternoon lost to an unreadable message.*/export async function importPrivateKey(pem: string): Promise<SigningKey> {const normalized = pem.includes('\\n') ? pem.replace(/\\n/g, '\n') : pemconst body = normalized.replace(/-----BEGIN [A-Z ]+-----/, '').replace(/-----END [A-Z ]+-----/, '').replace(/\s+/g, '')if (!body) throw new Error('no key material between the PEM markers')let der: Uint8Arraytry {der = Uint8Array.from(atob(body), (c) => c.charCodeAt(0))} catch {throw new Error('the PEM body is not valid base64')}return crypto.subtle.importKey('pkcs8',der.buffer as ArrayBuffer,{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },false,['sign'],)}/*** An access token for one mailbox, by impersonation.** `sub` is the whole mechanism: it is the mailbox being read, and it is what* domain-wide delegation authorizes. A token minted here can read that mailbox* and no other.*/export async function accessTokenFor(mailbox: string,sa: ServiceAccount,fetchImpl: typeof fetch = fetch,now: () => number = Date.now,): Promise<TokenResult> {let key: SigningKeytry {key = await importPrivateKey(sa.privateKey)} catch (err) {throw new GmailAuthError(mailbox, 'invalid_key', 0, errorMessage(err))}const iat = Math.floor(now() / 1000)const claims = {iss: sa.email,sub: mailbox, // impersonatescope: GMAIL_SCOPE,aud: TOKEN_URL,iat,// Google caps assertion lifetime at an hour and rejects a future iat, so a// clock more than a few minutes out fails as invalid_grant. Named in the// error rather than left as a mystery.exp: iat + 3600,}const signingInput = `${b64urlText(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))}.${b64urlText(JSON.stringify(claims))}`const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5',key,new TextEncoder().encode(signingInput),)const assertion = `${signingInput}.${b64url(new Uint8Array(sig))}`const res = await fetchImpl(TOKEN_URL, {method: 'POST',headers: { 'content-type': 'application/x-www-form-urlencoded' },body: new URLSearchParams({grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',assertion,}),})const body = (await res.json().catch(() => ({}))) as Record<string, unknown>if (!res.ok) {throw new GmailAuthError(mailbox,String(body.error ?? 'unknown'),res.status,typeof body.error_description === 'string' ? body.error_description : undefined,)}const token = body.access_tokenif (typeof token !== 'string' || !token) {// A 200 with no token is not success. Returning '' would surface as a 401 on// the next call and send someone debugging the wrong request.throw new GmailAuthError(mailbox, 'no access_token in a 200 response', res.status)}return { accessToken: token, expiresIn: Number(body.expires_in) || 0, scopes: scopesFrom(body) }}