# 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 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 |
</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 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$<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 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$<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 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 `<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)
