Skip to main content

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:

  1. 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.
  2. 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.
  3. 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_token 32-byte random, device_email, google_sub, authenticated_at, last_seen_at, expires_at = now + 30d, user_agent, ip).
  • Response sets a helm_device cookie: HttpOnly, SameSite=Lax, Secure, Max-Age=2592000 (30 days).
  • Every authenticated request slides device_sessions.last_seen_at and expires_at forward → 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_device cookie. A request with no device cookie redirects to GET /api/auth/google/start before any PIN UI shows.

Endpoints (new)

  • GET /api/auth/google/start — generates state + PKCE code-verifier, redirects to Google's authorization endpoint
  • GET /api/auth/google/callback — exchanges code for tokens, validates email against allowlist, mints device_sessions row + helm_device cookie, redirects to /
  • POST /api/auth/google/signout — marks current device_sessions row revoked, clears cookie
  • GET /api/auth/allowlist — current emails (Sys Admin only)
  • POST /api/auth/allowlist — add email
  • DELETE /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_sessions row, 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.jsonc vars; 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.revoked for Layer 1
  • existing auth.login, auth.logout, auth.failed for 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