From 60d5d38d921ee1034faa63f745077faa898f69ff Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 01:37:45 +0530 Subject: [PATCH] feat(hq): one-time gmail oauth connect script (HQ-1 task 12) Co-Authored-By: Claude Fable 5 --- apps/hq/scripts/gmail-connect.ts | 112 +++++++++++++++++++++++++++++ apps/hq/test/gmail-connect.test.ts | 13 ++++ 2 files changed, 125 insertions(+) create mode 100644 apps/hq/scripts/gmail-connect.ts create mode 100644 apps/hq/test/gmail-connect.test.ts diff --git a/apps/hq/scripts/gmail-connect.ts b/apps/hq/scripts/gmail-connect.ts new file mode 100644 index 0000000..f82ca3f --- /dev/null +++ b/apps/hq/scripts/gmail-connect.ts @@ -0,0 +1,112 @@ +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' +const SCOPE = 'https://www.googleapis.com/auth/gmail.send' + +/** 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 { + 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 + ? '

SiMS HQ: Gmail connected.

You can close this tab and return to the terminal.

' + : `

SiMS HQ: Gmail connect failed.

${error ?? 'No code returned.'}

`) + 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 { + 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 { + 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 + }) +} diff --git a/apps/hq/test/gmail-connect.test.ts b/apps/hq/test/gmail-connect.test.ts new file mode 100644 index 0000000..be0fdae --- /dev/null +++ b/apps/hq/test/gmail-connect.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from 'vitest' +import { authUrl } from '../scripts/gmail-connect' + +describe('gmail connect', () => { + it('builds the consent URL with offline access and gmail.send scope', () => { + const u = new URL(authUrl('cid-1', 'http://localhost:5190/callback')) + expect(u.origin + u.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth') + expect(u.searchParams.get('client_id')).toBe('cid-1') + expect(u.searchParams.get('access_type')).toBe('offline') + expect(u.searchParams.get('prompt')).toBe('consent') + expect(u.searchParams.get('scope')).toContain('gmail.send') + }) +})