# 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)