[object Object]

← back to George Gmail

george-gmail: pre-write dormant SA/DWD (domain-wide delegation) auth path for Workspace accounts

769c500719d5c0e03402dd0b225a1afd6de79385 · 2026-07-01 10:47:25 -0700 · Steve Abrams

Adds a service-account/JWT impersonation path alongside the existing OAuth refresh-token
path so the Workspace pair (steve@ + info@) can drop off the ~7-day Testing-mode token
treadmill. Fully DORMANT by default: with no GOOGLE_SA_KEYFILE set, saAuthFor() returns
null and the SA-override block is a no-op — every account keeps its refresh-token client
byte-for-byte. Arm via GOOGLE_SA_KEYFILE + GEORGE_SA_ACCOUNTS=steve-office,info (Workspace
only; consumer gmail keys can never be SA-backed). resolveAccount() reads the module-level
service-client vars per request, so it transparently picks up the SA clients when armed.

Verified by boot tests: dormant -> 0 SA lines + all accounts on refresh tokens; armed w/
missing or bogus keyfile -> clear warn + safe fallback + still boots. Live JWT impersonation
awaits Steve's gated console work (SA creation + domain-wide delegation authorization).
Arming checklist + rollback in PLAN-OAUTH-DURABILITY.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 769c500719d5c0e03402dd0b225a1afd6de79385
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 1 10:47:25 2026 -0700

    george-gmail: pre-write dormant SA/DWD (domain-wide delegation) auth path for Workspace accounts
    
    Adds a service-account/JWT impersonation path alongside the existing OAuth refresh-token
    path so the Workspace pair (steve@ + info@) can drop off the ~7-day Testing-mode token
    treadmill. Fully DORMANT by default: with no GOOGLE_SA_KEYFILE set, saAuthFor() returns
    null and the SA-override block is a no-op — every account keeps its refresh-token client
    byte-for-byte. Arm via GOOGLE_SA_KEYFILE + GEORGE_SA_ACCOUNTS=steve-office,info (Workspace
    only; consumer gmail keys can never be SA-backed). resolveAccount() reads the module-level
    service-client vars per request, so it transparently picks up the SA clients when armed.
    
    Verified by boot tests: dormant -> 0 SA lines + all accounts on refresh tokens; armed w/
    missing or bogus keyfile -> clear warn + safe fallback + still boots. Live JWT impersonation
    awaits Steve's gated console work (SA creation + domain-wide delegation authorization).
    Arming checklist + rollback in PLAN-OAUTH-DURABILITY.md.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 PLAN-OAUTH-DURABILITY.md | 32 ++++++++++++++++++------
 server.js                | 65 ++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 90 insertions(+), 7 deletions(-)

diff --git a/PLAN-OAUTH-DURABILITY.md b/PLAN-OAUTH-DURABILITY.md
index e8422eb..a800d9e 100644
--- a/PLAN-OAUTH-DURABILITY.md
+++ b/PLAN-OAUTH-DURABILITY.md
@@ -77,10 +77,28 @@ SA/DWD and "Internal" are **impossible** here (no Workspace over gmail.com). Opt
 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**.
 
-## What I can do without a gate (once you decide)
-- Add an SA/DWD auth path in `server.js` (`google.auth.JWT` with `subject=<impersonated user>`) for
-  steve@ + info@, alongside the existing refresh-token path — reversible, behind a feature check so the
-  old path stays until the SA is proven.
-- Add the 6-day token-age WARN to the poll loop for whichever consumer accounts remain.
-- Once SA/DWD is proven for both Workspace accounts, retire `com.steve.dw-appointments-oauth-canary`
-  (the symptom-watcher) — the alert class is gone, not just quiet.
+## 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.
diff --git a/server.js b/server.js
index e63c080..0f05b81 100644
--- a/server.js
+++ b/server.js
@@ -86,6 +86,36 @@ const SCOPES = FULL_WORKSPACE_SCOPES;
 const INFO_SCOPES = FULL_WORKSPACE_SCOPES;
 const PERSONAL_SCOPES = FULL_WORKSPACE_SCOPES;
 
