ARCHIVED. The passkeys-as-staff-door design; staff auth is Google-only since 0.9.0 and passkeys are a customer-optional convenience — current auth lives in ../architecture/auth.md. Nothing here is current — see docs/README.md for the live map.
Implementation map for the customer-optional passkey support and the rolling staff session.
Status: current as of 2026-06-07. Two things to read this doc against:
- Staff sign in with Google OAuth. Passkeys are not a staff requirement;
the staff passkey requirement and its enrol wall (
/auth/enroll-passkey) were built and then removed — that route no longer exists. Google is the only staff path. - Passkeys are a customer-only convenience — optional, never required.
Customers also keep magic links.
signInWithPasskey()is a secondary button on the sign-in page, surfaced only when this browser already holds a passkey (src/lib/auth/passkey-hint.ts).PasskeyManager(src/components/PasskeyManager.tsx) lives in the portal (/portal) for add/remove.
The rolling staff session below is still in force — but it's keyed purely
on the verified JWT session_id and applies to any office session (now Google
staff sessions), with no passkey/amr/aal assurance check. The office gate
is office role + live rolling session, no auth-method check, so a customer
promoted to staff while signed in keeps office access until that session idles
(15 min) / hits the 12 h cap — revoke sessions to cut it immediately. Role
management lives at /office/team.
Everything from "What each method does", "Decisions (locked)", the staff
lifecycle, and the passkey-derived assurance signal is superseded design
history for the brief period when passkeys were the staff door; it is retained
below, clearly marked, because it explains the shape of the session table and the
ceremonies that are still live. Genuinely-current content: the route map, the
staff_sessions table, the idle/absolute timeouts, and the customer passkey UX.
Build-time pivot (2026-06-05): the installed Supabase stack (
@supabase/auth-js@2.106.1, GoTrue withauth.webauthn_credentials/auth.webauthn_challenges) has native passkeys — primaryauth.signInWithPasskey()(usernameless one-tap),auth.registerPasskey(), andauth.passkey.*/auth.admin.passkey.*, behind the experimental flagauth.experimental.passkey: true. We use the native API instead of hand-rolling WebAuthn. That drops the@simplewebauthndependency, the customwebauthn_credentialstable, the challenge cookie, and thegenerateLink → verifyOtpsession bridge — GoTrue owns the ceremony, credential storage, and session issuance. The original hand-rolled design is kept below struck-through context where it helps; the Native implementation section is the source of truth.
Why passkeys at all
- Phishing resistance. Magic links are phishable/relayable. A passkey is
bound to the origin (
studio.chat) and signs nothing for a look-alike domain. - Better UX. One biometric tap beats round-tripping an email — especially with conditional-UI autofill.
These are why we keep passkeys as a customer convenience. (The phishing
argument originally motivated requiring passkeys for staff; that requirement
was dropped in favour of Google OAuth — Workspace-managed, centrally 2FA-able —
as the staff door. See the office auth docs, docs/architecture/auth.md.)
Superseded design history (passkeys-as-staff-door)
Everything in this section described the brief design where a passkey was a hard staff requirement with a forced enrol wall and a passkey-derived session assurance. None of it is current — staff use Google OAuth, the enrol wall (
/auth/enroll-passkey) is deleted, and the gate no longer readsamr/aal. Kept for context on the still-livestaff_sessionstable and the native passkey ceremonies that customers still use.
Decisions (locked) — historical
| # | Decision |
|---|---|
| Scope | Passkeys for both office and portal. |
| Staff requirement | Hard requirement — no office access without a passkey. A roaming security key (YubiKey) is the universal fallback for locked-down/incapable devices. A staff member with no passkey-capable authenticator at all is locked out by design. |
| Customer requirement | Optional. Magic link stays their standing sign-in; passkeys are a "faster next time" nicety. |
| Magic link | Kept, but its role differs by audience (below). |
| Enrolment trigger | Required wall for office (you cannot reach /office without enrolling/using a passkey). Opt-in prompt for portal customers. |
| Passwords | Removed entirely — signInWithPassword, sendPasswordReset, the password/reset form modes, /office/set-password, and SetPasswordForm all go away. Magic link is the new "reset." |
| Assurance store | Server-side session record (opaque pointer cookie), not a signed stateless cookie — revocable, auditable, and required for the rolling timeout. |
| Session policy | Passkey assurance is per-session. Staff sessions are rolling: idle beyond a limit ⇒ re-auth (a passkey tap, not a full magic-link). Optional absolute cap. Customers keep normal Supabase session longevity. |
| Paths | All auth ceremonies namespaced under /auth/* (not /account — see below). |
What each method did, per audience — historical
| Customers | Staff | |
|---|---|---|
| Magic link | Full standing sign-in | Bootstrap + recovery only — got you to the enrol wall, never to /office on its own |
| Passkey | Optional, faster | Required for any office access |
Historical staff lifecycle (no longer applies — staff use Google):
- First time / post-recovery: magic link → forced "set up a passkey" wall →
enrol (biometric) →
/office. - Normal: tap the passkey →
/office. - New device: magic link → wall → enrol a passkey here, or hybrid/QR.
Current reality: staff = Google OAuth only; magic links are declined for any
email holding an office role (src/app/auth/sign-in/actions.ts → emailIsStaff).
Customers: magic link is the standing sign-in, with an optional passkey on top.
Paths: /auth/* (why not /account)
The distinction is ceremony vs. home:
- Ceremonies (sign-in, OAuth/magic-link callback, the customer passkey
landing, sign-out) happen before/around having a session → live under
/auth/*. - Account home (manage passkeys, profile) is the authenticated self-service
area →
/portalfor customers, settings inside/officefor staff./accountwould be a fine name for that, but/account/sign-inis self-contradictory (you sign in to get an account context).
Route map (current):
/auth/sign-in— the unified sign-in for everyone (office + portal). Google OAuth, a customer magic link, or a customer passkey. The old/office/sign-inand/portal/loginare permanent redirects here./auth/callback— Google PKCE / magic-link landing; routes by audience (office role →/office, else →/portal, auto-creating a customer account)./auth/continue— the customer passkey landing (completessignInWithPasskey).~~/auth/enroll-passkey~~— deleted. It was the staff enrol wall; it no longer exists now that staff use Google.- Sign-out stays a server action (it also revokes the rolling staff-session row).
- Implemented under
src/app/auth/*sharing the dark sign-in layout (src/app/auth/layout.tsx).
Passkey management (add / remove) is PasskeyManager
(src/components/PasskeyManager.tsx), surfaced in the customer portal
(/portal). There is no staff passkey panel — staff don't use passkeys.
Native implementation (source of truth)
What GoTrue gives us (no custom code)
- Credential + challenge storage:
auth.webauthn_credentials/auth.webauthn_challengesin the Supabase-managedauthschema. We never create or touch these directly. - Sign-in:
supabase.auth.signInWithPasskey()— usernameless / discoverable, runs the fullnavigator.credentials.get()ceremony and issues a real session. This is the optional one-tap customer login (a secondary button on the sign-in page; not used by staff). - Enrolment:
supabase.auth.registerPasskey()— registers a passkey for the currently authenticated user (a signed-in customer adds one from the portal viaPasskeyManager). - Management:
supabase.auth.passkey.*(+admin.passkey.*) to list / remove credentials. - Capability check:
browserSupportsWebAuthn()from the SDK gates the UI.
All passkey methods require the experimental flag — the client is created with
auth: { experimental: { passkey: true } }. Calling them without it throws.
Client topology
Passkey ceremonies call navigator.credentials, so they must run in the
browser. New src/lib/auth/supabase-browser.ts builds a
createBrowserClient (@supabase/ssr) with the experimental flag; it syncs the
session into the same cookies the SSR server client already reads, so the
existing getSupabaseServerClient() / office + portal gates see the session with
no bridge. Magic-link stays a server action exactly as today.
Server config (supabase/config.toml)
Uncomment + set, then restart Supabase:
[auth.passkey]
enabled = true
[auth.webauthn]
rp_display_name = "studio.chat"
rp_id = "localhost"
rp_origins = ["http://localhost:3026"]
rp_id/rp_originsare the relying-party binding. Local useslocalhost(passkeys work there); a127.0.0.1origin would needrp_id = "127.0.0.1", so we standardize office/passkey testing onhttp://localhost:3026.- Prod sets
rp_id = "studio.chat",rp_origins = ["https://studio.chat"]in the Supabase dashboard (not this file) — tracked alongside the redirect allowlist. The Cloudflare tunnel (*.trycloudflare.com) and per-deploy preview hashes bind passkeys to ephemeral domains → useless; this only affects the customer passkey convenience (staff use Google), and the office dev-bypass remains the local office shortcut.
Assurance signal (how the gate knows a session used a passkey) — removed
Superseded. This passkey-derived assurance check is gone. The gate no longer reads
amr/aalto tell a passkey session from a magic-link one; there's no auth-method check at all.getSessionAssurance()(src/lib/auth/staff-session.ts) now returns only the verified JWTsession_id— the key for the rolling-session record. The historical design readsupabase.auth.getClaims()amr/aaland considered an MFA-factor (AAL2) fallback; neither is used now.
Staff session record (the only custom table — still current)
GoTrue has no per-domain idle policy, and we want the timeout staff-only (customers keep normal longevity), so we keep one lean table. This is live and unchanged — only what it's keyed against shifted (any office session, not a passkey one):
staff_sessions (RLS deny-all, service-role only):
| column | notes |
|---|---|
session_id text pk | GoTrue session id from the JWT session_id claim |
account_id uuid → accounts(id) | for admin "sign out everywhere" |
created_at timestamptz | absolute-cap anchor |
last_activity_at timestamptz | rolling-idle anchor |
revoked_at timestamptz | sign-out / admin kill |
No passkey_verified column — there is no assurance flag. The row keys off the
JWT session_id (trustworthy), so it can't be forged from the client.
Rolling-timeout enforcement (office gate) — current
Single chokepoint: src/lib/office/auth.ts (getOfficeAccess), backed by
evaluateStaffSession() in src/lib/auth/staff-session.ts. For non-dev-bypass
sessions:
- The gate requires a valid Supabase session + an office role + a live,
non-idle, non-revoked
staff_sessionsrow → office user. No auth-method check — a Google staff session is the normal case. There is no enrol wall and no redirect to/auth/enroll-passkey(that route is gone). evaluateStaffSession()lazily creates the row on the session's first office request, then enforces the absolute cap + idle limit and toucheslast_activity_at, throttled to ≥60s so a busy session isn't a write per request.- Idle limit (
OFFICE_IDLE_LIMIT_MIN, default 15 min):now − last_activity > limit⇒ stateidle⇒ the gate bounces to a re-auth (now a Google re-auth, not a passkey tap). Absolute cap (OFFICE_SESSION_MAX_HOURS, default 12 h):now − created_at > cap⇒ stateexpired⇒ full fresh sign-in. - Sign-out / revoke sets
revoked_at(revokeStaffSession); the sidebar sign-out callssupabase.auth.signOut().revokeAllStaffSessions()is the "sign out everywhere" / admin-kill path. - Dev-bypass stays exempt. Customers: no rolling timeout, no requirement.
Rollout
- Staff bootstrap is a Google sign-in — no passkey, no enrol step. The
"provision brandon as admin" note is "grant the role, then Google sign-in"
(see
docs/architecture/auth.md→ Provisioning). - Prod Supabase redirect allowlist must include
/auth/callback(tracked) and the Google provider must be enabled in the prod project; local Google is config-as-code ([auth.external.google]insupabase/config.toml).
What got removed (vs. the original password world)
signInWithPassword,sendPasswordReset, the password + reset form modes,/office/set-password, andSetPasswordForm— all gone.- The staff passkey requirement and the enrol wall (
/auth/enroll-passkey) — built, then removed in the Google-OAuth pivot. - Still alive: the customer magic-link infra (
src/app/api/auth/email/route.ts,src/lib/auth/email-templates.ts, the/office/emailspreview), customer passkeys (PasskeyManager,signInWithPasskey), and the rolling staff session.
Verification
Real Chrome (chrome-devtools MCP) on localhost (passkeys work there) + Mailpit
for magic links. Cover: staff Google sign-in → /office; staff magic link is
declined (lands at the portal/customer path, never /office); idle timeout →
re-auth; customer magic-link sign-in → /portal; customer passkey enrol +
sign-in via PasskeyManager / signInWithPasskey; remove-passkey.