Files
familysync/.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
T
Lucas BergerandClaude Sonnet 4.6 29f4a2e623 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 <noreply@anthropic.com>
2026-06-17 15:17:46 -04:00

1162 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>
## 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.
</user_constraints>
---
<phase_requirements>
## 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 110: 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 |
</phase_requirements>
---
## 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=<JWT>; 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 <new>
→ 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 110)
└── 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$<salt_base64url>$<hash_base64url>
*
* 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 7494; `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$<salt_b64url>$<hash_b64url> — 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<void> {
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<number | null> {
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<string, { count: number; lockedUntil: number; lockedOut: boolean }>();
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 140230 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 `<LoginPage />` 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<void> {
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)