mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

oauth.ts

Language TypeScript14.6 KB362 lines

packages/gmail-client/src/oauth.ts

  1. import { errorMessage } from '@meridian/shared'
  2. /**
  3. * Service-account auth: one key, every mailbox.
  4. *
  5. * One credential rather than seven. A service account with domain-wide
  6. * delegation is authorized once in Google Admin and can then impersonate any
  7. * @meridiandisplay.example mailbox, so there is no consent screen, no browser
  8. * flow per mailbox, and (the reason this matters) nothing to re-mint when a
  9. * role account changes hands, which is exactly the event per-mailbox refresh
  10. * tokens break on. Refresh tokens are supported too (see RefreshCredential).
  11. *
  12. * No SDK, and no Node. `googleapis` and `google-auth-library` both pull a
  13. * Node-shaped dependency tree the Worker cannot bundle. The whole flow is: build
  14. * a JWT, sign it with WebCrypto, POST it. WebCrypto is in Workers natively, so
  15. * this file is runtime-neutral (rule 7).
  16. */
  17. const TOKEN_URL = 'https://oauth2.googleapis.com/token'
  18. /**
  19. * The scopes, and why there are two.
  20. *
  21. * Reading is `gmail.readonly`. Replying from the Inbox tab needs `gmail.send`,
  22. * which permits exactly `users.messages.send`; it cannot read a message,
  23. * cannot touch a label, cannot create a draft. `gmail.compose` would add full
  24. * drafts CRUD the board never calls, and `gmail.modify` is avoided by keeping
  25. * read marks board-side.
  26. *
  27. * A refresh token carries the scopes it was consented with. Adding the send
  28. * scope to the consent screen changes nothing about a token already minted, so
  29. * every mailbox must be re-minted with `pnpm inbox:auth` before it can send.
  30. * That is not a state the board may guess at: `readMailbox` reports what
  31. * Google actually returned (`canSend`), and the compose window renders a
  32. * read-only token as read-only rather than offering a button that fails.
  33. */
  34. export const GMAIL_READ_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'
  35. export const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send'
  36. export const GMAIL_SCOPES = [GMAIL_READ_SCOPE, GMAIL_SEND_SCOPE] as const
  37. /** Space-joined, the form the OAuth `scope` parameter and the JWT claim take. */
  38. export const GMAIL_SCOPE = GMAIL_SCOPES.join(' ')
  39. /**
  40. * Did a grant come back with everything asked for?
  41. *
  42. * As a set, not a string compare. Google returns granted scopes in its own
  43. * order and may add ones it considers implied, so `granted !== requested` is
  44. * true on perfectly good grants and would cry wolf on every re-mint.
  45. * It lives here rather than in `scripts/inbox-auth.ts` because that script has
  46. * no test harness and this is the part worth pinning.
  47. */
  48. export function scopesGranted(
  49. granted: string,
  50. wanted: readonly string[] = GMAIL_SCOPES,
  51. ): { ok: boolean; missing: string[] } {
  52. const have = new Set(granted.trim().split(/\s+/).filter(Boolean))
  53. const missing = wanted.filter((w) => !have.has(w))
  54. return { ok: missing.length === 0, missing }
  55. }
  56. export interface ServiceAccount {
  57. /** `...@....iam.gserviceaccount.com`, the `client_email` in the key JSON. */
  58. email: string
  59. /** The `private_key` from the key JSON, PEM, PKCS#8. */
  60. privateKey: string
  61. }
  62. export interface TokenResult {
  63. accessToken: string
  64. expiresIn: number
  65. /**
  66. * The scopes this access token actually carries, as Google reported them.
  67. *
  68. * Null is not "none". It means the token response carried no `scope` field,
  69. * so we do not know, and the surface must render that as its own third
  70. * state rather than collapsing it into "cannot send" (the prime invariant:
  71. * absent and false do not look the same). Observed for free on every sync;
  72. * it costs no extra request.
  73. */
  74. scopes: string[] | null
  75. }
  76. /** `body.scope` -> a list, or null when Google reported none. */
  77. const scopesFrom = (body: Record<string, unknown>): string[] | null =>
  78. typeof body.scope === 'string' ? body.scope.trim().split(/\s+/).filter(Boolean) : null
  79. /**
  80. * Per-mailbox OAuth refresh token, for when domain-wide delegation is not
  81. * available: the delegation screen needs a Workspace super-admin.
  82. *
  83. * Both credential shapes stay supported, with refresh tokens taking precedence
  84. * per mailbox. If a super-admin ever adds the delegation entry, the service
  85. * account silently covers any mailbox without a token, and the tokens can be
  86. * deleted one by one, with no code change and no flag day.
  87. */
  88. export interface RefreshCredential {
  89. clientId: string
  90. clientSecret: string
  91. refreshToken: string
  92. }
  93. export type GmailCredential = ServiceAccount | RefreshCredential
  94. export const isRefreshCredential = (c: GmailCredential): c is RefreshCredential =>
  95. 'refreshToken' in c
  96. /** Refresh token -> access token. One POST, no SDK, same reasons as the JWT path. */
  97. export async function accessTokenForRefresh(
  98. mailbox: string,
  99. cred: RefreshCredential,
  100. fetchImpl: typeof fetch = fetch,
  101. ): Promise<TokenResult> {
  102. const res = await fetchImpl(TOKEN_URL, {
  103. method: 'POST',
  104. headers: { 'content-type': 'application/x-www-form-urlencoded' },
  105. body: new URLSearchParams({
  106. client_id: cred.clientId,
  107. client_secret: cred.clientSecret,
  108. refresh_token: cred.refreshToken,
  109. grant_type: 'refresh_token',
  110. }),
  111. })
  112. const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
  113. if (!res.ok) {
  114. throw new GmailAuthError(mailbox, String(body.error ?? 'unknown'), res.status)
  115. }
  116. const token = body.access_token
  117. if (typeof token !== 'string' || !token) {
  118. throw new GmailAuthError(mailbox, 'no access_token in a 200 response', res.status)
  119. }
  120. return { accessToken: token, expiresIn: Number(body.expires_in) || 0, scopes: scopesFrom(body) }
  121. }
  122. /** One entry point for either credential shape. */
  123. export async function accessTokenForCredential(
  124. mailbox: string,
  125. cred: GmailCredential,
  126. fetchImpl: typeof fetch = fetch,
  127. ): Promise<TokenResult> {
  128. return isRefreshCredential(cred)
  129. ? accessTokenForRefresh(mailbox, cred, fetchImpl)
  130. : accessTokenFor(mailbox, cred, fetchImpl)
  131. }
  132. /**
  133. * Errors worth telling apart, because they need opposite responses.
  134. *
  135. * `unauthorized_client` is the one that will actually happen during setup: the
  136. * service account exists and the key is valid, but nobody has authorized its
  137. * client ID for this scope in Google Admin, or the scope string there does not
  138. * match byte for byte. Google says only `unauthorized_client`, so this names the
  139. * candidates rather than picking one (rule 1).
  140. */
  141. export class GmailAuthError extends Error {
  142. constructor(
  143. readonly mailbox: string,
  144. readonly code: string,
  145. readonly status: number,
  146. detail?: string,
  147. ) {
  148. super(GmailAuthError.explain(mailbox, code, status, detail))
  149. this.name = 'GmailAuthError'
  150. }
  151. private static explain(mailbox: string, code: string, status: number, detail?: string): string {
  152. if (code === 'unauthorized_client') {
  153. return (
  154. `${mailbox}: the service account is not authorized to impersonate this mailbox ` +
  155. `(unauthorized_client). Either its client ID is not in Google Admin under ` +
  156. `Security → API controls → Domain-wide delegation, or the scopes there are not ` +
  157. `exactly "${GMAIL_SCOPES.join(',')}" (that field takes them comma-separated). ` +
  158. `Admin propagation can also take a few minutes.`
  159. )
  160. }
  161. if (code === 'invalid_grant') {
  162. return (
  163. `${mailbox}: Google rejected the credential (invalid_grant). For a service account: ` +
  164. `the mailbox does not exist or is suspended, or the clock is off by minutes. For a ` +
  165. `refresh token: it was revoked, the mailbox was suspended, or the OAuth consent ` +
  166. `screen is in Testing, which expires refresh tokens after 7 days.`
  167. )
  168. }
  169. if (code === 'invalid_key') {
  170. return `${mailbox}: the private key could not be read: ${detail ?? 'unparseable PEM'}.`
  171. }
  172. return `${mailbox}: token request failed (${code}, HTTP ${status})${detail ? `: ${detail}` : ''}.`
  173. }
  174. }
  175. /**
  176. * Read a service-account secret however it was pasted.
  177. *
  178. * The secret often holds something other than a bare key, most often the
  179. * whole downloaded JSON file, and "the PEM body is not valid base64" is all
  180. * the key parser can say about it. That is a completely reasonable thing to
  181. * paste: the file is what Google hands you, the prompt says
  182. * "enter a secret value", and the person pasting is doing it once under time
  183. * pressure with no way to see what landed. So the code meets the paste where it
  184. * is:
  185. *
  186. * - the full key-file JSON -> take private_key, and client_email if present
  187. * - a bare PEM -> as-is
  188. * - either, wrapped in stray quotes or with \n flattened -> unwrapped
  189. *
  190. * The email in the JSON wins over a separately-set GMAIL_SA_EMAIL only when the
  191. * caller passes none: one pasted file should be a complete configuration.
  192. */
  193. export function parseServiceAccountSecret(raw: string): { email: string | null; privateKey: string } {
  194. let v = raw.trim()
  195. // Strip one layer of accidental quoting before sniffing the format.
  196. if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
  197. const inner = v.slice(1, -1)
  198. // Only unwrap if it does not break JSON. A JSON document legitimately
  199. // starts with '{', not a quote, so this only affects quoted PEMs.
  200. if (!inner.trimStart().startsWith('{')) v = inner.trim()
  201. }
  202. // A common mistake: the OAuth client secret, pasted because it was on screen
  203. // when the secret prompt appeared. It is a real credential (just not this
  204. // one), and "not valid base64" says nothing. Name it.
  205. if (/^GOCSPX-/.test(v)) {
  206. throw new Error(
  207. 'this is an OAuth client secret (GOCSPX-…), not a service-account key. The inbox ' +
  208. 'needs the JSON key file from IAM & Admin → Service accounts → Keys. Set it with: ' +
  209. 'npx wrangler secret put GMAIL_SA_KEY < key.json',
  210. )
  211. }
  212. if (v.startsWith('{')) {
  213. let parsed: { client_email?: string; private_key?: string }
  214. try {
  215. parsed = JSON.parse(v) as typeof parsed
  216. } catch {
  217. // JSON that does not parse is usually a paste that captured only part of
  218. // the file. Say that, because "not valid base64" points nowhere.
  219. throw new Error(
  220. 'the secret looks like the key JSON file but does not parse; the paste probably ' +
  221. 'captured only part of it. Re-set it from the file itself: ' +
  222. 'npx wrangler secret put GMAIL_SA_KEY < key.json',
  223. )
  224. }
  225. if (!parsed.private_key) {
  226. throw new Error('the secret is JSON but has no private_key field. Is it the service account key file?')
  227. }
  228. return { email: parsed.client_email ?? null, privateKey: parsed.private_key }
  229. }
  230. return { email: null, privateKey: v }
  231. }
  232. const b64url = (bytes: Uint8Array): string => {
  233. let bin = ''
  234. for (const b of bytes) bin += String.fromCharCode(b)
  235. return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
  236. }
  237. /**
  238. * UTF-8 text -> base64url. Exported so `send.ts` encodes the outgoing MIME with
  239. * this one rather than a second copy: the TextEncoder step is why `btoa` cannot
  240. * throw on an accented subject or an emoji, and two implementations of that is
  241. * one implementation that eventually loses it.
  242. */
  243. export const b64urlText = (s: string): string => b64url(new TextEncoder().encode(s))
  244. // Derived rather than named: `CryptoKey` is not a global in this project's lib
  245. // set (ES2022 + @types/node), and pulling in DOM types to name one thing would
  246. // widen every file's globals.
  247. type SigningKey = Awaited<ReturnType<typeof crypto.subtle.importKey>>
  248. /**
  249. * PEM to a signing key.
  250. *
  251. * The `\n` trap. The `private_key` in Google's key JSON contains real newlines.
  252. * Once it has been through a shell, an env var or a secrets UI it very often
  253. * arrives with literal backslash-n instead, and the base64 body then fails to
  254. * decode with an error that says nothing useful. Both forms are accepted here,
  255. * because the alternative is an afternoon lost to an unreadable message.
  256. */
  257. export async function importPrivateKey(pem: string): Promise<SigningKey> {
  258. const normalized = pem.includes('\\n') ? pem.replace(/\\n/g, '\n') : pem
  259. const body = normalized
  260. .replace(/-----BEGIN [A-Z ]+-----/, '')
  261. .replace(/-----END [A-Z ]+-----/, '')
  262. .replace(/\s+/g, '')
  263. if (!body) throw new Error('no key material between the PEM markers')
  264. let der: Uint8Array
  265. try {
  266. der = Uint8Array.from(atob(body), (c) => c.charCodeAt(0))
  267. } catch {
  268. throw new Error('the PEM body is not valid base64')
  269. }
  270. return crypto.subtle.importKey(
  271. 'pkcs8',
  272. der.buffer as ArrayBuffer,
  273. { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
  274. false,
  275. ['sign'],
  276. )
  277. }
  278. /**
  279. * An access token for one mailbox, by impersonation.
  280. *
  281. * `sub` is the whole mechanism: it is the mailbox being read, and it is what
  282. * domain-wide delegation authorizes. A token minted here can read that mailbox
  283. * and no other.
  284. */
  285. export async function accessTokenFor(
  286. mailbox: string,
  287. sa: ServiceAccount,
  288. fetchImpl: typeof fetch = fetch,
  289. now: () => number = Date.now,
  290. ): Promise<TokenResult> {
  291. let key: SigningKey
  292. try {
  293. key = await importPrivateKey(sa.privateKey)
  294. } catch (err) {
  295. throw new GmailAuthError(mailbox, 'invalid_key', 0, errorMessage(err))
  296. }
  297. const iat = Math.floor(now() / 1000)
  298. const claims = {
  299. iss: sa.email,
  300. sub: mailbox, // impersonate
  301. scope: GMAIL_SCOPE,
  302. aud: TOKEN_URL,
  303. iat,
  304. // Google caps assertion lifetime at an hour and rejects a future iat, so a
  305. // clock more than a few minutes out fails as invalid_grant. Named in the
  306. // error rather than left as a mystery.
  307. exp: iat + 3600,
  308. }
  309. const signingInput = `${b64urlText(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))}.${b64urlText(JSON.stringify(claims))}`
  310. const sig = await crypto.subtle.sign(
  311. 'RSASSA-PKCS1-v1_5',
  312. key,
  313. new TextEncoder().encode(signingInput),
  314. )
  315. const assertion = `${signingInput}.${b64url(new Uint8Array(sig))}`
  316. const res = await fetchImpl(TOKEN_URL, {
  317. method: 'POST',
  318. headers: { 'content-type': 'application/x-www-form-urlencoded' },
  319. body: new URLSearchParams({
  320. grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
  321. assertion,
  322. }),
  323. })
  324. const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
  325. if (!res.ok) {
  326. throw new GmailAuthError(
  327. mailbox,
  328. String(body.error ?? 'unknown'),
  329. res.status,
  330. typeof body.error_description === 'string' ? body.error_description : undefined,
  331. )
  332. }
  333. const token = body.access_token
  334. if (typeof token !== 'string' || !token) {
  335. // A 200 with no token is not success. Returning '' would surface as a 401 on
  336. // the next call and send someone debugging the wrong request.
  337. throw new GmailAuthError(mailbox, 'no access_token in a 200 response', res.status)
  338. }
  339. return { accessToken: token, expiresIn: Number(body.expires_in) || 0, scopes: scopesFrom(body) }
  340. }