Skip to content

Gmail OAuth: validate scope, surface failures, kill orphan route#46

Merged
felixtosh merged 3 commits into
mainfrom
casa-tier2-prep
Jun 24, 2026
Merged

Gmail OAuth: validate scope, surface failures, kill orphan route#46
felixtosh merged 3 commits into
mainfrom
casa-tier2-prep

Conversation

@felixtosh

Copy link
Copy Markdown
Owner

Follow-up to the Gmail integration debugging session. Root cause confirmed by Google email from 2026-06-09: our OAuth verification for the restricted gmail.readonly scope was rejected (no response within 90 days). For unverified apps, Google silently strips restricted scopes from consent grants, so users wind up with tokens that 403 on every Gmail API call.

Stefan's two integrations (yazzbert@gmail.com + stefan@houseofbandits.at) both hit this — Issues #44 and #45.

What this PR changes

  • Callback: parses tokens.scope, redirects with ?error=missing_gmail_scope if gmail.readonly is absent. No integration is created with a half-scope grant.
  • Refresh paths (gmailSyncQueue + searchGmailCallable): parses scope on refresh response, sets needsReauth: true if gmail.readonly is missing, aborts. No more silently caching a token that will only fail.
  • Queue error handler: detects Gmail 403 insufficientPermissions and flips needsReauth: true, so the UI prompts Reconnect instead of looping.
  • UI: stuck-initial-sync detection (initialSyncComplete=false + lastSyncError + initialSyncStartedAt >24h) shows Reconnect Required badge instead of an indefinite Syncing spinner.
  • Delete /api/gmail/auth/route.ts: legacy POST endpoint that stored pre-acquired access tokens with no refresh_token; nothing called it.

Already done out of band

  • Functions deployed to prod (searchGmailCallable, onSyncQueueCreated, processGmailSyncQueue)
  • Stefan's two integrations flagged needsReauth: true directly via Firestore so his UI surfaces Reconnect today

Still needed (outside this PR)

  • Resubmit Google OAuth verification with the CASA Tier 2 package (/docs/casa/)
  • Add Stefan's two emails as Test Users on the OAuth consent screen so he can reconnect successfully before verification clears

Test plan

  • npm run lint → 0 errors / 40 warnings
  • npx tsc --noEmit clean (root + functions)
  • npm run build → ✓ Compiled successfully
  • (cd functions && npm test) → 469/469 pass
  • Functions deployed manually before merge so the frontend behavior matches once App Hosting deploys
  • After App Hosting deploy: Stefan's integration card shows Reconnect Required badge, not the Syncing spinner

🤖 Generated with Claude Code

felixtosh and others added 3 commits June 24, 2026 00:09
Admin impersonation (the headline feature):
- New `impersonateUser` callable in `functions/src/admin/impersonateUser.ts`:
  admin-only, mints a Firebase custom token signing the admin in as the
  target user, refuses to impersonate other admins (privilege confusion),
  logs the action server-side.
- `/admin/users` UserDetailPanel: "Impersonate (opens new tab)" button.
- New `/impersonate` route: consumes the token from the URL fragment,
  signs in via `signInWithCustomToken`, sets a per-tab marker, redirects
  to `/transactions`. Token never lands in URL/history.
- `ImpersonationBanner` in dashboard layout: persistent amber banner
  showing "Impersonating X · as admin Y", with an Exit button that
  signs out and clears the marker. sessionStorage scoping keeps the
  admin's main tab untouched.

CSP runtime fixes (CASA Tier 2 prod CSP was too tight):
- `frame-src` now allows `blob:` (PDF/file previews fetched as blobs)
  and `https://firebasestorage.googleapis.com` (issued invoice PDFs).
- Dev mode additionally relaxes connect-src/img-src/frame-src for
  `127.0.0.1:*` + `localhost:*` so Firebase emulators are reachable,
  and skips `upgrade-insecure-requests` + the `/__/auth/` rewrite that
  would otherwise route emulator OAuth callbacks to prod.
- PDF.js worker self-hosted via `new URL(..., import.meta.url)` instead
  of loading from `unpkg.com` (CSP would block it; CDN dep removed too).

