Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
63 KiB
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_subset,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 setsc.get('user')the same wayauth/devBypass.tsdoes — 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-authis 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:cryptoscrypt — 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_credentialstable —user_id(FK tousers, 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_credentialsrow → 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_credentialstable — 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:cryptoscrypt (stdlib, confirmed available) - JWT signing:
hono/utils/jwtJwt.sign/Jwt.verify(confirmed callable fromhono@4.12.23) - Cookie read/write:
hono/cookiegetCookie/setCookie(already used inpersistSessionCookie.ts) - Schema:
drizzle-ormmysqlTable(same pattern asmember_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]
// 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:
scryptSyncblocks the event loop. For login (infrequent in a 2-person household) this is acceptable. If async is preferred, usepromisify(scrypt)fromnode:util.timingSafeEqualrequires equal-length buffers —storedHash.lengthas 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]
// 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.
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]
// 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
noEchoHookmust be used onzValidatorfor 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-Forheader, 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.
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:
- User (authenticated as local user with valid
local-sessioncookie) clicks "Continue with OIDC" - PWA calls
POST /api/me/link-oidc→ server initiates OIDC authorization-code redirect withstateparameter encoding{ linkUserId: currentUserId, nonce } - OIDC callback fires →
processOAuthCallbackhandles the code exchange, getsiss+sub - Backend detects
linkUserIdin state → look up ifiss+subalready belongs to a different user → if yes, 409 error page; if no, UPDATEusers SET oidc_iss, oidc_sub WHERE id = linkUserId, DELETE fromlocal_credentials WHERE user_id = linkUserId - 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:
- Accepts
--usernameand--passwordCLI args - Connects to the DB using the same env vars as the app (
DB_HOST,DB_USER, etc.) - Upserts a
usersrow withis_admin=true, claimed=truefor the given username (or finds existing by username) - Upserts
local_credentialsfor that user with the hashed password - Prints the resulting user ID
This script is:
- Not imported by any production code
- Listed in
.dockerignorescripts/exclusion (verify.dockerignorecovers this; ifscripts/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 setsc.get('user')(existing behavior — unchanged)- A new companion
devSessionCookieMiddleware()mounted just afterdevAuthBypass()issues a signedlocal-sessioncookie forDEV_USER.id(usingLOCAL_SESSION_SECRET) on every request that doesn't already have one - The PWA login page sees
local-sessioncookie already set → skips to/calendar - Login-specific Playwright specs can explicitly clear the cookie and test the real login form
This means:
- The harness
global-setup.tsseedslocal_credentialsforDEV_USER(id=1) with a known dev-only username/password - The
devAuthBypass()function already handles the API-side auth - The PWA routing gate (which checks for a valid session cookie) works because a
local-sessioncookie 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 140–230 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):
- If setup not complete →
/setup - If
meQuerysucceeds (user authenticated) → normal app - If
meQueryfails 401 andauthMode.localEnabled→/login - If
meQueryfails 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.tsheader comment: "Authelia as the identity provider" → "generic OIDC identity provider"- Remove references to
OIDC_ISSUERbeing "Authelia base URL" in inline comments → "OIDC issuer URL" - The
app_configkeys 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)
// 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
// 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_SECRETenv var — must be added to the operator's Docker Compose file and to thegenerate-secretsscript. 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-04tests/routes/authMode.test.ts— covers AUTH-LOCAL-05tests/routes/localAuth.test.ts— covers AUTH-LOCAL-03/06/19apps/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
- Dev-bypass rework → Recommend Option C (bypass issues real
local-sessioncookie). Minimal harness change; satisfies D-14 and D-15. - Break-glass form → Recommend CLI script
scripts/reset-admin.ts(tsx, runnable viadocker exec). No new role model needed. - OIDC-link mechanism → Reuse OIDC callback (
/callback) with a signedstateparameter encoding{ linkUserId }. Simpler than a new endpoint becauseprocessOAuthCallbackalready handles the code exchange. Jwt.sign/Jwt.verifyimport →import { Jwt } from 'hono/utils/jwt'(namespace import) — not named exports.
Still Open (require planner decision)
-
OIDC-only user provisioning: How does an OIDC-only user get their
usersrow now that the setup wizard's single-unclaimed-row claim (Phase 12 D-08) only works for one pre-created user? The currentupsertUserinauth/user.tsalready handles this: whensetup_complete === trueand no unclaimed row exists, it inserts a new fully-claimed OIDC user row (step 3/5 inupsertUser). 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. -
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.
-
LOCAL_SESSION_SECRETandgenerate-secretsscript update: The Phase 12generate-secretsscript generatesSESSION_SECRET,APP_PASSWORD_ENCRYPTION_KEY, and VAPID keys. It should be extended to also generateLOCAL_SESSION_SECRET. Planner should include a task to updatescripts/generate-secrets.ts. -
scripts/in.dockerignore: Verify.dockerignorealready excludesscripts/. If not, a task to addscripts/to.dockerignoreis 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_USERshape, IMG-01 guardapps/api/src/auth/middleware.ts— OIDC middleware wiring,oidcConfigFallbackMiddlewareapps/api/src/auth/persistSessionCookie.ts—setCookiepattern, maxAge, httpOnly/Secureapps/api/src/auth/user.ts—upsertUser, first-login-claims, D-10 identity modelapps/api/src/db/schema.ts—users,memberCredentials,appConfigdefinitionsapps/api/src/index.ts— full middleware ordering, pre-auth routes,devBypassActivepatternapps/api/src/routes/admin.ts—requireAdmin,noEchoHook, admin route patternsapps/api/src/routes/me.ts—resolveUserId,resolveAdminAndSetupStatus,POST /credentialapps/api/src/routes/setup.ts— pre-auth route pattern,isSetupLocked,noEchoHookapps/api/src/lib/bootGuards.ts—assertNotDevBypassInProductionapps/api/tests/routes/login.test.ts— vitest mock patterns for auth testsapps/pwa/e2e/global-setup.ts— harness seed pattern, DEV_AUTH_BYPASS guardapps/pwa/src/App.tsx— PWA routing gate, setup gate,meQuery, route structureapps/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,timingSafeEqualavailable hono@4.12.23runtime — confirmed:Jwt.sign,Jwt.verifyfromhono/utils/jwt;getCookie,setCookiefromhono/cookiehono/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.verifyfromhono/utils/jwt - OIDC-link mechanism: MEDIUM — derived from CONTEXT.md D-12 + existing
upsertUserpatterns; specific implementation is ASSUMED
Research date: 2026-06-17 Valid until: 2026-07-17 (stable stack; scrypt parameters are stable)