# ADR-094: Google OAuth Token Lifecycle — In-Memory Only, Email Hint Persisted for Silent Re-Auth

## Status

Accepted, 2026-06-29.

## Status History

```yaml
status_history:
  - date: 2026-06-29
    status: Proposed
    changed_by: hkl
    reason: Formalising OAuth token storage pattern for Drive Consolidator multi-account connections
    changed_via: adr-kit (360lm)
  - date: 2026-06-29
    status: Accepted
    changed_by: hkl
    reason: Pattern live and stable; Drive Consolidator zero-backend PWA uses it
    changed_via: adr-kit (360lm)
```

## Context

Drive Consolidator is a zero-backend PWA that connects to multiple Google Drive accounts via Google Identity Services (GIS). Each connected account yields an OAuth access token (lifetime ~1 hour) used to make authenticated requests to the Google Drive API. The application maintains an in-memory JavaScript Map of connected accounts: `S.accounts: Map<email, {token, expiresAt, name, photo}>`.

The core security concern is that persisting an access token to `localStorage`, `sessionStorage`, `IndexedDB`, or any browser storage creates a stored credential accessible to any JavaScript on the same origin. An XSS vulnerability would expose that token to an attacker, who could then use it to access the user's Google Drive without waiting for token expiry. Even with encryption, the decryption key (accessible to any JS on the origin) remains vulnerable to XSS.

The alternative — requiring users to explicitly reconnect all accounts every time they log in to the PWA — creates poor UX, especially for users with 2–3 connected accounts who visit daily. A balance is needed: store something that enables silent re-auth without storing the credential itself.

**The Decision:** OAuth access tokens are never persisted. Only the account EMAIL ADDRESS is persisted in `localStorage` as `dc-user-{userId}-accounts`, a list used as a hint for re-authentication. On PIN login, the app iterates saved emails and calls `GIS.requestAccessToken({ prompt: '', login_hint: email })` — a silent re-auth that succeeds if the browser still has an active Google session cookie for that account. If the session expired or GIS shows a hidden consent prompt, silent auth fails silently after an 8-second timeout, and that account is skipped; the user can reconnect manually.

An email address has no exploitable value without an active Google session — it is a re-auth hint, not a credential. Email addresses are already public in most organisations, and even if leaked, they cannot be used to access the user's Google Drive without a valid session context at Google's servers.

## Decision

1. **OAuth access tokens are in-memory only.** Tokens live exclusively in a JavaScript Map (`S.accounts`) and are never written to persistent storage (`localStorage`, `sessionStorage`, `IndexedDB`, or any other browser store). When the page is closed or the user logs out, tokens are immediately garbage-collected.

2. **Account emails are persisted as re-auth hints.** The EMAIL ADDRESS of each connected account is stored in `localStorage` under the key `dc-user-{userId}-accounts` as a JSON array of email strings. This list serves solely to populate the `login_hint` parameter during silent re-auth; it is NOT a credential.

3. **Silent re-auth on PIN login.** After PIN authentication, the app calls `GIS.requestAccessToken()` for each saved email with `{ prompt: '', login_hint: email }`. The empty `prompt` ensures no UI is shown; the login_hint tells Google "if you have a session for this email, use it; otherwise, fail silently." If silent auth succeeds, the token is stored in `S.accounts[email]`. If it times out (after 8 seconds) or fails, that account is marked unavailable and skipped — the user can reconnect it manually later via the "Add Account" flow.

4. **Explicit removal deletes the email.** When a user clicks the ✕ button to disconnect an account, the account's email is removed from the persisted list in localStorage. The account will not be included in silent re-auth on next login.

5. **This pattern applies to any client-side Google API integration.** Any PWA or utility in the 360lm/Others ecosystem that integrates Google APIs (Drive, Sheets, Gmail) and requires multi-account support MUST follow this same pattern: no token persistence, email-based re-auth hint only.

**Decision Maker:** hkl

## Alternatives Considered

- **Persist tokens to localStorage.** Rejected: violates principle of least privilege for stored credentials. XSS exposure is unacceptable; token lifetime (1 hour) does not justify the risk. The assumption "we have no XSS vulnerabilities" is not a guarantee; defense-in-depth requires treating stored tokens as a liability.

- **Persist tokens encrypted in localStorage.** Rejected: encryption key must be derived from or accessible to JavaScript; if the key is hardcoded, it's useless (any XSS attacker sees it); if it's derived from user secrets (e.g. PIN), it must be recomputed on every access, adding latency and complexity. We gain no security — the XSS payload can decrypt just as easily as the app.