+// ─── Service-account / domain-wide-delegation (DORMANT until configured) ──────
+// The Testing-mode OAuth app expires refresh tokens ~weekly (see PLAN-OAUTH-DURABILITY.md).
+// For Workspace accounts we can switch to a service account with domain-wide delegation:
+// the SA impersonates the user and mints tokens per request — no refresh token, no weekly
+// expiry, no consent screen. FULLY DORMANT by default: with no GOOGLE_SA_KEYFILE this whole
+// path is skipped and every account keeps using its existing refresh-token client byte-for-byte.
+// To arm (AFTER Steve authorizes the SA client ID for the scopes in Workspace Admin →
+// Security → API controls → Domain-wide delegation), set in the DW-MCP .env:
+//   GOOGLE_SA_KEYFILE=/abs/path/sa-key.json     (route via the secrets skill)
+//   GEORGE_SA_ACCOUNTS=steve-office,info        (Workspace accounts ONLY)
+// Consumer @gmail accounts (steve-personal, agentabrams) CANNOT use DWD and are ignored
+// even if listed — they stay on the refresh-token path + the 6-day token-age WARN.
+const SA_KEYFILE = creds.GOOGLE_SA_KEYFILE || process.env.GOOGLE_SA_KEYFILE || creds.GEORGE_SA_KEYFILE || process.env.GEORGE_SA_KEYFILE || '';
+const SA_ACCOUNTS = String(creds.GEORGE_SA_ACCOUNTS || process.env.GEORGE_SA_ACCOUNTS || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
+// Only these Workspace accounts have an impersonable domain address; consumer gmail keys are
+// intentionally absent so they can never be SA-backed.
+const SA_IMPERSONATE = { 'steve-office': 'steve@designerwallcoverings.com', 'info': 'info@designerwallcoverings.com' };
+const SA_KEY_OK = !!SA_KEYFILE && (() => { try { fs.accessSync(SA_KEYFILE, fs.constants.R_OK); return true; } catch { return false; } })();
+if (SA_ACCOUNTS.length && !SA_KEY_OK) console.warn(`[${AGENT_NAME}] SA/DWD requested for [${SA_ACCOUNTS}] but key unreadable at "${SA_KEYFILE}" — staying on refresh-token path`);
+
+// Domain-wide-delegation JWT auth for `key`, or null if not SA-eligible/armed.
+function saAuthFor(key) {
+  if (!SA_KEY_OK || !SA_ACCOUNTS.includes(key)) return null;
+  const subject = SA_IMPERSONATE[key];
+  if (!subject) { console.warn(`[${AGENT_NAME}] SA/DWD skip "${key}" — no impersonable Workspace address (consumer gmail can't use DWD)`); return null; }
+  try {
+    return new google.auth.JWT({ keyFile: SA_KEYFILE, scopes: FULL_WORKSPACE_SCOPES, subject });
+  } catch (e) { console.error(`[${AGENT_NAME}] SA/DWD auth build failed for "${key}": ${e.message}`); return null; }
+}
+
 // Steve@ account (primary)
 let oauth2Client;
 let gmail;
@@ -192,6 +222,41 @@ try {
   console.error(`[${AGENT_NAME}] Agent Abrams client error: ${e.message}`);
 }
 
+// ─── SA/DWD override (DORMANT unless GOOGLE_SA_KEYFILE + GEORGE_SA_ACCOUNTS set) ─
+// For each SA-armed Workspace account, rebuild its per-service API clients on the
+// domain-wide-delegation JWT auth, replacing the refresh-token clients. Additive:
+// when SA is off, saAuthFor() returns null and this block is a no-op, leaving the
+// original refresh-token clients exactly as-is. resolveAccount() reads these
+// module-level vars per request, so it transparently picks up the SA clients.
+try {
+  const svc = (auth) => ({
+    gmail:    google.gmail   ({ version: 'v1', auth }),
+    tasks:    google.tasks   ({ version: 'v1', auth }),
+    drive:    google.drive   ({ version: 'v3', auth }),
+    docs:     google.docs    ({ version: 'v1', auth }),
+    sheets:   google.sheets  ({ version: 'v4', auth }),
+    slides:   google.slides  ({ version: 'v1', auth }),
+    calendar: google.calendar({ version: 'v3', auth }),
+    forms:    google.forms   ({ version: 'v1', auth }),
+  });
+  const saSteve = saAuthFor('steve-office');
+  if (saSteve) {
+    const s = svc(saSteve);
+    oauth2Client = saSteve; gmail = s.gmail; tasksApi = s.tasks;
+    drive = s.drive; docs = s.docs; sheets = s.sheets; slides = s.slides; calendar = s.calendar; forms = s.forms;
+    console.log(`[${AGENT_NAME}] steve-office → SA/DWD (impersonating ${SA_IMPERSONATE['steve-office']}) — refresh-token path bypassed`);
+  }
+  const saInfo = saAuthFor('info');
+  if (saInfo) {
+    const s = svc(saInfo);
+    infoOauth2Client = saInfo; infoGmail = s.gmail; infoTasksApi = s.tasks;
+    infoDrive = s.drive; infoDocs = s.docs; infoSheets = s.sheets; infoSlides = s.slides; infoCalendar = s.calendar; infoForms = s.forms;
+    console.log(`[${AGENT_NAME}] info → SA/DWD (impersonating ${SA_IMPERSONATE['info']}) — refresh-token path bypassed`);
+  }
+} catch (e) {
+  console.error(`[${AGENT_NAME}] SA/DWD override error (staying on refresh-token path): ${e.message}`);
+}
+
 // ─── Account resolver for per-service API endpoints ───
 // account query/body param: "steve-office" (default), "info", "steve-personal", "agentabrams"
 function resolveAccount(req) {

← 9e11f3d george: pre-emptive OAuth token-age monitor + durable-migrat  ·  back to George Gmail  ·  chore: v1.1.0 (session close — dormant SA/DWD path) 89b542a →