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.
119 lines
5.4 KiB
TypeScript
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
|
|
})
|
|
}
|