From 106b8284db5c9fecc468c2278f910f72ed2eeed5 Mon Sep 17 00:00:00 2001 From: TJ Baker <1617679+zaridan@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:31:14 -0700 Subject: [PATCH] feat(oauth): Gmail OAuth token persistence + refresh service (HT-38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AES-256-GCM token-crypto (src/store) + MailboxTokenStore (encrypt-at-rest over mailbox_oauth_tokens) + createGmailOAuthTokenService (getAccessToken: cache or refresh against Google's token endpoint; invalid_grant -> mailbox needs_reconnect; blank-refresh_token clobber guard) + minimal MailboxStore.markNeedsReconnect. Both refresh AND access tokens stored encrypted (HT-36 schema). token-crypto placed under src/store (its only consumer) so the store doesn't import mail/. New env: HELPTHREAD_TOKEN_ENC_KEY, GMAIL_OAUTH_CLIENT_ID/SECRET (composition-root injected, never hardcoded/logged). typecheck + biome clean; the ticket's own 50 tests pass in isolation (full suite via CI — a local full run hit sibling-worktree contention). Co-Authored-By: Claude Opus 4.8 --- src/mail/gmail-oauth.test.ts | 429 +++++++++++++++++++++++++++++++ src/mail/gmail-oauth.ts | 342 ++++++++++++++++++++++++ src/store/index.ts | 4 + src/store/mailbox-tokens.test.ts | 200 ++++++++++++++ src/store/mailbox-tokens.ts | 161 ++++++++++++ src/store/mailboxes.test.ts | 89 +++++++ src/store/mailboxes.ts | 55 ++++ src/store/token-crypto.test.ts | 163 ++++++++++++ src/store/token-crypto.ts | 154 +++++++++++ 9 files changed, 1597 insertions(+) create mode 100644 src/mail/gmail-oauth.test.ts create mode 100644 src/mail/gmail-oauth.ts create mode 100644 src/store/mailbox-tokens.test.ts create mode 100644 src/store/mailbox-tokens.ts create mode 100644 src/store/mailboxes.test.ts create mode 100644 src/store/mailboxes.ts create mode 100644 src/store/token-crypto.test.ts create mode 100644 src/store/token-crypto.ts diff --git a/src/mail/gmail-oauth.test.ts b/src/mail/gmail-oauth.test.ts new file mode 100644 index 0000000..9afe5d2 --- /dev/null +++ b/src/mail/gmail-oauth.test.ts @@ -0,0 +1,429 @@ +/** + * `createGmailOAuthTokenService` against REAL PGlite-backed + * `MailboxTokenStore`/`MailboxStore` instances (real encryption, real SQL — + * only Google's token endpoint is faked, via an injected `fetchImpl`, the + * same convention `sender.test.ts` uses for `createGmailEmailSender`). No + * real network call, no real Google credentials. + */ + +import { randomBytes } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createPgliteDb, type Db } from '../db/client.js' +import { migrate } from '../db/migrate.js' +import { createMailboxTokenStore, type MailboxTokenStore } from '../store/mailbox-tokens.js' +import { createMailboxStore, type MailboxStore } from '../store/mailboxes.js' +import { ENCRYPTION_KEY_BYTES } from '../store/token-crypto.js' +import { createGmailOAuthTokenService } from './gmail-oauth.js' + +const KEY = randomBytes(ENCRYPTION_KEY_BYTES) +const CLIENT_ID = 'test-client-id.apps.googleusercontent.com' +const CLIENT_SECRET = 'test-client-secret' + +interface RecordedCall { + url: string + init: RequestInit +} + +/** A fake token-endpoint `fetch` that records every call and always resolves with `status`/`body`. */ +function fakeTokenEndpoint(status: number, body: unknown) { + const calls: RecordedCall[] = [] + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), init: init ?? {} }) + return new Response(JSON.stringify(body), { status }) + }) as unknown as typeof fetch + return { fetchImpl, calls } +} + +async function insertMailbox(db: Db, address = 'mailbox@example.test'): Promise { + const rows = await db.query<{ id: string }>( + "INSERT INTO mailboxes (address, provider) VALUES ($1, 'gmail') RETURNING id", + [address], + ) + return rows[0].id +} + +async function mailboxStatus(db: Db, mailboxId: string): Promise { + const rows = await db.query<{ status: string }>('SELECT status FROM mailboxes WHERE id = $1', [ + mailboxId, + ]) + return rows[0].status +} + +describe('createGmailOAuthTokenService', () => { + let db: Db | undefined + + afterEach(async () => { + await db?.close() + db = undefined + }) + + async function freshStores(): Promise<{ + db: Db + tokenStore: MailboxTokenStore + mailboxStore: MailboxStore + }> { + db = await createPgliteDb() + await migrate(db) + return { + db, + tokenStore: createMailboxTokenStore(db, KEY), + mailboxStore: createMailboxStore(db), + } + } + + // --- construction validation -------------------------------------------- + + it('throws at construction on an empty clientId or clientSecret', async () => { + const { tokenStore, mailboxStore } = await freshStores() + expect(() => + createGmailOAuthTokenService({ tokenStore, mailboxStore, clientId: '', clientSecret: 'x' }), + ).toThrow(/clientId/) + expect(() => + createGmailOAuthTokenService({ tokenStore, mailboxStore, clientId: 'x', clientSecret: '' }), + ).toThrow(/clientSecret/) + }) + + // --- no stored tokens ---------------------------------------------------- + + it('throws when no tokens are stored for the mailbox, without calling the token endpoint', async () => { + const { db, tokenStore, mailboxStore } = await freshStores() + const mailboxId = await insertMailbox(db) + const { fetchImpl } = fakeTokenEndpoint(200, {}) + const service = createGmailOAuthTokenService({ + tokenStore, + mailboxStore, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + fetchImpl, + }) + + await expect(service.getAccessToken(mailboxId)).rejects.toThrow(/no stored OAuth tokens/) + expect(fetchImpl).not.toHaveBeenCalled() + }) + + // --- cache hit ------------------------------------------------------------- + + it('returns the cached access token without refreshing when it is well within its expiry', async () => { + const { db, tokenStore, mailboxStore } = await freshStores() + const mailboxId = await insertMailbox(db) + await tokenStore.upsertTokens(mailboxId, { + refreshToken: 'refresh-token', + accessToken: 'cached-access-token', + accessTokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1h out + }) + const { fetchImpl } = fakeTokenEndpoint(200, {}) + const service = createGmailOAuthTokenService({ + tokenStore, + mailboxStore, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + fetchImpl, + }) + + const token = await service.getAccessToken(mailboxId) + + expect(token).toBe('cached-access-token') + expect(fetchImpl).not.toHaveBeenCalled() + }) + + // --- refresh: cache miss / near-expiry ------------------------------------ + + it('refreshes when no access token has ever been cached, and persists the result', async () => { + const { db, tokenStore, mailboxStore } = await freshStores() + const mailboxId = await insertMailbox(db) + await tokenStore.upsertTokens(mailboxId, { refreshToken: 'refresh-token' }) + const { fetchImpl, calls } = fakeTokenEndpoint(200, { + access_token: 'fresh-access-token', + expires_in: 3600, + token_type: 'Bearer', + }) + const service = createGmailOAuthTokenService({ + tokenStore, + mailboxStore, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + fetchImpl, + }) + + const token = await service.getAccessToken(mailboxId) + + expect(token).toBe('fresh-access-token') + expect(calls).toHaveLength(1) + + const stored = await tokenStore.getTokens(mailboxId) + expect(stored?.accessToken).toBe('fresh-access-token') + expect(stored?.accessTokenExpiresAt).not.toBeNull() + expect(stored?.accessTokenExpiresAt?.getTime()).toBeGreaterThan(Date.now() + 3_500_000) + }) + + it('refreshes when the cached token is within the expiry skew, and posts the correct request shape', async () => { + const { db, tokenStore, mailboxStore } = await freshStores() + const mailboxId = await insertMailbox(db) + await tokenStore.upsertTokens(mailboxId, { + refreshToken: 'refresh-token-value', + accessToken: 'stale-access-token', + accessTokenExpiresAt: new Date(Date.now() + 60 * 1000), // 1 min out — inside the default 5 min skew + }) + const { fetchImpl, calls } = fakeTokenEndpoint(200, { + access_token: 'fresh-access-token', + expires_in: 3600, + scope: 'https://www.googleapis.com/auth/gmail.send', + token_type: 'Bearer', + }) + const service = createGmailOAuthTokenService({ + tokenStore, + mailboxStore, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + fetchImpl, + }) + + const token = await service.getAccessToken(mailboxId) + expect(token).toBe('fresh-access-token') + + expect(calls).toHaveLength(1) + const { url, init } = calls[0] + expect(url).toBe('https://oauth2.googleapis.com/token') + expect(init.method).toBe('POST') + const headers = new Headers(init.headers) + expect(headers.get('Content-Type')).toBe('application/x-www-form-urlencoded') + + const params = new URLSearchParams(String(init.body)) + expect(params.get('grant_type')).toBe('refresh_token') + expect(params.get('client_id')).toBe(CLIENT_ID) + expect(params.get('client_secret')).toBe(CLIENT_SECRET) + expect(params.get('refresh_token')).toBe('refresh-token-value') + + const stored = await tokenStore.getTokens(mailboxId) + expect(stored?.accessToken).toBe('fresh-access-token') + // No refresh_token in the response → the original is kept, not cleared. + expect(stored?.refreshToken).toBe('refresh-token-value') + expect(stored?.scopes).toBe('https://www.googleapis.com/auth/gmail.send') + }) + + it('does not refresh when the cached token is fresh enough to clear a custom expirySkewMs', async () => { + const { db, tokenStore, mailboxStore } = await freshStores() + const mailboxId = await insertMailbox(db) + await tokenStore.upsertTokens(mailboxId, { + refreshToken: 'refresh-token', + accessToken: 'cached-access-token', + accessTokenExpiresAt: new Date(Date.now() + 30 * 1000), // 30s out + }) + const { fetchImpl } = fakeTokenEndpoint(200, {}) + const service = createGmailOAuthTokenService({ + tokenStore, + mailboxStore, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + fetchImpl, + expirySkewMs: 5_000, // much smaller than the 30s remaining + }) + + const token = await service.getAccessToken(mailboxId) + expect(token).toBe('cached-access-token') + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('persists a NEW refresh token when the response includes one (RFC 6749 §6 rotation)', async () => { + const { db, tokenStore, mailboxStore } = await freshStores() + const mailboxId = await insertMailbox(db) + await tokenStore.upsertTokens(mailboxId, { refreshToken: 'old-refresh-token' }) + const { fetchImpl } = fakeTokenEndpoint(200, { + access_token: 'fresh-access-token', + expires_in: 3600, + refresh_token: 'rotated-refresh-token', + }) + const service = createGmailOAuthTokenService({ + tokenStore, + mailboxStore, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + fetchImpl, + }) + + await service.getAccessToken(mailboxId) + + const stored = await tokenStore.getTokens(mailboxId) + expect(stored?.refreshToken).toBe('rotated-refresh-token') + }) + + it('carries forward the existing scopes when the refresh response omits `scope`', async () => { + const { db, tokenStore, mailboxStore } = await freshStores() + const mailboxId = await insertMailbox(db) + await tokenStore.upsertTokens(mailboxId, { + refreshToken: 'refresh-token', + scopes: 'https://www.googleapis.com/auth/gmail.send', + }) + const { fetchImpl } = fakeTokenEndpoint(200, { access_token: 'fresh', expires_in: 3600 }) + const service = createGmailOAuthTokenService({ + tokenStore, + mailboxStore, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + fetchImpl, + }) + + await service.getAccessToken(mailboxId) + + const stored = await tokenStore.getTokens(mailboxId) + expect(stored?.scopes).toBe('https://www.googleapis.com/auth/gmail.send') + }) + + // --- invalid_grant → needs_reconnect ---------------------------------------- + + it('on invalid_grant: marks the mailbox needs_reconnect, throws a clear error, and never leaks secrets', async () => { + const { db, tokenStore, mailboxStore } = await freshStores() + const mailboxId = await insertMailbox(db) + const secretRefreshToken = 'super-secret-refresh-token-do-not-leak' + await tokenStore.upsertTokens(mailboxId, { refreshToken: secretRefreshToken }) + const { fetchImpl } = fakeTokenEndpoint(400, { + error: 'invalid_grant', + error_description: 'Token has been expired or revoked.', + }) + const service = createGmailOAuthTokenService({ + tokenStore, + mailboxStore, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + fetchImpl, + }) + + let caught: unknown + try { + await service.getAccessToken(mailboxId) + } catch (err) { + caught = err + } + + expect(caught).toBeInstanceOf(Error) + expect((caught as Error).message).toContain('invalid_grant') + expect((caught as Error).message).toContain('needs_reconnect') + expect(String(caught)).not.toContain(secretRefreshToken) + expect(String(caught)).not.toContain(CLIENT_SECRET) + + expect(await mailboxStatus(db, mailboxId)).toBe('needs_reconnect') + }) + + it('a mailbox marked needs_reconnect via invalid_grant keeps its (still-encrypted) stored tokens untouched', async () => { + const { db, tokenStore, mailboxStore } = await freshStores() + const mailboxId = await insertMailbox(db) + await tokenStore.upsertTokens(mailboxId, { refreshToken: 'refresh-token' }) + const { fetchImpl } = fakeTokenEndpoint(400, { error: 'invalid_grant' }) + const service = createGmailOAuthTokenService({ + tokenStore, + mailboxStore, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + fetchImpl, + }) + + await expect(service.getAccessToken(mailboxId)).rejects.toThrow() + + // The row is left alone — needs_reconnect is a mailbox-level flag, not a + // token deletion; reconnecting overwrites it via upsertTokens later. + const stored = await tokenStore.getTokens(mailboxId) + expect(stored?.refreshToken).toBe('refresh-token') + }) + + // --- other refresh failures: throw, but do NOT mark needs_reconnect -------- + + it('on a non-invalid_grant error (e.g. server_error): throws WITHOUT marking needs_reconnect', async () => { + const { db, tokenStore, mailboxStore } = await freshStores() + const mailboxId = await insertMailbox(db) + await tokenStore.upsertTokens(mailboxId, { refreshToken: 'refresh-token' }) + const { fetchImpl } = fakeTokenEndpoint(500, { error: 'server_error' }) + const service = createGmailOAuthTokenService({ + tokenStore, + mailboxStore, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + fetchImpl, + }) + + await expect(service.getAccessToken(mailboxId)).rejects.toThrow(/500/) + expect(await mailboxStatus(db, mailboxId)).toBe('active') + }) + + it('on invalid_client: throws WITHOUT marking needs_reconnect (it is a config error, not a dead grant)', async () => { + const { db, tokenStore, mailboxStore } = await freshStores() + const mailboxId = await insertMailbox(db) + await tokenStore.upsertTokens(mailboxId, { refreshToken: 'refresh-token' }) + const { fetchImpl } = fakeTokenEndpoint(401, { error: 'invalid_client' }) + const service = createGmailOAuthTokenService({ + tokenStore, + mailboxStore, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + fetchImpl, + }) + + await expect(service.getAccessToken(mailboxId)).rejects.toThrow(/invalid_client/) + expect(await mailboxStatus(db, mailboxId)).toBe('active') + }) + + it('on a malformed success response (missing access_token): throws a clear error', async () => { + const { db, tokenStore, mailboxStore } = await freshStores() + const mailboxId = await insertMailbox(db) + await tokenStore.upsertTokens(mailboxId, { refreshToken: 'refresh-token' }) + const { fetchImpl } = fakeTokenEndpoint(200, { token_type: 'Bearer' }) + const service = createGmailOAuthTokenService({ + tokenStore, + mailboxStore, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + fetchImpl, + }) + + await expect(service.getAccessToken(mailboxId)).rejects.toThrow(/malformed/) + expect(await mailboxStatus(db, mailboxId)).toBe('active') + }) + + it('on a non-JSON error body: throws with just the status, without crashing on parse', async () => { + const { db, tokenStore, mailboxStore } = await freshStores() + const mailboxId = await insertMailbox(db) + await tokenStore.upsertTokens(mailboxId, { refreshToken: 'refresh-token' }) + const fetchImpl = vi.fn( + async () => new Response('not json', { status: 502 }), + ) as unknown as typeof fetch + const service = createGmailOAuthTokenService({ + tokenStore, + mailboxStore, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + fetchImpl, + }) + + await expect(service.getAccessToken(mailboxId)).rejects.toThrow(/502/) + }) + + // --- timeout --------------------------------------------------------------- + + it('passes an abort signal to fetch and rejects when the refresh call outlives timeoutMs', async () => { + const { db, tokenStore, mailboxStore } = await freshStores() + const mailboxId = await insertMailbox(db) + await tokenStore.upsertTokens(mailboxId, { refreshToken: 'refresh-token' }) + + // A fetch that never resolves on its own — it settles ONLY via the abort + // signal, exactly like a stalled token endpoint would (same pattern as + // sender.test.ts's identical timeout test). + const fetchImpl = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal + expect(signal).toBeDefined() + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }), + ) as unknown as typeof fetch + const service = createGmailOAuthTokenService({ + tokenStore, + mailboxStore, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + fetchImpl, + timeoutMs: 20, + }) + + await expect(service.getAccessToken(mailboxId)).rejects.toThrow(/timeout|timed out|aborted/i) + expect(await mailboxStatus(db, mailboxId)).toBe('active') + }) +}) diff --git a/src/mail/gmail-oauth.ts b/src/mail/gmail-oauth.ts new file mode 100644 index 0000000..e55060d --- /dev/null +++ b/src/mail/gmail-oauth.ts @@ -0,0 +1,342 @@ +/** + * Gmail OAuth2 access-token acquisition + refresh (HT-38; gmail-push.md §7: + * "OAuth token acquisition/refresh → HT-38; the connect/consent flow → + * HT-40"). This is what turns a stored, encrypted refresh token + * (`src/store/mailbox-tokens.ts`, migration 010) into the live bearer access + * token the Gmail adapters need — most directly, `getAccessToken` here is + * built to be handed to `createGmailEmailSender`'s `getAccessToken: () => + * Promise` option (`src/providers/adapters/gmail/sender.ts`), bound + * to one mailbox: `() => tokenService.getAccessToken(mailboxId)`. + * + * ## What this module does NOT do + * + * - **Mint the first refresh token.** That is the connect/consent OAuth + * flow (HT-40) — this module only ever reads a refresh token that flow + * already stored via `MailboxTokenStore.upsertTokens`. + * - **Encrypt or decrypt anything directly.** `MailboxTokenStore` owns that + * boundary (`src/store/mailbox-tokens.ts`, `src/store/token-crypto.ts`) — + * this module only ever sees plaintext token strings, already + * decrypted-on-read / about to be encrypted-on-write by the store. + * - **Renew a Gmail `watch()` subscription.** That's HT-42, which has its + * OWN failure→`needs_reconnect` transition (gmail-push.md §6) but reuses + * {@link MailboxStore.markNeedsReconnect} rather than duplicating it. + * + * ## Caching and the expiry skew + * + * `getAccessToken` returns the cached access token from `MailboxTokenStore` + * without a network call whenever one exists AND is not within + * {@link GmailOAuthTokenServiceOptions.expirySkewMs} of its real expiry. + * The skew exists so a token is never handed to a caller a moment before it + * expires mid-use (e.g. a slow Gmail `messages.send` call straddling the + * expiry instant): refreshing a little early is free (Google does not + * invalidate the old access token when a new one is issued — see below), + * whereas a request that starts with an about-to-expire token can fail + * partway through. The default, {@link DEFAULT_EXPIRY_SKEW_MS} (5 minutes), + * mirrors the same margin Google's own `google-auth-library` client uses for + * this exact purpose. + * + * ## The refresh call (RFC 6749 §6; Google's token endpoint) + * + * One `POST https://oauth2.googleapis.com/token`, `application/ + * x-www-form-urlencoded`, `grant_type=refresh_token` + + * `client_id`/`client_secret`/`refresh_token` — verified against Google's + * own OAuth2 web-server-flow documentation. Deliberately a raw injected + * `fetch` call, not the `googleapis` SDK: a token refresh is one POST with a + * small, stable JSON response shape, and adding a whole SDK dependency for + * it would be exactly the kind of unrequested complexity CLAUDE.md's + * "simplicity first" rule warns against. `fetchImpl` is injected (default + * the global `fetch`) purely so tests never hit Google — the same pattern + * `createGmailEmailSender` already uses (`src/providers/adapters/gmail/sender.ts`). + * + * A refresh response MAY include a new `refresh_token` (RFC 6749 §6: "the + * authorization server MAY issue a new refresh token"); when present it + * replaces the stored one, otherwise the existing refresh token is kept + * (Google's normal behavior: refresh tokens are not rotated on every + * refresh). Google does not invalidate the previous access token when + * issuing a new one, so an early/skewed refresh can never race a still-valid + * cached token into invalidity. + * + * ## `invalid_grant`: the mailbox needs reconnecting, not a crash + * + * A refresh token can die outside this module's control — the user revoked + * Helpthread's access, an admin disabled the OAuth grant, or Google expired + * it. The token endpoint reports this as an `invalid_grant` error (RFC 6749 + * §5.2). This is an EXPECTED, operator-actionable outcome, not a bug: on + * `invalid_grant`, {@link GmailOAuthTokenService.getAccessToken} marks the + * mailbox `needs_reconnect` (`MailboxStore.markNeedsReconnect` — + * gmail-push.md §5/§6's same operator-visible state) and THROWS a clear, + * specific error — it does not return a sentinel, and it does not let the + * process crash uncaught. Every other refresh failure (network error, + * timeout, `invalid_client`, a malformed response, an unrelated non-2xx) + * also throws, but WITHOUT touching mailbox status: those are not proof the + * grant itself is dead, so marking the mailbox for manual reconnection would + * be an overreaction to what may be a transient fault. + * + * ## Never log or leak a token + * + * `client_secret` and the refresh/access token values never appear in a + * thrown error message or a log line anywhere in this module — matching + * `createGmailEmailSender`'s same discipline for the access token it + * consumes. Error messages here are built ONLY from the mailbox id, HTTP + * status, and the OAuth `error`/`error_description` fields (which by + * protocol design never carry credential material). + * + * ## No cross-call refresh locking + * + * If two `getAccessToken` calls for the SAME mailbox race while its cached + * token is stale, both may independently POST a refresh. This is wasted + * work, not a correctness bug: Google does not invalidate the loser's + * freshly-issued access token, both calls still return a valid token, and + * the store's last write simply wins for what gets cached next time. Adding + * a lock/single-flight guard would be a reasonable follow-up if refresh + * volume ever makes the duplicate calls matter, but is speculative + * complexity this ticket does not add — see the HT-38 implementation report. + */ + +import type { MailboxTokenStore, StoredMailboxTokens } from '../store/mailbox-tokens.js' +import type { MailboxStore } from '../store/mailboxes.js' + +/** Google's OAuth2 token endpoint (RFC 6749 §3.2). Fixed — not configurable, since Google has exactly one. */ +const GMAIL_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token' + +/** + * Default safety margin before a cached access token's real expiry at which + * it is treated as already expired and proactively refreshed. See the + * module doc's "Caching and the expiry skew" section. + */ +export const DEFAULT_EXPIRY_SKEW_MS = 5 * 60 * 1000 + +/** Default bound on the refresh HTTP call, matching `createGmailEmailSender`'s own default (`src/providers/adapters/gmail/sender.ts`). */ +const DEFAULT_TIMEOUT_MS = 30_000 + +/** Options for {@link createGmailOAuthTokenService}. */ +export interface GmailOAuthTokenServiceOptions { + /** Where encrypted tokens are read from / written back to. See `src/store/mailbox-tokens.ts`. */ + tokenStore: MailboxTokenStore + + /** Used to mark a mailbox `needs_reconnect` on an `invalid_grant` refresh failure. See `src/store/mailboxes.ts`. */ + mailboxStore: MailboxStore + + /** + * The Google OAuth2 client's id/secret (an Internal Workspace app per the + * inbound-email architecture decision — memory, 2026-07-13). Deploy-time + * configuration, e.g. `GMAIL_OAUTH_CLIENT_ID`/`GMAIL_OAUTH_CLIENT_SECRET` — + * injected by the composition root, NEVER hardcoded (same discipline as + * the token encryption key; see `token-crypto.ts`'s module doc). Both must + * be non-empty strings; validated eagerly at construction so a + * misconfigured deploy fails at boot, not on a mailbox's first send. + */ + clientId: string + clientSecret: string + + /** + * Injectable `fetch` implementation, for tests (see `gmail-oauth.test.ts`). + * Defaults to the global `fetch`. Matches `createGmailEmailSender`'s + * `fetchImpl` option (`src/providers/adapters/gmail/sender.ts`). + */ + fetchImpl?: typeof fetch + + /** Safety margin before real expiry at which a cached token is treated as stale. Defaults to {@link DEFAULT_EXPIRY_SKEW_MS}. */ + expirySkewMs?: number + + /** Milliseconds before the refresh HTTP call is abandoned. Defaults to {@link DEFAULT_TIMEOUT_MS}. */ + timeoutMs?: number +} + +/** The Gmail OAuth token service: one method, {@link getAccessToken}. See the module doc for its full contract. */ +export interface GmailOAuthTokenService { + /** + * Return a live Gmail API access token for `mailboxId` — from cache when + * still fresh, otherwise by refreshing against Google's token endpoint + * first. See the module doc for the caching, refresh, and + * `invalid_grant`→`needs_reconnect` behavior. + * + * Throws if: no tokens are stored for `mailboxId` (never connected); the + * refresh token was rejected as `invalid_grant` (mailbox is marked + * `needs_reconnect` first — see the module doc); the refresh call fails + * for any other reason (network, timeout, non-2xx, malformed response). + * Never returns an invalid/expired token, and never silently swallows a + * failure into an empty or fabricated string. + */ + getAccessToken(mailboxId: string): Promise +} + +/** Shape of the fields this module reads from a token-endpoint JSON response. Every field is read defensively (typeof-checked) since it comes off the network. */ +interface TokenResponseBody { + access_token?: unknown + expires_in?: unknown + refresh_token?: unknown + scope?: unknown + error?: unknown + error_description?: unknown +} + +/** Build the Gmail OAuth token service. See the module doc for the full contract. */ +export function createGmailOAuthTokenService( + options: GmailOAuthTokenServiceOptions, +): GmailOAuthTokenService { + const { + tokenStore, + mailboxStore, + clientId, + clientSecret, + fetchImpl = fetch, + expirySkewMs = DEFAULT_EXPIRY_SKEW_MS, + timeoutMs = DEFAULT_TIMEOUT_MS, + } = options + + assertNonEmpty('clientId', clientId) + assertNonEmpty('clientSecret', clientSecret) + + return { + async getAccessToken(mailboxId: string): Promise { + const tokens = await tokenStore.getTokens(mailboxId) + if (tokens === null) { + throw new Error( + `getAccessToken: mailbox ${mailboxId} has no stored OAuth tokens — connect it via the OAuth flow first`, + ) + } + + if (isStillFresh(tokens, expirySkewMs)) { + // isStillFresh only returns true when accessToken is non-null. + return tokens.accessToken as string + } + + return refresh(mailboxId, tokens, { + tokenStore, + mailboxStore, + clientId, + clientSecret, + fetchImpl, + timeoutMs, + }) + }, + } +} + +/** True when `tokens` carries a cached access token that will remain valid for at least `skewMs` longer. */ +function isStillFresh(tokens: StoredMailboxTokens, skewMs: number): boolean { + if (tokens.accessToken === null || tokens.accessTokenExpiresAt === null) { + return false + } + return tokens.accessTokenExpiresAt.getTime() - Date.now() > skewMs +} + +/** + * Refresh `mailboxId`'s access token against Google's token endpoint, + * persist the result, and return the new access token. See the module doc + * for the full request/response/error-handling contract. + */ +async function refresh( + mailboxId: string, + tokens: StoredMailboxTokens, + deps: { + tokenStore: MailboxTokenStore + mailboxStore: MailboxStore + clientId: string + clientSecret: string + fetchImpl: typeof fetch + timeoutMs: number + }, +): Promise { + const { tokenStore, mailboxStore, clientId, clientSecret, fetchImpl, timeoutMs } = deps + + const body = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: clientId, + client_secret: clientSecret, + refresh_token: tokens.refreshToken, + }) + + const response = await fetchImpl(GMAIL_TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + // Bounds the whole exchange, same rationale as createGmailEmailSender's + // identical use of AbortSignal.timeout (src/providers/adapters/gmail/sender.ts). + signal: AbortSignal.timeout(timeoutMs), + }) + + const parsed = await parseJsonObject(response) + + if (!response.ok) { + const errorCode = typeof parsed.error === 'string' ? parsed.error : undefined + if (errorCode === 'invalid_grant') { + // The refresh token itself is dead (revoked/expired) — no retry of + // ours can fix this; the mailbox needs a human to reconnect it. + await mailboxStore.markNeedsReconnect(mailboxId) + throw new Error( + `getAccessToken: mailbox ${mailboxId}'s refresh token was rejected (invalid_grant — revoked or expired). ` + + `Marked the mailbox needs_reconnect; it must be reconnected via the OAuth flow before it can send or receive again.`, + ) + } + const description = + typeof parsed.error_description === 'string' ? parsed.error_description : undefined + throw new Error( + `getAccessToken: token refresh failed for mailbox ${mailboxId}: HTTP ${response.status}` + + (errorCode ? ` (${errorCode}${description ? `: ${description}` : ''})` : ''), + ) + } + + const accessToken = parsed.access_token + const expiresIn = parsed.expires_in + if ( + typeof accessToken !== 'string' || + accessToken.length === 0 || + typeof expiresIn !== 'number' || + !Number.isFinite(expiresIn) + ) { + throw new Error( + `getAccessToken: malformed token refresh response for mailbox ${mailboxId} (missing or invalid access_token/expires_in)`, + ) + } + + // RFC 6749 §6: the server MAY issue a new refresh token; Google normally + // does not on an ordinary refresh, so keep the existing one unless a new + // one was actually returned. Likewise carry the existing scopes forward + // when the response omits `scope` (Google may omit it when unchanged). Both + // fall back on an EMPTY string too, not just a missing field — a blank + // `refresh_token` would otherwise silently clobber a good stored one with + // an unusable value, permanently breaking the mailbox until reconnect. + const refreshToken = + typeof parsed.refresh_token === 'string' && parsed.refresh_token.length > 0 + ? parsed.refresh_token + : tokens.refreshToken + const scopes = + typeof parsed.scope === 'string' && parsed.scope.length > 0 + ? parsed.scope + : (tokens.scopes ?? undefined) + const accessTokenExpiresAt = new Date(Date.now() + expiresIn * 1000) + + await tokenStore.upsertTokens(mailboxId, { + refreshToken, + accessToken, + accessTokenExpiresAt, + scopes, + }) + + return accessToken +} + +/** + * Best-effort parse of a response body as a JSON object. Returns `{}` on any + * failure (non-JSON body, empty body, a JSON value that isn't an object) so + * callers can uniformly read fields off the result without a second + * try/catch — a body that fails to parse simply has no fields, which the + * caller's `typeof` checks already treat as "absent." + */ +async function parseJsonObject(response: Response): Promise { + try { + const value: unknown = await response.json() + return typeof value === 'object' && value !== null ? (value as TokenResponseBody) : {} + } catch { + return {} + } +} + +/** Throw a clear error unless `value` is a non-empty string. Used for eager, boot-time validation of required config. */ +function assertNonEmpty(field: string, value: string): void { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`createGmailOAuthTokenService: ${field} must be a non-empty string`) + } +} diff --git a/src/store/index.ts b/src/store/index.ts index 91df636..db99949 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -16,3 +16,7 @@ export type { StoredThread, } from './conversations.js' export { createConversationStore } from './conversations.js' +export type { MailboxTokenStore, StoredMailboxTokens, UpsertTokensInput } from './mailbox-tokens.js' +export { createMailboxTokenStore } from './mailbox-tokens.js' +export type { MailboxStore } from './mailboxes.js' +export { createMailboxStore } from './mailboxes.js' diff --git a/src/store/mailbox-tokens.test.ts b/src/store/mailbox-tokens.test.ts new file mode 100644 index 0000000..fe73539 --- /dev/null +++ b/src/store/mailbox-tokens.test.ts @@ -0,0 +1,200 @@ +import { randomBytes } from 'node:crypto' +import { afterEach, describe, expect, it } from 'vitest' +import { createPgliteDb, type Db } from '../db/client.js' +import { migrate } from '../db/migrate.js' +import { createMailboxTokenStore } from './mailbox-tokens.js' +import { ENCRYPTION_KEY_BYTES } from './token-crypto.js' + +const RANDOM_UUID = '00000000-0000-4000-8000-000000000000' +const KEY = randomBytes(ENCRYPTION_KEY_BYTES) + +async function insertMailbox(db: Db, address = 'mailbox@example.test'): Promise { + const rows = await db.query<{ id: string }>( + "INSERT INTO mailboxes (address, provider) VALUES ($1, 'gmail') RETURNING id", + [address], + ) + return rows[0].id +} + +describe('createMailboxTokenStore', () => { + let db: Db | undefined + + afterEach(async () => { + await db?.close() + db = undefined + }) + + async function freshStore(key: Buffer = KEY) { + db = await createPgliteDb() + await migrate(db) + return { db, store: createMailboxTokenStore(db, key) } + } + + it('getTokens returns null when no token row exists for the mailbox', async () => { + const { db, store } = await freshStore() + const mailboxId = await insertMailbox(db) + expect(await store.getTokens(mailboxId)).toBeNull() + }) + + it('upsertTokens with only refreshToken → getTokens round-trips it with null access fields', async () => { + const { db, store } = await freshStore() + const mailboxId = await insertMailbox(db) + + await store.upsertTokens(mailboxId, { refreshToken: 'refresh-token-value' }) + const tokens = await store.getTokens(mailboxId) + + expect(tokens).not.toBeNull() + expect(tokens?.mailboxId).toBe(mailboxId) + expect(tokens?.refreshToken).toBe('refresh-token-value') + expect(tokens?.accessToken).toBeNull() + expect(tokens?.accessTokenExpiresAt).toBeNull() + expect(tokens?.scopes).toBeNull() + expect(tokens?.updatedAt).toBeInstanceOf(Date) + }) + + it('upsertTokens with every field → getTokens round-trips all of them', async () => { + const { db, store } = await freshStore() + const mailboxId = await insertMailbox(db) + const expiresAt = new Date('2026-08-01T00:00:00.000Z') + + await store.upsertTokens(mailboxId, { + refreshToken: 'refresh-token-value', + accessToken: 'access-token-value', + accessTokenExpiresAt: expiresAt, + scopes: 'https://www.googleapis.com/auth/gmail.send', + }) + const tokens = await store.getTokens(mailboxId) + + expect(tokens?.refreshToken).toBe('refresh-token-value') + expect(tokens?.accessToken).toBe('access-token-value') + expect(tokens?.accessTokenExpiresAt?.toISOString()).toBe(expiresAt.toISOString()) + expect(tokens?.scopes).toBe('https://www.googleapis.com/auth/gmail.send') + }) + + it('a second upsertTokens call replaces the row (ON CONFLICT DO UPDATE), not a second row', async () => { + const { db, store } = await freshStore() + const mailboxId = await insertMailbox(db) + + await store.upsertTokens(mailboxId, { + refreshToken: 'refresh-v1', + accessToken: 'access-v1', + accessTokenExpiresAt: new Date('2026-01-01T00:00:00.000Z'), + scopes: 'scope-v1', + }) + await store.upsertTokens(mailboxId, { + refreshToken: 'refresh-v2', + accessToken: 'access-v2', + accessTokenExpiresAt: new Date('2026-02-01T00:00:00.000Z'), + scopes: 'scope-v2', + }) + + const tokens = await store.getTokens(mailboxId) + expect(tokens?.refreshToken).toBe('refresh-v2') + expect(tokens?.accessToken).toBe('access-v2') + expect(tokens?.scopes).toBe('scope-v2') + + const rowCount = await db.query<{ n: number }>( + 'SELECT count(*)::int AS n FROM mailbox_oauth_tokens WHERE mailbox_id = $1', + [mailboxId], + ) + expect(rowCount[0].n).toBe(1) + }) + + it('upsertTokens is a full replace: omitting accessToken on the second call clears the previously cached one', async () => { + const { store } = await freshStore() + const db2 = db as Db + const mailboxId = await insertMailbox(db2) + + await store.upsertTokens(mailboxId, { + refreshToken: 'refresh-v1', + accessToken: 'access-v1', + accessTokenExpiresAt: new Date('2026-01-01T00:00:00.000Z'), + }) + await store.upsertTokens(mailboxId, { refreshToken: 'refresh-v1' }) + + const tokens = await store.getTokens(mailboxId) + expect(tokens?.accessToken).toBeNull() + expect(tokens?.accessTokenExpiresAt).toBeNull() + }) + + it('two different mailboxes have independent token rows', async () => { + const { db, store } = await freshStore() + const mailboxA = await insertMailbox(db, 'a@example.test') + const mailboxB = await insertMailbox(db, 'b@example.test') + + await store.upsertTokens(mailboxA, { refreshToken: 'refresh-a' }) + await store.upsertTokens(mailboxB, { refreshToken: 'refresh-b' }) + + expect((await store.getTokens(mailboxA))?.refreshToken).toBe('refresh-a') + expect((await store.getTokens(mailboxB))?.refreshToken).toBe('refresh-b') + }) + + it('getTokens returns null for a mailbox id that does not exist at all', async () => { + const { store } = await freshStore() + expect(await store.getTokens(RANDOM_UUID)).toBeNull() + }) + + // --- encryption-at-rest: the security-critical property --------------- + + it('the stored ciphertext bytes never contain the plaintext refresh token', async () => { + const { db, store } = await freshStore() + const mailboxId = await insertMailbox(db) + const secret = 'super-secret-refresh-token-value-should-not-appear-in-storage' + + await store.upsertTokens(mailboxId, { refreshToken: secret }) + + const rows = await db.query<{ refresh_token_ciphertext: Uint8Array }>( + 'SELECT refresh_token_ciphertext FROM mailbox_oauth_tokens WHERE mailbox_id = $1', + [mailboxId], + ) + const raw = Buffer.from(rows[0].refresh_token_ciphertext) + expect(raw.toString('utf8')).not.toContain(secret) + expect(raw.toString('base64')).not.toContain(Buffer.from(secret).toString('base64')) + }) + + it('the stored ciphertext for the same plaintext differs across two mailboxes (random IV, no deterministic leakage)', async () => { + const { db, store } = await freshStore() + const mailboxA = await insertMailbox(db, 'a2@example.test') + const mailboxB = await insertMailbox(db, 'b2@example.test') + + await store.upsertTokens(mailboxA, { refreshToken: 'identical-value' }) + await store.upsertTokens(mailboxB, { refreshToken: 'identical-value' }) + + const rows = await db.query<{ mailbox_id: string; refresh_token_ciphertext: Uint8Array }>( + 'SELECT mailbox_id, refresh_token_ciphertext FROM mailbox_oauth_tokens WHERE mailbox_id IN ($1, $2)', + [mailboxA, mailboxB], + ) + const [a, b] = rows + expect( + Buffer.from(a.refresh_token_ciphertext).equals(Buffer.from(b.refresh_token_ciphertext)), + ).toBe(false) + }) + + it('getTokens throws (rather than returning garbage) when decrypted with the wrong key', async () => { + const { db, store } = await freshStore(KEY) + const mailboxId = await insertMailbox(db) + await store.upsertTokens(mailboxId, { refreshToken: 'refresh-token-value' }) + + const wrongKeyStore = createMailboxTokenStore(db, randomBytes(ENCRYPTION_KEY_BYTES)) + await expect(wrongKeyStore.getTokens(mailboxId)).rejects.toThrow(/decrypt failed/) + }) + + it('getTokens throws when the stored ciphertext has been tampered with at rest', async () => { + const { db, store } = await freshStore() + const mailboxId = await insertMailbox(db) + await store.upsertTokens(mailboxId, { refreshToken: 'refresh-token-value' }) + + const rows = await db.query<{ refresh_token_ciphertext: Uint8Array }>( + 'SELECT refresh_token_ciphertext FROM mailbox_oauth_tokens WHERE mailbox_id = $1', + [mailboxId], + ) + const tampered = Buffer.from(rows[0].refresh_token_ciphertext) + tampered[tampered.length - 1] ^= 0xff + await db.query( + 'UPDATE mailbox_oauth_tokens SET refresh_token_ciphertext = $1 WHERE mailbox_id = $2', + [new Uint8Array(tampered), mailboxId], + ) + + await expect(store.getTokens(mailboxId)).rejects.toThrow(/decrypt failed/) + }) +}) diff --git a/src/store/mailbox-tokens.ts b/src/store/mailbox-tokens.ts new file mode 100644 index 0000000..28fcd30 --- /dev/null +++ b/src/store/mailbox-tokens.ts @@ -0,0 +1,161 @@ +/** + * `MailboxTokenStore` — persistence for `mailbox_oauth_tokens` (migration + * 010, `src/db/migrate.ts`): the per-mailbox OAuth refresh/access token + * pair, always encrypted at rest. + * + * This module is the encrypt/decrypt boundary migration 010's doc comment + * calls out as HT-38's to own ("this migration only reserves the column + * shape a ciphertext value will live in... HT-38 owns the encrypt/decrypt"). + * Every write goes through {@link encrypt} before it reaches the database; + * every read goes through {@link decrypt} before a plaintext token value + * leaves this module. Callers (`src/mail/gmail-oauth.ts`) only ever see + * plaintext strings in and out — the `bytea` ciphertext shape is entirely an + * implementation detail of this store. + * + * ## `upsertTokens` writes the whole row, verbatim + * + * `mailbox_id` is the table's PRIMARY KEY (a per-mailbox singleton — one + * OAuth grant per connected mailbox), so "upsert" is `INSERT ... ON CONFLICT + * (mailbox_id) DO UPDATE`. Every optional field (`accessToken`, + * `accessTokenExpiresAt`, `scopes`) that is OMITTED is written as `NULL` — + * this is a full replace of the row's optional columns, not a partial + * merge with whatever was there before (the same "persisted verbatim, no + * second-guessing" convention `ConversationStore.setConversationTags` uses + * for its own replace-set write). `refreshToken` has no such omit case: the + * column is `NOT NULL`, and the type requires it on every call. + * + * In practice this module has exactly one caller-side calling convention + * that matters: `gmail-oauth.ts`'s refresh path always passes the (possibly + * unchanged) `refreshToken` together with a freshly-fetched `accessToken` + + * `accessTokenExpiresAt`, so the full-replace semantics never surprise it. + * A future caller that wants to update ONLY the refresh token while + * preserving a still-valid cached access token would need to read the + * current row first and pass its access fields back through — this store + * does not do that merge on a caller's behalf, on purpose (see the + * module-level open question in the HT-38 implementation report). + */ + +import type { Db } from '../db/client.js' +import { decrypt, encrypt } from './token-crypto.js' + +/** Input to {@link MailboxTokenStore.upsertTokens}. */ +export interface UpsertTokensInput { + /** The OAuth refresh token, plaintext — encrypted by this method before it is written. Required: the column is `NOT NULL`. */ + refreshToken: string + /** The current OAuth access token, plaintext — encrypted before it is written. Omitted (or `undefined`) writes `NULL` (no cached access token). */ + accessToken?: string + /** Wall-clock expiry of `accessToken`. Omitted writes `NULL`. Meaningless without `accessToken` — see {@link MailboxTokenStore.getTokens}'s doc on how a caller should treat that combination. */ + accessTokenExpiresAt?: Date + /** The token endpoint's raw space-delimited OAuth `scope` string (RFC 6749 §5.1), stored verbatim. Omitted writes `NULL`. */ + scopes?: string +} + +/** A mailbox's OAuth tokens as read back from storage — plaintext (already decrypted), camelCase, timestamps as `Date`. */ +export interface StoredMailboxTokens { + mailboxId: string + refreshToken: string + /** `null` when no access token has ever been cached for this mailbox. */ + accessToken: string | null + /** `null` exactly when {@link accessToken} is `null` (in normal operation — see {@link UpsertTokensInput.accessTokenExpiresAt}'s doc for the degenerate case where a caller wrote one without the other). */ + accessTokenExpiresAt: Date | null + scopes: string | null + updatedAt: Date +} + +/** Persistence for per-mailbox OAuth tokens. See the module doc for the encrypt/decrypt and upsert-replace contracts. */ +export interface MailboxTokenStore { + /** + * Insert or replace `mailboxId`'s token row. `input.refreshToken` and + * `input.accessToken` (when given) are encrypted with this store's + * configured key before the write — see the module doc for the + * full-row-replace semantics of the optional fields. + */ + upsertTokens(mailboxId: string, input: UpsertTokensInput): Promise + + /** + * Read back `mailboxId`'s tokens, decrypted. Returns `null` if no row + * exists (the mailbox has never completed an OAuth grant). Throws if + * decryption fails (wrong key, or the stored ciphertext is corrupted/ + * tampered — see `token-crypto.ts`'s `decrypt`) rather than returning a + * silently-wrong value. + */ + getTokens(mailboxId: string): Promise +} + +/** Raw `mailbox_oauth_tokens` row shape, before mapping to {@link StoredMailboxTokens}. */ +interface MailboxTokenRow { + mailbox_id: string + refresh_token_ciphertext: Uint8Array + access_token_ciphertext: Uint8Array | null + access_token_expires_at: Date | string | null + scopes: string | null + updated_at: Date | string +} + +const TOKEN_COLUMNS = + 'mailbox_id, refresh_token_ciphertext, access_token_ciphertext, access_token_expires_at, scopes, updated_at' + +/** + * Create a {@link MailboxTokenStore} backed by `db`, encrypting/decrypting + * with `encryptionKey`. `encryptionKey` must be a 32-byte `Buffer` — decode + * it once at the composition root via `token-crypto.ts`'s + * `decodeEncryptionKey` (e.g. from the `HELPTHREAD_TOKEN_ENC_KEY` env var) + * and pass the result in here. Never hardcode a key or read an env var + * inside this module — see `token-crypto.ts`'s module doc. + */ +export function createMailboxTokenStore(db: Db, encryptionKey: Buffer): MailboxTokenStore { + return { + async upsertTokens(mailboxId, input) { + const refreshTokenCiphertext = encrypt(input.refreshToken, encryptionKey) + const accessTokenCiphertext = + input.accessToken !== undefined ? encrypt(input.accessToken, encryptionKey) : null + + await db.query( + `INSERT INTO mailbox_oauth_tokens (mailbox_id, refresh_token_ciphertext, access_token_ciphertext, access_token_expires_at, scopes, updated_at) + VALUES ($1, $2, $3, $4, $5, now()) + ON CONFLICT (mailbox_id) DO UPDATE SET + refresh_token_ciphertext = EXCLUDED.refresh_token_ciphertext, + access_token_ciphertext = EXCLUDED.access_token_ciphertext, + access_token_expires_at = EXCLUDED.access_token_expires_at, + scopes = EXCLUDED.scopes, + updated_at = now()`, + [ + mailboxId, + refreshTokenCiphertext, + accessTokenCiphertext, + input.accessTokenExpiresAt ?? null, + input.scopes ?? null, + ], + ) + }, + + async getTokens(mailboxId) { + const rows = await db.query( + `SELECT ${TOKEN_COLUMNS} FROM mailbox_oauth_tokens WHERE mailbox_id = $1`, + [mailboxId], + ) + const row = rows[0] + if (row === undefined) { + return null + } + + return { + mailboxId: row.mailbox_id, + refreshToken: decrypt(row.refresh_token_ciphertext, encryptionKey), + accessToken: + row.access_token_ciphertext === null + ? null + : decrypt(row.access_token_ciphertext, encryptionKey), + accessTokenExpiresAt: + row.access_token_expires_at === null ? null : toDate(row.access_token_expires_at), + scopes: row.scopes, + updatedAt: toDate(row.updated_at), + } + }, + } +} + +/** Coerce a `timestamptz` column value into a `Date` — same defensive coercion as `src/store/conversations.ts`'s `toDate`. */ +function toDate(value: Date | string): Date { + return value instanceof Date ? value : new Date(value) +} diff --git a/src/store/mailboxes.test.ts b/src/store/mailboxes.test.ts new file mode 100644 index 0000000..ab331e1 --- /dev/null +++ b/src/store/mailboxes.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createPgliteDb, type Db } from '../db/client.js' +import { migrate } from '../db/migrate.js' +import { createMailboxStore } from './mailboxes.js' + +const RANDOM_UUID = '00000000-0000-4000-8000-000000000000' + +async function insertMailbox( + db: Db, + overrides: { address?: string; provider?: string; status?: string } = {}, +): Promise { + const rows = await db.query<{ id: string }>( + 'INSERT INTO mailboxes (address, provider, status) VALUES ($1, $2, $3) RETURNING id', + [ + overrides.address ?? 'mailbox@example.test', + overrides.provider ?? 'gmail', + overrides.status ?? 'active', + ], + ) + return rows[0].id +} + +describe('createMailboxStore', () => { + let db: Db | undefined + + afterEach(async () => { + await db?.close() + db = undefined + }) + + async function freshStore() { + db = await createPgliteDb() + await migrate(db) + return { db, store: createMailboxStore(db) } + } + + it('markNeedsReconnect flips an active mailbox to needs_reconnect', async () => { + const { db, store } = await freshStore() + const mailboxId = await insertMailbox(db, { status: 'active' }) + + await store.markNeedsReconnect(mailboxId) + + const rows = await db.query<{ status: string }>('SELECT status FROM mailboxes WHERE id = $1', [ + mailboxId, + ]) + expect(rows[0].status).toBe('needs_reconnect') + }) + + it('bumps updated_at', async () => { + const { db, store } = await freshStore() + const mailboxId = await insertMailbox(db) + const before = await db.query<{ updated_at: Date }>( + 'SELECT updated_at FROM mailboxes WHERE id = $1', + [mailboxId], + ) + // Force a distinguishable prior timestamp so a same-instant now() still + // reads as strictly later — a bare now()-vs-now() race is not what this + // test is proving. + await db.query('UPDATE mailboxes SET updated_at = $1 WHERE id = $2', [ + new Date(before[0].updated_at.getTime() - 60_000), + mailboxId, + ]) + + await store.markNeedsReconnect(mailboxId) + + const after = await db.query<{ updated_at: Date }>( + 'SELECT updated_at FROM mailboxes WHERE id = $1', + [mailboxId], + ) + expect(after[0].updated_at.getTime()).toBeGreaterThan(before[0].updated_at.getTime() - 60_000) + }) + + it('is idempotent — marking an already needs_reconnect mailbox succeeds', async () => { + const { db, store } = await freshStore() + const mailboxId = await insertMailbox(db, { status: 'needs_reconnect' }) + + await expect(store.markNeedsReconnect(mailboxId)).resolves.toBeUndefined() + + const rows = await db.query<{ status: string }>('SELECT status FROM mailboxes WHERE id = $1', [ + mailboxId, + ]) + expect(rows[0].status).toBe('needs_reconnect') + }) + + it('throws for a mailbox id that does not exist', async () => { + const { store } = await freshStore() + await expect(store.markNeedsReconnect(RANDOM_UUID)).rejects.toThrow(/no mailbox/) + }) +}) diff --git a/src/store/mailboxes.ts b/src/store/mailboxes.ts new file mode 100644 index 0000000..cd87940 --- /dev/null +++ b/src/store/mailboxes.ts @@ -0,0 +1,55 @@ +/** + * `MailboxStore` — persistence for the `mailboxes` table's lifecycle status + * (migration 009, `src/db/migrate.ts`). + * + * Deliberately narrow: HT-38 (this ticket, OAuth token persistence/refresh) + * only needs ONE mutation — marking a mailbox `needs_reconnect` when its + * OAuth refresh token turns out to be revoked or expired + * (`src/mail/gmail-oauth.ts`'s `getAccessToken`, on an `invalid_grant` + * response). A full `mailboxes` CRUD surface — creating a row on connect, + * listing mailboxes, resolving an `emailAddress` to one, transitioning back + * to `active` — belongs to HT-40 (the connect/consent flow, which is what + * actually inserts mailbox rows in the first place). `watch()` renewal + * (HT-42) needs this SAME needs_reconnect transition on a failed renewal + * (specs/mail/gmail-push.md §6: "mark the mailbox needs-reconnect and + * surface it") and should call this method rather than duplicate the SQL — + * that shared reuse is why this is a small store module rather than a raw + * query buried inline in `gmail-oauth.ts`. + */ + +import type { Db } from '../db/client.js' + +/** Persistence operations for a mailbox's own lifecycle state. See the module doc for why this is intentionally narrow today. */ +export interface MailboxStore { + /** + * Mark `mailboxId` `needs_reconnect` — the operator-visible, resolvable + * state gmail-push.md §5/§6 puts a mailbox into when its OAuth grant is + * revoked/expired or a `watch()` renewal fails. A single `UPDATE ... + * RETURNING id`; idempotent (marking an already-`needs_reconnect` mailbox + * again is a harmless no-op write, still bumping `updated_at`). + * + * Throws if no mailbox exists with this id. Every caller reaches this + * method with a `mailboxId` it just read a `mailbox_oauth_tokens` row for, + * and `mailbox_oauth_tokens.mailbox_id` is a `REFERENCES mailboxes(id)` + * foreign key (migration 010), so a missing mailbox row here is + * structurally unreachable in practice — thrown rather than silently + * no-op'd, matching `ConversationStore.setThreadDeliveryStatus`'s same + * throw-on-zero-rows convention (`src/store/conversations.ts`). + */ + markNeedsReconnect(mailboxId: string): Promise +} + +/** Create a {@link MailboxStore} backed by `db`. */ +export function createMailboxStore(db: Db): MailboxStore { + return { + async markNeedsReconnect(mailboxId) { + const updated = await db.query<{ id: string }>( + "UPDATE mailboxes SET status = 'needs_reconnect', updated_at = now() WHERE id = $1 RETURNING id", + [mailboxId], + ) + if (updated.length === 0) { + throw new Error(`markNeedsReconnect: no mailbox with id ${mailboxId}`) + } + }, + } +} diff --git a/src/store/token-crypto.test.ts b/src/store/token-crypto.test.ts new file mode 100644 index 0000000..dbfac47 --- /dev/null +++ b/src/store/token-crypto.test.ts @@ -0,0 +1,163 @@ +import { randomBytes } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { decodeEncryptionKey, decrypt, ENCRYPTION_KEY_BYTES, encrypt } from './token-crypto.js' + +const KEY = randomBytes(ENCRYPTION_KEY_BYTES) +const OTHER_KEY = randomBytes(ENCRYPTION_KEY_BYTES) + +describe('encrypt / decrypt round-trip', () => { + it('decrypts exactly what was encrypted', () => { + const plaintext = 'a-refresh-token-value-1234567890' + const ciphertext = encrypt(plaintext, KEY) + expect(decrypt(ciphertext, KEY)).toBe(plaintext) + }) + + it('round-trips an empty string', () => { + const ciphertext = encrypt('', KEY) + expect(decrypt(ciphertext, KEY)).toBe('') + }) + + it('round-trips non-ASCII text', () => { + const plaintext = 'token-with-unicode-☃-emoji-🔑-and-ünïcödé' + const ciphertext = encrypt(plaintext, KEY) + expect(decrypt(ciphertext, KEY)).toBe(plaintext) + }) + + it('round-trips a long value', () => { + const plaintext = 'x'.repeat(5000) + const ciphertext = encrypt(plaintext, KEY) + expect(decrypt(ciphertext, KEY)).toBe(plaintext) + }) + + it('output layout is iv(12) || authTag(16) || ciphertext(N), and length grows with plaintext', () => { + const short = encrypt('a', KEY) + const longer = encrypt('a'.repeat(10), KEY) + expect(short.length).toBe(12 + 16 + 1) + expect(longer.length).toBe(12 + 16 + 10) + }) +}) + +describe('random IV per encryption', () => { + it('encrypting the same plaintext twice produces different bytes each time', () => { + const a = encrypt('same-value', KEY) + const b = encrypt('same-value', KEY) + expect(Buffer.from(a).equals(Buffer.from(b))).toBe(false) + // ...but both still decrypt back to the original value. + expect(decrypt(a, KEY)).toBe('same-value') + expect(decrypt(b, KEY)).toBe('same-value') + }) + + it('the first 12 bytes (the IV) differ across calls', () => { + const a = encrypt('value', KEY) + const b = encrypt('value', KEY) + expect(Buffer.from(a.slice(0, 12)).equals(Buffer.from(b.slice(0, 12)))).toBe(false) + }) +}) + +describe('tamper detection', () => { + function tamperedCopy(bytes: Uint8Array, index: number): Uint8Array { + const copy = Buffer.from(bytes) + copy[index] = copy[index] ^ 0xff + return new Uint8Array(copy) + } + + it('flipping a ciphertext byte makes decrypt throw', () => { + const ciphertext = encrypt('sensitive-value', KEY) + const tampered = tamperedCopy(ciphertext, ciphertext.length - 1) + expect(() => decrypt(tampered, KEY)).toThrow(/decrypt failed/) + }) + + it('flipping an auth-tag byte makes decrypt throw', () => { + const ciphertext = encrypt('sensitive-value', KEY) + // Auth tag occupies bytes [12, 28). + const tampered = tamperedCopy(ciphertext, 20) + expect(() => decrypt(tampered, KEY)).toThrow(/decrypt failed/) + }) + + it('flipping an IV byte makes decrypt throw (or at least never returns the original plaintext)', () => { + const plaintext = 'sensitive-value' + const ciphertext = encrypt(plaintext, KEY) + const tampered = tamperedCopy(ciphertext, 0) + // A corrupted IV changes the keystream, which almost always also fails + // the auth-tag check (the tag is computed over the real IV) — but the + // strong, always-true property is simply "never yields the original + // plaintext silently." + let decrypted: string | undefined + try { + decrypted = decrypt(tampered, KEY) + } catch { + // Expected: throwing is correct behavior. + } + expect(decrypted).not.toBe(plaintext) + }) + + it('decrypting with the wrong key throws', () => { + const ciphertext = encrypt('sensitive-value', KEY) + expect(() => decrypt(ciphertext, OTHER_KEY)).toThrow(/decrypt failed/) + }) + + it('a truncated ciphertext (too short for iv+tag) throws a clear error', () => { + const tooShort = new Uint8Array(10) + expect(() => decrypt(tooShort, KEY)).toThrow(/too short/) + }) + + it('an empty byte array throws a clear error', () => { + expect(() => decrypt(new Uint8Array(0), KEY)).toThrow(/too short/) + }) +}) + +describe('key validation', () => { + it('encrypt rejects a key that is not 32 bytes', () => { + expect(() => encrypt('value', randomBytes(16))).toThrow(/32-byte/) + expect(() => encrypt('value', randomBytes(33))).toThrow(/32-byte/) + }) + + it('decrypt rejects a key that is not 32 bytes', () => { + const ciphertext = encrypt('value', KEY) + expect(() => decrypt(ciphertext, randomBytes(16))).toThrow(/32-byte/) + }) + + it('never includes the plaintext, key, or ciphertext bytes in a thrown error message', () => { + const secretPlaintext = 'super-secret-refresh-token-do-not-leak' + const ciphertext = encrypt(secretPlaintext, KEY) + const tampered = (() => { + const copy = Buffer.from(ciphertext) + copy[copy.length - 1] ^= 0xff + return new Uint8Array(copy) + })() + + let message = '' + try { + decrypt(tampered, KEY) + } catch (err) { + message = String(err) + } + expect(message).not.toContain(secretPlaintext) + expect(message).not.toContain(KEY.toString('base64')) + expect(message).not.toContain(Buffer.from(tampered).toString('base64')) + }) +}) + +describe('decodeEncryptionKey', () => { + it('decodes a valid base64-encoded 32-byte key', () => { + const raw = randomBytes(ENCRYPTION_KEY_BYTES) + const decoded = decodeEncryptionKey(raw.toString('base64')) + expect(decoded.equals(raw)).toBe(true) + }) + + it('a decoded key round-trips through encrypt/decrypt', () => { + const raw = randomBytes(ENCRYPTION_KEY_BYTES) + const key = decodeEncryptionKey(raw.toString('base64')) + const ciphertext = encrypt('value', key) + expect(decrypt(ciphertext, key)).toBe('value') + }) + + it('throws on a key that decodes to the wrong length', () => { + expect(() => decodeEncryptionKey(randomBytes(16).toString('base64'))).toThrow(/expected 32/) + expect(() => decodeEncryptionKey(randomBytes(64).toString('base64'))).toThrow(/expected 32/) + }) + + it('throws on an empty string', () => { + expect(() => decodeEncryptionKey('')).toThrow(/expected 32/) + }) +}) diff --git a/src/store/token-crypto.ts b/src/store/token-crypto.ts new file mode 100644 index 0000000..1a87c47 --- /dev/null +++ b/src/store/token-crypto.ts @@ -0,0 +1,154 @@ +/** + * AES-256-GCM authenticated encryption for OAuth token ciphertext columns + * (`mailbox_oauth_tokens.refresh_token_ciphertext` / + * `access_token_ciphertext`, migration 010, `src/db/migrate.ts`). + * + * This is the ONLY place in the codebase that turns an OAuth bearer + * credential into ciphertext or back — migration 010's doc comment is + * explicit that the schema only reserves `bytea` columns and holds no + * opinion on how they're encrypted ("HT-38 owns the encrypt/decrypt; this + * migration only reserves the column"). This module is that opinion, and + * `src/store/mailbox-tokens.ts` is its only caller. + * + * ## Wire format + * + * {@link encrypt} returns a single flat `Uint8Array`: + * + * ``` + * iv (12 bytes) || authTag (16 bytes) || ciphertext (N bytes) + * ``` + * + * 12 bytes is the NIST SP 800-38D–recommended (and Node's default) GCM + * nonce size; 16 bytes is the full 128-bit GCM authentication tag, also + * Node's default. Packing IV + tag + ciphertext into one value — rather than + * three separate columns — keeps the ciphertext column shape migration 010 + * already committed to (one `bytea` per secret); the format lives entirely + * inside this module's read/write pair and nowhere else needs to know it. + * + * ## Key handling + * + * The key is a 32-byte `Buffer` (AES-256), supplied by the CALLER on every + * call — this module never reads an env var, never caches a key, and never + * hardcodes one. The intended shape (per the HT-38 task): decode it ONCE + * (base64, via {@link decodeEncryptionKey}) at the deploy-time composition + * root — from `HELPTHREAD_TOKEN_ENC_KEY` or equivalent — and thread the + * resulting `Buffer` down through `createMailboxTokenStore` + * (`src/store/mailbox-tokens.ts`). This key is exactly as sensitive as the + * mailbox tokens it protects (losing it makes every stored token permanently + * undecryptable; leaking it defeats the point of encrypting the column at + * all) and MUST come from a secret manager in any real deployment, never a + * repo file or a hardcoded literal. + * + * ## Random IV per encryption + * + * {@link encrypt} draws a fresh CSPRNG IV (`node:crypto.randomBytes`) on + * every call. GCM's confidentiality guarantee is void if the SAME (key, IV) + * pair is ever reused for two different plaintexts, so every encryption of + * the same token value — e.g. re-encrypting an unchanged refresh token + * alongside a freshly-refreshed access token — produces different ciphertext + * bytes. This is why {@link encrypt} is not, and must never be made, + * deterministic. + * + * ## Tamper detection is the whole point + * + * {@link decrypt} calls `decipher.final()`, which THROWS if the GCM + * authentication tag does not verify — a single flipped bit anywhere in the + * IV, tag, or ciphertext (storage corruption, or a deliberate tamper attempt + * against the raw DB row) is rejected outright, never silently "decrypted" + * into garbage plaintext. This is authenticated encryption, not merely + * confidentiality: a `decrypt` throw means "this ciphertext is not + * trustworthy," and callers must not treat it as a soft/ignorable failure. + */ + +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' + +/** AES-256 key size in bytes. `aes-256-gcm` accepts nothing else. */ +export const ENCRYPTION_KEY_BYTES = 32 + +/** GCM nonce (IV) size in bytes — NIST SP 800-38D's recommended size, and Node's default for `aes-256-gcm`. */ +const IV_BYTES = 12 + +/** GCM authentication tag size in bytes — the full 128-bit tag, Node's default. */ +const AUTH_TAG_BYTES = 16 + +/** Minimum valid {@link encrypt} output length: an IV and a tag, even for an empty plaintext. */ +const MIN_CIPHERTEXT_BYTES = IV_BYTES + AUTH_TAG_BYTES + +const ALGORITHM = 'aes-256-gcm' + +/** Throw a clear, non-secret-leaking error unless `key` is exactly a {@link ENCRYPTION_KEY_BYTES}-byte `Buffer`. */ +function assertKey(key: Buffer): void { + if (!Buffer.isBuffer(key) || key.length !== ENCRYPTION_KEY_BYTES) { + throw new Error( + `token-crypto: encryption key must be a ${ENCRYPTION_KEY_BYTES}-byte Buffer (got ${ + Buffer.isBuffer(key) ? `${key.length} bytes` : typeof key + })`, + ) + } +} + +/** + * Encrypt `plaintext` under `key` (AES-256-GCM, fresh random 12-byte IV). + * Returns `iv || authTag || ciphertext` as one `Uint8Array` — see the module + * doc for the wire format and why the IV must be fresh on every call. + */ +export function encrypt(plaintext: string, key: Buffer): Uint8Array { + assertKey(key) + const iv = randomBytes(IV_BYTES) + const cipher = createCipheriv(ALGORITHM, key, iv) + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + const authTag = cipher.getAuthTag() + return new Uint8Array(Buffer.concat([iv, authTag, ciphertext])) +} + +/** + * Decrypt `bytes` produced by {@link encrypt} under the SAME `key`. Verifies + * the GCM authentication tag — throws if `bytes` is too short to even + * contain an IV + tag, or if the tag does not match (wrong key, or the bytes + * were corrupted/tampered with since encryption). Never returns unverified + * plaintext. + */ +export function decrypt(bytes: Uint8Array, key: Buffer): string { + assertKey(key) + const buf = Buffer.from(bytes) + if (buf.length < MIN_CIPHERTEXT_BYTES) { + throw new Error( + `token-crypto: ciphertext is ${buf.length} bytes, too short to contain a ${IV_BYTES}-byte IV + ${AUTH_TAG_BYTES}-byte auth tag`, + ) + } + const iv = buf.subarray(0, IV_BYTES) + const authTag = buf.subarray(IV_BYTES, MIN_CIPHERTEXT_BYTES) + const ciphertext = buf.subarray(MIN_CIPHERTEXT_BYTES) + + const decipher = createDecipheriv(ALGORITHM, key, iv) + decipher.setAuthTag(authTag) + try { + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return plaintext.toString('utf8') + } catch (cause) { + // Never include the ciphertext or key material in the thrown message — + // only the fact of the failure is safe/useful to surface. + throw new Error( + 'token-crypto: decrypt failed — ciphertext is corrupted, tampered with, or was encrypted under a different key', + { cause }, + ) + } +} + +/** + * Decode a base64-encoded encryption key (e.g. the `HELPTHREAD_TOKEN_ENC_KEY` + * env var) into the `Buffer` {@link encrypt}/{@link decrypt} expect. Validates + * the decoded length eagerly — meant to be called once at composition-root + * startup, not lazily on the first token operation — so a misconfigured key + * fails loudly at boot rather than on a mailbox's first connection attempt. + */ +export function decodeEncryptionKey(base64Key: string): Buffer { + const key = Buffer.from(base64Key, 'base64') + if (key.length !== ENCRYPTION_KEY_BYTES) { + throw new Error( + `token-crypto: decoded encryption key is ${key.length} bytes, expected ${ENCRYPTION_KEY_BYTES} ` + + `(base64-encode a ${ENCRYPTION_KEY_BYTES}-byte key, e.g. \`openssl rand -base64 32\`)`, + ) + } + return key +}