feat(hq): one-time gmail oauth connect script (HQ-1 task 12)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
94734d04bf
commit
60d5d38d92
@ -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<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
|
||||
})
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue