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

inbox-auth.ts

Language TypeScript9.3 KB227 lines

scripts/inbox-auth.ts

  1. /**
  2. * Mint one mailbox's Gmail refresh token and put it on the Worker. One command,
  3. * no shell setup, no copy-paste.
  4. *
  5. * pnpm inbox:auth hello@meridiandisplay.example ~/Downloads/client_secret_xxx.json
  6. *
  7. * Run once per mailbox, with the browser signed in as that mailbox. The client
  8. * JSON is the file Google Cloud Console hands you for the OAuth client
  9. * (Credentials → the client → Download JSON).
  10. *
  11. * What it does with secrets, precisely: reads the client file, runs the consent
  12. * flow, exchanges the code, then pipes the refresh token straight into
  13. * `wrangler secret put` over stdin (and, on first use, the client id and secret
  14. * too). Nothing is printed, nothing is written to disk, nothing lands in the
  15. * shell profile. This repo has no .env reader and must not grow one.
  16. */
  17. import { readFileSync } from 'node:fs'
  18. import { createServer } from 'node:http'
  19. import { spawn } from 'node:child_process'
  20. import { fileURLToPath } from 'node:url'
  21. import { INBOX_MAILBOXES } from '../config/inbox.js'
  22. import { GMAIL_SCOPE, GMAIL_SEND_SCOPE, scopesGranted } from '@meridian/gmail-client'
  23. import { errorMessage } from '@meridian/shared'
  24. /**
  25. * Both scopes: read, and send.
  26. *
  27. * Imported rather than repeated: a refresh token carries the scopes it was
  28. * consented with, so a scope string that drifts between this script and the
  29. * client is a token that reads fine and silently cannot send. After the consent
  30. * screen changes, every mailbox must be re-run through here; the board shows a
  31. * mailbox still on an old token as read-only rather than offering a Reply
  32. * button that fails.
  33. */
  34. const SCOPE = GMAIL_SCOPE
  35. const PORT = 8811
  36. const REDIRECT = `http://localhost:${PORT}/callback`
  37. const WORKER_DIR = fileURLToPath(new URL('../apps/worker', import.meta.url))
  38. const target = process.argv[2]?.trim().toLowerCase()
  39. const clientFile = process.argv[3]
  40. const mailbox = INBOX_MAILBOXES.find((m) => m.email === target)
  41. if (!mailbox || !clientFile) {
  42. console.error(`Usage: pnpm inbox:auth <mailbox> <client_secret_....json>\n`)
  43. console.error(`Known mailboxes (config/inbox.ts):`)
  44. for (const m of INBOX_MAILBOXES) console.error(` ${m.email.padEnd(30)} -> ${m.secret}`)
  45. process.exit(1)
  46. }
  47. // Google wraps the client in {"web": {...}} or {"installed": {...}}.
  48. let clientId = ''
  49. let clientSecret = ''
  50. try {
  51. const raw = JSON.parse(readFileSync(clientFile, 'utf8')) as Record<
  52. string,
  53. { client_id?: string; client_secret?: string; redirect_uris?: string[] }
  54. >
  55. const inner = raw.web ?? raw.installed
  56. clientId = inner?.client_id ?? ''
  57. clientSecret = inner?.client_secret ?? ''
  58. if (!clientId || !clientSecret) throw new Error('no client_id/client_secret inside')
  59. // Catch the two production clients in the same Downloads folder before any
  60. // browser opens: the meridiandisplay.example site integration and the Cloudflare
  61. // Access IdP. Minting mail tokens against either would entangle live systems
  62. // with this board for no reason.
  63. const uris = inner?.redirect_uris ?? []
  64. if (uris.length && !uris.includes(REDIRECT)) {
  65. console.error(`That client's redirect URIs are:\n ${uris.join('\n ')}`)
  66. console.error(`\nNone of them is ${REDIRECT}, so this is not the inbox client;`)
  67. console.error(`it looks like a production integration. Create a fresh OAuth client`)
  68. console.error(`(Web application) with exactly that redirect URI, download its JSON,`)
  69. console.error(`and pass that file instead.`)
  70. process.exit(1)
  71. }
  72. } catch (err) {
  73. console.error(`Could not read ${clientFile}: ${errorMessage(err)}`)
  74. process.exit(1)
  75. }
  76. /** Pipe a value into `wrangler secret put`: stdin only, never argv or a file. */
  77. function putSecret(name: string, value: string): Promise<void> {
  78. return new Promise((resolve, reject) => {
  79. const p = spawn('npx', ['wrangler', 'secret', 'put', name], {
  80. cwd: WORKER_DIR,
  81. stdio: ['pipe', 'ignore', 'pipe'],
  82. })
  83. let err = ''
  84. p.stderr.on('data', (d: Buffer) => (err += d.toString()))
  85. p.on('close', (code) =>
  86. code === 0 ? resolve() : reject(new Error(`wrangler secret put ${name} exited ${code}: ${err.slice(-300)}`)),
  87. )
  88. p.stdin.end(value)
  89. })
  90. }
  91. const authUrl =
  92. 'https://accounts.google.com/o/oauth2/v2/auth?' +
  93. new URLSearchParams({
  94. client_id: clientId,
  95. redirect_uri: REDIRECT,
  96. response_type: 'code',
  97. scope: SCOPE,
  98. // offline + consent every time: without both, Google only returns a refresh
  99. // token on a mailbox's first-ever authorization and silently omits it after.
  100. access_type: 'offline',
  101. prompt: 'consent',
  102. login_hint: mailbox.email,
  103. })
  104. console.log(`\nMailbox: ${mailbox.email}`)
  105. console.log(`\n1. Sign the browser in as ${mailbox.email}.`)
  106. console.log(`2. Open: http://localhost:${PORT}\n`)
  107. console.log(`Listening. Ctrl-C to give up.`)
  108. const TIMEOUT_MIN = 10
  109. const timer = setTimeout(() => {
  110. console.error(`\nNothing arrived in ${TIMEOUT_MIN} minutes. Nothing was changed; run it again.`)
  111. process.exit(1)
  112. }, TIMEOUT_MIN * 60_000)
  113. timer.unref?.()
  114. const server = createServer(async (req, res) => {
  115. const url = new URL(req.url ?? '/', `http://localhost:${PORT}`)
  116. if (url.pathname === '/') {
  117. res.writeHead(302, { location: authUrl }).end()
  118. return
  119. }
  120. if (url.pathname === '/favicon.ico') {
  121. res.writeHead(204).end()
  122. return
  123. }
  124. if (url.pathname !== '/callback') {
  125. res.writeHead(404).end('not here')
  126. return
  127. }
  128. const code = url.searchParams.get('code')
  129. const err = url.searchParams.get('error')
  130. // A bare hit on /callback (link preview, prefetch, stray click) is noise, not
  131. // an outcome, so the server keeps waiting.
  132. if (!code && !err) {
  133. res.writeHead(400, { 'content-type': 'text/plain' })
  134. .end('No authorization code on this request. Start at http://localhost:' + PORT)
  135. console.error(` (ignored a bare hit on /callback, still waiting)`)
  136. return
  137. }
  138. if (err) {
  139. res.writeHead(400, { 'content-type': 'text/plain' }).end(`Authorization failed: ${err}`)
  140. console.error(`\nGoogle refused: ${err}`)
  141. if (err === 'access_denied') {
  142. console.error(`Consent was declined, or this Google account is outside the Workspace org.`)
  143. console.error(`Check you are signed in as ${mailbox.email}.`)
  144. }
  145. if (err === 'redirect_uri_mismatch') {
  146. console.error(`The OAuth client has no redirect URI matching ${REDIRECT}.`)
  147. console.error(`Add it under Credentials -> the client -> Authorized redirect URIs.`)
  148. }
  149. process.exit(1)
  150. }
  151. if (!code) return
  152. clearTimeout(timer)
  153. const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
  154. method: 'POST',
  155. headers: { 'content-type': 'application/x-www-form-urlencoded' },
  156. body: new URLSearchParams({
  157. code,
  158. client_id: clientId,
  159. client_secret: clientSecret,
  160. redirect_uri: REDIRECT,
  161. grant_type: 'authorization_code',
  162. }),
  163. })
  164. const body = (await tokenRes.json()) as { refresh_token?: string; error?: string; scope?: string }
  165. if (!tokenRes.ok || !body.refresh_token) {
  166. res.writeHead(500, { 'content-type': 'text/plain' }).end('No refresh token. See the terminal.')
  167. console.error(`\nNo refresh token came back (${body.error ?? `HTTP ${tokenRes.status}`}).`)
  168. console.error(`Usual cause: a prior authorization for this mailbox. Google issues the`)
  169. console.error(`refresh token once per grant. Revoke at`)
  170. console.error(`https://myaccount.google.com/permissions (signed in as this mailbox), retry.`)
  171. process.exit(1)
  172. }
  173. // Compared as a set, not as a string. Google returns granted scopes in its
  174. // own order, so a string compare fires on perfectly good grants, on every
  175. // re-mint, which is how a real warning stops being read. scopesGranted() lives in the client because this script has no
  176. // test harness and that comparison is the part worth pinning.
  177. if (body.scope) {
  178. const { ok, missing } = scopesGranted(body.scope)
  179. if (!ok) {
  180. console.error(`\nWARNING: the grant is missing ${missing.length} requested scope(s):`)
  181. for (const m of missing) console.error(` ${m}`)
  182. console.error(` got: ${body.scope}`)
  183. if (missing.includes(GMAIL_SEND_SCOPE)) {
  184. console.error(`\nThis token can READ but NOT SEND. The board will show ${mailbox.email}`)
  185. console.error(`as read-only in the reply window rather than offering a button that fails.`)
  186. console.error(`Add ${GMAIL_SEND_SCOPE} to the OAuth consent screen, then run this again.`)
  187. }
  188. }
  189. }
  190. res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
  191. .end(`<h1>Done</h1><p><b>${mailbox.email}</b> is being uploaded to the Worker. Check the terminal, then run the next mailbox.</p>`)
  192. console.log(`\nToken received. Uploading to the Worker...`)
  193. try {
  194. // Client id/secret every time: idempotent, and it guarantees the pair on
  195. // the Worker always matches the client the tokens were minted against.
  196. await putSecret('GMAIL_CLIENT_ID', clientId)
  197. await putSecret('GMAIL_CLIENT_SECRET', clientSecret)
  198. await putSecret(mailbox.secret, body.refresh_token)
  199. console.log(`\nDone: ${mailbox.secret} is on the Worker. Nothing was written to disk.`)
  200. console.log(`Next mailbox: sign in as it, then rerun with its address.\n`)
  201. process.exit(0)
  202. } catch (e) {
  203. console.error(`\nUpload failed: ${errorMessage(e)}`)
  204. console.error(`The token was NOT saved anywhere. Run this mailbox again.`)
  205. process.exit(1)
  206. }
  207. })
  208. server.listen(PORT)