Auth flow simplification:
- Switched OAuth from `signInWithRedirect` to `signInWithPopup`
  everywhere. Firebase's 2026 guidance: redirect needs a custom
  authDomain or reverse proxy since browsers (Chrome M115+, Firefox
  109+, Safari 16.1+) blocked third-party cookies in cross-site
  iframes. Popup sidesteps that entirely and works with the local
  Auth Emulator. Dropped the dead `getRedirectResult` effect, the
  `pendingRedirect` SSR state, and the `fibuki_oauth_provider`
  sessionStorage dance.
- Wrapped `firebaseUser.getIdTokenResult` in try/catch so a transient
  network failure no longer crashes the dev server with an unhandled
  rejection.
- Emulator host pinned to `127.0.0.1` (was `localhost`) to avoid the
  macOS IPv6 resolution that emulators don't bind.

Settings/identity:
- On save, mirror the top-level tax residence country onto the personal
  entity's address so invoice sender snapshots carry a country.

Test plan:
- `npm run lint` → 0 errors, 40 warnings (unchanged)
- `npx tsc --noEmit` → clean (both root and functions)
- `npm run build` → ✓ Compiled successfully
- `(cd functions && npm test)` → 469/469 pass

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- New `bulkRetryExtraction` callable (admin or self): processes up to 50
  errored files per call in waves of 5 concurrent extractions, returns
  { processed, succeeded, failed, hasMore, sampleErrors }. Mirrors
  `retryFileExtraction`'s reset semantics so partner/transaction
  matching also re-runs.
- UserDetailPanel: "Rescan all errored files" button — handler polls
  the callable up to 10 batches (500 files max) until hasMore is false,
  then surfaces a summary toast.
- Resolves the friend's pain point of having to click the spinner on
  each errored file individually after a Gemini model deprecation.

Test plan:
- `npm run lint` → 0 errors / 40 warnings
- `npx tsc --noEmit` (root + functions) → clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…phan route

Background: Google rejected our OAuth verification for the restricted
`gmail.readonly` scope on 2026-06-09 (no response within 90 days). For
unverified apps, Google silently strips restricted scopes from consent
grants for non-test users, leaving users with tokens that 403 on every
Gmail API call.

Our code was treating these stripped-scope tokens as valid:
- `/api/gmail/callback` stored whatever Google returned, no scope check
- Refresh paths in `gmailSyncQueue.ts` and `searchGmailCallable.ts`
  silently accepted refreshed tokens without `gmail.readonly`
- Gmail 403 `insufficientPermissions` did not flip `needsReauth`, so the
  UI showed "Scanning for invoices…" forever instead of a Reconnect CTA

Fixes:
- Callback: parse `tokens.scope`, redirect with `?error=missing_gmail_scope`
  if `gmail.readonly` is absent; no integration is created.
- Refresh paths (both call sites): parse `scope` on the refresh response,
  set `needsReauth: true` + `lastError` and abort if `gmail.readonly` is
  missing, so future calls don't keep using a useless token.
- Queue-processor error handler: detect Gmail 403 with reason
  `insufficientPermissions` (or the equivalent error string) and flip
  `needsReauth: true` so the integration's UI prompts a reconnect.
- Gmail integration card: derive `isStuckInitialSync` when
  `initialSyncComplete === false`, `lastSyncError` is set, and
  `initialSyncStartedAt` is >24h old — treat as needsReauth visually
  (Reconnect Required badge instead of indefinite Syncing spinner).
- Delete `/api/gmail/auth/route.ts` — legacy POST endpoint that stored
  pre-acquired access tokens with an empty refresh_token; nothing in the
  app or extensions called it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@@ -0,0 +1,57 @@
import admin from 'firebase-admin';
import { execSync } from 'node:child_process';
import { decrypt } from '../lib/utils/encryption.js';
const auth = admin.auth();
const db = admin.firestore();

const ENCRYPTION_KEY = execSync(
@felixtosh felixtosh merged commit 1e64f6d into main Jun 24, 2026
7 checks passed
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.

2 participants