You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/scripts/gmail-connect.ts

119 lines
5.4 KiB
TypeScript

import http from 'node:http'
import { encrypt } from '../src/crypto'
import { openDb } from '../src/db'
import { saveAccount } from '../src/repos-email'
/**
* One-time Gmail OAuth connect (loopback flow). Run on the HQ server:
*
* GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... HQ_SECRET_KEY=... \
* npx tsx apps/hq/scripts/gmail-connect.ts
*
* Prints a consent URL, waits once on http://localhost:5190/callback,
* exchanges the code for a refresh token, encrypts it with HQ_SECRET_KEY
* (AES-256-GCM, crypto.ts) and stores it via saveAccount. Re-run whenever
* the token dies (invalid_grant → dashboard banner). Tokens are never logged.
*/
const CALLBACK_PORT = 5190
const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth'
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
const PROFILE_ENDPOINT = 'https://gmail.googleapis.com/gmail/v1/users/me/profile'
// Two scopes: send (HQ-1) and readonly (HQ-2 bounce polling reads mailer-daemon DSNs).
// The company mailbox is not yet connected in production, so requesting both now
// costs no extra consent — the first connect grants them together.
const SCOPE = [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.readonly',
].join(' ')
/** Consent URL — offline access + forced consent so Google returns a refresh token. */
export function authUrl(clientId: string, redirectUri: string): string {
const u = new URL(AUTH_ENDPOINT)
u.searchParams.set('client_id', clientId)
u.searchParams.set('redirect_uri', redirectUri)
u.searchParams.set('response_type', 'code')
u.searchParams.set('scope', SCOPE)
u.searchParams.set('access_type', 'offline')
u.searchParams.set('prompt', 'consent')
return u.toString()
}
/** Listens once on the loopback callback and resolves with the auth code. */
function waitForCode(): Promise<string> {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url ?? '/', `http://localhost:${CALLBACK_PORT}`)
if (url.pathname !== '/callback') { res.writeHead(404).end(); return }
const code = url.searchParams.get('code')
const error = url.searchParams.get('error')
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(code !== null
? '<h1>SiMS HQ: Gmail connected.</h1><p>You can close this tab and return to the terminal.</p>'
: `<h1>SiMS HQ: Gmail connect failed.</h1><p>${error ?? 'No code returned.'}</p>`)
server.close()
if (code !== null) resolve(code)
else reject(new Error(`Google returned no code (${error ?? 'unknown error'})`))
})
server.on('error', reject)
server.listen(CALLBACK_PORT)
})
}
async function exchangeCode(
code: string, clientId: string, clientSecret: string, redirectUri: string,
): Promise<{ accessToken: string; refreshToken: string }> {
const res = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code', code,
client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri,
}).toString(),
})
const json = await res.json() as { access_token?: string; refresh_token?: string; error?: string }
if (!res.ok || typeof json.access_token !== 'string' || typeof json.refresh_token !== 'string') {
// json.error is Google's error code (e.g. invalid_grant) — never a token.
throw new Error(`Code exchange failed: ${json.error ?? `HTTP ${res.status}`}`)
}
return { accessToken: json.access_token, refreshToken: json.refresh_token }
}
async function fetchAddress(accessToken: string): Promise<string> {
const res = await fetch(PROFILE_ENDPOINT, { headers: { authorization: `Bearer ${accessToken}` } })
const json = await res.json() as { emailAddress?: string }
if (!res.ok || typeof json.emailAddress !== 'string') {
throw new Error(`Could not read the Gmail profile (HTTP ${res.status})`)
}
return json.emailAddress
}
async function main(): Promise<void> {
const clientId = process.env['GOOGLE_CLIENT_ID']
const clientSecret = process.env['GOOGLE_CLIENT_SECRET']
const keyHex = process.env['HQ_SECRET_KEY']
if (clientId === undefined || clientSecret === undefined || keyHex === undefined) {
console.error('Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET and HQ_SECRET_KEY, then re-run.')
process.exitCode = 1
return
}
const redirectUri = `http://localhost:${CALLBACK_PORT}/callback`
console.log('Open this URL in a browser signed in as the sending Gmail account:\n')
console.log(` ${authUrl(clientId, redirectUri)}\n`)
console.log(`Waiting for Google to redirect to ${redirectUri} ...`)
const code = await waitForCode()
const { accessToken, refreshToken } = await exchangeCode(code, clientId, clientSecret, redirectUri)
const address = await fetchAddress(accessToken)
const account = saveAccount(openDb(process.env['HQ_DATA_DIR']), address, encrypt(refreshToken, keyHex))
console.log(`Connected ${account.address} — refresh token stored encrypted. HQ can now send email.`)
}
// Direct launch only (tsx / bundled); vitest imports authUrl instead.
if (process.argv[1]?.endsWith('gmail-connect.ts') || process.argv[1]?.endsWith('gmail-connect.cjs')) {
main().catch((err: unknown) => {
console.error(err instanceof Error ? err.message : String(err))
process.exitCode = 1
})
}