← back to George Gmail
PLAN-OAUTH-DURABILITY.md
105 lines
# George OAuth durability — retire the weekly-token treadmill
**Status:** decision memo (read-only inventory done 2026-07-01). Migration itself is Steve-gated
(Google Cloud Console + Workspace Admin + identity). Council 2026-07-01 idea #3 (vp-engineering).
## Problem
George re-authorizes every ~7 days. Calendar push + fleet mail silently die on expiry, and we
built a canary (`com.steve.dw-appointments-oauth-canary`) to WATCH the symptom instead of removing
the cause. **Root cause:** the single Google Cloud OAuth app is in **"Testing"** publishing status,
which caps refresh-token lifetime at 7 days. This is monitoring debt, not a monitoring gap.
## Inventory (what actually exists)
One shared **OAuth "Web application" client** (`GMAIL_CLIENT_ID`/`SECRET`), reused across 4 mailboxes,
each with its own refresh token in `.env`:
| Account | Address | Type | Refresh-token env |
|--------------------|----------------------------------|---------------|---------------------------|
| Steve Office (main)| steve@designerwallcoverings.com | **Workspace** | `GMAIL_REFRESH_TOKEN` |
| Info catch-all | info@designerwallcoverings.com | **Workspace** | `INFO_REFRESH_TOKEN` |
| Steve Personal | steveabramsdesigns@gmail.com | consumer gmail| `PERSONAL_REFRESH_TOKEN` |
| Agent Abrams | theagentabrams@gmail.com | consumer gmail| `AGENTABRAMS_REFRESH_TOKEN`|
Flow: `access_type:offline` + `prompt:consent`; each `/auth/*` callback captures a new refresh token
and rewrites `.env` (server.js ~L291–472).
**Scopes requested:** `gmail.modify`, `gmail.settings.basic`, `gmail.settings.sharing`, `drive`
(**RESTRICTED**); `calendar`, `documents`, `spreadsheets`, `presentations`, `forms.body`,
`forms.responses.readonly`, `tasks` (sensitive).
## Why the naïve fix is a trap
"Just flip the app to Production" — with **External** user type **and restricted scopes**
(`gmail.modify`, `drive`) — triggers Google OAuth verification **including an annual third-party
CASA security assessment** (paid, weeks of lead time) plus brand verification. Do NOT go here blindly.
## The split that avoids verification entirely
The 4 accounts fall into two classes with different best fixes:
### A. The 2 Workspace accounts (steve@ + info@) — the fleet-critical ones
These carry the whole fleet send path **and** the info@ catch-all across 209 domains. Two durable,
**verification-free, $0** options:
1. **Service account + domain-wide delegation (RECOMMENDED).** In the DW Workspace's Cloud project,
create a service account, enable domain-wide delegation, and authorize its client ID for the 11
scopes in **Workspace Admin → Security → API controls → Domain-wide delegation**. The SA then
impersonates steve@ / info@ and mints tokens per request — **no refresh token, no 7-day expiry,
no consent screen, no verification, no CASA.** This is the single biggest treadmill-killer.
2. **Interim one-toggle:** if the Cloud project lives in the designerwallcoverings.com Workspace org,
flip the OAuth consent screen **User Type → Internal**. Internal apps skip verification and their
refresh tokens don't hit the 7-day cap. Zero code change. Caveat: Internal breaks the 2 consumer
accounts on the shared client (they can't authorize an Internal app), forcing the split below —
so SA/DWD is the cleaner long-term home for these two anyway.
### B. The 2 consumer @gmail accounts (personal + agentabrams)
SA/DWD and "Internal" are **impossible** here (no Workspace over gmail.com). Options, best first:
1. **Fold their function into the Workspace and retire consumer OAuth.** If personal/agentabrams sends
can go out as steve@ or a Workspace alias, delete these two clients → the whole system becomes
durable. Lowest long-term maintenance.
2. **Reduce scopes + verify.** If they must stay, a *separate* External client requesting only
NON-restricted scopes (e.g. `gmail.send` sensitive-tier for send-only, drop `gmail.modify`/`drive`)
avoids CASA — leaving only free brand verification (~days). Requires confirming send-only is enough.
3. **Keep + pre-empt.** Leave them on weekly re-consent but add a **6-day token-age WARN** (Council #2
companion): stamp `last_good_ts` per account on each successful poll, emit a CNCP WARN at age > 6d
while the token still works, so re-consent is a scheduled 30-second task, never a silent mid-pipeline
failure. Folds into the existing 15-min poll, no new process.
## Recommendation
- **steve@ + info@ → service account + domain-wide delegation** (kills the treadmill for the
fleet-critical mail, verification-free, $0).
- **personal + agentabrams → fold into Workspace and retire** if their sends can originate as steve@/alias;
otherwise keep them on weekly re-consent **plus the 6-day WARN** so it's never silent.
## Gated actions (Steve only — I cannot touch Google consoles / identity)
1. Confirm the Cloud project sits in the designerwallcoverings.com **Workspace org** (decides whether the
Internal toggle is even available and whether DWD can be authorized).
2. Google Cloud Console → create the service account (+ JSON key routed via the `secrets` skill).
3. Workspace Admin → API controls → authorize the SA client ID for the 11 scopes (domain-wide delegation).
4. Decide consumer-account fate: **fold-into-Workspace** vs **keep + 6-day WARN**.
## Code already shipped (dormant, verified)
- **6-day token-age WARN** — `george-token-broker/poll.mjs` (commit `a14768c`). Warns before the
weekly expiry instead of after `invalid_grant`. Live once the broker is armed (DRY_RUN=0 + loaded).
- **SA/DWD auth path** — `george-gmail/server.js` (this commit). `google.auth.JWT` with
`subject=<impersonated user>`, behind a feature check. **Fully dormant** with no env set — verified
by boot tests: no env → 0 SA lines, all accounts on refresh tokens; `GEORGE_SA_ACCOUNTS` set but key
missing/bogus → warns + safe fallback + still boots. Consumer gmail keys can never be SA-backed
(no impersonable address in `SA_IMPERSONATE`).
## Arming checklist (Steve — gated console + identity work)
1. Confirm the Cloud project is in the designerwallcoverings.com **Workspace org**.
2. Create a **service account**; download its JSON key; route it via the `secrets` skill →
set `GOOGLE_SA_KEYFILE=/abs/path/sa-key.json` in `DW-MCP/.env`.
3. Workspace Admin → Security → API controls → **Domain-wide delegation** → add the SA client ID with
the 11 scopes (the `FULL_WORKSPACE_SCOPES` list in server.js).
4. Set `GEORGE_SA_ACCOUNTS=steve-office,info` in `DW-MCP/.env`.
5. Restart George (`launchctl kickstart -k gui/$(id -u)/com.steve.george-gmail`); confirm the boot log
shows `steve-office → SA/DWD` + `info → SA/DWD`, then `GET /api/profile?account=info` returns the
right `emailAddress` (proves impersonation).
6. Add `"sa_backed": ["steve-office","info"]` to `george-token-broker/config.json` so the age-WARN
stops tracking those two (they no longer mint weekly tokens).
7. Once proven, retire `com.steve.dw-appointments-oauth-canary` — the alert class is gone, not just quiet.
**Rollback:** unset `GEORGE_SA_ACCOUNTS` (or `GOOGLE_SA_KEYFILE`) and restart → instant return to the
refresh-token path, no code change.