URL: https://mkdocs.justinsforge.com/memory/handoffs/google-oauth-permanent-fix-2026-05-04/
Google OAuth Permanent Fix, 2026-05-04¶
Companion to coord-stall-debug-2026-05-04. The bot stall debug uncovered a dead Google Calendar OAuth grant; investigation ballooned into a full audit of every Google integration. This handoff captures the plan to make all of them permanent (no more 7-day re-auth cycle, no verification quagmire).
Current state, end of session 2026-05-04¶
- Forge calendar OAuth grant: DEAD. Direct test against Google's token endpoint with the on-disk creds returns
invalid_grant: Token has been expired or revoked. Worked at 09:29 today, dead by 14:16. No code path can rescue it; needs re-auth. - OAuth client
954659188872-a67tq56ojj1brneahqia5rv20m2a2ppt.apps.googleusercontent.comwas pushed from Testing → In Production today in an attempt to dodge the 7-day testing-mode refresh-token expiry. This was a mistake: production-with-sensitive-scopes triggers a hard verification gate, so the consent screen now returnsThis app is blockedand we can't even re-auth. - Same OAuth client is shared by 3 n8n credentials (per sqlite audit of n8n's
credentials_entitytable): gmailOAuth2 '[email protected]'(Workspace business Gmail)gmailOAuth2 '[email protected]'(consumer Gmail)googleCalendarOAuth2Api 'Google Calendar account - [email protected]'(consumer Calendar)- All three n8n credentials are likely dead too as a side effect of pushing the shared client to production.
- n8n workflow blast radius (active=ON only): ~15 workflows depend on these credentials. Personal Gmail operations: list, get-body, archive, label-modify, filter-create, get-headers (List-Unsubscribe), star, mailto-unsub. Business Gmail: same plus draft mgmt. Calendar: create/list/update/delete/patch.
- Things NOT affected (separate OAuth clients, confirmed safe):
- Forge Drive via rclone (uses rclone's own verified shared client
202264815644-...) - Forge Gmail tools via Claude MCP (
mcp__claude_ai_Gmail__*, uses Anthropic's claude.ai OAuth) - YouTube integration (
forge_youtube_*.py) uses the same broken client but noyoutube.envexists yet, so nothing is currently authed and nothing is broken on that front. Defer.
Why pushing to Production made it worse, in one paragraph¶
Google OAuth scopes split into Sensitive (calendar, gmail.send, gmail.compose, gmail.metadata) and Restricted (gmail.modify, gmail.readonly, drive). Testing-mode unverified apps get a soft "Advanced > Continue (unsafe)" warning interstitial AND a 7-day refresh-token expiry. Production-mode unverified apps get a HARD BLOCK on the consent screen with no escape. Sensitive-scope verification is achievable solo (privacy policy + demo video + ~6 weeks of Google review). Restricted-scope verification additionally requires a CASA security assessment by a licensed third-party auditor at $15-75k/year, designed for SaaS companies, infeasible for personal use. Justin's n8n personal Gmail flows use gmail.modify (Restricted), so verification is permanently off the table for that flow.
The architectural insight¶
The reason rclone Drive doesn't have this problem is that rclone is a verified app — Drive operations ride on rclone's verification, not the user's. The pattern that solves Justin's problem long-term is: stop being your own publisher. Use someone else's verified OAuth app, or use a non-OAuth auth model entirely (service account + DWD, App Password, Apps Script).
The plan¶
Step 0, TONIGHT, 15 min: unblock everything¶
Already broken, currently zero working state on these creds. Get back to "works for 7 days" baseline before doing the real fix.
- https://console.cloud.google.com/apis/credentials/consent → flip publishing status from Production → Testing.
- On same page, scroll to Test Users → add both
[email protected]and[email protected]. - https://console.cloud.google.com/apis/credentials → click the OAuth 2.0 Client ID → under Authorized redirect URIs, add
http://localhost:8765alongside the existinghttps://n8n.justinkrystal.com/rest/oauth2-credential/callback. Save. - Re-auth Forge calendar:
python3 ~/forge/scripts/forge_google_calendar_reauth.py. Helper exists at that path; manual code-paste flow. - Re-auth 3 n8n credentials: open n8n UI → Credentials → for each Google credential, click → Sign in with Google → re-consent. n8n's redirect URI is already on the client.
After step 0: state is "working with a 7-day timer." Tomorrow's work makes most of it permanent.
Step 1, calendar service-account migration¶
Effort: ~30 min. Result: forge calendar + n8n calendar credential both permanent, never expire.
- https://console.cloud.google.com/iam-admin/serviceaccounts → Create Service Account in the same Cloud project. Name:
forge-calendar-sa. - Generate a JSON key → download → save to
~/.forge-secrets/google-calendar-sa.json(chmod 600). - https://calendar.google.com → Settings → My calendars → "[email protected]" → Share with specific people → add
forge-calendar-sa@<project>.iam.gserviceaccount.comwith permission "Make changes and manage sharing." Repeat for any other personal calendars Forge needs to read/write. - Rewrite auth in
forge_google_calendar.py:_refresh()to use service-account JWT signing instead of OAuth refresh-token flow. Usegoogle-authlibrary or hand-roll JWT (see Google's "Service Accounts" docs, ~30 lines). - n8n: replace
googleCalendarOAuth2Apicredential type withgoogleApi(service-account JSON). Update all 5 workflows referencing the old credential. - Delete the old OAuth Calendar credential from n8n.
- Smoke test create/update/delete events on personal calendar via both Forge and n8n.
Step 2, business Gmail service-account + DWD¶
Effort: ~30 min. Result: n8n business Gmail ([email protected]) credential permanent.
- Reuse
forge-calendar-sa(or create a new SA, doesn't matter; rename toforge-google-saif reusing). - Edit the SA in Cloud Console → enable "Domain-wide delegation."
- https://admin.google.com → Security → Access and data control → API controls → Domain-wide Delegation → Add new client. Paste the SA's Client ID. Authorize scopes:
https://mail.google.com/,https://www.googleapis.com/auth/gmail.modify,https://www.googleapis.com/auth/gmail.compose,https://www.googleapis.com/auth/gmail.readonly. Save. - n8n: create new credential of type
googleApi(orgmailServiceAccountif available). Configure: SA JSON, impersonate user =[email protected]. - Replace credential in 9 active n8n workflows referencing the old
[email protected]OAuth credential. - Delete old OAuth credential.
- Smoke test list/draft/archive/filter on business Gmail via n8n.
Step 3, personal Gmail, decide between two paths¶
Both are permanent, no code-side OAuth, no 7-day expiry. Pick one based on philosophy.
PATH A: forward-and-alias into Workspace. Lower effort, mirrors personal mail into Workspace mailbox. Reuses Step 2's service account and DWD. Loses isolation between personal and business mail.
- https://mail.google.com/ on
[email protected]→ Settings → Forwarding and POP/IMAP → Forward all incoming →[email protected]. Set "archive Gmail's copy" so personal inbox stays clean. - In Workspace
[email protected]→ Settings → Filters and Blocked Addresses → create filter onto:[email protected]→ apply labelPersonal/Inbox. - https://mail.google.com/mail/u/0/#settings/accounts → Send mail as → Add
[email protected]→ choose "send through Gmail's SMTP servers" (NOT "treat as alias"). Authenticate with App Password from[email protected](requires 2FA enabled there). - Update 6 n8n personal-Gmail workflows: switch credential to the Step-2 service-account credential, add label filter
label:personal/inboxto query strings, set From header to[email protected]on outbound nodes. - CRITICAL TEST BEFORE MIGRATING: forward one newsletter through, inspect raw message in Workspace → verify
List-UnsubscribeandList-Unsubscribe-Postheaders survive forwarding. If they don't, the unsubscribe-sender workflow breaks and Path A is non-viable.
Risks: header preservation on forwarded mail, blast radius (personal mail now mirrored in Workspace; if Workspace compromised, personal exposed too — Justin's personal Gmail is the recovery email for his entire digital life per CLAUDE.md), spam filter divergence first week.
PATH B: Apps Script Web App. Higher effort, keeps personal mail genuinely isolated. No mirroring.
- Open script.google.com signed in as
[email protected]. New project. - Write handler functions covering current 6 workflows:
doPost(e)dispatcher →listInbox,getMessage,archive,addLabel,getHeaders,sendMailto,createFilter. UseGmailAppfor most ops, enable Gmail Advanced Service for filter creation. - Auth model: bearer token in
X-Forge-Authheader, compared against a hardcoded random secret. Reject everything else. - Deploy as Web App: Execute as = Me, Access = Anyone. Save deployment URL.
- Save URL + secret to
~/.forge-secrets/gmail-personal-appsscript.env. - Replace 6 n8n Gmail node calls with HTTP Request nodes pointing at deployment URL with secret header. Update parameter mapping.
- Smoke test each operation.
- Commit Apps Script source to
forge/integrations/apps-script/personal-gmail.gsfor git backup (Apps Script has version history but git is canonical).
Risks: Apps Script consumer-Gmail quotas (100 sent emails/day, ~6 hr execution time/day — well under personal volume), cold-start latency 500-2000ms first call after idle, account-bound (if [email protected] ever locked, script goes with it; same blast radius as today's OAuth credential, no worse).
Recommendation: Path A is faster and reuses Step-2 work, IF the header preservation test passes. Path B is cleaner architecturally and isolates personal mail. Run the header test first; if List-Unsubscribe survives, do Path A. If not, do Path B.
Step 4, quality-of-life: re-auth helpers (only if anything stays on OAuth)¶
Only relevant if Path A test fails AND Justin doesn't want to do Path B yet. In that case personal Gmail stays on OAuth, 7-day cycle, and we add:
- Build
scripts/forge_n8n_oauth_reauth.shthat opens the n8n credentials page in browser at the right credential. - Add Sunday morning cron to
forge_followupqueue: notify Justin "re-auth Google OAuth weekly chore" with link to the script.
If Steps 1-3 are all done, this step is unnecessary. Default plan assumes it's not needed.
Step 5, deferred decisions, no work tonight¶
- YouTube integration: when wiring up
forge_youtube_oauth_setup.pyfor real, it'll hit the same Cloud project and same restrictions. Decide then whether to use a dedicated YouTube OAuth client (subject to verification) or pivot to YouTube Data API service-account flow if Google supports it for the channel's owner. Probably needs verification since it accesses your channel data. - Brand email consolidation (gusoutdoor.co, shopnovadesign.com): one of the AIs proposed full hub-and-spoke with all brand mail funneled to
[email protected]. Real architectural call but not gated by today's OAuth issue. First step is to find where brand email is hosted:dig MX gusoutdoor.co +shortanddig MX shopnovadesign.com +short. Decision can wait weeks or months. - Don't migrate
[email protected]itself. It's the recovery email anchor for Justin's entire digital life, owns the JustinWieb-VR YouTube channel, holds personal Photos library, anchors "Sign in with Google" identity for every consumer service. Operationally treat it as immutable. Forge automation reaches into it via Path A or B; the account itself stays as-is forever.
Verification checklist¶
After each step, smoke-test before moving on:
- Step 0: forge_google_calendar.py self-test passes (creates and deletes a test event). All 3 n8n credentials show "connected" in n8n UI.
- Step 1: forge_google_calendar.py self-test still passes against new SA auth. n8n calendar workflow
create-calendar-eventruns end-to-end. - Step 2: n8n
verify-gmail-businessworkflow runs successfully. n8ncreate-gmail-draft-businesscreates a draft visible in Workspace inbox. - Step 3 (Path A): forwarded test email arrives in Workspace,
List-Unsubscribeheader present, n8nunsubscribe-senderworkflow extracts header correctly. - Step 3 (Path B): Apps Script deployment URL responds 200 with valid bearer,
listInboxreturns messages,archiveactually archives,getHeadersreturnsList-Unsubscribe.
Files this handoff touches¶
- Modified today:
forge_telegram_brain.py:591,638-651,686-694,forge_telegram_coordinator_bot.py:36-46,318-321,forge_bot_watchdog.py:36-50,71-86,105-117,forge_google_calendar.py:74-115(refresh_token persistence, harmless and stays). - Created today:
scripts/forge_google_calendar_reauth.py. - Will modify in Steps 1-3:
forge_google_calendar.py:_refresh(swap to JWT signing in Step 1), n8n workflow JSON (~15 workflows, Steps 1-3),~/.forge-secrets/google-calendar-sa.json(new),~/.forge-secrets/gmail-personal-appsscript.env(new, only if Path B), Apps Script source atintegrations/apps-script/personal-gmail.gs(new, only if Path B).
Decisions deferred to whoever picks this up¶
- Path A vs Path B for personal Gmail.
- Whether to create one shared
forge-google-saSA or one per integration. - Whether to commit the SA JSON key to a sealed-secret store or keep it in
~/.forge-secrets/. - Whether to run Step 4 (re-auth helpers) defensively even if Steps 1-3 succeed — probably no.
Open questions for Justin¶
- Run
dig MX gusoutdoor.co +shortanddig MX shopnovadesign.com +shortto determine brand email hosting before deciding on Step 5 brand consolidation. - Confirm which Workspace plan
justinwieb.comis on — affects storage budget if Path A doubles personal mail volume into Workspace. - Confirm 2FA is enabled on
[email protected](required if Path A is chosen, for App Password generation).
[Claude Code, debug worker session 2026-05-04 14:00-15:30 CT]