Skip to content

feat(oauth): Gmail OAuth token persistence + refresh service (HT-38) - #37

Merged
zaridan merged 1 commit into
mainfrom
feat/ht-38-gmail-oauth-token-service
Jul 14, 2026
Merged

feat(oauth): Gmail OAuth token persistence + refresh service (HT-38)#37
zaridan merged 1 commit into
mainfrom
feat/ht-38-gmail-oauth-token-service

Conversation

@zaridan

@zaridan zaridan commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Implements HT-38 [E] — the Gmail OAuth token persistence + refresh service, under the HT-33 epic. Turns a stored, encrypted refresh token into the live access token the Gmail adapters need.

What's here

  • src/store/token-crypto.ts — AES-256-GCM authenticated encryption. Fresh CSPRNG IV per call, full 128-bit auth tag, decrypt throws on any tamper (wrong key / corrupted bytes), key injected from env and never hardcoded or logged. Wire format iv‖tag‖ciphertext in one bytea.
  • src/store/mailbox-tokens.tsMailboxTokenStore: encrypt-at-rest CRUD over mailbox_oauth_tokens (migration 010). Plaintext in/out, ciphertext in the DB.
  • src/mail/gmail-oauth.tscreateGmailOAuthTokenService: getAccessToken(mailboxId) returns a cached token when fresh, else refreshes against Google's token endpoint (injected fetch, so tests never hit Google). invalid_grant → mailbox needs_reconnect (operator-actionable, not a crash); other failures throw without touching status. Guards a blank refresh_token from clobbering a good stored one.
  • src/store/mailboxes.ts — minimal MailboxStore.markNeedsReconnect (HT-42 reuses it).

Both OAuth secrets (refresh and access token) are stored encrypted — HT-36's schema decision realized here.

Review notes

  • Crypto verified — I read it end to end: correct AES-256-GCM, no key/ciphertext leakage in errors, random IV, tamper-throws. Tests exercise round-trip, wrong-key, and byte-flip tamper on IV / tag / ciphertext.
  • Layering fix I made: the agent placed token-crypto.ts in src/mail/, but its only consumer is the store — I moved it to src/store/ so the store doesn't import from mail/.
  • New env vars (injected at the composition root, HT-43): HELPTHREAD_TOKEN_ENC_KEY (base64 32-byte AES key), GMAIL_OAUTH_CLIENT_ID, GMAIL_OAUTH_CLIENT_SECRET.
  • Local typecheck + biome clean; the ticket's own 50 tests pass in isolation. A full-suite run locally hit flaky failures from three sibling worktrees sharing node_modules during concurrent agent test runs — CI runs the suite isolated, which is the authoritative check.

Scope: token persistence + refresh + crypto only — no connect/consent flow (HT-40), webhook (HT-39), or watch() (HT-42).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added Gmail OAuth access-token handling, including caching, automatic refresh, token rotation, and reconnect status updates when authorization expires.
    • Added secure storage for mailbox OAuth tokens with encryption and protected retrieval.
    • Added mailbox support for marking accounts as requiring reconnection.
    • Exposed mailbox and token storage services for application use.
  • Tests

    • Added comprehensive coverage for OAuth refresh behavior, encryption, token persistence, error handling, and timeout scenarios.

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 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds encrypted mailbox OAuth token storage, mailbox reconnect status updates, and a Gmail OAuth service that returns cached access tokens or refreshes and persists new ones through Google’s token endpoint.

Changes

Gmail OAuth token lifecycle

Layer / File(s) Summary
Token encryption primitives
src/store/token-crypto.ts, src/store/token-crypto.test.ts
Adds AES-256-GCM encryption/decryption, base64 key validation, randomized IVs, tamper detection, and comprehensive crypto tests.
Encrypted mailbox token persistence
src/store/mailbox-tokens.ts, src/store/mailbox-tokens.test.ts, src/store/index.ts
Adds typed token storage with encrypted token columns, upsert replacement semantics, decrypted reads, timestamp conversion, and barrel exports.
Mailbox reconnect status transition
src/store/mailboxes.ts, src/store/mailboxes.test.ts
Adds markNeedsReconnect, updating mailbox status and timestamps while rejecting unknown mailbox IDs.
Gmail OAuth access-token refresh
src/mail/gmail-oauth.ts, src/mail/gmail-oauth.test.ts
Adds cached-token reuse, expiry-skew refreshes, form-encoded Google requests, token rotation and scope carry-forward, timeout handling, response validation, and invalid_grant reconnect transitions.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant GmailOAuthTokenService
  participant MailboxTokenStore
  participant GoogleTokenEndpoint
  participant MailboxStore

  Caller->>GmailOAuthTokenService: getAccessToken(mailboxId)
  GmailOAuthTokenService->>MailboxTokenStore: getTokens(mailboxId)
  alt Cached token is fresh
    MailboxTokenStore-->>GmailOAuthTokenService: stored access token
    GmailOAuthTokenService-->>Caller: access token
  else Token requires refresh
    GmailOAuthTokenService->>GoogleTokenEndpoint: POST refresh-token form
    GoogleTokenEndpoint-->>GmailOAuthTokenService: refreshed token or error
    alt Refresh succeeds
      GmailOAuthTokenService->>MailboxTokenStore: upsertTokens(updated tokens)
      GmailOAuthTokenService-->>Caller: new access token
    else invalid_grant
      GmailOAuthTokenService->>MailboxStore: markNeedsReconnect(mailboxId)
      GmailOAuthTokenService-->>Caller: reconnect-required error
    end
  end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: Gmail OAuth token persistence and refresh service.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-38-gmail-oauth-token-service

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/mail/gmail-oauth.test.ts (1)

159-270: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add coverage for blank (empty-string) refresh_token/scope in the response.

Existing tests only cover the "field omitted" case for rotation/carry-forward (lines 159-202, 227-248, 250-270). The guard at gmail-oauth.ts lines 301-308 explicitly also handles the "field present but empty string" case — untested here, despite being a stated PR objective (preventing blank tokens from clobbering stored ones).

✅ Suggested additional test
+  it('keeps the existing refresh token when the response returns an empty-string refresh_token', 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: '',
+    })
+    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('old-refresh-token')
+  })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mail/gmail-oauth.test.ts` around lines 159 - 270, Add test coverage in
the Gmail OAuth tests for refresh responses containing empty-string
refresh_token and scope values. Verify getAccessToken preserves the previously
stored refresh token and scopes, matching the existing omitted-field rotation
and carry-forward tests and covering the guards in the token refresh handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/mail/gmail-oauth.test.ts`:
- Around line 159-270: Add test coverage in the Gmail OAuth tests for refresh
responses containing empty-string refresh_token and scope values. Verify
getAccessToken preserves the previously stored refresh token and scopes,
matching the existing omitted-field rotation and carry-forward tests and
covering the guards in the token refresh handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1a54071b-e70e-4674-bd52-40f6d07b7ab1

📥 Commits

Reviewing files that changed from the base of the PR and between 262ffc3 and 106b828.

📒 Files selected for processing (9)
  • src/mail/gmail-oauth.test.ts
  • src/mail/gmail-oauth.ts
  • src/store/index.ts
  • src/store/mailbox-tokens.test.ts
  • src/store/mailbox-tokens.ts
  • src/store/mailboxes.test.ts
  • src/store/mailboxes.ts
  • src/store/token-crypto.test.ts
  • src/store/token-crypto.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant