Files
familysync/.planning/milestones/v1.1-phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
T
2026-06-18 22:21:38 -04:00

63 KiB
Raw Blame History

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 tableuser_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

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]

// 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]

// 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]

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

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:

// 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.


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]

// 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]

// 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]

// 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.


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.

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:

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:

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]

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

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:

// 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:

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]


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]


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)

// 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;
  }
}
// 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)

// 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 importimport { 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.tsc.set('user') pattern, DEV_USER shape, IMG-01 guard
  • apps/api/src/auth/middleware.ts — OIDC middleware wiring, oidcConfigFallbackMiddleware
  • apps/api/src/auth/persistSessionCookie.tssetCookie pattern, maxAge, httpOnly/Secure
  • apps/api/src/auth/user.tsupsertUser, first-login-claims, D-10 identity model
  • apps/api/src/db/schema.tsusers, memberCredentials, appConfig definitions
  • apps/api/src/index.ts — full middleware ordering, pre-auth routes, devBypassActive pattern
  • apps/api/src/routes/admin.tsrequireAdmin, noEchoHook, admin route patterns
  • apps/api/src/routes/me.tsresolveUserId, resolveAdminAndSetupStatus, POST /credential
  • apps/api/src/routes/setup.ts — pre-auth route pattern, isSetupLocked, noEchoHook
  • apps/api/src/lib/bootGuards.tsassertNotDevBypassInProduction
  • 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)