ADR-0026 — Google OAuth + device sessions
- Status: Accepted
- Date: 2026-05-13
- Decision-makers: Tom Anderson
- Supersedes: extends ADR-0013 — PIN is now Layer 2 of a two-layer model, not the only gate
Context
ADR-0013 framed staff PIN as the entire auth surface, with the 5-digit keyspace defended by rate-limiting. That works in a perfect-isolation deployment — the operator app only running on locked-down shop tablets. The moment Helm goes to production at helm-swicked.kvick.bike with a real DNS name, the URL itself is the attack surface: anyone in the world can hit the sign-in overlay and start trying PINs.
Three weaknesses fall out of "PIN only" against a public URL:
- PIN keyspace is 100,000. The rate limit is the security boundary; if it slips or has a bug, brute-force is hours not centuries.
- No identification of which browser is at the till. A laptop borrowed from a former employee can sit on a Starbucks Wi-Fi and try PINs all day.
- No revocation tier between "delete the staff row" and "wait for the cookie to expire." The shop owner has no clean "lock the back-office iPad someone walked off with" affordance.
Options considered:
- Stay PIN-only + harden rate limits. Minimum viable. Doesn't address browser identity or per-device revoke.
- Replace PIN with full username/password. Adds friction (long input on a touch terminal during a sale). Loses the "5-second swap between staff" benefit PIN was chosen for.
- Per-device certificate auth. Strongest. Operationally heavy — every new tablet needs cert provisioning.
- Two-layer: OAuth at the device + PIN at the till. Layer 1 is a one-time-per-device 30-day login that gates the browser; Layer 2 is the existing PIN flow that identifies which staff is at the till right now. The PIN does what it's good at (fast staff swap); Google OAuth does what it's good at (identity at scale, easy revoke).
Decision
Two-layer authentication, with day-one Swicked allowlist seeded in migration 015.
Layer 1 — device authentication via Google OAuth
- Each browser that wants to use Helm must first authenticate via Google OAuth (Authorization Code flow with PKCE).
- A per-shop allowlist of authorized Google emails lives in
shop_config.authorized_google_emails(JSON array, normalized to lowercase). Day-one seed for Swicked:tanderson1963@gmail.com,james@swicked.com,chenoa@swicked.com,hello@kvick.ca. - On successful OAuth + allowlist match, the Worker writes a row to
device_sessions(device_token32-byte random,device_email,google_sub,authenticated_at,last_seen_at,expires_at = now + 30d,user_agent,ip). - Response sets a
helm_devicecookie:HttpOnly,SameSite=Lax,Secure,Max-Age=2592000(30 days). - Every authenticated request slides
device_sessions.last_seen_atandexpires_atforward → 30-day rolling window. A browser idle for >30 days falls back to OAuth. - Sign-out / admin revoke writes
revoked_at+revoked_reason. Revoked rows stay (audit) but can't authenticate.
Layer 2 — staff identification via PIN (existing)
- Same PBKDF2-SHA256 5-digit PIN flow from ADR-0013.
- 60-second idle timeout, lockout-only sign-in, admin code
466687→ Sys Admin (still works as a PIN-equivalent). - The PIN flow only runs after the device has a valid
helm_devicecookie. A request with no device cookie redirects toGET /api/auth/google/startbefore any PIN UI shows.
Endpoints (new)
GET /api/auth/google/start— generates state + PKCE code-verifier, redirects to Google's authorization endpointGET /api/auth/google/callback— exchanges code for tokens, validates email against allowlist, mintsdevice_sessionsrow +helm_devicecookie, redirects to/POST /api/auth/google/signout— marks currentdevice_sessionsrow revoked, clears cookieGET /api/auth/allowlist— current emails (Sys Admin only)POST /api/auth/allowlist— add emailDELETE /api/auth/allowlist— remove email
Consequences
Positive:
- Browser identity becomes a first-class concept. "Who's at the till" (PIN) is decoupled from "which device is this" (OAuth).
- Revocation has a clean tier: kick a device by revoking its
device_sessionsrow, or by removing the email from the allowlist (a future sweep cron will revoke all matching device_sessions). - PIN's rate-limit story is dramatically less load-bearing — an attacker can't even reach the PIN screen without an allowlisted Google account.
- Day-one allowlist is configured in code (migration 015), so new shop deployments don't need a manual "set up authorized emails" step.
- 30-day sliding window means a staff member who works at the shop weekly stays signed in indefinitely on their tablet; only true abandonment forces re-auth.
Negative:
- Adds a Google dependency. If Google OAuth has an outage, new device logins fail (existing sessions keep working until they slide out).
- Requires per-shop Google OAuth client configuration (Client ID in
wrangler.jsoncvars; Client Secret as a Pages secret). - The "Sign in with Google" pattern is unfamiliar in some bike-shop contexts; staff training needs to cover it on day-one.
- A staff member at a personal email (
gmail.com) gets a real Google identity exposed — a minor privacy ask. The allowlist makes it deliberate.
Mitigations:
- The OAuth client ID is per-shop, not Kvick-global; outage exposure is per-deployment.
- Once the device cookie is set, no further Google interaction is needed for 30 days. The staff member sees a one-time Google sign-in on their first day, then PINs forever after.
- A future "use a shared shop@swicked.com Google Workspace identity" pattern is the natural answer for staff who don't want personal accounts exposed.
Notes
The Sys Admin reset code 466687 continues to work after Layer 1 — it becomes a PIN-equivalent for direct Sys Admin entry. It does not bypass Google OAuth; the device still needs to be authenticated.
The audit chain logs both layers' events:
device.authenticated,device.signed_out,device.revokedfor Layer 1- existing
auth.login,auth.logout,auth.failedfor Layer 2
This ADR doesn't replace ADR-0013; it composes with it. PIN hashing, lockout semantics, and the staff_sessions table are unchanged. The change is the gate before the gate.
See also
- Security model — the two-layer flow drawn out
- Slice 1 — Identity & Audit — endpoints + schema
- ADR-0013: PBKDF2 PIN hashing — Layer 2 details