Files
famdone/auth-v2.md
T
2026-08-16 10:11:39 +01:00

9.5 KiB

Auth architecture

Current implementation: SvelteKit directly + PocketBase. Hono is not part of auth today — it is reserved for future services (email, etc.). The /api proxy target still points at it, but no auth traffic flows through it.

                        ┌────────────────────────────────────────────────────────┐
                        │                      BROWSER (Svelte)                  │
                        │   /login /signup /join forms · $app/forms · enhance   │
                        └──────────────────────────┬─────────────────────────────┘
                                                   │ 1. form action POST
                                                   │    /login · /signup · /join
                                                   ▼
                        ┌────────────────────────────────────────────────────────┐
                        │                SVELTEKIT SERVER (Node)                 │
                        │                                                        │
                        │  hooks.server.ts  (runs once per request, first)      │
                        │    · read httpOnly cookie  pb_token                   │
                        │    · createPbClient(token) → pb.authRefresh()         │
                        │    · → { id, name, username, role, famId, color }     │
                        │    · event.locals.user / event.locals.pbToken         │
                        │    · route guards: public vs authed vs admin-only     │
                        │                                                        │
                        │  +page.server.ts (actions / loads)                    │
                        │    signup.ts / login.ts / member-otp.ts / session.ts  │
                        │    · createSuperClient (signup / OTP, superuser)      │
                        │    · createPbClient(token)  (server-to-server)        │
                        └──────────────────────────┬─────────────────────────────┘
                                                   │ 2. PB API (REST)            │
                                                   ▼
                        ┌────────────────────────────────────────────────────────┐
                        │              POCKETBASE (SERVER_IP:8090)               │
                        │  collections: users (auth) · fams · ...              │
                        │                                                        │
                        │  PB is the SOURCE OF TRUTH:                           │
                        │  · password hashing (bcrypt-style)                    │
                        │  · JWT token issue + expiry (exp claim)               │
                        │  · authRefresh / token rotation                       │
                        │  · per-collection API rules (authz boundary)          │
                        └────────────────────────────────────────────────────────┘

  Future (NOT auth):  SvelteKit → /api proxy → Hono → email & other services

Roles (in the users collection)

Role Auth Session cookie Record in
Admin PB email + password pb_token (24hr JWT) users (role parent)
Member Invite OTP + server-derived password pb_token (httpOnly) users (role child)
Superuser PB _superusers (server-side only, pb-admin) — —
  • Admins (parents) authenticate via email/password → PB JWT in an httpOnly pb_token cookie. The session user is { id, name, username, role: 'parent', famId, color }.
  • Members (children) are users records with role='child'; their PB username is the composite {famSlug}:{handle} (globally unique auth identity) where handle is the whitespace-free lowercase form of their name, and name keeps the raw display name. Their PB password is derived server-side as MEMBER_SECRET + famSlug + username (they never know or type it). Access is gated by a 20-minute OTP in user_configs. On join they authWithPassword and get the same httpOnly pb_token cookie. There is no separate members collection anymore.
  • Platform superuser (_superusers) is used only server-side by pb-admin.ts for cross-family / signup / OTP writes. Not an app role.

Request lifecycle (authenticated)

  1. Browser sends request; sends cookie pb_token (httpOnly, sameSite=lax, secure in prod).
  2. hooks.server.ts extracts the token.
  3. createPbClient(token) builds a PB client pre-authenticated as that user.
  4. pb.collection('users').authRefresh():
    • validates the token (PB JWTs can't be checked offline),
    • returns the fresh record → event.locals.user = { id, name, username, role, famId, color },
    • returns a fresh token; if it changed, the cookie is rolled forward.
  5. Route guard runs (public / authed / admin-only).
  6. +page.server.ts / +server.ts use locals.user for identity; use the token-backed PB client for any CRUD so PB's collection rules apply.

Because pb_token is httpOnly, the browser PB SDK cannot read it from document.cookie. It is instead seeded from the SSR page.data.pbToken prop via initPb(token) in the [fam]/+layout.svelte onMount.

Signup flow (/signup)

Multi-step form on a single route. All steps run as form actions on the server; the use:enhance handler only advances the step on a non-failure result.

   Step 1 (?/signup)   familyName + yourName + email + password
      · create fams  → fam (slug = slugify(familyName))
      · create users → parent (role 'parent', name = yourName, username = `{famSlug}:{handle(yourName)}`)
      · create settings
      · authWithPassword(email, password) → set httpOnly pb_token cookie → step 2

   Step 2 (?/child)   child's first name (optional)
      · issueAccess() → upserts child user + OTP → returns { code, joinUrl } → step 3
      · or "Skip for now" → dashboard

   Step 3              show OTP join code + joinUrl (+ "Go to dashboard" link)

All create calls use the superuser client (createSuperClient / pbAdmin), which bypasses PB collection rules. PB requires a username on users auth records — for both parents and children it's the composite {famSlug}:{handle} (globally unique so PB's auth-identity unique index holds, even though the URL segment is only per-family), where handle is the whitespace-free lowercase form of the name ("Joe Edhook" → joeedhook). name keeps the raw human-entered display name. The URL segment is handleOf(username) (part after the last :) → /{famSlug}/{handle}; parents still authenticate with email+password and land on the fam dashboard /{famSlug}.

Child join flow (/{famSlug}/join/{username}?code=…)

  1. Admin issues a child in Settings → issueAccess (member-otp.ts): createChild({ name, famId, famSlug }) upserts the users record (username = {famSlug}:{handle(name)}, name = display, password = derived), and upserts a user_configs row with a 20-min otp. Returns { otp, joinUrl }.
  2. The join page auto-fills the OTP from the ?code= query param; the child submits the form.
  3. redeemOtp verifies the fam slug, the child role, the OTP + its TTL, then authWithPassword(famUsername(famSlug, handle), derivePassword(famSlug, handle)) and returns a fresh JWT, which is set as the pb_token cookie.
  4. Child is redirected to their own kanban /{famSlug}/{username}.

Key decisions to replicate in another project

  • Server-only PB client lives in src/lib/server/; never imported by browser code. Secrets (superuser creds, MEMBER_SECRET) stay server-side.
  • Absolute PB base URL in the SDK (e.g. http://host:8090), NOT a relative /pb. All calls run in Node where relative URLs fail. The Vite /pb proxy is a browser-only convenience and is unnecessary for server-side calls.
  • Env vars: read via $app/env/private for private vars (declared in src/env.ts), never $app/env/public.
  • createSuperClient = anonymous client + _superusers auth, used for signup and OTP writes. Superusers bypass PB collection rules.
  • Session cookie: PB JWT in pb_token, httpOnly:true; expiry governed by the token's exp, cookie maxAge is just a ceiling; authRefresh rolls it forward.
  • Authz boundary lives in PocketBase collection rules (famId scoping), not just app code.
  • Child identity is a users record; username (slug) is used for URLs and the derived password, name for display. Never store raw deviceToken; the OTP gate is transient.