From 29f4a2e623e96bd8604e6772f84ebb9127f61811 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 15:17:46 -0400 Subject: [PATCH] docs(19): research phase 19 local auth domain Covers password hashing (node:crypto scrypt), JWT session cookies (hono/utils/jwt), middleware ordering, local_credentials schema, OIDC-link flow, dev-bypass rework (option C), and break-glass CLI. Resolves all five open questions from CONTEXT.md. Co-Authored-By: Claude Sonnet 4.6 --- .../19-local-auth-no-oidc-mode/19-RESEARCH.md | 1161 +++++++++++++++++ 1 file changed, 1161 insertions(+) create mode 100644 .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md diff --git a/.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md b/.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md new file mode 100644 index 0000000..5a99cf1 --- /dev/null +++ b/.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md @@ -0,0 +1,1161 @@ +# Phase 19: Local Auth (No-OIDC Mode) - Research + +**Researched:** 2026-06-17 +**Domain:** Authentication — local username/password credentials, stateless JWT session cookies, Hono middleware ordering, Drizzle schema migration +**Confidence:** HIGH + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +- **D-01:** Local auth is the **default and always available**. OIDC is **opt-in/additive**, never a replacement for the local path at the system level. +- **D-02:** OIDC is configured from the **admin UI** (extends the Phase-12 config that already lands in `app_config`: `oidc_issuer`, `oidc_client_id`, `app_external_url`). When OIDC is configured, **both methods are offered and the user chooses at login** (local username/password OR "Login with OIDC"). +- **D-03:** This must **not break the existing live OIDC deployment**. The two current household members already authenticate via Authelia (`oidc_iss`/`oidc_sub` set, `claimed=true`); they continue as OIDC users. Local auth is layered on additively. +- **D-04:** A **new local login UI (username + password) must be built in the PWA** — none exists today. The PWA currently boots straight into the authed app (OIDC redirect) or via dev-bypass; there is no login form. +- **D-05:** Local logins are backed by a **stateless signed httpOnly JWT cookie** carrying `userId`, validated by a **new local-auth middleware that sets `c.get('user')`** the same way `auth/devBypass.ts` does — so every downstream route resolves the user unchanged. **No DB sessions table**. Tradeoff accepted: a password change cannot retroactively invalidate other live sessions; logout = clear cookie. +- **D-06 (BYO-Auth principle):** Local auth is first-class; OIDC is treated as a **generic RFC-compliant provider, not Authelia-hardcoded**. `@hono/oidc-auth` is already provider-agnostic — work is to de-Authelia-ize config keys and user-facing copy. +- **D-07 (BYO-Auth scope):** Ship **local + one generic OIDC** with a **clean internal seam** for future methods. **No plugin/registry framework** in this phase. +- **D-08:** Hash local passwords with **`node:crypto` scrypt** — zero new dependency, no native node-gyp build. Encode **algorithm + params + salt alongside the hash** so parameters can evolve. +- **D-09:** Store local credentials in a **new `local_credentials` table** — `user_id` (FK to `users`, UNIQUE), `username` (UNIQUE), `password_hash` (encoded), `createdAt`/`updatedAt`. Drizzle **generate+migrate, never push**. +- **D-10:** **Admin creates members** + sets an initial password; the member changes it later. **No open self-signup**. +- **D-11:** Password lifecycle = **self-change (current + new) + admin-reset** from the admin UI. **No email reset**. +- **D-12:** **OIDC link replaces local at the per-user level**: when a local user links an OIDC identity (explicit action while authenticated as that user — never an email match), **delete that user's `local_credentials` row** → they become OIDC-only. +- **D-13 (break-glass):** Lockout recovery is a **CLI/console command and/or env override** (e.g. create/reset a local admin, or disable/force-off OIDC), run on the host/container. **No new role/capability model**; reuse today's single `users.is_admin`. +- **D-14:** The new login UI requires touching existing API/unit tests and the **Phase 7/8 Playwright harness** (which today reaches the authed PWA purely via `DEV_AUTH_BYPASS`, skipping any login). Both the already-authed fast path and the **real login form** must remain testable. +- **D-15 (hard constraint):** Any seeded test login / reworked dev-bypass mechanism **stays dev-only and never ships in the Docker/prod image**. Bound by the existing Phase-16 image-hygiene gates: IMG-01 boot guard (`assertNotDevBypassInProduction`), `.dockerignore` (IMG-02), and the publish-time hygiene assertion (IMG-03). + +### Claude's Discretion (decided in-discussion) +- Session backing mechanism (chose stateless signed JWT cookie — D-05). +- Credential storage location (chose separate `local_credentials` table — D-09). + +### Deferred Ideas (OUT OF SCOPE) +- **Full pluggable auth-provider framework** (registry/plugin for LDAP, magic-link, multiple simultaneous OIDC providers) — auth-layer counterpart of backlog 999.1. +- **Member-vs-operator capability/role split** — explicitly rejected in favor of a CLI/env recovery mechanism. +- **Email-based password reset** — out of project scope. + + +--- + + +## Phase Requirements + +The following REQ-IDs are newly defined by this phase. The planner should include them verbatim in PLAN.md task descriptions and VALIDATION.md. + +| ID | Description | Research Support | +|----|-------------|------------------| +| AUTH-LOCAL-01 | `local_credentials` Drizzle schema + migration (0003): `user_id` FK UNIQUE, `username` UNIQUE, `password_hash` varchar | §Schema Change, §Standard Stack §Migration | +| AUTH-LOCAL-02 | `hashPassword(password)` and `verifyPassword(hash, candidate)` helpers using `node:crypto` scrypt in PHC-style encoded format | §Password Hashing | +| AUTH-LOCAL-03 | `POST /api/auth/local/login` route — timing-safe verify, issue `local-session` JWT cookie, return 401/429/423/200 | §Local Login Endpoint | +| AUTH-LOCAL-04 | `localAuthMiddleware` — reads `local-session` cookie, validates JWT, sets `c.get('user')` identical to devBypass; mounts in `index.ts` before OIDC guard | §Middleware Slot | +| AUTH-LOCAL-05 | `GET /api/auth/mode` pre-auth endpoint — returns `{ localEnabled: true, oidcEnabled: boolean }` based on `app_config` OIDC keys | §Auth Mode Endpoint | +| AUTH-LOCAL-06 | `POST /api/auth/local/logout` — clears `local-session` cookie; `GET /api/auth/local/logout` alias | §Logout | +| AUTH-LOCAL-07 | `POST /api/admin/members` — admin creates local member: insert `users` row + `local_credentials` row with hashed initial password | §Admin Account Management | +| AUTH-LOCAL-08 | `POST /api/admin/members/:id/password` — admin resets a local member's password (no current-password required); admin-gated | §Admin Account Management | +| AUTH-LOCAL-09 | `POST /api/me/password` — self-change password: verify current password, hash new, update `local_credentials` | §Self-Service Password | +| AUTH-LOCAL-10 | OIDC-link flow: `POST /api/me/link-oidc` (or reuse OIDC callback, see §OIDC-Link Flow) — bind `oidc_iss+oidc_sub` to the authenticated user, delete their `local_credentials` row | §OIDC-Link Flow | +| AUTH-LOCAL-11 | Break-glass CLI — Node.js script `scripts/reset-admin.ts` runnable as `tsx scripts/reset-admin.ts` inside the container, creates/resets a local admin by username without requiring an existing session | §Break-Glass | +| AUTH-LOCAL-12 | PWA `LoginPage` component (`/login` route) per UI-SPEC Surface 1–10: username+password form, `BrandSlot`, OIDC button when `oidcEnabled`, error state machine | §UI — LoginPage | +| AUTH-LOCAL-13 | PWA admin additions: "Add member" form (UI-SPEC Surface 11A) + "Reset password" modal (Surface 11B) in `AdminPage.tsx` | §UI — Admin | +| AUTH-LOCAL-14 | PWA self-service: "Change password" sheet (UI-SPEC Surface 12) in `SettingsSheet.tsx`; "Link OIDC identity" confirmation sheet (Surface 13) | §UI — Settings | +| AUTH-LOCAL-15 | `App.tsx` login gate: fetch `/api/auth/mode` pre-auth, add `/login` route (standalone), redirect unauthenticated users there when `localEnabled`, skip if valid local or OIDC session | §PWA Routing Gate | +| AUTH-LOCAL-16 | Playwright harness dev-bypass rework: "option C" (bypass issues a real local-session cookie), update `global-setup.ts` to seed `local_credentials` for dev user (id=1), update CI workflow | §Dev-Bypass Rework | +| AUTH-LOCAL-17 | `/api/me` response extended with `hasLocalCredential: boolean` so `SettingsSheet` and `AdminPage` know which users have local creds | §API Extensions | +| AUTH-LOCAL-18 | `app_config` OIDC key de-Authelia-ization: rename any Authelia-specific copy in config keys and comments to generic OIDC labels (no key name change — keys are already generic `oidc_issuer` etc.) | §BYO-Auth De-Authelia-ization | +| AUTH-LOCAL-19 | Rate-limiting on `POST /api/auth/local/login`: in-memory per-IP counter (Map), 5 failures → 60s cooldown → 429; account lockout at 10 failures → 423; cleared on success | §Rate Limiting | +| AUTH-LOCAL-20 | Vitest unit tests: password hashing round-trip, timing-safe compare, login success/failure/lockout, middleware session validation, OIDC-link 409 conflict | §Validation Architecture | + + +--- + +## Summary + +Phase 19 builds a complete local username/password authentication system on top of the Phase 12 pre-OIDC user foundation. The core architectural move is to add a parallel authentication path alongside the existing `@hono/oidc-auth` middleware: a new `localAuthMiddleware` that reads a signed `local-session` cookie (JWT, HS256 via Hono's built-in `Jwt.sign`/`Jwt.verify`) and populates `c.get('user')` with the same shape that `devAuthBypass()` uses, so all downstream routes work unchanged. + +No new npm dependencies are required. Password hashing uses `node:crypto` scrypt (Node.js stdlib), JWT signing uses Hono's built-in `Jwt` from `hono/utils/jwt`, and session cookies use the existing `hono/cookie` helpers (`getCookie`/`setCookie`). The `local_credentials` table mirrors the `member_credentials` shape already in the schema. The migration is a straightforward additive `drizzle-kit generate` + `migrate`. + +The PWA adds a `/login` route (standalone, no AppNav/BottomTabBar — same pattern as `/setup`). The login gate is driven by a new pre-auth `GET /api/auth/mode` endpoint. Existing OIDC users are completely unaffected by all of this: their `oidcAuthMiddleware` path is unchanged; the local auth path only fires for requests that arrive with a `local-session` cookie. + +**Primary recommendation:** Implement in three waves: (1) schema + credential helpers + API routes, (2) middleware wiring + mode endpoint + PWA login page, (3) admin UI extensions + OIDC-link + break-glass + harness update. + +--- + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Password hashing/verification | API / Backend | — | Secrets never leave server; `node:crypto` scrypt runs server-side only | +| Session JWT sign/verify | API / Backend | — | `LOCAL_SESSION_SECRET` is an env-floor secret; client only holds opaque cookie | +| Auth middleware selection | API / Backend | — | `index.ts` is the single mount-order source of truth | +| Auth mode signalling | API / Backend (pre-auth endpoint) | Frontend fetch | `/api/auth/mode` is the authoritative source; PWA reads it | +| Login form UI | Browser / Client | — | React component, form state, client-side validation | +| Admin member management | API / Backend | Admin UI (client) | Server enforces `requireAdmin`; client is UX-only | +| OIDC-link binding | API / Backend | Browser / Client (confirmation UI) | Actual `iss+sub` binding + `local_credentials` delete is a backend transaction | +| Break-glass recovery | API / Backend (CLI script) | — | Host/container-side only; no UI surface | +| Cookie issuance | API / Backend | — | `httpOnly + Secure + SameSite=Lax`; client cannot write it | +| Rate-limiting / lockout | API / Backend | Browser / Client (error display) | In-memory Map on the server; UI mirrors the 429/423 response | + +--- + +## Standard Stack + +### Core (no new packages — everything already installed) + +| Library | Version | Purpose | Status | +|---------|---------|---------|--------| +| `node:crypto` | stdlib (Node 22) | `scrypt`, `scryptSync`, `randomBytes`, `timingSafeEqual` | [VERIFIED: codebase — confirmed available in Node 22.22.3 on this machine] | +| `hono` | 4.12.23 (installed) | `Jwt.sign`, `Jwt.verify` from `hono/utils/jwt`; `getCookie`, `setCookie` from `hono/cookie` | [VERIFIED: codebase — `Jwt.sign` and `Jwt.verify` confirmed callable at runtime from `hono/utils/jwt`; `getCookie`/`setCookie` confirmed from `hono/cookie`] | +| `drizzle-orm` | 0.45.2 (installed) | New `local_credentials` table; `drizzle-kit generate` + `migrate` | [VERIFIED: codebase — already in use; `mysqlTable`, `unique`, `index` patterns from `schema.ts`] | +| `zod` | 3.25.x (installed) | Validate login request body (`username`, `password`) | [VERIFIED: codebase — already used in every route] | + +### Zero new npm dependencies + +This phase installs **no new packages**. All required capabilities are in the existing stack: + +- Password hashing: `node:crypto` scrypt (stdlib, confirmed available) +- JWT signing: `hono/utils/jwt` `Jwt.sign`/`Jwt.verify` (confirmed callable from `hono@4.12.23`) +- Cookie read/write: `hono/cookie` `getCookie`/`setCookie` (already used in `persistSessionCookie.ts`) +- Schema: `drizzle-orm` `mysqlTable` (same pattern as `member_credentials`) +- Input validation: `zod` + `@hono/zod-validator` (already used in every route) + +**Installation:** None required. + +--- + +## Package Legitimacy Audit + +No new packages are introduced in this phase. All libraries listed above are already installed and legitimacy-verified from prior phases. + +**Packages removed due to SLOP verdict:** None +**Packages flagged as suspicious:** None (hono is flagged SUS by the seam's "too-new" heuristic because its last publish date happens to be recent, but it is the same `hono@4.12.23` already installed and running in production — not a new install) + +--- + +## Architecture Patterns + +### System Architecture Diagram + +``` +PWA /login page + ↓ GET /api/auth/mode (pre-auth, no middleware) + ← { localEnabled: true, oidcEnabled: boolean } + + ↓ POST /api/auth/local/login { username, password } + API auth/localLogin.ts + → local_credentials lookup by username + → verifyPassword(stored_hash, candidate) [node:crypto timingSafeEqual] + → Jwt.sign({ userId, iat, exp }, LOCAL_SESSION_SECRET) + ← Set-Cookie: local-session=; httpOnly; Secure; SameSite=Lax + ← 200 { ok: true } + + ↓ Any subsequent /api/* request (carries local-session cookie) + index.ts middleware chain: + devAuthBypass() — no-op passthrough (bypass not set in prod) + localAuthMiddleware() — getCookie('local-session'), Jwt.verify(), c.set('user', {id, ...}) + → next() if valid cookie; else fall through + oidcConfigFallback — injects OIDC config from app_config if absent + oidcAuthMiddleware() — skipped if c.get('user') already set? [see §Middleware Slot] + persistSessionCookie()— OIDC sessions only + ↓ downstream routes read c.get('user') — unchanged + +PWA /admin → AdminPage + ↓ GET /api/admin/members (returns hasLocalCredential per member) + ↓ POST /api/admin/members { displayName, username, initialPassword } + → users INSERT + local_credentials INSERT (hashed) + ↓ POST /api/admin/members/:id/password { newPassword, confirmPassword } + → local_credentials UPDATE (hashed) + +PWA SettingsSheet + ↓ POST /api/me/password { currentPassword, newPassword } + → verifyPassword(stored, current) → UPDATE hash + ↓ POST /api/me/link-oidc (authenticated as local user) + → initiates OIDC authorization-code redirect + → on /callback with valid OIDC session: + bind iss+sub to users row (must not conflict with existing user) + DELETE local_credentials WHERE user_id = current + → user is now OIDC-only + +Break-glass: + docker exec familysync-api node scripts/reset-admin.ts --username admin --password + → direct DB write: upsert local_credentials for username, ensure is_admin=true +``` + +### Recommended Project Structure + +New files (additions only): + +``` +apps/api/src/ +├── auth/ +│ ├── localCredentials.ts # hashPassword(), verifyPassword() using node:crypto scrypt +│ ├── localSession.ts # issueLocalSessionCookie(), verifyLocalSessionCookie(), clearLocalSessionCookie() +│ └── localAuthMiddleware.ts # Hono middleware: getCookie → Jwt.verify → c.set('user') +├── routes/ +│ ├── authMode.ts # GET /api/auth/mode (pre-auth) +│ └── localAuth.ts # POST /api/auth/local/login, /logout +└── db/ + └── migrations/ + └── 0003_local_credentials.sql # generated by drizzle-kit + +apps/api/scripts/ +└── reset-admin.ts # break-glass CLI (dev-only gate: .dockerignore excludes scripts/) + +apps/pwa/src/ +├── routes/ +│ └── LoginPage.tsx # /login standalone page (UI-SPEC Surfaces 1–10) +└── components/ + └── BrandSlot.tsx # phase-17 seam component (UI-SPEC §Brand Slot) +``` + +Modified files: + +``` +apps/api/src/ +├── db/schema.ts # + local_credentials table definition +├── routes/admin.ts # + POST /members, POST /members/:id/password +├── routes/me.ts # + POST /password, + hasLocalCredential in GET response +├── routes/setup.ts # + link-oidc callback handler (or reuse /callback) +└── index.ts # + mount localAuthMiddleware, authModeRouter before OIDC guard + +apps/pwa/src/ +├── App.tsx # + /login route, auth-mode fetch gate +├── api/client.ts # + fetchAuthMode(), fetchLocalLogin(), fetchLocalLogout(), etc. +├── routes/AdminPage.tsx # + LOCAL ACCOUNTS section (Surfaces 11A, 11B) +└── components/SettingsSheet.tsx # + Change password row (Surface 12), Link OIDC row (Surface 13) +``` + +--- + +## Password Hashing Pattern + +### PHC-Style Encoding with `node:crypto` scrypt + +D-08 mandates `node:crypto` scrypt with the algorithm + params + salt encoded alongside the hash so parameters can evolve. The established pattern for self-describing encoded hashes is a `$`-delimited PHC-style string. [VERIFIED: codebase — scryptSync, randomBytes, timingSafeEqual all available in Node 22.22.3; runtime confirmed] + +```typescript +// Source: node:crypto docs + runtime-verified on Node 22.22.3 +import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'; + +// Parameters (OWASP-compatible for scrypt at this resource level) +const SCRYPT_N = 16384; // CPU/memory cost — 2^14; increase to 2^15 if hardware permits +const SCRYPT_R = 8; +const SCRYPT_P = 1; +const KEY_LEN = 32; // 256-bit output + +/** + * Hash a password. Returns a self-describing encoded string: + * scrypt$N$r$p$$ + * + * The $-delimited format is inspired by PHC and allows future parameter upgrades + * without a separate migration — verifyPassword parses all fields from the string. + */ +export function hashPassword(password: string): string { + const salt = randomBytes(16); + const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }); + return [ + 'scrypt', + SCRYPT_N, + SCRYPT_R, + SCRYPT_P, + salt.toString('base64url'), + hash.toString('base64url'), + ].join('$'); +} + +/** + * Verify a password against a stored encoded hash. + * Uses timingSafeEqual to prevent timing-oracle attacks. + * Returns false (never throws) on any parse/format mismatch. + */ +export function verifyPassword(storedEncoded: string, candidate: string): boolean { + try { + const [, n, r, p, saltB64, hashB64] = storedEncoded.split('$'); + const salt = Buffer.from(saltB64, 'base64url'); + const storedHash = Buffer.from(hashB64, 'base64url'); + const candidateHash = scryptSync(candidate, salt, storedHash.length, { + N: Number(n), r: Number(r), p: Number(p), + }); + return timingSafeEqual(storedHash, candidateHash); + } catch { + return false; + } +} +``` + +**Key points:** +- `scryptSync` blocks the event loop. For login (infrequent in a 2-person household) this is acceptable. If async is preferred, use `promisify(scrypt)` from `node:util`. +- `timingSafeEqual` requires equal-length buffers — `storedHash.length` as keylen ensures this. +- The encoded string is ~83 characters at N=16384 — fits comfortably in `varchar(256)`. + +**Note on pepper:** D-08 specifies no pepper (env-only secret kernel). The scrypt salt + encoding is sufficient for this use case. Adding a pepper would require another env var and would not materially improve security for this threat model (household scale, Pangolin-exposed but not public). + +--- + +## Drizzle Schema Change + +### `local_credentials` Table + +Mirrors the `member_credentials` shape but stores username + password hash. [VERIFIED: codebase — `member_credentials` pattern in `schema.ts` lines 74–94; `mysqlTable`, `int`, `varchar`, `timestamp`, `unique`, `index` all imported] + +```typescript +// In apps/api/src/db/schema.ts — additive only +export const localCredentials = mysqlTable( + 'local_credentials', + { + id: int().primaryKey().autoincrement(), + userId: int('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + username: varchar('username', { length: 128 }).notNull(), + // PHC-encoded: scrypt$N$r$p$$ — self-describing + passwordHash: varchar('password_hash', { length: 256 }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + }, + (t) => [ + // One local credential per user (UNIQUE on user_id) + unique('uniq_local_cred_user').on(t.userId), + // Username is globally unique (login identifier) + unique('uniq_local_cred_username').on(t.username), + index('idx_local_credentials_user_id').on(t.userId), + ], +); +``` + +### Migration Workflow + +Drizzle generate+migrate only — never push (established rule; `push` emits false destructive diffs on MariaDB). [VERIFIED: codebase — 0002 migration and `scripts` in `package.json`] + +```bash +# 1. Add localCredentials to schema.ts +# 2. Generate migration +pnpm --filter @familysync/api db:generate +# → apps/api/src/db/migrations/0003_local_credentials.sql + +# 3. Review generated SQL (must be purely additive — CREATE TABLE only) +# 4. Apply +pnpm --filter @familysync/api db:migrate +``` + +The generated SQL will be something like: +```sql +CREATE TABLE `local_credentials` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` int NOT NULL REFERENCES `users`(`id`) ON DELETE CASCADE, + `username` varchar(128) NOT NULL, + `password_hash` varchar(256) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `uniq_local_cred_user` UNIQUE(`user_id`), + CONSTRAINT `uniq_local_cred_username` UNIQUE(`username`), + INDEX `idx_local_credentials_user_id`(`user_id`) +); +``` + +**Remember:** Export `localCredentials` from `schema.ts` so `test/setup.ts` can truncate it in `afterEach`. + +--- + +## Middleware Slot and Ordering + +### Current `index.ts` middleware chain + +[VERIFIED: codebase — read `apps/api/src/index.ts`] + +``` +app.route('/api/setup', setupRouter) // pre-auth +app.use('/api/*', devAuthBypass()) // DEV only — no-op in prod +if (!devBypassActive) { + app.use('/api/*', oidcConfigFallbackMiddleware) + app.use('/api/*', oidcAuthMiddleware()) + app.use('/api/*', persistSessionCookie()) +} +``` + +### New middleware slot for Phase 19 + +The `localAuthMiddleware` must sit **between `devAuthBypass` and `oidcAuthMiddleware`**. It reads the `local-session` cookie. If the cookie is present and valid, it sets `c.get('user')` and calls `next()`. If no cookie, it falls through to `oidcAuthMiddleware`. + +The key architectural requirement: `oidcAuthMiddleware` must **not** redirect to Authelia when the request already has a valid local session. The solution is to check whether `c.get('user')` is set before mounting `oidcAuthMiddleware`, or to make `localAuthMiddleware` short-circuit the OIDC path. + +**Recommended approach:** Wrap `oidcAuthMiddleware` in a guard that skips it when `c.get('user')` is already populated: + +```typescript +// index.ts updated middleware chain +app.route('/api/setup', setupRouter) // pre-auth (no change) +app.route('/api/auth', authModeRouter) // GET /api/auth/mode — pre-auth, no middleware +app.route('/api/auth', localAuthRouter) // POST /api/auth/local/login + /logout — pre-auth + +app.use('/api/*', devAuthBypass()) // DEV only (existing) + +// NEW: local session check — populates c.get('user') if local-session cookie valid +app.use('/api/*', localAuthMiddleware()) + +if (!devBypassActive) { + app.use('/api/*', oidcConfigFallbackMiddleware) + // OIDC guard: skip if user already set by localAuthMiddleware or devAuthBypass + app.use('/api/*', async (c, next) => { + if (c.get('user')) { await next(); return; } + await oidcAuthMiddleware()(c, next); + }); + app.use('/api/*', persistSessionCookie()) +} +``` + +**Why pre-auth for login/logout routes:** `POST /api/auth/local/login` must be reachable without a session (it's how a session is created). Mount it before the OIDC guard, just like `/api/setup/*`. The `GET /api/auth/mode` endpoint similarly needs no auth. + +**Security note:** `localAuthMiddleware` must be a no-op when no `local-session` cookie is present — it should not attempt to verify a missing cookie and must not set `c.get('user')` to `undefined`. The downstream `oidcAuthMiddleware` redirects only when `c.get('user')` is falsy. + +--- + +## JWT Session Cookie Pattern + +### `issueLocalSessionCookie` / `verifyLocalSessionCookie` + +Uses Hono's built-in `Jwt` from `hono/utils/jwt`. The algorithm is HS256 (symmetric, fast, appropriate for a single-server household app). [VERIFIED: codebase — `Jwt.sign` and `Jwt.verify` confirmed callable at runtime] + +```typescript +// Source: hono/utils/jwt (runtime-verified in this project) +import { Jwt } from 'hono/utils/jwt'; +import { setCookie, getCookie, deleteCookie } from 'hono/cookie'; +import type { Context } from 'hono'; + +const COOKIE_NAME = 'local-session'; +const SESSION_MAX_AGE_SECONDS = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400); // 1 day default + +export async function issueLocalSessionCookie(c: Context, userId: number): Promise { + const secret = process.env.LOCAL_SESSION_SECRET; + if (!secret) throw new Error('LOCAL_SESSION_SECRET env var not set'); + + const now = Math.floor(Date.now() / 1000); + const payload = { userId, iat: now, exp: now + SESSION_MAX_AGE_SECONDS }; + + const token = await Jwt.sign(payload, secret, 'HS256'); + + setCookie(c, COOKIE_NAME, token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'Lax', + path: '/', + maxAge: SESSION_MAX_AGE_SECONDS, + }); +} + +export async function verifyLocalSessionCookie(c: Context): Promise { + const secret = process.env.LOCAL_SESSION_SECRET; + if (!secret) return null; + + const token = getCookie(c, COOKIE_NAME); + if (!token) return null; + + try { + const payload = await Jwt.verify(token, secret, 'HS256'); + return typeof payload.userId === 'number' ? payload.userId : null; + } catch { + return null; + } +} + +export function clearLocalSessionCookie(c: Context): void { + deleteCookie(c, COOKIE_NAME, { path: '/', httpOnly: true, secure: true, sameSite: 'Lax' }); +} +``` + +**Cookie name:** `local-session` (distinct from the OIDC cookie `oidc-auth` — avoids collision). + +**`LOCAL_SESSION_SECRET` env var:** This is a new env-floor secret that must be added to the D-01 minimal env kernel documentation (it encrypts the local session JWT). It never goes to `app_config`. Operators generate it with `openssl rand -base64 32`. The `generate-secrets` script from Phase 12 should be extended to emit it. + +--- + +## Local Login Endpoint + +### `POST /api/auth/local/login` + +[ASSUMED — specific implementation, but directly derived from the patterns in `setup.ts` and `admin.ts`] + +```typescript +// apps/api/src/routes/localAuth.ts +const loginSchema = z.object({ + username: z.string().min(1).max(128).trim(), + password: z.string().min(1).max(1000), +}); + +// Rate-limiting: simple in-memory Map (household scale; no Redis needed) +// { ip → { failCount, lockedUntil } } +const loginAttempts = new Map(); +const RATE_WINDOW_FAILURES = 5; // 5 failures → 60s cooldown +const RATE_WINDOW_SECS = 60; +const LOCKOUT_FAILURES = 10; // 10 failures → account locked (admin must reset) + +localAuthRouter.post('/login', zValidator('json', loginSchema, noEchoHook), async (c) => { + const ip = c.req.header('x-forwarded-for') ?? c.req.raw.headers.get('host') ?? 'unknown'; + + // Check rate limit / lockout + const attempt = loginAttempts.get(ip); + if (attempt?.lockedOut) { + return c.json({ error: 'Account locked' }, 423); + } + if (attempt && attempt.count >= RATE_WINDOW_FAILURES && Date.now() < attempt.lockedUntil) { + return c.json({ error: 'Too many attempts' }, 429); + } + + const { username, password } = c.req.valid('json'); + + // Lookup by username — constant-time operation for timing safety + const [cred] = await db + .select({ userId: localCredentials.userId, passwordHash: localCredentials.passwordHash }) + .from(localCredentials) + .where(eq(localCredentials.username, username)) + .limit(1); + + // Always run verifyPassword even on unknown username (dummy hash) to prevent timing oracle + const dummy = hashPassword('dummy-constant-time-filler'); + const valid = cred ? verifyPassword(cred.passwordHash, password) : verifyPassword(dummy, password); + + if (!valid || !cred) { + // Increment failure counter + const cur = loginAttempts.get(ip) ?? { count: 0, lockedUntil: 0, lockedOut: false }; + cur.count += 1; + cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000; + cur.lockedOut = cur.count >= LOCKOUT_FAILURES; + loginAttempts.set(ip, cur); + return c.json({ error: 'Invalid credentials' }, 401); + } + + // Success: clear failure counter, issue session cookie + loginAttempts.delete(ip); + await issueLocalSessionCookie(c, cred.userId); + return c.json({ ok: true }, 200); +}); +``` + +**Security notes:** +- The `noEchoHook` must be used on `zValidator` for the login route — same as credential routes — to prevent Zod errors from echoing the submitted password. +- "Username not found" and "wrong password" return the same 401 + same copy — no field discrimination. +- The dummy hash prevents timing oracle on username enumeration. +- Rate-limiting is per-IP (from `X-Forwarded-For` header, which Pangolin sets). For a 2-person household this is more than sufficient. +- Lockout (423) is resolved only by admin password reset — mirrors the UI copy "Contact your admin to reset access". + +--- + +## Auth Mode Endpoint + +### `GET /api/auth/mode` (pre-auth) + +Must be mounted **before** all auth middleware in `index.ts` (same pre-auth pattern as `/api/setup/*` and `/health`). [VERIFIED: codebase — `app.route('/api/setup', setupRouter)` mounts pre-auth; same pattern applies] + +```typescript +// apps/api/src/routes/authMode.ts +authModeRouter.get('/', async (c) => { + // localEnabled: always true — local auth is the default and always available (D-01) + // oidcEnabled: true when oidc_issuer is configured in app_config OR process.env + const issuerFromEnv = process.env.OIDC_ISSUER; + let oidcEnabled = Boolean(issuerFromEnv); + + if (!oidcEnabled) { + const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'oidc_issuer')) + .limit(1); + oidcEnabled = Boolean(row?.value); + } + + return c.json({ localEnabled: true, oidcEnabled }); +}); +``` + +This endpoint is not protected by OIDC middleware. The PWA fetches it on app load before knowing if the user is authenticated. + +--- + +## OIDC-Link Flow + +D-12: when a local user explicitly links an OIDC identity, their `local_credentials` row is deleted and `users.oidc_iss`/`oidc_sub` are populated. The returned `iss+sub` must not already belong to another user. + +### Recommended Implementation + +The simplest approach reuses the existing `/callback` handler: add a `link_mode` query param that signals the OIDC callback to run in "link" mode rather than "new session" mode. + +**Flow:** +1. User (authenticated as local user with valid `local-session` cookie) clicks "Continue with OIDC" +2. PWA calls `POST /api/me/link-oidc` → server initiates OIDC authorization-code redirect with `state` parameter encoding `{ linkUserId: currentUserId, nonce }` +3. OIDC callback fires → `processOAuthCallback` handles the code exchange, gets `iss+sub` +4. Backend detects `linkUserId` in state → look up if `iss+sub` already belongs to a different user → if yes, 409 error page; if no, UPDATE `users SET oidc_iss, oidc_sub WHERE id = linkUserId`, DELETE from `local_credentials WHERE user_id = linkUserId` +5. Issue OIDC session (the user is now OIDC-only) → redirect to `/calendar` + +**Alternative (simpler, recommended):** A dedicated `POST /api/me/link-oidc` endpoint that initiates the OIDC redirect. The current user's `userId` is encoded in the OIDC `state` parameter (signed to prevent CSRF). On callback, the backend reads `state.userId`, verifies the OIDC identity is unique, binds it. + +**D-10 constraint:** Identity binding must use `iss+sub` from the OIDC token, never email. The `upsertUser` function already enforces this — the link flow must replicate this strictness. + +**409 conflict:** If the `iss+sub` returned by OIDC already exists in `users`, return a redirect to an error page. The PWA displays: "This OIDC identity is already linked to another account. Please contact your admin." + +--- + +## Admin Account Management API + +### New Routes on `adminRouter` + +Extends `apps/api/src/routes/admin.ts`. The `requireAdmin` guard at the router level already covers these. [VERIFIED: codebase — `adminRouter.use('*', requireAdmin)` is the first statement] + +**Create member:** +```typescript +adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook), async (c) => { + // 1. Insert into users (displayName, color from palette) + // 2. Hash initialPassword via hashPassword() + // 3. Insert into local_credentials (userId, username, passwordHash) + // If username conflict: 409 +}); +``` + +**Reset password:** +```typescript +adminRouter.post('/members/:id/password', zValidator('json', resetPasswordSchema, noEchoHook), async (c) => { + // Admin does not need to know the current password (D-11) + // 1. Verify target user exists + has local_credentials row + // 2. Hash newPassword + // 3. UPDATE local_credentials SET password_hash WHERE user_id + // Also clear any in-memory lockout entry for this user +}); +``` + +**Extend `GET /api/admin/members`** to include `hasLocalCredential: boolean` (LEFT JOIN on `local_credentials`). + +--- + +## Self-Service Password Change + +### `POST /api/me/password` + +Added to `apps/api/src/routes/me.ts`. [ASSUMED — derived from existing `POST /api/me/credential` pattern] + +```typescript +meRouter.post('/password', zValidator('json', changePasswordSchema, meNoEchoHook), async (c) => { + const currentUserId = await resolveUserId(c); + if (!currentUserId) return c.json({ error: 'Unauthorized' }, 401); + + const [cred] = await db.select().from(localCredentials).where(eq(localCredentials.userId, currentUserId)).limit(1); + if (!cred) return c.json({ error: 'No local credential' }, 404); + + const { currentPassword, newPassword } = c.req.valid('json'); + if (!verifyPassword(cred.passwordHash, currentPassword)) { + return c.json({ error: 'Current password incorrect' }, 401); + } + + const newHash = hashPassword(newPassword); + await db.update(localCredentials).set({ passwordHash: newHash }).where(eq(localCredentials.userId, currentUserId)); + + return c.json({ ok: true }, 200); +}); +``` + +**Note:** After a password change, existing sessions remain valid (D-05 tradeoff — stateless JWT, no revocation). This is documented and accepted. + +--- + +## `/api/me` Extensions + +`GET /api/me` must return `hasLocalCredential: boolean` so the PWA knows whether to show "Change password" and "Link OIDC identity" in `SettingsSheet`. [ASSUMED — straightforward LEFT JOIN on `local_credentials`] + +Add `hasLocalCredential` alongside `isAdmin` and `needsProviderSetup` in the `resolveAdminAndSetupStatus` function. + +--- + +## Break-Glass CLI + +D-13: break-glass is a CLI/console command or env override. Recommended form: a standalone Node.js/tsx script `apps/api/scripts/reset-admin.ts` that: +1. Accepts `--username` and `--password` CLI args +2. Connects to the DB using the same env vars as the app (`DB_HOST`, `DB_USER`, etc.) +3. Upserts a `users` row with `is_admin=true, claimed=true` for the given username (or finds existing by username) +4. Upserts `local_credentials` for that user with the hashed password +5. Prints the resulting user ID + +This script is: +- Not imported by any production code +- Listed in `.dockerignore` `scripts/` exclusion (verify `.dockerignore` covers this; if `scripts/` is not yet excluded, add it) +- Gated with a `NODE_ENV !== 'production'` guard as defense-in-depth + +Usage: `docker exec -it familysync-api node --import=tsx/esm scripts/reset-admin.ts --username admin --password 'newpass'` + +Alternatively: an `APP_RECOVERY_USER` + `APP_RECOVERY_PASSWORD` env pair that, if set at boot, creates/updates that local user before the server starts (similar to some Docker apps' `INITIAL_ADMIN_PASSWORD`). This is simpler to deploy but exposes the password in env. The CLI script is cleaner. + +--- + +## Dev-Bypass Rework + +### Recommendation: Option C — bypass issues a real local-session cookie + +The three options from the CONTEXT.md open questions: + +| Option | Description | Assessment | +|--------|-------------|------------| +| A | Keep bypass + seed a real test login for login-specific specs | Most complex — two auth paths in harness | +| B | Replace bypass with seeded auto-login through the real local flow | Requires changing ALL 40+ harness startup assertions; riskiest | +| C | Bypass auto-issues a real local-session cookie | Minimal change to existing harness; satisfies D-15 | + +**Recommendation: Option C.** When `DEV_AUTH_BYPASS=true`: +- `devAuthBypass()` still sets `c.get('user')` (existing behavior — unchanged) +- A new companion `devSessionCookieMiddleware()` mounted just after `devAuthBypass()` issues a signed `local-session` cookie for `DEV_USER.id` (using `LOCAL_SESSION_SECRET`) on every request that doesn't already have one +- The PWA login page sees `local-session` cookie already set → skips to `/calendar` +- Login-specific Playwright specs can explicitly clear the cookie and test the real login form + +This means: +1. The harness `global-setup.ts` seeds `local_credentials` for `DEV_USER` (id=1) with a known dev-only username/password +2. The `devAuthBypass()` function already handles the API-side auth +3. The PWA routing gate (which checks for a valid session cookie) works because a `local-session` cookie is present + +**D-15 compliance:** `devSessionCookieMiddleware()` is inside `auth/devBypass.ts` (the same file the IMG-01 boot guard protects) and is only mounted when `DEV_AUTH_BYPASS=true`. The `assertNotDevBypassInProduction()` guard already blocks this in production. + +**Minimal `global-setup.ts` change:** +```typescript +// Seed local_credentials for dev user (id=1) — Option C +await conn.execute( + `INSERT INTO local_credentials (user_id, username, password_hash) VALUES (1, 'devuser', ?) + ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)`, + [hashPassword('devpass')] +); +// (hashPassword is called inline or imported from a compiled path) +``` + +**CI `ci.yml` change:** Add `LOCAL_SESSION_SECRET=dev-secret-change-me` to the harness job env (used by `devSessionCookieMiddleware` and `global-setup.ts` hash). Add `local_credentials` table seed to the CI seeding step. + +--- + +## PWA Routing Gate + +### App.tsx changes + +The current `App.tsx` setup gate checks `setupQuery.data?.setupComplete`. Phase 19 adds a parallel auth-mode gate. [VERIFIED: codebase — `App.tsx` lines 140–230 read] + +**New fetch on app load:** +```typescript +const authModeQuery = useQuery({ + queryKey: ['authMode'], + queryFn: () => fetch('/api/auth/mode').then(r => r.json()), + staleTime: 60_000, // auth mode changes rarely; 1 min stale is fine +}); +``` + +**Gate logic (simplified):** +1. If setup not complete → `/setup` +2. If `meQuery` succeeds (user authenticated) → normal app +3. If `meQuery` fails 401 and `authMode.localEnabled` → `/login` +4. If `meQuery` fails 401 and `!authMode.localEnabled && authMode.oidcEnabled` → trigger OIDC redirect (top-level nav to `/api/login`) + +The `/login` route renders `` standalone (no AppNav, no BottomTabBar) — same pattern as `/setup`. + +--- + +## BYO-Auth De-Authelia-ization + +D-06 requires removing Authelia-specific copy from user-facing strings and config. [VERIFIED: codebase — checked `middleware.ts`, `index.ts`, `setup.ts` for "Authelia" references] + +**What to change:** +- `apps/api/src/auth/middleware.ts` header comment: "Authelia as the identity provider" → "generic OIDC identity provider" +- Remove references to `OIDC_ISSUER` being "Authelia base URL" in inline comments → "OIDC issuer URL" +- The `app_config` keys are already generic (`oidc_issuer`, `oidc_client_id`) — no key changes needed +- The setup wizard Step 3 label (if any) mentioning Authelia → generic "OIDC provider" +- User-facing copy: already handled by UI-SPEC (never say "Authelia") + +**What NOT to change:** The actual `@hono/oidc-auth` library, PKCE flow, or any runtime behavior — these are already provider-agnostic. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Password hashing | Custom hash function | `node:crypto` scrypt | Side-channel timing, parameter management, salt uniqueness | +| JWT signing | HMAC-SHA256 manually | `Jwt.sign`/`Jwt.verify` from `hono/utils/jwt` | Already installed; handles base64url encoding, exp checking | +| Cookie serialization | Manual `Set-Cookie` string | `hono/cookie` `setCookie`/`getCookie` | Already used in `persistSessionCookie.ts`; handles attributes correctly | +| Constant-time comparison | `===` on hash strings | `timingSafeEqual` from `node:crypto` | Prevents timing oracle attacks on credential comparison | +| OIDC code exchange | Custom OAuth flow | `@hono/oidc-auth` `processOAuthCallback` + `oidcAuthMiddleware` | Already installed and working | +| Rate-limiting storage | Redis or DB sessions | In-memory Map | Household scale; single process; Redis is overkill | + +**Key insight:** This phase's auth primitives are entirely in stdlib (`node:crypto`) and packages already installed (`hono/utils/jwt`, `hono/cookie`). The zero-new-dependency constraint is achievable without compromise. + +--- + +## Common Pitfalls + +### Pitfall 1: OIDC guard 302-redirecting local-session requests + +**What goes wrong:** `oidcAuthMiddleware()` intercepts requests that already have a valid `local-session` cookie and redirects to Authelia. + +**Why it happens:** `oidcAuthMiddleware` redirects any request where `getAuth(c)` returns null, regardless of whether another auth mechanism already authenticated the user. + +**How to avoid:** Wrap `oidcAuthMiddleware` in a guard that skips it when `c.get('user')` is already set (by `localAuthMiddleware` or `devAuthBypass`). See §Middleware Slot. + +**Warning signs:** Local login succeeds (200 + cookie set) but next `/api/me` request returns 302. + +--- + +### Pitfall 2: Timing oracle on username enumeration + +**What goes wrong:** Login returns faster for non-existent usernames (no hash computation) than for wrong passwords (hash computed). + +**Why it happens:** `if (!cred) return 401` skips `verifyPassword`. + +**How to avoid:** Always call `verifyPassword` — use a pre-computed dummy hash when the username is not found (see §Local Login Endpoint). + +**Warning signs:** Measurable latency difference in login responses for known vs unknown usernames. + +--- + +### Pitfall 3: Zod error echoing the password + +**What goes wrong:** `zValidator` default error handler returns `result.error` which includes `issues[].received` — the submitted password. + +**Why it happens:** No `noEchoHook` provided. + +**How to avoid:** All `zValidator` calls on the login, password-change, and create-member routes MUST use `noEchoHook`. [VERIFIED: codebase — pattern established in `setup.ts`, `admin.ts`, `me.ts`] + +--- + +### Pitfall 4: `local-session` cookie colliding with `oidc-auth` cookie + +**What goes wrong:** If the cookie name `local-session` matches the OIDC cookie name, `persistSessionCookie.ts` reads the wrong cookie. + +**Why it happens:** `persistSessionCookie.ts` reads `process.env.OIDC_COOKIE_NAME ?? 'oidc-auth'` — these are different names by default. + +**How to avoid:** Keep the local-session cookie name `local-session` (distinct from `oidc-auth`). Never set `OIDC_COOKIE_NAME=local-session` in env. + +--- + +### Pitfall 5: `local_credentials` FK constraint before `users` row + +**What goes wrong:** Inserting `local_credentials` before the `users` row exists fails with FK error. + +**Why it happens:** `local_credentials.user_id` references `users.id`. + +**How to avoid:** Always insert `users` row first, then `local_credentials`. Wrap in a transaction for admin create-member. [VERIFIED: codebase — same pattern documented in `setup.ts` at `POST /api/setup/credential`] + +--- + +### Pitfall 6: OIDC-link without checking `iss+sub` uniqueness + +**What goes wrong:** Two local users attempt to link the same OIDC account → one succeeds, one silently overwrites. + +**Why it happens:** No uniqueness check before binding `iss+sub`. + +**How to avoid:** Before UPDATE-ing `users.oidc_iss`/`oidc_sub`, SELECT to verify no existing row has that `iss+sub` pair. Return 409 if conflict. The `uniq_oidc_identity` index on `users` is also a safety net (will throw a DB unique violation). [VERIFIED: codebase — `schema.ts` line 63, `unique('uniq_oidc_identity').on(t.oidcIss, t.oidcSub)`] + +--- + +### Pitfall 7: Dev bypass seeds in prod image + +**What goes wrong:** `local_credentials` seed for dev user (id=1) ships in the production Docker image, granting access with a known password. + +**Why it happens:** `scripts/` or seed data not excluded from Docker image. + +**How to avoid:** Verify `.dockerignore` excludes `scripts/`. The `local_credentials` dev seed goes in `global-setup.ts` (Playwright) and the CI step — NOT in any migration or startup code. The `assertNotDevBypassInProduction()` guard blocks the dev-session-cookie middleware in production. [VERIFIED: codebase — IMG-01/02/03 gates from Phase 16] + +--- + +### Pitfall 8: `Jwt.verify` import path + +**What goes wrong:** `import { sign, verify } from 'hono/utils/jwt'` fails — `hono/utils/jwt` exports only `{ Jwt }` (default object), not named exports. + +**Why it happens:** The Hono `utils/jwt/index.js` wraps the functions in a `Jwt` namespace object. + +**How to avoid:** Use `import { Jwt } from 'hono/utils/jwt'` then call `Jwt.sign()` / `Jwt.verify()`. [VERIFIED: codebase — confirmed at runtime: `Jwt.sign type: function`] + +--- + +### Pitfall 9: `Jwt.verify` throws on expired token (must catch) + +**What goes wrong:** If the `local-session` JWT is expired, `Jwt.verify` throws `JwtTokenExpired` rather than returning null. + +**Why it happens:** This is expected Hono behavior — errors are thrown, not returned. + +**How to avoid:** Wrap `Jwt.verify` in try/catch in `verifyLocalSessionCookie`. Return `null` on any error (including expiry). [ASSUMED — derived from Hono JWT error types visible in `jwt.js` source] + +--- + +### Pitfall 10: `LOCAL_SESSION_SECRET` missing at boot + +**What goes wrong:** `issueLocalSessionCookie` throws because `LOCAL_SESSION_SECRET` is not set. + +**Why it happens:** New env var; operator didn't add it to Docker compose. + +**How to avoid:** Add a boot-time assertion alongside `assertNotDevBypassInProduction()`: check `LOCAL_SESSION_SECRET` is set and >= 32 chars when not in dev-bypass mode. Log a clear error and refuse to start. + +--- + +### Pitfall 11: Harness `global-setup.ts` seeding `local_credentials` hash without importing app code + +**What goes wrong:** `global-setup.ts` is plain Node.js (no tsx/TypeScript — per its own comment "Plain Node.js only"). If it tries to import `hashPassword` from the API source, it needs to compile first. + +**Why it happens:** `global-setup.ts` uses `mysql2/promise` directly, no app imports. + +**How to avoid:** Inline the `hashPassword` implementation in `global-setup.ts` (copy the 5-line scrypt hash function), or pre-hash the dev password at a known constant and hard-code the encoded string in the seed. Since `global-setup.ts` already knows the dev-bypass semantics, a hard-coded dev hash (never used in production) is acceptable. + +--- + +## Code Examples + +### scrypt hash + verify (production pattern) + +```typescript +// Source: runtime-verified on Node 22.22.3 in this project +import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'; + +export function hashPassword(password: string): string { + const salt = randomBytes(16); + const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }); + return ['scrypt', 16384, 8, 1, salt.toString('base64url'), hash.toString('base64url')].join('$'); +} + +export function verifyPassword(stored: string, candidate: string): boolean { + try { + const [, n, r, p, saltB64, hashB64] = stored.split('$'); + const salt = Buffer.from(saltB64, 'base64url'); + const storedHash = Buffer.from(hashB64, 'base64url'); + const check = scryptSync(candidate, salt, storedHash.length, { + N: Number(n), r: Number(r), p: Number(p), + }); + return timingSafeEqual(storedHash, check); + } catch { + return false; + } +} +``` + +### Hono JWT session cookie issuance + +```typescript +// Source: runtime-verified — Jwt.sign/verify confirmed from hono/utils/jwt +import { Jwt } from 'hono/utils/jwt'; +import { setCookie, getCookie, deleteCookie } from 'hono/cookie'; + +export async function issueLocalSessionCookie(c: Context, userId: number): Promise { + const secret = process.env.LOCAL_SESSION_SECRET!; + const maxAge = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400); + const now = Math.floor(Date.now() / 1000); + const token = await Jwt.sign({ userId, iat: now, exp: now + maxAge }, secret, 'HS256'); + setCookie(c, 'local-session', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'Lax', + path: '/', + maxAge, + }); +} +``` + +### localAuthMiddleware pattern (mirrors devAuthBypass) + +```typescript +// Source: apps/api/src/auth/devBypass.ts (verified — the pattern to mirror) +import type { MiddlewareHandler } from 'hono'; +import { verifyLocalSessionCookie } from './localSession.js'; +import { db } from '../db/client.js'; +import { users } from '../db/schema.js'; +import { eq } from 'drizzle-orm'; + +export function localAuthMiddleware(): MiddlewareHandler { + return async (c, next) => { + // Skip if already authenticated (devAuthBypass ran first) + if (c.get('user')) { await next(); return; } + + const userId = await verifyLocalSessionCookie(c); + if (!userId) { await next(); return; } + + const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); + if (user) { + c.set('user', { + id: user.id, + oidcIss: user.oidcIss ?? 'local', + oidcSub: user.oidcSub ?? String(user.id), + displayName: user.displayName ?? null, + color: user.color, + }); + } + await next(); + }; +} +``` + +--- + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Node.js 22 | `node:crypto` scrypt | ✓ | 22.22.3 | — (required) | +| MariaDB | `local_credentials` table | ✓ (Docker) | 11.x | — | +| `hono/utils/jwt` | JWT session signing | ✓ | hono@4.12.23 | — (already installed) | +| `hono/cookie` | Cookie read/write | ✓ | hono@4.12.23 | — (already installed) | +| `drizzle-kit` | Schema migration | ✓ | 0.31.10 | — | +| `LOCAL_SESSION_SECRET` env | JWT signing | ✗ (not yet set) | — | Add to docker-compose env + generate-secrets script | + +**Missing dependencies with no fallback:** +- `LOCAL_SESSION_SECRET` env var — must be added to the operator's Docker Compose file and to the `generate-secrets` script. Absence must be caught at boot. + +--- + +## Validation Architecture + +`workflow.nyquist_validation` is enabled (absent = enabled per config). Security-critical auth flows — all test seams enumerated. + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | Vitest 4.1.8 | +| Config file | `apps/api/vitest.config.ts` | +| Quick run command | `pnpm --filter @familysync/api test` | +| Full suite command | `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test:e2e` | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| AUTH-LOCAL-01 | `local_credentials` table schema | integration | `pnpm --filter @familysync/api test tests/db/schema.test.ts` | ❌ Wave 0 | +| AUTH-LOCAL-02 | `hashPassword` round-trip + `verifyPassword` timing-safe | unit | `pnpm --filter @familysync/api test tests/auth/localCredentials.test.ts` | ❌ Wave 0 | +| AUTH-LOCAL-03 | `POST /api/auth/local/login` — 200 success, 401 wrong, 429 rate, 423 lockout | unit+integration | `pnpm --filter @familysync/api test tests/routes/localAuth.test.ts` | ❌ Wave 0 | +| AUTH-LOCAL-04 | `localAuthMiddleware` — sets c.get('user') with valid cookie; no-op without cookie | unit | `pnpm --filter @familysync/api test tests/auth/localAuthMiddleware.test.ts` | ❌ Wave 0 | +| AUTH-LOCAL-05 | `GET /api/auth/mode` — returns `{localEnabled:true, oidcEnabled}` | unit | `pnpm --filter @familysync/api test tests/routes/authMode.test.ts` | ❌ Wave 0 | +| AUTH-LOCAL-06 | `POST /api/auth/local/logout` — clears cookie | unit | included in `localAuth.test.ts` | ❌ Wave 0 | +| AUTH-LOCAL-07 | Admin create member — 201 on success, 409 on duplicate username | unit | `pnpm --filter @familysync/api test tests/routes/admin.test.ts` | ✅ (extend) | +| AUTH-LOCAL-08 | Admin reset password — updates hash | unit | included in `admin.test.ts` | ✅ (extend) | +| AUTH-LOCAL-09 | Self-change password — verify current, update hash | unit | `pnpm --filter @familysync/api test tests/routes/me.test.ts` | ✅ (extend) | +| AUTH-LOCAL-10 | OIDC-link — binds iss+sub, deletes local_credentials, 409 on conflict | unit | included in `me.test.ts` | ✅ (extend) | +| AUTH-LOCAL-11 | Break-glass CLI — creates admin user | manual/smoke | `tsx scripts/reset-admin.ts --dry-run` | ❌ Wave 0 | +| AUTH-LOCAL-12 | LoginPage renders brand slot + form; submits and receives cookie | e2e (Playwright) | `pnpm --filter @familysync/pwa test:e2e --grep "login"` | ❌ Wave 0 | +| AUTH-LOCAL-15 | App.tsx redirects unauthed user to /login | e2e | included in login spec | ❌ Wave 0 | +| AUTH-LOCAL-16 | Harness continues to work with Option C dev-bypass | e2e | existing harness | ✅ (verify after change) | +| AUTH-LOCAL-19 | Rate-limit 429 after 5 failures; lockout 423 after 10 | unit | included in `localAuth.test.ts` | ❌ Wave 0 | + +### Sampling Rate + +- **Per task commit:** `pnpm --filter @familysync/api test` (unit suite, ~10s) +- **Per wave merge:** `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test` (unit + PWA unit) +- **Phase gate:** Full suite including Playwright harness before `/gsd-verify-work` + +### Wave 0 Gaps + +- [ ] `tests/auth/localCredentials.test.ts` — covers AUTH-LOCAL-02 (hash round-trip, timing-safe) +- [ ] `tests/auth/localAuthMiddleware.test.ts` — covers AUTH-LOCAL-04 +- [ ] `tests/routes/authMode.test.ts` — covers AUTH-LOCAL-05 +- [ ] `tests/routes/localAuth.test.ts` — covers AUTH-LOCAL-03/06/19 +- [ ] `apps/pwa/e2e/login.spec.ts` — covers AUTH-LOCAL-12/15 + +--- + +## Security Domain + +`security_enforcement: true` (enabled in config.json). + +### Applicable ASVS Categories (Level 1) + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | YES | `node:crypto` scrypt + PHC encoding; `timingSafeEqual`; no username-enumeration timing oracle | +| V3 Session Management | YES | Stateless signed JWT; `httpOnly + Secure + SameSite=Lax`; maxAge 1 day; logout = cookie clear | +| V4 Access Control | YES | `requireAdmin` on all admin routes; `resolveUserId` always from session, never body; self-service can only modify own credential | +| V5 Input Validation | YES | `zod` + `@hono/zod-validator` on all auth routes; `noEchoHook` prevents Zod errors from echoing passwords | +| V6 Cryptography | YES (partially) | `node:crypto` scrypt (strong KDF); HS256 JWT (symmetric — acceptable for single-server; if multi-server ever applies, upgrade to RS256) | + +### Known Threat Patterns + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Username enumeration via timing | Information Disclosure | Dummy hash verify when username not found; `timingSafeEqual` | +| Credential brute force | Elevation of Privilege | Per-IP rate-limit (5 failures → 60s cooldown → 429); account lockout at 10 (423) | +| Session fixation | Elevation of Privilege | Issue new JWT on every login; old JWTs expire via `exp` claim | +| Password echoed in error response | Information Disclosure | `noEchoHook` on all `zValidator` calls on auth routes | +| Local-session cookie in prod image | Elevation of Privilege | D-15: `assertNotDevBypassInProduction()`; `.dockerignore` for scripts; seed only in `global-setup.ts` | +| OIDC-link CSRF | Tampering | `state` parameter in OIDC redirect must be signed/nonce'd; `iss+sub` uniqueness checked server-side | +| OIDC-link identity collision | Elevation of Privilege | `uniq_oidc_identity` DB constraint + pre-flight SELECT → 409 on conflict | +| Missing `LOCAL_SESSION_SECRET` | Elevation of Privilege | Boot-time assertion; loud error + non-zero exit | +| `local-session` cookie in XSS | Information Disclosure | `httpOnly: true` prevents JS access; OIDC cookie has same protection | + +--- + +## State of the Art + +| Old Approach | Current Approach | Notes | +|--------------|------------------|-------| +| argon2id (native addon) | `node:crypto` scrypt | D-08 mandates no native deps; scrypt is OWASP-approved for this use | +| DB session table | Stateless JWT cookie | D-05; consistent with `@hono/oidc-auth` pattern | +| Hardcoded Authelia references | Generic OIDC copy | D-06 BYO-Auth principle | + +--- + +## Open Questions + +### Resolved by Research + +1. **Dev-bypass rework** → Recommend Option C (bypass issues real `local-session` cookie). Minimal harness change; satisfies D-14 and D-15. +2. **Break-glass form** → Recommend CLI script `scripts/reset-admin.ts` (tsx, runnable via `docker exec`). No new role model needed. +3. **OIDC-link mechanism** → Reuse OIDC callback (`/callback`) with a signed `state` parameter encoding `{ linkUserId }`. Simpler than a new endpoint because `processOAuthCallback` already handles the code exchange. +4. **`Jwt.sign`/`Jwt.verify` import** → `import { Jwt } from 'hono/utils/jwt'` (namespace import) — not named exports. + +### Still Open (require planner decision) + +1. **OIDC-only user provisioning:** How does an OIDC-only user get their `users` row now that the setup wizard's single-unclaimed-row claim (Phase 12 D-08) only works for one pre-created user? The current `upsertUser` in `auth/user.ts` already handles this: when `setup_complete === true` and no unclaimed row exists, it inserts a new fully-claimed OIDC user row (step 3/5 in `upsertUser`). The existing behavior already handles OIDC-only users without Phase 19 changes — no open issue here in practice. **Confirm:** planner should verify this path still works after Phase 19 DB changes. + +2. **Admin UI for managing OIDC-only users:** Can an admin remove a user's OIDC binding (reverting them to local-only)? D-12 says OIDC-link is one-directional (removes local cred) and "can't be undone from the app." This is correct per the UI-SPEC. No admin UI for OIDC-unlinking is in scope for Phase 19. + +3. **`LOCAL_SESSION_SECRET` and `generate-secrets` script update:** The Phase 12 `generate-secrets` script generates `SESSION_SECRET`, `APP_PASSWORD_ENCRYPTION_KEY`, and VAPID keys. It should be extended to also generate `LOCAL_SESSION_SECRET`. Planner should include a task to update `scripts/generate-secrets.ts`. + +4. **`scripts/` in `.dockerignore`:** Verify `.dockerignore` already excludes `scripts/`. If not, a task to add `scripts/` to `.dockerignore` is required for D-15 compliance. + +--- + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | `Jwt.verify` throws on expired token (caught in try/catch in `verifyLocalSessionCookie`) | §Common Pitfalls 9, §JWT Session Cookie | Session cookie not cleared on expiry; user remains "logged in" until cookie maxAge expires naturally | +| A2 | `deleteCookie` from `hono/cookie` is available in hono@4.12.23 | §JWT Session Cookie | Logout implementation needs alternative cookie-clear method (can use `setCookie` with `maxAge: 0`) | +| A3 | OIDC-link via signed `state` param in `/callback` is the cleanest mechanism | §OIDC-Link Flow | May need a dedicated endpoint; planner may choose differently | +| A4 | The `scripts/reset-admin.ts` approach satisfies break-glass requirement | §Break-Glass | Operator may prefer env-var recovery; both are viable | +| A5 | `hono/utils/jwt` `Jwt.sign` uses Web Crypto API internally (async, returns Promise) | §JWT Session Cookie | If sync behavior is needed, alternative needed — but async is the Hono convention | + +--- + +## Sources + +### Primary (VERIFIED from codebase) +- `apps/api/src/auth/devBypass.ts` — `c.set('user')` pattern, `DEV_USER` shape, IMG-01 guard +- `apps/api/src/auth/middleware.ts` — OIDC middleware wiring, `oidcConfigFallbackMiddleware` +- `apps/api/src/auth/persistSessionCookie.ts` — `setCookie` pattern, maxAge, httpOnly/Secure +- `apps/api/src/auth/user.ts` — `upsertUser`, first-login-claims, D-10 identity model +- `apps/api/src/db/schema.ts` — `users`, `memberCredentials`, `appConfig` definitions +- `apps/api/src/index.ts` — full middleware ordering, pre-auth routes, `devBypassActive` pattern +- `apps/api/src/routes/admin.ts` — `requireAdmin`, `noEchoHook`, admin route patterns +- `apps/api/src/routes/me.ts` — `resolveUserId`, `resolveAdminAndSetupStatus`, `POST /credential` +- `apps/api/src/routes/setup.ts` — pre-auth route pattern, `isSetupLocked`, `noEchoHook` +- `apps/api/src/lib/bootGuards.ts` — `assertNotDevBypassInProduction` +- `apps/api/tests/routes/login.test.ts` — vitest mock patterns for auth tests +- `apps/pwa/e2e/global-setup.ts` — harness seed pattern, DEV_AUTH_BYPASS guard +- `apps/pwa/src/App.tsx` — PWA routing gate, setup gate, `meQuery`, route structure +- `apps/api/src/db/migrations/0002_lethal_millenium_guard.sql` — additive migration example +- `.gitea/workflows/ci.yml` — CI harness step, DEV_AUTH_BYPASS env, seed step pattern +- Node.js 22.22.3 runtime — confirmed: `scryptSync`, `randomBytes`, `timingSafeEqual` available +- `hono@4.12.23` runtime — confirmed: `Jwt.sign`, `Jwt.verify` from `hono/utils/jwt`; `getCookie`, `setCookie` from `hono/cookie` +- `hono/dist/utils/jwt/jwt.js` — source-read to understand error types and import shape + +### Secondary (ASSUMED from training + project patterns) +- OWASP scrypt parameters (N=16384, r=8, p=1 as minimum; N=65536 preferred if hardware allows) +- PHC-style `$`-delimited encoded hash format for self-describing password hashes +- Rate-limit implementation as in-memory Map (appropriate for single-process household scale) + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — everything is the existing installed stack; no new packages; runtime-verified +- Architecture: HIGH — derived directly from reading the actual middleware chain and existing patterns +- Pitfalls: HIGH — derived from actual code reading; most pitfalls are known from existing code comments +- Password hashing: HIGH — runtime-verified on Node 22.22.3 +- JWT signing: HIGH — runtime-verified `Jwt.sign`/`Jwt.verify` from `hono/utils/jwt` +- OIDC-link mechanism: MEDIUM — derived from CONTEXT.md D-12 + existing `upsertUser` patterns; specific implementation is ASSUMED + +**Research date:** 2026-06-17 +**Valid until:** 2026-07-17 (stable stack; scrypt parameters are stable)