inbox-auth.ts
Language TypeScript9.3 KB227 lines
/*** Mint one mailbox's Gmail refresh token and put it on the Worker. One command,* no shell setup, no copy-paste.** pnpm inbox:auth hello@meridiandisplay.example ~/Downloads/client_secret_xxx.json** Run once per mailbox, with the browser signed in as that mailbox. The client* JSON is the file Google Cloud Console hands you for the OAuth client* (Credentials → the client → Download JSON).** What it does with secrets, precisely: reads the client file, runs the consent* flow, exchanges the code, then pipes the refresh token straight into* `wrangler secret put` over stdin (and, on first use, the client id and secret* too). Nothing is printed, nothing is written to disk, nothing lands in the* shell profile. This repo has no .env reader and must not grow one.*/import { readFileSync } from 'node:fs'import { createServer } from 'node:http'import { spawn } from 'node:child_process'import { fileURLToPath } from 'node:url'import { INBOX_MAILBOXES } from '../config/inbox.js'import { GMAIL_SCOPE, GMAIL_SEND_SCOPE, scopesGranted } from '@meridian/gmail-client'import { errorMessage } from '@meridian/shared'/*** Both scopes: read, and send.** Imported rather than repeated: a refresh token carries the scopes it was* consented with, so a scope string that drifts between this script and the* client is a token that reads fine and silently cannot send. After the consent* screen changes, every mailbox must be re-run through here; the board shows a* mailbox still on an old token as read-only rather than offering a Reply* button that fails.*/const SCOPE = GMAIL_SCOPEconst PORT = 8811const REDIRECT = `http://localhost:${PORT}/callback`const WORKER_DIR = fileURLToPath(new URL('../apps/worker', import.meta.url))const target = process.argv[2]?.trim().toLowerCase()const clientFile = process.argv[3]const mailbox = INBOX_MAILBOXES.find((m) => m.email === target)if (!mailbox || !clientFile) {console.error(`Usage: pnpm inbox:auth <mailbox> <client_secret_....json>\n`)console.error(`Known mailboxes (config/inbox.ts):`)for (const m of INBOX_MAILBOXES) console.error(` ${m.email.padEnd(30)} -> ${m.secret}`)process.exit(1)}// Google wraps the client in {"web": {...}} or {"installed": {...}}.let clientId = ''let clientSecret = ''try {const raw = JSON.parse(readFileSync(clientFile, 'utf8')) as Record<string,{ client_id?: string; client_secret?: string; redirect_uris?: string[] }>const inner = raw.web ?? raw.installedclientId = inner?.client_id ?? ''clientSecret = inner?.client_secret ?? ''if (!clientId || !clientSecret) throw new Error('no client_id/client_secret inside')// Catch the two production clients in the same Downloads folder before any// browser opens: the meridiandisplay.example site integration and the Cloudflare// Access IdP. Minting mail tokens against either would entangle live systems// with this board for no reason.const uris = inner?.redirect_uris ?? []if (uris.length && !uris.includes(REDIRECT)) {console.error(`That client's redirect URIs are:\n ${uris.join('\n ')}`)console.error(`\nNone of them is ${REDIRECT}, so this is not the inbox client;`)console.error(`it looks like a production integration. Create a fresh OAuth client`)console.error(`(Web application) with exactly that redirect URI, download its JSON,`)console.error(`and pass that file instead.`)process.exit(1)}} catch (err) {console.error(`Could not read ${clientFile}: ${errorMessage(err)}`)process.exit(1)}/** Pipe a value into `wrangler secret put`: stdin only, never argv or a file. */function putSecret(name: string, value: string): Promise<void> {return new Promise((resolve, reject) => {const p = spawn('npx', ['wrangler', 'secret', 'put', name], {cwd: WORKER_DIR,stdio: ['pipe', 'ignore', 'pipe'],})let err = ''p.stderr.on('data', (d: Buffer) => (err += d.toString()))p.on('close', (code) =>code === 0 ? resolve() : reject(new Error(`wrangler secret put ${name} exited ${code}: ${err.slice(-300)}`)),)p.stdin.end(value)})}const authUrl ='https://accounts.google.com/o/oauth2/v2/auth?' +new URLSearchParams({client_id: clientId,redirect_uri: REDIRECT,response_type: 'code',scope: SCOPE,// offline + consent every time: without both, Google only returns a refresh// token on a mailbox's first-ever authorization and silently omits it after.access_type: 'offline',prompt: 'consent',login_hint: mailbox.email,})console.log(`\nMailbox: ${mailbox.email}`)console.log(`\n1. Sign the browser in as ${mailbox.email}.`)console.log(`2. Open: http://localhost:${PORT}\n`)console.log(`Listening. Ctrl-C to give up.`)const TIMEOUT_MIN = 10const timer = setTimeout(() => {console.error(`\nNothing arrived in ${TIMEOUT_MIN} minutes. Nothing was changed; run it again.`)process.exit(1)}, TIMEOUT_MIN * 60_000)timer.unref?.()const server = createServer(async (req, res) => {const url = new URL(req.url ?? '/', `http://localhost:${PORT}`)if (url.pathname === '/') {res.writeHead(302, { location: authUrl }).end()return}if (url.pathname === '/favicon.ico') {res.writeHead(204).end()return}if (url.pathname !== '/callback') {res.writeHead(404).end('not here')return}const code = url.searchParams.get('code')const err = url.searchParams.get('error')// A bare hit on /callback (link preview, prefetch, stray click) is noise, not// an outcome, so the server keeps waiting.if (!code && !err) {res.writeHead(400, { 'content-type': 'text/plain' }).end('No authorization code on this request. Start at http://localhost:' + PORT)console.error(` (ignored a bare hit on /callback, still waiting)`)return}if (err) {res.writeHead(400, { 'content-type': 'text/plain' }).end(`Authorization failed: ${err}`)console.error(`\nGoogle refused: ${err}`)if (err === 'access_denied') {console.error(`Consent was declined, or this Google account is outside the Workspace org.`)console.error(`Check you are signed in as ${mailbox.email}.`)}if (err === 'redirect_uri_mismatch') {console.error(`The OAuth client has no redirect URI matching ${REDIRECT}.`)console.error(`Add it under Credentials -> the client -> Authorized redirect URIs.`)}process.exit(1)}if (!code) returnclearTimeout(timer)const tokenRes = await fetch('https://oauth2.googleapis.com/token', {method: 'POST',headers: { 'content-type': 'application/x-www-form-urlencoded' },body: new URLSearchParams({code,client_id: clientId,client_secret: clientSecret,redirect_uri: REDIRECT,grant_type: 'authorization_code',}),})const body = (await tokenRes.json()) as { refresh_token?: string; error?: string; scope?: string }if (!tokenRes.ok || !body.refresh_token) {res.writeHead(500, { 'content-type': 'text/plain' }).end('No refresh token. See the terminal.')console.error(`\nNo refresh token came back (${body.error ?? `HTTP ${tokenRes.status}`}).`)console.error(`Usual cause: a prior authorization for this mailbox. Google issues the`)console.error(`refresh token once per grant. Revoke at`)console.error(`https://myaccount.google.com/permissions (signed in as this mailbox), retry.`)process.exit(1)}// Compared as a set, not as a string. Google returns granted scopes in its// own order, so a string compare fires on perfectly good grants, on every// re-mint, which is how a real warning stops being read. scopesGranted() lives in the client because this script has no// test harness and that comparison is the part worth pinning.if (body.scope) {const { ok, missing } = scopesGranted(body.scope)if (!ok) {console.error(`\nWARNING: the grant is missing ${missing.length} requested scope(s):`)for (const m of missing) console.error(` ${m}`)console.error(` got: ${body.scope}`)if (missing.includes(GMAIL_SEND_SCOPE)) {console.error(`\nThis token can READ but NOT SEND. The board will show ${mailbox.email}`)console.error(`as read-only in the reply window rather than offering a button that fails.`)console.error(`Add ${GMAIL_SEND_SCOPE} to the OAuth consent screen, then run this again.`)}}}res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(`<h1>Done</h1><p><b>${mailbox.email}</b> is being uploaded to the Worker. Check the terminal, then run the next mailbox.</p>`)console.log(`\nToken received. Uploading to the Worker...`)try {// Client id/secret every time: idempotent, and it guarantees the pair on// the Worker always matches the client the tokens were minted against.await putSecret('GMAIL_CLIENT_ID', clientId)await putSecret('GMAIL_CLIENT_SECRET', clientSecret)await putSecret(mailbox.secret, body.refresh_token)console.log(`\nDone: ${mailbox.secret} is on the Worker. Nothing was written to disk.`)console.log(`Next mailbox: sign in as it, then rerun with its address.\n`)process.exit(0)} catch (e) {console.error(`\nUpload failed: ${errorMessage(e)}`)console.error(`The token was NOT saved anywhere. Run this mailbox again.`)process.exit(1)}})server.listen(PORT)