- **Use a service worker to hide tokens from main-thread JS.** Rejected: tokens still live in browser memory (SW scope), and XSS in the PWA cannot be blocked by a SW; SW storage does not isolate credentials from compromised scripts on the same origin. Adds significant complexity for no real security gain.

- **Require users to reconnect on every PWA login (no auto-reconnect).** Rejected: poor UX. Users with 2–3 linked Google accounts and daily login frequency will experience friction. The PWA would feel less capable than alternatives (e.g. web-based Drive search) that remember connections.

- **Store refresh tokens instead of access tokens.** Rejected: refresh tokens live even longer (weeks to years), multiplying the credential exposure window. Refresh tokens can be used to issue new access tokens; exfiltration of a refresh token is worse than exfiltration of a short-lived access token. Also adds complexity of token refresh logic.

- **Auto-reconnect with unlimited timeout.** Rejected: if GIS shows a hidden consent prompt (happens when Google's security policies require manual review), the request hangs indefinitely, blocking the accounts screen. An 8-second timeout is a pragmatic trade-off: it unblocks the user, and manual reconnection via the UI is still available.

- **Persist email + partial auth state (e.g. encrypted session ID).** Rejected: still a form of credential storage; does not materially reduce XSS risk. Simplicity of "just email" is preferred.

## Consequences

**Positive:**
- No stored credentials in browser memory after page close — minimal XSS attack surface.
- Email-only persistence is transparent; no sensitive material at rest.
- Silent re-auth UX is seamless for users with active Google sessions (the common case).
- Token rotation (1-hour expiry) is automatic; no token-refresh logic needed in the app.
- Removal of an account from the saved list is immediate and final — old connections will not re-authenticate on next login.

**Negative / Trade-offs:**
- If a user's Google session expires (e.g. Google logged them out for security reasons, or they cleared cookies), silent re-auth fails silently. They must manually reconnect the account via "Add Account" flow. Most users expect this for OAuth integrations, but it is a friction point.
- Silent re-auth with an 8-second timeout means ~8 seconds * N accounts added to login time. For 1–3 accounts, this is negligible; for 10+ accounts, users may see a "Reconnecting accounts..." delay. Mitigation: practical limit of 5 accounts in UI; if users need more, architecture must change (e.g. lazy-load accounts).
- Email addresses in localStorage reveal which Google accounts the user has connected, even if the app is logged out. This is low-sensitivity but non-zero; users should be aware.
- If Google's GIS API changes its silent auth behavior or timeout semantics, the PWA must adapt. Dependency on Google's implementation details is a risk.

**Risks and Mitigations:**
- **Risk:** XSS attacker calls `GIS.requestAccessToken()` manually and obtains a token before the 8-second timeout. Mitigation: token is obtained; attacker can use it for the next ~1 hour. This is unavoidable in any browser-based OAuth flow (XSS with access to GIS can always steal tokens). Accepted risk — the PWA is deployed to a trusted origin with strict CSP and no untrusted third-party scripts. Stored tokens do NOT reduce this risk; they only increase the window for exfiltration if XSS is discovered later.
- **Risk:** Email list in localStorage grows unbounded if user keeps adding accounts without removing old ones. Mitigation: UI enforces a practical limit (e.g. 5 accounts max); overflow is rejected with "Too many accounts connected."
- **Risk:** User clears all localStorage (e.g. via browser settings) and loses the email list. Mitigation: expected behavior; user must manually reconnect on next login. Acceptable — same UX as most OAuth apps.
- **Risk:** Silent re-auth fails due to network issue or GIS outage (not Google session expiry). Mitigation: after 8-second timeout, mark account as unavailable; user can retry manually via UI. Graceful degradation — PWA still functions with manually connected accounts.

## Related Decisions

- ADR-093 (zero-backend PWA pattern) — companion ADR; Drive Consolidator is the reference implementation of zero-backend, and this ADR governs its OAuth token lifecycle.
- ADR-026 (cross-PWA session bridge via localStorage) — different pattern; employee sessions are small, have TTL, and do not represent stored credentials like OAuth tokens.
- ADR-011 (PIN-based auth, no passwords) — employee login via PIN is the trigger for silent re-auth on saved Google accounts.

## References

- Google Identity Services API documentation — `requestAccessToken({ prompt: '', login_hint })` https://developers.google.com/identity/gsi/reference
- Drive Consolidator (`/others/consolidator/`) — reference implementation; `S.accounts` Map, auto-reconnect on login, account removal flow.
- `/shared/google-signin.js` (if exists) — shared Google OAuth helper for reuse across other PWAs.
- Security best practice: OWASP Storage Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html#local-storage
