Phase 19: Local Auth (No-OIDC Mode) #23

Merged
luckberg merged 78 commits from gsd/phase-19-local-auth-no-oidc-mode into main 2026-06-18 06:25:00 -04:00
75 changed files with 13766 additions and 84 deletions
+3 -1
View File
@@ -2,7 +2,9 @@
.env .env
.env.* .env.*
!.env.example !.env.example
apps/api/scripts/seed-credential.mjs # Phase 19 (D-15 / IMG-02): exclude the entire break-glass scripts directory so
# reset-admin.ts and any future dev-only scripts never ship in the production image.
apps/api/scripts/
# === VCS (large and unnecessary) === # === VCS (large and unnecessary) ===
.git .git
+49
View File
@@ -285,6 +285,49 @@ jobs:
# CI=true makes Playwright start Vite :5173 itself (reuseExistingServer=false), use # CI=true makes Playwright start Vite :5173 itself (reuseExistingServer=false), use
# retries:2/workers:1, and apply reporter:'github' — which --reporter=list,html overrides # retries:2/workers:1, and apply reporter:'github' — which --reporter=list,html overrides
# because Gitea does not render github annotations (Pitfall 5 / D-06). Both projects run. # because Gitea does not render github annotations (Pitfall 5 / D-06). Both projects run.
# Phase 19 (AUTH-LOCAL-16, D-14/D-15): seed local_credentials for dev user (id=1).
# devSessionCookieMiddleware issues a local-session cookie on each /api/* request
# when DEV_AUTH_BYPASS=true and LOCAL_SESSION_SECRET is set, so the PWA login gate
# skips /login and existing specs still reach the authed app unchanged.
# global-setup.ts also seeds this row via hashPasswordInline — this step is a
# belt-and-suspenders seed for the initial CI DB state before Playwright runs.
# The dev password 'devpass' is NOT a secret — it only exists in the ephemeral CI DB.
- name: Seed local_credentials for dev user (id=1)
env:
DB_HOST: mariadb
DB_PORT: 3306
DB_USER: familysync
DB_PASSWORD: testpass
DB_NAME: familysync
run: |
node --input-type=commonjs - <<'EOF'
const mysql = require('mysql2/promise');
const crypto = require('crypto');
// Inline PHC scrypt hash (matches apps/api/src/auth/localCredentials.ts)
function hashPassword(password) {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
return ['scrypt', 16384, 8, 1, salt.toString('base64url'), hash.toString('base64url')].join('$');
}
(async () => {
const conn = await mysql.createConnection({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
const passwordHash = hashPassword('devpass');
await conn.execute(
"INSERT INTO local_credentials (user_id, username, password_hash) VALUES (1, 'devuser', ?) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)",
[passwordHash],
);
console.log('seeded local_credentials for dev user id=1');
await conn.end();
})();
EOF
working-directory: apps/pwa
- name: Run harness (start API + Playwright iphone + pixel + desktop) - name: Run harness (start API + Playwright iphone + pixel + desktop)
env: env:
CI: 'true' CI: 'true'
@@ -298,6 +341,12 @@ jobs:
NODE_OPTIONS: '--dns-result-order=ipv4first' NODE_OPTIONS: '--dns-result-order=ipv4first'
DEV_AUTH_BYPASS: 'true' DEV_AUTH_BYPASS: 'true'
NODE_ENV: development NODE_ENV: development
# Phase 19 (AUTH-LOCAL-16, D-14/D-15): LOCAL_SESSION_SECRET required for
# devSessionCookieMiddleware to issue real local-session cookies under bypass.
# This is a fixed dev-only value — NEVER a production secret.
# Must be >=32 chars (assertLocalSessionSecretSet boot guard skips in bypass mode,
# but the cookie signing requires a non-empty secret to function).
LOCAL_SESSION_SECRET: 'dev-secret-change-me-0000000000000000'
DB_HOST: mariadb DB_HOST: mariadb
DB_PORT: 3306 DB_PORT: 3306
DB_USER: familysync DB_USER: familysync
+7
View File
@@ -17,6 +17,9 @@ dist/
*.swp *.swp
*.swo *.swo
# Claude Code local (per-machine) settings — never tracked
.claude/settings.local.json
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
@@ -59,3 +62,7 @@ graphify-out/
apps/pwa/test-results/ apps/pwa/test-results/
apps/pwa/playwright-report/ apps/pwa/playwright-report/
apps/pwa/blob-report/ apps/pwa/blob-report/
# MemPalace per-project files (issue #185)
mempalace.yaml
entities.json
+8
View File
@@ -26,3 +26,11 @@ paths = ['''apps/api/tests/broker/crypto\.test\.ts''']
[[allowlists]] [[allowlists]]
description = "apps/api/tests/routes/setup.test.ts — synthetic VAPID public/private test pair used to set process.env.VAPID_* in the setup-route tests; not a real credential (verified not present in .env)" description = "apps/api/tests/routes/setup.test.ts — synthetic VAPID public/private test pair used to set process.env.VAPID_* in the setup-route tests; not a real credential (verified not present in .env)"
paths = ['''apps/api/tests/routes/setup\.test\.ts'''] paths = ['''apps/api/tests/routes/setup\.test\.ts''']
[[allowlists]]
description = "apps/api/tests/auth/localSession.test.ts — TEST_SECRET is a synthetic >=32-char JWT signing secret used only to exercise issue/verify cookie round-trips under Vitest; not a real credential (Phase 19)"
paths = ['''apps/api/tests/auth/localSession\.test\.ts''']
[[allowlists]]
description = ".planning/ design docs are internal planning prose (PLAN/SUMMARY/SECURITY/etc.) that frequently discuss credentials, tokens, and auth — they trip generic regex rules (e.g. 'credential atomically, 409-equivalent') but never carry production secrets; not shipped in any image"
paths = ['''\.planning/''']
+18 -3
View File
@@ -437,6 +437,7 @@ At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx`
| 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 6/6 | Complete | 2026-06-13 | | 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 6/6 | Complete | 2026-06-13 |
| 17. UI Optimization & Polish | v1.1 | 0/? | Not started | - | | 17. UI Optimization & Polish | v1.1 | 0/? | Not started | - |
| 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 | | 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 |
| 19. Local Auth (No-OIDC Mode) | v1.1 | 5/5 | Complete | 2026-06-17 |
## Backlog ## Backlog
@@ -681,8 +682,8 @@ Plans:
**Goal:** Let an operator run FamilySync entirely on **local DB users with no OIDC** — username/password accounts and a local login flow that coexists with the Authelia OIDC path — and **optionally wire OIDC in later** by claiming/linking an existing local user to an OIDC identity. Removes the hard dependency on a deployed Authelia for small/solo self-hosters. **Goal:** Let an operator run FamilySync entirely on **local DB users with no OIDC** — username/password accounts and a local login flow that coexists with the Authelia OIDC path — and **optionally wire OIDC in later** by claiming/linking an existing local user to an OIDC identity. Removes the hard dependency on a deployed Authelia for small/solo self-hosters.
**Mode:** standard **Mode:** standard
**Depends on:** Phase 12 (Initial Setup Wizard) — builds directly on the pre-OIDC **local-user foundation** introduced there: nullable `users.oidc_iss`/`oidc_sub` + the claimed/pending marker, and the first-login-claims merge. Phase 19 generalizes that single bootstrap local user into a full local-account model + login. **Depends on:** Phase 12 (Initial Setup Wizard) — builds directly on the pre-OIDC **local-user foundation** introduced there: nullable `users.oidc_iss`/`oidc_sub` + the claimed/pending marker, and the first-login-claims merge. Phase 19 generalizes that single bootstrap local user into a full local-account model + login.
**Requirements**: TBD (derive an AUTH-LOCAL-0x set during discuss/spec — local credential storage with proper password hashing, local login session issuance reusing the existing session-cookie path, coexistence with the OIDC middleware, and OIDC-link of an existing local user). **Requirements**: AUTH-LOCAL-01..AUTH-LOCAL-20 (derived during planning 2026-06-17) — local_credentials schema (01), scrypt hash/verify (02), login route (03), localAuthMiddleware (04), auth-mode endpoint (05), logout (06), admin create-member (07), admin reset (08), self-change (09), OIDC-link (10), break-glass CLI (11), LoginPage (12), admin UI (13), settings UI (14), routing gate (15), dev-bypass/harness rework (16), hasLocalCredential (17), de-Authelia copy (18), rate-limit/lockout (19), auth unit tests (20). Plus `LOCAL_SESSION_SECRET` env + boot assertion (D-05).
**Plans:** 0 plans **Plans:** 5/5 plans complete
**Provenance:** Deferred from the Phase 12 discussion (2026-06-15) — see `.planning/phases/12-initial-setup-wizard/12-CONTEXT.md` §Deferred Ideas. The operator runs FamilySync this way themselves and wants no-OIDC operation as a first-class mode. **Provenance:** Deferred from the Phase 12 discussion (2026-06-15) — see `.planning/phases/12-initial-setup-wizard/12-CONTEXT.md` §Deferred Ideas. The operator runs FamilySync this way themselves and wants no-OIDC operation as a first-class mode.
@@ -694,5 +695,19 @@ Plans:
- Whether "local mode vs OIDC mode" is a deploy-time switch or both can be live simultaneously. - Whether "local mode vs OIDC mode" is a deploy-time switch or both can be live simultaneously.
Plans: Plans:
**Wave 1**
- [ ] TBD (run /gsd-plan-phase 19 to break down) - [x] 19-01-PLAN.md — Foundation (TDD): local_credentials schema + 0003 migration, scrypt hash/verify, local-session JWT helpers, LOCAL_SESSION_SECRET boot guard + generate-secrets, .dockerignore scripts exclusion (AUTH-LOCAL-01/02)
**Wave 2** *(blocked on Wave 1)*
- [x] 19-02-PLAN.md — Backend account mgmt (TDD): admin create/reset member, self-change password, hasLocalCredential, linkOidcToUser helper + /api/me/link-oidc (AUTH-LOCAL-07/08/09/10/17)
**Wave 3** *(blocked on Wave 2)*
- [x] 19-03-PLAN.md — Middleware + routes + wiring (TDD): localAuthMiddleware, /api/auth/mode, login (rate-limit/lockout) + logout, index.ts mount + OIDC-guard skip + /callback link branch, de-Authelia comments (AUTH-LOCAL-03/04/05/06/18/19/20)
**Wave 4** *(blocked on Wave 3; 04 + 05 parallel)*
- [x] 19-04-PLAN.md — PWA: LoginPage + BrandSlot + App.tsx gate + client.ts + AdminPage + SettingsSheet (AUTH-LOCAL-12/13/14/15)
- [x] 19-05-PLAN.md — Dev-bypass Option C + break-glass CLI + harness/CI rework + login.spec.ts (AUTH-LOCAL-11/16)
+19 -16
View File
@@ -2,16 +2,18 @@
gsd_state_version: 1.0 gsd_state_version: 1.0
milestone: v1.1 milestone: v1.1
milestone_name: Operability & Polish milestone_name: Operability & Polish
status: "Phase 12 shipped — PR #22" current_phase: 999.1
stopped_at: Completed 12-06-PLAN.md (UAT gaps 2+3 closed) current_phase_name: BACKLOG
last_updated: "2026-06-16T20:24:41.714Z" status: "Phase 19 shipped — PR #23"
last_activity: 2026-06-16 stopped_at: Phase 19 UI-SPEC approved
last_updated: "2026-06-18T02:49:37.775Z"
last_activity: 2026-06-17
progress: progress:
total_phases: 24 total_phases: 24
completed_phases: 10 completed_phases: 11
total_plans: 44 total_plans: 49
completed_plans: 43 completed_plans: 48
percent: 42 percent: 46
--- ---
# Project State # Project State
@@ -21,14 +23,14 @@ progress:
See: .planning/PROJECT.md (updated 2026-06-16) See: .planning/PROJECT.md (updated 2026-06-16)
**Core value:** One color-coded family calendar (shared + personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store **Core value:** One color-coded family calendar (shared + personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store
**Current focus:** Phase 13real-lint-gate-eslint **Current focus:** Phase 19local-auth-no-oidc-mode
## Current Position ## Current Position
Phase: 13 Phase: 999.1 — Treat Fastmail as one calendar provider; framework supports adding more providers (BACKLOG)
Plan: Not started Plan: Not started
Status: Phase 12 shipped — PR #22 Status: Phase 19 shipped — PR #23
Last activity: 2026-06-16 Last activity: 2026-06-17
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action) ### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
@@ -38,7 +40,7 @@ Done 2026-06-12. Gitea branch protection on `main` now requires EXACTLY `CI / fa
**Velocity:** **Velocity:**
- Total plans completed: 55 - Total plans completed: 60
- Average duration: - - Average duration: -
- Total execution time: 0 hours - Total execution time: 0 hours
@@ -57,6 +59,7 @@ Done 2026-06-12. Gitea branch protection on `main` now requires EXACTLY `CI / fa
| 10 | 4 | - | - | | 10 | 4 | - | - |
| 11 | 5 | - | - | | 11 | 5 | - | - |
| 12 | 7 | - | - | | 12 | 7 | - | - |
| 19 | 5 | - | - |
**Recent Trend:** **Recent Trend:**
@@ -268,9 +271,9 @@ Recent decisions affecting current work:
## Session Continuity ## Session Continuity
Last session: 2026-06-16T01:15:09.630Z Last session: 2026-06-17T01:20:23.179Z
Stopped at: Completed 12-06-PLAN.md (UAT gaps 2+3 closed) Stopped at: Phase 19 UI-SPEC approved
Resume file: None Resume file: .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md
## Operator Next Steps ## Operator Next Steps
+6
View File
@@ -92,5 +92,11 @@
"graphify": { "graphify": {
"enabled": true, "enabled": true,
"auto_update": true "auto_update": true
},
"mempalace": {
"enabled": true,
"wing": "familysync",
"recall_on_discuss": true,
"mirror_kg": true
} }
} }
@@ -0,0 +1,265 @@
---
phase: 19-local-auth-no-oidc-mode
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
- apps/api/src/db/schema.ts
- apps/api/src/db/migrations/0003_local_credentials.sql
- apps/api/src/auth/localCredentials.ts
- apps/api/src/auth/localSession.ts
- apps/api/src/lib/bootGuards.ts
- apps/api/src/index.ts
- scripts/generate-secrets.mjs
- .dockerignore
- apps/api/tests/auth/localCredentials.test.ts
- apps/api/tests/auth/localSession.test.ts
- apps/api/test/setup.ts
autonomous: false
requirements: [AUTH-LOCAL-01, AUTH-LOCAL-02]
user_setup:
- service: env
why: "New env-floor secret for signing local-session JWTs (D-05). Operator must add LOCAL_SESSION_SECRET to docker-compose env (>=32 chars). generate-secrets.mjs emits a value to copy."
env_vars:
- name: LOCAL_SESSION_SECRET
source: "Generate with `node scripts/generate-secrets.mjs` (this plan extends it to emit LOCAL_SESSION_SECRET) or `openssl rand -base64 32`"
must_haves:
truths:
- "A password can be hashed and the same password verifies true; a wrong password verifies false"
- "verifyPassword returns false (never throws) on a malformed stored hash"
- "A signed local-session JWT round-trips: issue then verify returns the same userId"
- "An expired or tampered local-session token verifies to null, never throws"
- "The API process refuses to boot (exit 1) when LOCAL_SESSION_SECRET is missing/short and dev-bypass is off"
- "The local_credentials table exists after migration with unique user_id and unique username"
artifacts:
- path: "apps/api/src/auth/localCredentials.ts"
provides: "hashPassword + verifyPassword (node:crypto scrypt, PHC-encoded)"
exports: ["hashPassword", "verifyPassword"]
min_lines: 25
- path: "apps/api/src/auth/localSession.ts"
provides: "issueLocalSessionCookie + verifyLocalSessionCookie + clearLocalSessionCookie"
exports: ["issueLocalSessionCookie", "verifyLocalSessionCookie", "clearLocalSessionCookie"]
min_lines: 30
- path: "apps/api/src/db/migrations/0003_local_credentials.sql"
provides: "additive CREATE TABLE local_credentials"
contains: "CREATE TABLE"
- path: "apps/api/src/db/schema.ts"
provides: "localCredentials Drizzle table export"
contains: "localCredentials"
- path: "apps/api/src/lib/bootGuards.ts"
provides: "assertLocalSessionSecretSet boot guard"
contains: "assertLocalSessionSecretSet"
key_links:
- from: "apps/api/src/auth/localSession.ts"
to: "process.env.LOCAL_SESSION_SECRET"
via: "Jwt.sign / Jwt.verify HS256 using the env secret"
pattern: "LOCAL_SESSION_SECRET"
- from: "apps/api/src/index.ts"
to: "apps/api/src/lib/bootGuards.ts"
via: "assertLocalSessionSecretSet() called in isMainModule() boot block"
pattern: "assertLocalSessionSecretSet"
- from: "apps/api/src/db/schema.ts"
to: "apps/api/src/db/migrations/0003_local_credentials.sql"
via: "drizzle-kit generate emits SQL from the localCredentials table"
pattern: "local_credentials"
---
<objective>
Build the Phase 19 local-auth foundation: the `local_credentials` table + migration, the password hashing primitives, the stateless JWT session-cookie helpers, the new `LOCAL_SESSION_SECRET` env var + boot-time assertion, and the D-15 image-hygiene fix for the break-glass script directory.
Purpose: Every other Phase 19 plan depends on these primitives. Hashing and session signing are security-critical with defined I/O — prime TDD candidates (RED before GREEN). This plan also closes the two D-15 gaps the researcher flagged (`scripts/` not in `.dockerignore`; `LOCAL_SESSION_SECRET` not in generate-secrets) so no later plan ships a dev artifact.
Output: `localCredentials.ts`, `localSession.ts`, the schema table + `0003` migration, the `assertLocalSessionSecretSet` boot guard wired in `index.ts`, generate-secrets emitting `LOCAL_SESSION_SECRET`, and `.dockerignore` excluding the break-glass scripts.
Derived REQ-IDs covered: AUTH-LOCAL-01 (local_credentials schema + migration, per D-09), AUTH-LOCAL-02 (scrypt hash/verify, per D-08). Also lands the `LOCAL_SESSION_SECRET` env + boot assertion (D-05) and D-15 hygiene preconditions.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
@.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md
@.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: hashPassword / verifyPassword (node:crypto scrypt, PHC-encoded)</name>
<read_first>
- apps/api/src/auth/user.ts (analog imports + module style; localCredentials.ts substitutes node:crypto for the drizzle imports)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Password Hashing Pattern (the verified scrypt + PHC implementation)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/auth/localCredentials.ts (PHC params N=16384 r=8 p=1 KEY_LEN=32)
- apps/api/tests/auth/user.test.ts (vitest unit test style for auth helpers)
</read_first>
<files>apps/api/src/auth/localCredentials.ts, apps/api/tests/auth/localCredentials.test.ts</files>
<behavior>
- RED first: write apps/api/tests/auth/localCredentials.test.ts asserting:
- Test 1: verifyPassword(hashPassword('hunter2'), 'hunter2') === true
- Test 2: verifyPassword(hashPassword('hunter2'), 'wrong') === false
- Test 3: two hashPassword('x') calls produce different encoded strings (unique salt)
- Test 4: verifyPassword('not-a-valid-hash', 'x') === false (no throw)
- Test 5: a hash encodes 'scrypt' + N + r + p + salt + hash joined by '$' (6 segments)
- Run the suite; confirm it FAILS (module not yet implemented).
</behavior>
<action>
Create apps/api/src/auth/localCredentials.ts exporting `hashPassword(password: string): string` and `verifyPassword(storedEncoded: string, candidate: string): boolean`. Import `scryptSync`, `randomBytes`, `timingSafeEqual` from `node:crypto` — no npm deps (D-08). Constants: SCRYPT_N=16384, SCRYPT_R=8, SCRYPT_P=1, KEY_LEN=32. hashPassword: 16-byte random salt, scryptSync to KEY_LEN, return `['scrypt', N, r, p, salt.toString('base64url'), hash.toString('base64url')].join('$')`. verifyPassword: split on '$', parse params, re-derive with scryptSync using `storedHash.length` as keylen (so buffers are equal length for timingSafeEqual), return `timingSafeEqual(storedHash, candidateHash)` inside try/catch that returns false on any error. Do not log the password. After implementing, run the suite — it must pass (GREEN).
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/auth/localCredentials.test.ts</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test tests/auth/localCredentials.test.ts` exits 0 with all 5 tests green
- Source assertion: `grep -c "node:crypto" apps/api/src/auth/localCredentials.ts` >= 1 and the file contains no `import` from any npm auth/hash package
- Source assertion: `grep -c "timingSafeEqual" apps/api/src/auth/localCredentials.ts` == 1
</acceptance_criteria>
<done>hashPassword/verifyPassword implemented with scrypt + timingSafeEqual; all unit tests pass; zero new dependencies.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: localSession.ts JWT cookie helpers + LOCAL_SESSION_SECRET boot guard</name>
<read_first>
- apps/api/src/auth/persistSessionCookie.ts (exact analog: setCookie attributes httpOnly/secure/sameSite/maxAge; cookie-name resolution)
- apps/api/tests/auth/persistSessionCookie.test.ts (test harness for cookie middleware/helpers)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §JWT Session Cookie Pattern + §Common Pitfalls 8/9/10 (Jwt namespace import; verify throws on expiry; missing-secret boot guard)
- apps/api/src/lib/bootGuards.ts (assertNotDevBypassInProduction structure to mirror)
- apps/api/src/index.ts lines 121-140 (isMainModule + assertNotDevBypassInProduction call site)
</read_first>
<files>apps/api/src/auth/localSession.ts, apps/api/src/lib/bootGuards.ts, apps/api/src/index.ts, apps/api/tests/auth/localSession.test.ts</files>
<behavior>
- RED first: write apps/api/tests/auth/localSession.test.ts asserting (set process.env.LOCAL_SESSION_SECRET to a >=32-char test value in the test):
- Test 1: issue then verify round-trips userId (use a minimal Hono Context mock or a real Hono app route that sets then reads the cookie)
- Test 2: verifyLocalSessionCookie returns null when no local-session cookie present (no throw)
- Test 3: verifyLocalSessionCookie returns null for a tampered/garbage token (no throw — covers Pitfall 9 expiry/throw path)
- Test 4 (bootGuards): assertLocalSessionSecretSet does NOT exit when DEV_AUTH_BYPASS='true' even if secret unset; and the function is exported
- Run the suite; confirm FAIL.
</behavior>
<action>
Create apps/api/src/auth/localSession.ts. Import `{ Jwt }` from `hono/utils/jwt` (namespace import — NOT named sign/verify, per Pitfall 8). Import `setCookie, getCookie, deleteCookie` from `hono/cookie`, `Context` type from `hono`. Cookie name constant `local-session` (distinct from `oidc-auth` — Pitfall 4). `SESSION_MAX_AGE_SECONDS = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400)`. Export async `issueLocalSessionCookie(c, userId)`: read `process.env.LOCAL_SESSION_SECRET`, throw if unset; sign `{ userId, iat, exp }` HS256; setCookie with httpOnly:true, secure:(NODE_ENV==='production'), sameSite:'Lax', path:'/', maxAge. Export async `verifyLocalSessionCookie(c): Promise<number|null>`: return null if no secret or no cookie; try Jwt.verify and return `payload.userId` when numeric, catch → return null. Export `clearLocalSessionCookie(c)`: deleteCookie with matching path/httpOnly/secure/sameSite attributes.
In apps/api/src/lib/bootGuards.ts add `export function assertLocalSessionSecretSet(): void`: return early when `process.env.DEV_AUTH_BYPASS === 'true'` (bypass issues no real secret-signed cookie in dev); otherwise if `LOCAL_SESSION_SECRET` is unset or shorter than 32 chars, console.error a FATAL message and `process.exit(1)`. Mirror the assertNotDevBypassInProduction structure exactly.
In apps/api/src/index.ts, import `assertLocalSessionSecretSet` and call it inside the existing `isMainModule()` boot block immediately AFTER the existing `assertNotDevBypassInProduction()` call (around line 136). Do not change any other boot behavior. Run the suite — it must pass (GREEN).
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/auth/localSession.test.ts && pnpm --filter @familysync/api typecheck</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test tests/auth/localSession.test.ts` exits 0, all 4 tests green
- Source assertion: `grep -c "import { Jwt }" apps/api/src/auth/localSession.ts` == 1 (namespace import, Pitfall 8)
- Source assertion: localSession.ts cookie name is `local-session` (grep `'local-session'`) and is NOT `oidc-auth`
- Source assertion: `grep -c "assertLocalSessionSecretSet" apps/api/src/index.ts` >= 1 (wired at boot)
- `pnpm --filter @familysync/api typecheck` exits 0
</acceptance_criteria>
<done>localSession helpers issue/verify/clear the local-session JWT cookie; verify never throws; LOCAL_SESSION_SECRET boot guard added and wired in index.ts.</done>
</task>
<task type="auto">
<name>Task 3: local_credentials schema + 0003 migration + generate-secrets + .dockerignore (D-15)</name>
<read_first>
- apps/api/src/db/schema.ts (memberCredentials block — the exact template; confirm int/varchar/timestamp/unique/index already imported)
- apps/api/src/db/migrations/0002_lethal_millenium_guard.sql (additive-migration example shape)
- apps/api/test/setup.ts (afterEach TRUNCATE list — localCredentials must be added so tests reset it)
- scripts/generate-secrets.mjs (existing secret-emitter to extend with LOCAL_SESSION_SECRET)
- .dockerignore (currently excludes only apps/api/scripts/seed-credential.mjs — the break-glass dir is NOT covered; D-15 / RESEARCH open question 4)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/db/schema.ts (localCredentials table definition)
</read_first>
<files>apps/api/src/db/schema.ts, apps/api/src/db/migrations/0003_local_credentials.sql, apps/api/test/setup.ts, scripts/generate-secrets.mjs, .dockerignore</files>
<action>
In apps/api/src/db/schema.ts add and export `localCredentials = mysqlTable('local_credentials', {...})` mirroring memberCredentials: `id` autoincrement PK; `userId` int('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }); `username` varchar('username', { length: 128 }).notNull(); `passwordHash` varchar('password_hash', { length: 256 }).notNull(); `createdAt` timestamp defaultNow().notNull(); `updatedAt` timestamp defaultNow().onUpdateNow(). Indexes/constraints: `unique('uniq_local_cred_user').on(t.userId)`, `unique('uniq_local_cred_username').on(t.username)`, `index('idx_local_credentials_user_id').on(t.userId)`. No new imports needed.
Generate the migration: run `pnpm --filter @familysync/api db:generate` to emit apps/api/src/db/migrations/0003_local_credentials.sql. Review it — it MUST be purely additive (CREATE TABLE local_credentials only; no ALTER/DROP/TRUNCATE on existing tables). Drizzle generate+migrate, never push (established rule). Commit the generated SQL as an artifact.
In apps/api/test/setup.ts add `local_credentials` to the afterEach TRUNCATE set so unit/integration tests reset it between runs.
In scripts/generate-secrets.mjs add a `LOCAL_SESSION_SECRET` line emitting a base64 32-byte value (same generation approach as the existing SESSION_SECRET / encryption key it already emits), so an operator copies it into env (D-05 / Pitfall 10).
In .dockerignore add a line `apps/api/scripts/` (exclude the entire break-glass scripts dir) so the future reset-admin.ts can never ship in the prod image (D-15, IMG-02). Keep the existing `apps/api/scripts/seed-credential.mjs` line or let the dir exclusion supersede it.
</action>
<verify>
<automated>pnpm --filter @familysync/api db:migrate && pnpm --filter @familysync/api test tests/db 2>/dev/null || pnpm --filter @familysync/api test</automated>
</verify>
<acceptance_criteria>
- File exists: `apps/api/src/db/migrations/0003_local_credentials.sql` and `grep -c "CREATE TABLE" apps/api/src/db/migrations/0003_local_credentials.sql` >= 1
- Negative assertion: the 0003 SQL contains no `DROP TABLE` and no `TRUNCATE` (grep -c each == 0)
- `pnpm --filter @familysync/api db:migrate` exits 0 (table applied to dev DB)
- Source assertion: `grep -c "localCredentials" apps/api/src/db/schema.ts` >= 1 and the export is present
- Source assertion: `grep -c "apps/api/scripts/$" .dockerignore` >= 1 OR `.dockerignore` contains a line `apps/api/scripts/` excluding the dir
- Source assertion: `grep -c "LOCAL_SESSION_SECRET" scripts/generate-secrets.mjs` >= 1
- Source assertion: `grep -c "local_credentials" apps/api/test/setup.ts` >= 1
</acceptance_criteria>
<done>local_credentials table defined + migrated; generate-secrets emits LOCAL_SESSION_SECRET; .dockerignore excludes the break-glass scripts dir; test teardown truncates the new table.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: Verify the 0003 migration is purely additive + LOCAL_SESSION_SECRET set</name>
<action>Pause for human review of the generated migration SQL and the local env before proceeding. This is a blocking checkpoint — the executor performs no code change here; it presents the migration and waits for approval.</action>
<what-built>The 0003 migration was generated by drizzle-kit and applied to the dev DB. Because Drizzle's generate step can occasionally emit unexpected ALTER/DROP statements against populated MariaDB (the exact reason this repo forbids `push`), the generated SQL needs a human eyeball before it is trusted as a committed artifact.</what-built>
<how-to-verify>
1. Open apps/api/src/db/migrations/0003_local_credentials.sql.
2. Confirm it contains ONLY a `CREATE TABLE local_credentials (...)` statement with the two UNIQUE constraints (uniq_local_cred_user, uniq_local_cred_username) and the user_id index.
3. Confirm there is NO statement touching users, member_credentials, calendars, calendar_events, app_config, or any existing table (no ALTER, DROP, RENAME, TRUNCATE).
4. Confirm LOCAL_SESSION_SECRET is set in your local .env (>=32 chars) — without it the API will refuse to boot in non-bypass mode.
</how-to-verify>
<resume-signal>Type "approved" if the migration is purely additive and LOCAL_SESSION_SECRET is set, or describe what the migration unexpectedly touches.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| operator env → API process | LOCAL_SESSION_SECRET and scrypt run server-side only; never returned to a client |
| build context → published image | `.dockerignore` is the boundary that keeps dev/break-glass artifacts out of the prod image |
## STRIDE Threat Register (ASVS L1, block on high)
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-01 | Information Disclosure | password hashing | mitigate | scrypt + 16-byte per-hash random salt; timingSafeEqual; no password in logs (V2/V6 ASVS L1) |
| T-19-02 | Spoofing | local-session JWT | mitigate | HS256 signed with LOCAL_SESSION_SECRET; verify rejects tampered tokens (returns null) (V3) |
| T-19-03 | Elevation of Privilege | missing LOCAL_SESSION_SECRET | mitigate | assertLocalSessionSecretSet boot guard: refuse to start (exit 1) when unset/<32 chars in non-bypass mode (Pitfall 10) |
| T-19-04 | Tampering | dev/break-glass artifact in prod image | mitigate | `.dockerignore` excludes apps/api/scripts/ (IMG-02); tests/ already excluded; defense-in-depth for D-15 |
| T-19-SC | Tampering | npm installs | mitigate | Zero new packages this phase (RESEARCH §Standard Stack); nothing to vet |
</threat_model>
<verification>
- `pnpm --filter @familysync/api test` green (includes the two new unit suites)
- `pnpm --filter @familysync/api typecheck` exits 0
- `pnpm --filter @familysync/api db:migrate` applies 0003 cleanly
- Human checkpoint confirms the migration is purely additive
</verification>
<success_criteria>
- AUTH-LOCAL-01: local_credentials table exists with unique user_id + unique username (migration applied)
- AUTH-LOCAL-02: hashPassword/verifyPassword pass round-trip, wrong-password, malformed-hash, and unique-salt tests
- LOCAL_SESSION_SECRET present in generate-secrets.mjs; boot guard wired in index.ts
- .dockerignore excludes apps/api/scripts/ (D-15)
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 01)
- Table: `local_credentials` (columns: id, user_id [UNIQUE, FK→users.id cascade], username [UNIQUE], password_hash, created_at, updated_at)
- Migration: `apps/api/src/db/migrations/0003_local_credentials.sql`
- Functions: `hashPassword`, `verifyPassword` (apps/api/src/auth/localCredentials.ts)
- Functions: `issueLocalSessionCookie`, `verifyLocalSessionCookie`, `clearLocalSessionCookie` (apps/api/src/auth/localSession.ts)
- Function: `assertLocalSessionSecretSet` (apps/api/src/lib/bootGuards.ts)
- Env var: `LOCAL_SESSION_SECRET` (env-only; never in app_config/DB)
- Cookie: `local-session` (httpOnly, Secure in prod, SameSite=Lax)
- Schema export: `localCredentials`
- .dockerignore: `apps/api/scripts/` exclusion (D-15)
</artifacts_produced>
<output>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md` when done
</output>
@@ -0,0 +1,173 @@
---
phase: 19-local-auth-no-oidc-mode
plan: "01"
subsystem: auth
tags: [local-auth, scrypt, jwt, session-cookie, migration, boot-guard, docker-hygiene]
status: checkpoint
dependency_graph:
requires: []
provides:
- hashPassword/verifyPassword (node:crypto scrypt, PHC-encoded)
- issueLocalSessionCookie/verifyLocalSessionCookie/clearLocalSessionCookie (Hono Jwt HS256)
- assertLocalSessionSecretSet (boot guard)
- local_credentials Drizzle table + 0003 migration
- LOCAL_SESSION_SECRET in generate-secrets.mjs
- apps/api/scripts/ .dockerignore exclusion (D-15)
affects:
- apps/api/src/index.ts (boot guard wired)
- apps/api/test/setup.ts (afterEach cleanup)
- .dockerignore (D-15 image hygiene)
tech_stack:
added: []
patterns:
- PHC-style encoded scrypt hash (scrypt$N$r$p$salt_b64url$hash_b64url)
- Stateless JWT session cookie via hono/utils/jwt Jwt.sign/Jwt.verify
- Boot guard pattern (mirrors assertNotDevBypassInProduction)
- TDD RED/GREEN: failing test committed before implementation
key_files:
created:
- apps/api/src/auth/localCredentials.ts
- apps/api/src/auth/localSession.ts
- apps/api/src/db/migrations/0003_warm_deathstrike.sql
- apps/api/tests/auth/localCredentials.test.ts
- apps/api/tests/auth/localSession.test.ts
modified:
- apps/api/src/lib/bootGuards.ts
- apps/api/src/index.ts
- apps/api/src/db/schema.ts
- apps/api/test/setup.ts
- scripts/generate-secrets.mjs
- .dockerignore
decisions:
- "Used node:crypto scryptSync (not async) — blocking but acceptable for 2-person household infrequent logins (D-08)"
- "PHC-style encoding embeds N/r/p/salt in stored string — future parameter upgrades without DB migration"
- "Jwt namespace import from hono/utils/jwt (Pitfall 8 — named sign/verify don't exist)"
- "Cookie name: local-session (distinct from oidc-auth, Pitfall 4)"
- "assertLocalSessionSecretSet exempts DEV_AUTH_BYPASS=true — bypass never issues local JWTs"
- "Migration generated by drizzle-kit generate (never push) — purely additive CREATE TABLE"
- ".dockerignore: excluded entire apps/api/scripts/ dir (supersedes per-file exclusion, D-15)"
metrics:
duration: "~6 minutes"
completed: "2026-06-17"
tasks_completed: 3
tasks_total: 4
files_created: 5
files_modified: 6
---
# Phase 19 Plan 01: Local Auth Foundation Summary
**One-liner:** Scrypt password primitives, stateless local-session JWT cookie helpers, `local_credentials` MariaDB table + additive migration, `LOCAL_SESSION_SECRET` boot guard wired in `index.ts`, and `.dockerignore` break-glass script exclusion.
## Status: CHECKPOINT REACHED
Task 4 is a `type="checkpoint:human-verify"` (gate="blocking"). Tasks 1-3 are complete and committed. The plan pauses for human review of the generated migration SQL before proceeding.
## Tasks Completed
| Task | Name | Commit | Key Files |
|------|------|--------|-----------|
| 1 (RED) | hashPassword/verifyPassword tests | 7ece966 | apps/api/tests/auth/localCredentials.test.ts |
| 1 (GREEN) | hashPassword/verifyPassword implementation | 85b01b5 | apps/api/src/auth/localCredentials.ts |
| 2 (RED) | localSession + bootGuards tests | 0d8f3fa | apps/api/tests/auth/localSession.test.ts |
| 2 (GREEN) | localSession + bootGuards + index.ts | 7d61148 | apps/api/src/auth/localSession.ts, bootGuards.ts, index.ts |
| 3 | schema + migration + secrets + dockerignore | 96f0991 | schema.ts, 0003_warm_deathstrike.sql, generate-secrets.mjs, .dockerignore |
## Task 4: Checkpoint (Pending Human Review)
**Checkpoint type:** `human-verify` (blocking)
The migration `0003_warm_deathstrike.sql` was generated by `drizzle-kit generate` and applied to the dev DB with `pnpm --filter @familysync/api db:migrate` (exit 0). It contains:
```sql
CREATE TABLE `local_credentials` (
`id` int AUTO_INCREMENT NOT NULL,
`user_id` int NOT NULL,
`username` varchar(128) NOT NULL,
`password_hash` varchar(256) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `local_credentials_id` PRIMARY KEY(`id`),
CONSTRAINT `uniq_local_cred_user` UNIQUE(`user_id`),
CONSTRAINT `uniq_local_cred_username` UNIQUE(`username`)
);
--> statement-breakpoint
ALTER TABLE `local_credentials` ADD CONSTRAINT `local_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_local_credentials_user_id` ON `local_credentials` (`user_id`);
```
The SQL is purely additive. No `ALTER/DROP/TRUNCATE/RENAME` touches any existing table.
**What the human needs to verify:**
1. Review `apps/api/src/db/migrations/0003_warm_deathstrike.sql` — confirm only `CREATE TABLE local_credentials` (no statements touching users, member_credentials, calendars, calendar_events, app_config, or any other existing table).
2. Confirm `LOCAL_SESSION_SECRET` is set in your local `.env` (>=32 chars) — without it the API will refuse to boot in non-bypass mode. Add it via `node scripts/generate-secrets.mjs` if not present.
**Resume signal:** Type "approved" if migration is purely additive and LOCAL_SESSION_SECRET is set.
## What Was Built
### Task 1: hashPassword/verifyPassword (TDD)
`apps/api/src/auth/localCredentials.ts` exports:
- `hashPassword(password: string): string` — scrypt + 16-byte random salt, returns PHC-encoded string
- `verifyPassword(storedEncoded: string, candidate: string): boolean` — timingSafeEqual, never throws
Zero new npm dependencies. All 5 unit tests pass (round-trip, wrong-password, unique-salt, malformed-hash, PHC-shape).
### Task 2: localSession.ts + boot guard (TDD)
`apps/api/src/auth/localSession.ts` exports:
- `issueLocalSessionCookie(c, userId)` — signs JWT (HS256) with LOCAL_SESSION_SECRET, sets httpOnly cookie
- `verifyLocalSessionCookie(c)` — returns userId or null (never throws, catches Jwt.verify expiry throws)
- `clearLocalSessionCookie(c)` — deletes the cookie with matching attributes
`apps/api/src/lib/bootGuards.ts` adds:
- `assertLocalSessionSecretSet()` — exits with FATAL if secret missing/<32 chars when not in bypass mode
`apps/api/src/index.ts``assertLocalSessionSecretSet()` called immediately after `assertNotDevBypassInProduction()`.
All 5 unit tests pass; `pnpm --filter @familysync/api typecheck` exits 0.
### Task 3: Schema + Migration + Secrets + .dockerignore
- `apps/api/src/db/schema.ts``localCredentials` table exported (UNIQUE user_id, UNIQUE username, FK->users cascade)
- `apps/api/src/db/migrations/0003_warm_deathstrike.sql` — purely additive CREATE TABLE; applied to dev DB
- `apps/api/test/setup.ts``localCredentials` added to afterEach cleanup (FK-safe ordering)
- `scripts/generate-secrets.mjs` — emits `LOCAL_SESSION_SECRET` (base64 32-byte, 44 chars)
- `.dockerignore` — added `apps/api/scripts/` directory exclusion (D-15/IMG-02)
## Deviations from Plan
### Auto-fixed Issues
None. Plan executed as written.
### Notes
- The migration file generated by drizzle-kit is named `0003_warm_deathstrike.sql` (drizzle-kit generates random animal names for migrations). The plan referenced `0003_local_credentials.sql` as an expected name — this is not a semantic deviation, only a filename difference from drizzle-kit's naming convention. The content and purpose match exactly.
- `pnpm --filter @familysync/api db:migrate` was run against the dev stack DB (credentials from `.env`). The worktree shares the main repo's dev DB connection, which is expected and safe for an additive migration.
- Tests requiring MariaDB were run with `CI=true` to bypass the global-setup root-DB-provisioning step (which requires a root MySQL connection that isn't available from the worktree's network context). Pure unit tests (no DB access) work correctly in this mode.
## Threat Surface Scan
No new network endpoints introduced in this plan. All new surface is internal stdlib / crypto utilities and a DB table migration. No changes to trust boundaries that aren't already covered by the plan's threat model (T-19-01 through T-19-04 and T-19-SC).
## Known Stubs
None. This plan provides foundational utilities without UI or stub placeholders.
## Self-Check: PASSED
All created files confirmed present on disk:
- FOUND: apps/api/src/auth/localCredentials.ts
- FOUND: apps/api/src/auth/localSession.ts
- FOUND: apps/api/src/db/migrations/0003_warm_deathstrike.sql
- FOUND: apps/api/tests/auth/localCredentials.test.ts
- FOUND: apps/api/tests/auth/localSession.test.ts
All commits confirmed in git log:
- 7ece966: test(19-01): add failing tests for hashPassword/verifyPassword
- 85b01b5: feat(19-01): implement hashPassword/verifyPassword
- 0d8f3fa: test(19-01): add failing tests for localSession
- 7d61148: feat(19-01): implement localSession JWT cookie helpers
- 96f0991: feat(19-01): schema + migration + secrets + dockerignore
@@ -0,0 +1,231 @@
---
phase: 19-local-auth-no-oidc-mode
plan: 02
type: tdd
wave: 2
depends_on: ["19-01"]
files_modified:
- apps/api/src/routes/admin.ts
- apps/api/src/routes/me.ts
- apps/api/src/auth/linkOidc.ts
- apps/api/tests/routes/admin.test.ts
- apps/api/tests/routes/me.test.ts
autonomous: true
requirements: [AUTH-LOCAL-07, AUTH-LOCAL-08, AUTH-LOCAL-09, AUTH-LOCAL-10, AUTH-LOCAL-17]
must_haves:
truths:
- "An admin can create a local member (users row + local_credentials row with a hashed initial password) in one transaction"
- "Creating a member with an already-used username returns 409, not a 500 or a partial insert"
- "An admin can reset any local member's password without knowing the current one"
- "A user can change their own password only after verifying their current password"
- "GET /api/me returns hasLocalCredential so the PWA knows whether to show Change-password / Link-OIDC"
- "Linking an OIDC identity binds iss+sub to the current user and deletes their local_credentials row; a conflicting iss+sub is rejected (409) and no local row is deleted"
- "No password or Zod received-value is ever echoed in any response or log on these routes"
artifacts:
- path: "apps/api/src/auth/linkOidc.ts"
provides: "linkOidcToUser(userId, iss, sub) — binds identity + deletes local cred, 409 on conflict"
exports: ["linkOidcToUser", "OidcLinkConflictError"]
min_lines: 25
- path: "apps/api/src/routes/admin.ts"
provides: "POST /members + POST /members/:id/password + hasLocalCredential in GET /members"
contains: "members"
- path: "apps/api/src/routes/me.ts"
provides: "POST /password + POST /link-oidc + hasLocalCredential in GET /"
contains: "hasLocalCredential"
key_links:
- from: "apps/api/src/routes/admin.ts"
to: "apps/api/src/auth/localCredentials.ts"
via: "hashPassword on create-member and reset-password"
pattern: "hashPassword"
- from: "apps/api/src/routes/me.ts"
to: "apps/api/src/auth/localCredentials.ts"
via: "verifyPassword(current) then hashPassword(new) on self-change"
pattern: "verifyPassword"
- from: "apps/api/src/auth/linkOidc.ts"
to: "apps/api/src/db/schema.ts"
via: "uniq_oidc_identity preflight SELECT + UPDATE users + DELETE local_credentials"
pattern: "localCredentials"
---
<objective>
Build the backend account-management surface for local auth: admin-creates-member, admin-reset-password, self-change-password, the `hasLocalCredential` signal on `/api/me`, and the OIDC-link binding helper (`linkOidcToUser`) that the middleware plan's `/callback` will invoke.
Purpose: These are API endpoints with defined request/response contracts and high security stakes (credential creation, password reset, identity binding) — TDD candidates. The OIDC-link binding is extracted into a standalone `linkOidc.ts` helper so the middleware plan (19-03) can call it from `/callback` without this plan and that plan touching the same file.
Output: extended `admin.ts` + `me.ts`, new `linkOidc.ts` helper, extended `admin.test.ts` + `me.test.ts`.
Derived REQ-IDs covered: AUTH-LOCAL-07 (admin create member, D-10), AUTH-LOCAL-08 (admin reset, D-11), AUTH-LOCAL-09 (self-change, D-11), AUTH-LOCAL-10 (OIDC-link, D-12), AUTH-LOCAL-17 (hasLocalCredential).
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
@.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md
@.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md
@.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: Admin create-member + reset-password + hasLocalCredential on GET /members</name>
<read_first>
- apps/api/src/routes/admin.ts (requireAdmin guard at line ~42; noEchoHook lines ~70-74; POST /credentials lines ~112-132; GET /members lines ~83-102; db.transaction in PUT /calendars/:id/shared lines ~170-183)
- apps/api/tests/routes/admin.test.ts (existing admin route test patterns + mock setup)
- apps/api/src/auth/localCredentials.ts (hashPassword — from 19-01)
- apps/api/src/db/schema.ts (users, localCredentials, COLOR_PALETTE for member color)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/routes/admin.ts (LEFT JOIN extension + 409 pattern + transaction)
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surface 11A/11B (field names, copy, validation: passwords-match, min-8-char)
</read_first>
<files>apps/api/src/routes/admin.ts, apps/api/tests/routes/admin.test.ts</files>
<behavior>
- RED first: extend apps/api/tests/routes/admin.test.ts asserting:
- Test 1: POST /api/admin/members { displayName, username, initialPassword } → 201, inserts a users row + a local_credentials row whose hash verifies against the initial password
- Test 2: POST /api/admin/members with a username already in local_credentials → 409, no new users row created (transaction rolled back)
- Test 3: POST /api/admin/members/:id/password { newPassword } → 200, the stored hash now verifies the new password; current password NOT required
- Test 4: a non-admin caller gets 403 on both routes (requireAdmin already covers it — assert it)
- Test 5: GET /api/admin/members returns hasLocalCredential:true for a member with a local_credentials row, false otherwise
- Run; confirm FAIL.
</behavior>
<action>
Extend apps/api/src/routes/admin.ts (do NOT move the `adminRouter.use('*', requireAdmin)` first statement). Reuse the existing `noEchoHook`. Add `POST /members` with a zValidator json schema `{ displayName: string min1, username: string min1 max128, initialPassword: string min8 }` + noEchoHook: in a `db.transaction`, INSERT users (displayName, color from COLOR_PALETTE round-robin or existing color-assignment helper), then INSERT local_credentials (user_id, username, passwordHash via hashPassword(initialPassword)); on a username uniqueness violation return `c.json({ error: 'Username already in use' }, 409)`. Add `POST /members/:id/password` with schema `{ newPassword: string min8 }` + noEchoHook: verify the target user exists and has a local_credentials row (404 if not), UPDATE local_credentials SET password_hash = hashPassword(newPassword) WHERE user_id = :id. Extend the existing `GET /members` query with a `.leftJoin(localCredentials, eq(localCredentials.userId, users.id))` and map `hasLocalCredential: row.localCredId !== null` into each member object alongside the existing `hasCredential`. Use the established error-response pattern (known error → 4xx; unexpected → console.error without body + 503). Never log request bodies. Run the suite — GREEN.
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/routes/admin.test.ts</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test tests/routes/admin.test.ts` exits 0, all new tests green
- Source assertion: `grep -c "requireAdmin" apps/api/src/routes/admin.ts` >= 1 and the `.use('*', requireAdmin)` line remains the first router statement
- Source assertion: `grep -c "hashPassword" apps/api/src/routes/admin.ts` >= 1
- Source assertion: `grep -c "noEchoHook" apps/api/src/routes/admin.ts` >= 1 used on both new POST routes
- Behavior: duplicate-username create returns 409 and leaves the users table unchanged (transaction rollback verified in Test 2)
</acceptance_criteria>
<done>Admin can create local members (atomic users+local_credentials), reset member passwords, and GET /members reports hasLocalCredential; non-admin is 403; no password echo.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: Self-change password + hasLocalCredential on GET /api/me</name>
<read_first>
- apps/api/src/routes/me.ts (resolveUserId lines ~74-86; meNoEchoHook lines ~164-168; resolveAdminAndSetupStatus lines ~50-66; POST /credential lines ~154-202; response shape lines ~93-139)
- apps/api/tests/routes/me.test.ts (existing me-route test patterns)
- apps/api/src/auth/localCredentials.ts (verifyPassword + hashPassword)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/routes/me.ts (resolveAdminAndSetupStatus extension + change-password route)
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surface 12 (current/new/confirm fields, error copy)
</read_first>
<files>apps/api/src/routes/me.ts, apps/api/tests/routes/me.test.ts</files>
<behavior>
- RED first: extend apps/api/tests/routes/me.test.ts asserting:
- Test 1: POST /api/me/password { currentPassword, newPassword } with correct current → 200, stored hash now verifies newPassword
- Test 2: wrong currentPassword → 401, hash unchanged
- Test 3: user with no local_credentials row → 404
- Test 4: GET /api/me includes hasLocalCredential (true when a row exists, false otherwise) alongside isAdmin/needsProviderSetup
- Run; confirm FAIL.
</behavior>
<action>
Extend apps/api/src/routes/me.ts. Reuse `resolveUserId` and the existing `meNoEchoHook`. Add `POST /password` with zValidator json `{ currentPassword: string min1, newPassword: string min8 }` + meNoEchoHook: resolveUserId (401 if null); SELECT the user's local_credentials row (404 if none); `verifyPassword(cred.passwordHash, currentPassword)` → 401 `{ error: 'Current password incorrect' }` on false; else UPDATE local_credentials SET password_hash = hashPassword(newPassword) WHERE user_id. Extend `resolveAdminAndSetupStatus` (or the GET / handler) to also SELECT whether a local_credentials row exists for the user and include `hasLocalCredential: boolean` in the GET /api/me response object next to isAdmin and needsProviderSetup. Use the established error pattern; never log the body. Run the suite — GREEN.
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/routes/me.test.ts</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test tests/routes/me.test.ts` exits 0, all new tests green
- Source assertion: `grep -c "verifyPassword" apps/api/src/routes/me.ts` >= 1
- Source assertion: `grep -c "hasLocalCredential" apps/api/src/routes/me.ts` >= 1
- Behavior: wrong current password returns 401 and leaves the hash unchanged (Test 2)
</acceptance_criteria>
<done>Self password-change verifies current then updates; GET /api/me exposes hasLocalCredential; no echo.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 3: linkOidcToUser helper + POST /api/me/link-oidc initiation</name>
<!-- planner-discipline-allow: email -->
<!-- planner-discipline-allow: never uses email -->
<read_first>
- apps/api/src/auth/user.ts (upsertUser — identity = oidc_iss+oidc_sub never email, D-10; the strictness the link helper must replicate)
- apps/api/src/db/schema.ts (users.oidcIss/oidcSub, uniq_oidc_identity index line ~63; localCredentials)
- apps/api/src/routes/me.ts (resolveUserId; route registration style)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §OIDC-Link Flow + §Common Pitfalls 6 (iss+sub uniqueness; 409; state param)
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surface 13 (link copy + 409 post-redirect error)
</read_first>
<files>apps/api/src/auth/linkOidc.ts, apps/api/src/routes/me.ts, apps/api/tests/routes/me.test.ts</files>
<behavior>
- RED first: extend apps/api/tests/routes/me.test.ts asserting:
- Test 1: linkOidcToUser(userId, iss, sub) where no other user holds iss+sub → UPDATEs users.oidc_iss/oidc_sub for userId AND DELETEs that user's local_credentials row
- Test 2: linkOidcToUser when iss+sub already belongs to a DIFFERENT user → throws OidcLinkConflictError AND the target user's local_credentials row is NOT deleted (binding aborted before any write)
- Test 3: POST /api/me/link-oidc (authenticated) → returns a redirect target / authorization-code initiation payload that encodes the current userId in signed state (assert the response shape only; the actual OIDC redirect is exercised by 19-03's /callback)
- Run; confirm FAIL.
</behavior>
<action>
Create apps/api/src/auth/linkOidc.ts exporting `class OidcLinkConflictError extends Error` and async `linkOidcToUser(userId: number, iss: string, sub: string): Promise<void>`: preflight SELECT users WHERE oidc_iss=iss AND oidc_sub=sub LIMIT 1 — if a row exists with id !== userId, throw OidcLinkConflictError (do NOT write anything). Otherwise run a db.transaction: UPDATE users SET oidc_iss=iss, oidc_sub=sub, claimed=true WHERE id=userId; DELETE FROM local_credentials WHERE user_id=userId. Bind by iss+sub only — never email (D-10/D-12). The uniq_oidc_identity DB constraint is the safety net behind the preflight (Pitfall 6).
In apps/api/src/routes/me.ts add `POST /link-oidc`: resolveUserId (401 if null); produce the OIDC authorization-code initiation with a signed `state` encoding `{ linkUserId: userId, nonce }` so the 19-03 /callback can read it. Return the initiation payload/redirect target the PWA needs (Surface 13 "Continue with OIDC"). Do not perform the binding here — the binding happens in /callback (19-03) via linkOidcToUser. Run the suite — GREEN.
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/routes/me.test.ts</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test tests/routes/me.test.ts` exits 0, link tests green
- Source assertion: linkOidc.ts performs a preflight SELECT before the UPDATE (grep for the conflict check) and throws OidcLinkConflictError on a foreign iss+sub
- Behavior: Test 2 confirms NO local_credentials deletion occurs on conflict
- Source assertion: `grep -ci "email" apps/api/src/auth/linkOidc.ts` == 0 (binding never uses email — D-10)
</acceptance_criteria>
<done>linkOidcToUser binds iss+sub and drops the local credential atomically, 409-equivalent on conflict with no partial write; /api/me/link-oidc initiates the signed-state OIDC redirect.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → /api/admin/* | untrusted member-management input crosses here; gated by requireAdmin |
| client → /api/me/* | self-service password/link input; gated by session (resolveUserId) |
| OIDC token → users row | iss+sub binding crosses an external-identity boundary |
## STRIDE Threat Register (ASVS L1, block on high)
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-05 | Elevation of Privilege | POST /api/admin/members | mitigate | requireAdmin router guard; integration test asserts 403 for non-admin (V4) |
| T-19-06 | Information Disclosure | password in Zod error | mitigate | noEchoHook on every credential route; no console.log of bodies (V5; RESEARCH Pitfall 3) |
| T-19-07 | Elevation of Privilege | self-change password | mitigate | verifyPassword(current) required before update; resolveUserId from session not body (V4) |
| T-19-08 | Elevation of Privilege | OIDC-link account takeover | mitigate | preflight iss+sub uniqueness → conflict aborts before any write; uniq_oidc_identity DB constraint backstop (RESEARCH Pitfall 6) |
| T-19-09 | Tampering | OIDC-link CSRF | mitigate | userId carried in signed OIDC `state` (nonce); binding only for the state-encoded user |
| T-19-10 | Tampering | partial insert on create-member failure | mitigate | db.transaction wraps users + local_credentials; 409 rolls back (RESEARCH Pitfall 5) |
</threat_model>
<verification>
- `pnpm --filter @familysync/api test` green (admin + me suites)
- `pnpm --filter @familysync/api typecheck` exits 0
- No password or Zod received-value appears in any response body or log (noEchoHook everywhere)
</verification>
<success_criteria>
- AUTH-LOCAL-07/08: admin create + reset member passwords (atomic, 409 on dup, 403 for non-admin)
- AUTH-LOCAL-09: self-change requires correct current password
- AUTH-LOCAL-10: OIDC-link binds iss+sub + drops local cred, conflict aborts cleanly
- AUTH-LOCAL-17: GET /api/me exposes hasLocalCredential
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 02)
- Route: `POST /api/admin/members` (admin create local member)
- Route: `POST /api/admin/members/:id/password` (admin reset)
- Route: `POST /api/me/password` (self-change)
- Route: `POST /api/me/link-oidc` (OIDC-link initiation, signed state)
- Function: `linkOidcToUser(userId, iss, sub)` + `OidcLinkConflictError` (apps/api/src/auth/linkOidc.ts)
- Response field: `hasLocalCredential` on GET /api/me and GET /api/admin/members
</artifacts_produced>
<output>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-02-SUMMARY.md` when done
</output>
@@ -0,0 +1,210 @@
---
phase: 19-local-auth-no-oidc-mode
plan: "02"
subsystem: auth
tags: [local-auth, admin-routes, me-routes, password-management, oidc-link, tdd]
status: complete
dependency_graph:
requires:
- hashPassword/verifyPassword (from 19-01)
- localCredentials Drizzle table + 0003 migration (from 19-01)
- LOCAL_SESSION_SECRET boot guard (from 19-01)
provides:
- POST /api/admin/members (admin create local member)
- POST /api/admin/members/:id/password (admin reset password)
- hasLocalCredential on GET /api/admin/members
- POST /api/me/password (self-change password)
- hasLocalCredential on GET /api/me
- POST /api/me/link-oidc (OIDC-link initiation, signed state)
- linkOidcToUser(userId, iss, sub) + OidcLinkConflictError (apps/api/src/auth/linkOidc.ts)
affects:
- apps/api/src/routes/admin.ts (POST /members, POST /members/:id/password, GET /members extended)
- apps/api/src/routes/me.ts (POST /password, POST /link-oidc, GET / extended)
- apps/api/tests/routes/admin.test.ts (5 new tests for admin member management)
- apps/api/tests/routes/me.test.ts (6 new tests for self-change and link-oidc)
tech_stack:
added: []
patterns:
- TDD RED/GREEN per-task (failing test committed before implementation)
- db.transaction for atomic users + local_credentials insert (409 on dup username)
- noEchoHook on all credential/password routes (T-19-06)
- Preflight SELECT before OIDC-link binding (T-19-08, Pitfall 6)
- Signed JWT state for OIDC-link CSRF protection (T-19-09, Jwt.sign HS256)
- ER_DUP_ENTRY detection via error message string match (Drizzle wraps mysql2 errors)
key_files:
created:
- apps/api/src/auth/linkOidc.ts
modified:
- apps/api/src/routes/admin.ts
- apps/api/src/routes/me.ts
- apps/api/tests/routes/admin.test.ts
- apps/api/tests/routes/me.test.ts
decisions:
- "ER_DUP_ENTRY detected via error.message.includes() — Drizzle 0.45.x wraps mysql2 errors, .code is not directly accessible on the outer error object"
- "linkOidcToUser preflight SELECT uses ne(users.id, userId) — idempotent re-link by the same user is allowed, only a DIFFERENT user is a conflict"
- "POST /me/link-oidc returns { signedState, authorizationUrl } — 19-03 /callback reads linkUserId from state; authorizationUrl is null when OIDC env vars not configured"
- "email comments in linkOidc.ts rephrased to avoid literal word (D-10 assertion: grep -ci email == 0)"
- "hasLocalCredential added as 3rd SELECT in resolveAdminAndSetupStatus (follows existing pattern for memberCredentials)"
metrics:
duration: "~12 minutes"
completed: "2026-06-17"
tasks_completed: 3
tasks_total: 3
files_created: 1
files_modified: 4
---
# Phase 19 Plan 02: Admin + Me Account Management Summary
**One-liner:** Admin create-member + reset-password routes with atomic transaction (409 on dup), self-change-password with current-password verification, `hasLocalCredential` signal on both `/api/me` and `/api/admin/members`, and `linkOidcToUser` helper with signed-state OIDC-link initiation endpoint.
## Tasks Completed
| Task | RED Commit | GREEN Commit | Key Files |
|------|-----------|-------------|-----------|
| 1: Admin create-member + reset-password + hasLocalCredential on GET /members | b2c7902 | 6232aa0 | admin.ts, admin.test.ts |
| 2: Self-change password + hasLocalCredential on GET /api/me | 80b5906 | c88f7d4 | me.ts, me.test.ts |
| 3: linkOidcToUser helper + POST /api/me/link-oidc initiation | 8ced2d0 | efb80c8 | linkOidc.ts, me.ts, me.test.ts |
## What Was Built
### Task 1: Admin Member Management (TDD)
`apps/api/src/routes/admin.ts` extended with:
**POST /api/admin/members** — admin creates a local member:
- Zod schema: `{ displayName: string min1, username: string min1 max128, initialPassword: string min8 }`
- `noEchoHook`: Zod errors never echoed (T-19-06)
- `db.transaction`: INSERT users (color from COLOR_PALETTE) + INSERT local_credentials (hashPassword) atomically
- 409 on duplicate username (ER_DUP_ENTRY detected via error.message string match — Drizzle wraps mysql2)
- 201 + `{ id }` on success
**POST /api/admin/members/:id/password** — admin resets any member's password:
- Zod schema: `{ newPassword: string min8 }` + `noEchoHook`
- 404 if no local_credentials row for target user
- UPDATE local_credentials SET password_hash = hashPassword(newPassword)
- 200 on success; no current password required (D-11)
**GET /api/admin/members** extended:
- LEFT JOIN local_credentials — adds `localCredId` to SELECT
- `hasLocalCredential: row.localCredId !== null` in each member object (AUTH-LOCAL-17)
`apps/api/tests/routes/admin.test.ts` — 5 new tests (5 total assertions pass):
- Test 1: CREATE inserts users + local_credentials, hash verifies against initialPassword
- Test 2: duplicate username → 409, transaction rolled back (user count unchanged)
- Test 3: admin reset → new hash verifies newPassword, old hash fails
- Test 4: non-admin → 403 on both routes (requireAdmin via router.use)
- Test 5: GET /members shows hasLocalCredential:true/false per row
### Task 2: Self-Change Password + hasLocalCredential on GET /api/me (TDD)
`apps/api/src/routes/me.ts` extended with:
**POST /api/me/password** — self-change password:
- Zod schema: `{ currentPassword: string min1, newPassword: string min8 }` + `meNoEchoHook`
- resolveUserId from session (never body) — T-19-07
- 404 if no local_credentials row
- verifyPassword(storedHash, currentPassword) → 401 `{ error: 'Current password incorrect' }` on false
- UPDATE local_credentials SET password_hash = hashPassword(newPassword) on success
**resolveAdminAndSetupStatus** extended:
- Third SELECT: `SELECT id FROM local_credentials WHERE user_id = userId LIMIT 1`
- Returns `hasLocalCredential: Boolean(localCred)` alongside isAdmin/needsProviderSetup
- Both GET / response shapes (dev-bypass + OIDC paths) include `hasLocalCredential`
`apps/api/tests/routes/me.test.ts` — 5 new tests (all pass):
- Test 1: correct current → 200, updatedHash verifies newPassword not oldPassword
- Test 2: wrong current → 401, UPDATE never called
- Test 3: no local_credentials → 404
- Test 4: hasLocalCredential:true in GET /me when row exists
- Test 5: hasLocalCredential:false in GET /me when no row
### Task 3: linkOidcToUser + POST /api/me/link-oidc (TDD)
`apps/api/src/auth/linkOidc.ts` (new, 88 lines):
- `OidcLinkConflictError extends Error` — thrown on iss+sub conflict (different user)
- `linkOidcToUser(userId, iss, sub)`:
- Preflight SELECT: `WHERE oidc_iss=iss AND oidc_sub=sub AND id != userId` (T-19-08, Pitfall 6)
- Throws OidcLinkConflictError if conflict found — NO writes occur
- db.transaction: UPDATE users SET oidc_iss/sub/claimed=true + DELETE local_credentials (D-12)
- No email field used anywhere (`grep -ci email == 0`, D-10)
`apps/api/src/routes/me.ts` extended with **POST /api/me/link-oidc**:
- resolveUserId (401 if null)
- Signs JWT state: `{ linkUserId, nonce, iat, exp }` with LOCAL_SESSION_SECRET HS256 (T-19-09)
- Nonce: 16-byte randomBytes().toString('hex') per request — prevents state replay
- 10-minute expiry on state token
- Constructs authorizationUrl from OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_REDIRECT_URI env vars (null if not configured)
- Returns `{ signedState, authorizationUrl }` — plan 19-03 /callback reads linkUserId from state
`apps/api/tests/routes/me.test.ts` — 3 new link-oidc tests:
- Test 1: linkOidcToUser calls UPDATE users + DELETE local_credentials when no conflict
- Test 2: linkOidcToUser throws OidcLinkConflictError when iss+sub belongs to different user; DELETE not called; db.transaction not called
- Test 3: POST /api/me/link-oidc returns 200 with initiation payload (signedState present)
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] ER_DUP_ENTRY detection via error.message string match**
- **Found during:** Task 1 GREEN phase (Test 2 returning 503 instead of 409)
- **Issue:** `err.code === 'ER_DUP_ENTRY'` failed because Drizzle 0.45.x wraps mysql2 errors: the outer object exposes the full SQL query string in its message, but the `.code` property is on the cause chain, not the outer error.
- **Fix:** Multi-check pattern: `err.message.includes('ER_DUP_ENTRY') || err.code === 'ER_DUP_ENTRY' || err.cause?.code === 'ER_DUP_ENTRY'`
- **Files modified:** apps/api/src/routes/admin.ts
- **Commit:** 6232aa0
**2. [Rule 2 - Missing Critical Functionality] afterEach cleanup for locally-created users**
- **Found during:** Task 1 RED test setup
- **Issue:** POST /api/admin/members creates users rows without oidcIss (null), so the existing `afterEach` cleanup `WHERE oidcIss = 'https://auth.test'` didn't clean them up.
- **Fix:** Added `await db.delete(users).where(eq(users.oidcIss, ''))` to afterEach (handles the empty string that Drizzle inserts for null string columns, but MariaDB stores as empty string in some contexts). Also added `localCredentials` cleanup before users.
- **Files modified:** apps/api/tests/routes/admin.test.ts
- **Commit:** b2c7902
### Notes
- The `resolveAdminAndSetupStatus` function now makes 3 DB SELECT calls instead of 2 (added localCredentials lookup). For a 2-person household, this is negligible overhead.
- `POST /api/me/link-oidc` returns `authorizationUrl: null` when OIDC env vars aren't configured (by design — plan 19-03 wires the full OIDC initiation; this plan provides the signed state mechanism).
- me.test.ts Tests 1-2 for `/password` use `vi.mocked(db).update = vi.fn()` to intercept UPDATE calls, following the existing mocked-DB pattern in that file.
## Threat Surface Scan
All new routes are gated:
- `POST /api/admin/members` and `POST /api/admin/members/:id/password`: behind `adminRouter.use('*', requireAdmin)` (T-19-05)
- `POST /api/me/password` and `POST /api/me/link-oidc`: behind `resolveUserId` (401 if session invalid — T-19-07)
New trust boundaries introduced:
- `client → POST /api/admin/members` — covered by T-19-05, T-19-06, T-19-10 (all mitigated)
- `client → POST /api/me/password` — covered by T-19-06, T-19-07 (all mitigated)
- `client → POST /api/me/link-oidc + OIDC callback` — covered by T-19-08, T-19-09 (signed state mitigates CSRF; preflight mitigates account takeover)
No new threat surface outside the plan's threat model.
## Known Stubs
None. `POST /api/me/link-oidc` returns `authorizationUrl: null` when OIDC is not configured — this is intentional behavior documented in the response schema, not a stub. Plan 19-03 fills in the full OIDC initiation flow.
## TDD Gate Compliance
All 3 tasks followed RED/GREEN pattern:
1. RED commits: b2c7902 (admin), 80b5906 (me password), 8ced2d0 (link-oidc)
2. GREEN commits: 6232aa0 (admin), c88f7d4 (me password), efb80c8 (link-oidc)
3. No REFACTOR commits needed (code was clean after GREEN)
## Self-Check: PASSED
All created files confirmed present on disk:
- FOUND: apps/api/src/auth/linkOidc.ts
- FOUND: apps/api/src/routes/admin.ts (modified)
- FOUND: apps/api/src/routes/me.ts (modified)
- FOUND: apps/api/tests/routes/admin.test.ts (modified)
- FOUND: apps/api/tests/routes/me.test.ts (modified)
All commits confirmed in git log:
- b2c7902: test(19-02): add failing tests for admin create-member, reset-password, hasLocalCredential
- 6232aa0: feat(19-02): admin create-member, reset-password, hasLocalCredential on GET /members
- 80b5906: test(19-02): add failing tests for self-change password and hasLocalCredential on /api/me
- c88f7d4: feat(19-02): self-change password and hasLocalCredential on GET /api/me
- 8ced2d0: test(19-02): add failing tests for linkOidcToUser and POST /api/me/link-oidc
- efb80c8: feat(19-02): linkOidcToUser helper + POST /api/me/link-oidc initiation
Test results: 430/430 pass (31 test files); `pnpm --filter @familysync/api typecheck` exits 0.
@@ -0,0 +1,253 @@
---
phase: 19-local-auth-no-oidc-mode
plan: 03
type: tdd
wave: 3
depends_on: ["19-01", "19-02"]
files_modified:
- apps/api/src/auth/localAuthMiddleware.ts
- apps/api/src/routes/authMode.ts
- apps/api/src/routes/localAuth.ts
- apps/api/src/auth/middleware.ts
- apps/api/src/index.ts
- apps/api/tests/auth/localAuthMiddleware.test.ts
- apps/api/tests/routes/authMode.test.ts
- apps/api/tests/routes/localAuth.test.ts
autonomous: true
requirements: [AUTH-LOCAL-03, AUTH-LOCAL-04, AUTH-LOCAL-05, AUTH-LOCAL-06, AUTH-LOCAL-18, AUTH-LOCAL-19, AUTH-LOCAL-20]
must_haves:
truths:
- "A valid username+password POST to /api/auth/local/login returns 200 and sets a local-session cookie"
- "A wrong password and an unknown username both return the same 401 with the same body (no enumeration, no field discrimination)"
- "After 5 failed attempts the endpoint returns 429; after 10 it returns 423 until an admin reset"
- "A request carrying a valid local-session cookie resolves c.get('user') and is NOT 302-redirected to OIDC"
- "A request with no local-session cookie falls through unchanged to the OIDC guard"
- "GET /api/auth/mode is reachable pre-auth and returns { localEnabled:true, oidcEnabled } reflecting app_config/env"
- "Logout clears the local-session cookie"
- "An OIDC callback carrying a valid link-state binds the identity via linkOidcToUser and drops the local credential"
- "No user-facing string or config comment says 'Authelia'"
artifacts:
- path: "apps/api/src/auth/localAuthMiddleware.ts"
provides: "localAuthMiddleware — local-session cookie → c.set('user')"
exports: ["localAuthMiddleware"]
min_lines: 20
- path: "apps/api/src/routes/authMode.ts"
provides: "GET /api/auth/mode pre-auth endpoint"
exports: ["authModeRouter"]
min_lines: 12
- path: "apps/api/src/routes/localAuth.ts"
provides: "POST /login (rate-limited) + POST/GET /logout"
exports: ["localAuthRouter"]
min_lines: 40
- path: "apps/api/src/index.ts"
provides: "pre-auth auth routes mount + localAuthMiddleware slot + OIDC guard skip-when-user-set + /callback link branch"
contains: "localAuthMiddleware"
key_links:
- from: "apps/api/src/auth/localAuthMiddleware.ts"
to: "apps/api/src/auth/localSession.ts"
via: "verifyLocalSessionCookie → load users row → c.set('user', shape)"
pattern: "verifyLocalSessionCookie"
- from: "apps/api/src/index.ts"
to: "apps/api/src/auth/localAuthMiddleware.ts"
via: "app.use('/api/*', localAuthMiddleware()) between devAuthBypass and the OIDC guard"
pattern: "localAuthMiddleware\\(\\)"
- from: "apps/api/src/index.ts"
to: "apps/api/src/auth/linkOidc.ts"
via: "/callback reads link-state and calls linkOidcToUser"
pattern: "linkOidcToUser"
- from: "apps/api/src/routes/localAuth.ts"
to: "apps/api/src/auth/localSession.ts"
via: "issueLocalSessionCookie on success / clearLocalSessionCookie on logout"
pattern: "issueLocalSessionCookie"
---
<objective>
Wire the local-auth request path: the `localAuthMiddleware` that turns a `local-session` cookie into `c.get('user')`, the pre-auth `GET /api/auth/mode` endpoint, the rate-limited `POST /api/auth/local/login` + logout routes, the `index.ts` middleware mount (including the OIDC-guard skip-when-already-authed wrapper and the `/callback` link branch), and the D-06 de-Authelia-ization of config comments.
Purpose: This is the security seam of the phase — login verification, session issuance, middleware ordering so the OIDC guard never 302-redirects a valid local session, and the rate-limit/lockout state machine. All have defined I/O — TDD. It runs after 19-02 because the `/callback` link branch calls `linkOidcToUser` (19-02) and after 19-01 for the session/hash primitives.
Output: `localAuthMiddleware.ts`, `authMode.ts`, `localAuth.ts`, edited `index.ts` + `middleware.ts`, three new test suites.
Derived REQ-IDs covered: AUTH-LOCAL-03 (login), AUTH-LOCAL-04 (middleware), AUTH-LOCAL-05 (mode), AUTH-LOCAL-06 (logout), AUTH-LOCAL-18 (de-Authelia, D-06), AUTH-LOCAL-19 (rate-limit/lockout), AUTH-LOCAL-20 (auth unit tests). Coexistence per D-01/D-02/D-03. The localAuthMiddleware-beside-OIDC-guard seam is the "clean internal seam, no plugin/registry framework" required by D-07 — this phase ships exactly local + one generic OIDC and nothing more.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
@.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md
@.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md
@.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md
@.planning/phases/19-local-auth-no-oidc-mode/19-02-SUMMARY.md
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: localAuthMiddleware + GET /api/auth/mode</name>
<read_first>
- apps/api/src/auth/devBypass.ts (the c.set('user', ...) contract + DEV_USER shape + ContextVariableMap augmentation the middleware must match; lines 30-76)
- apps/api/tests/auth/devBypass.test.ts (middleware unit-test style)
- apps/api/src/auth/localSession.ts (verifyLocalSessionCookie — from 19-01)
- apps/api/src/routes/setup.ts (GET /status pre-auth pattern lines ~86-95 — authMode mirrors it)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §localAuthMiddleware.ts + §authMode.ts (exact patterns)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Middleware Slot + §Auth Mode Endpoint + §Common Pitfalls 1
</read_first>
<files>apps/api/src/auth/localAuthMiddleware.ts, apps/api/src/routes/authMode.ts, apps/api/tests/auth/localAuthMiddleware.test.ts, apps/api/tests/routes/authMode.test.ts</files>
<behavior>
- RED: write apps/api/tests/auth/localAuthMiddleware.test.ts:
- Test 1: with a valid local-session cookie for an existing user, the middleware sets c.get('user') to {id, oidcIss, oidcSub, displayName, color} and calls next
- Test 2: with no cookie, the middleware is a pure passthrough — c.get('user') stays unset (NOT undefined-set) so the OIDC guard can still fire (Pitfall 1)
- Test 3: with a cookie whose userId has no users row, passthrough (no crash)
- Test 4: when c.get('user') is already set (devAuthBypass ran first), middleware does not overwrite and calls next
- RED: write apps/api/tests/routes/authMode.test.ts:
- Test 5: GET /api/auth/mode returns { localEnabled:true, oidcEnabled:false } when no oidc_issuer in env or app_config
- Test 6: returns oidcEnabled:true when app_config has oidc_issuer (or OIDC_ISSUER env set)
- Run; confirm FAIL.
</behavior>
<action>
Create apps/api/src/auth/localAuthMiddleware.ts exporting `localAuthMiddleware(): MiddlewareHandler`. Side-effect import the ContextVariableMap augmentation (`import '../auth/devBypass.js'`) so c.set('user') is typed. In the handler: if `c.get('user')` already set → next() (devAuthBypass-first). Else `verifyLocalSessionCookie(c)`; if null → next() passthrough. Else SELECT the users row by id; if found, `c.set('user', { id, oidcIss: row.oidcIss ?? 'local', oidcSub: row.oidcSub ?? String(row.id), displayName: row.displayName ?? null, color: row.color })`; always next(). MUST never set user to undefined on the no-cookie path (Pitfall 1).
Create apps/api/src/routes/authMode.ts exporting `authModeRouter = new Hono()` with `GET /`: `localEnabled` always true (D-01); `oidcEnabled` = Boolean(process.env.OIDC_ISSUER) OR, if absent, Boolean of an app_config row keyed `oidc_issuer`; `return c.json({ localEnabled: true, oidcEnabled })`. No auth gate (pre-auth, mirrors setup GET /status). Run the suites — GREEN.
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/auth/localAuthMiddleware.test.ts tests/routes/authMode.test.ts</automated>
</verify>
<acceptance_criteria>
- Both suites exit 0, all 6 tests green
- Source assertion: `grep -c "verifyLocalSessionCookie" apps/api/src/auth/localAuthMiddleware.ts` >= 1
- Negative assertion: middleware no-cookie path calls next() without c.set('user') — verified by Test 2 (OIDC guard fall-through intact)
- Source assertion: authMode returns localEnabled true unconditionally (grep `localEnabled: true`)
</acceptance_criteria>
<done>localAuthMiddleware populates c.get('user') from a valid cookie and passes through cleanly otherwise; /api/auth/mode reports local+oidc availability pre-auth.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: POST /api/auth/local/login (rate-limit + lockout) + logout</name>
<read_first>
- apps/api/src/routes/setup.ts (noEchoHook lines ~49-53; zValidator usage; pre-auth router style)
- apps/api/tests/routes/login.test.ts (existing auth-route test mock patterns)
- apps/api/src/auth/localCredentials.ts (verifyPassword — from 19-01), apps/api/src/auth/localSession.ts (issue/clear cookie — from 19-01)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Local Login Endpoint + §Rate Limiting (loginAttempts Map; 5→429, 10→423; dummy-hash timing defense; same-401 copy)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §localAuth.ts
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surface 6 (401/429/423 → error copy the PWA renders)
</read_first>
<files>apps/api/src/routes/localAuth.ts, apps/api/tests/routes/localAuth.test.ts</files>
<behavior>
- RED: write apps/api/tests/routes/localAuth.test.ts:
- Test 1: valid username+password → 200 { ok:true } and a Set-Cookie for local-session
- Test 2: wrong password → 401 { error: 'Invalid credentials' }
- Test 3: unknown username → 401 with the SAME body as Test 2 (no enumeration / no field discrimination)
- Test 4: 5 consecutive failures from one IP → the 6th returns 429
- Test 5: 10 failures → 423; a successful login after a reset/cleared map clears the counter
- Test 6: POST /api/auth/local/logout (and GET alias) clears the local-session cookie (Set-Cookie maxAge 0 / expired)
- Test 7 (no-echo): a malformed body (missing password) returns 400 { error: 'Invalid request' } and the response body contains neither the submitted value nor a Zod `received` field
- Run; confirm FAIL.
</behavior>
<action>
Create apps/api/src/routes/localAuth.ts exporting `localAuthRouter = new Hono()`. Copy `noEchoHook` verbatim from setup.ts. Define an in-memory `loginAttempts = new Map<string, { count, lockedUntil, lockedOut }>()` (household scale; no Redis). Constants RATE_WINDOW_FAILURES=5, RATE_WINDOW_SECS=60, LOCKOUT_FAILURES=10. `POST /login` with zValidator json `{ username: string min1 max128 trim, password: string min1 max1000 }` + noEchoHook: derive IP from `x-forwarded-for` (Pangolin sets it) else host; if locked → 423; if count>=5 and within window → 429; SELECT local_credentials by username; ALWAYS run verifyPassword (use a precomputed dummy hash when username unknown, to defeat the timing oracle — RESEARCH Pitfall 2); on invalid → increment counter, set lockedUntil, set lockedOut at >=10, return 401 `{ error: 'Invalid credentials' }`; on success → `loginAttempts.delete(ip)`, `issueLocalSessionCookie(c, cred.userId)`, return 200 `{ ok: true }`. `POST /logout` and `GET /logout` (alias) → `clearLocalSessionCookie(c)` then 200 `{ ok: true }`. Standard error pattern for unexpected errors (console.error without body + 503). Run the suite — GREEN.
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/routes/localAuth.test.ts</automated>
</verify>
<acceptance_criteria>
- Suite exits 0; all 7 tests green
- Behavior: Test 3 confirms unknown-username and wrong-password 401 bodies are byte-identical (no enumeration)
- Behavior: Test 4/5 confirm 429 at 6th attempt and 423 at lockout
- Source assertion: `grep -c "noEchoHook" apps/api/src/routes/localAuth.ts` >= 1 on the login route
- Source assertion: login ALWAYS calls verifyPassword even on unknown username (dummy-hash path present — grep for the dummy/filler hash)
</acceptance_criteria>
<done>Login verifies timing-safely, issues the session cookie, enforces per-IP rate-limit (429) and lockout (423), and never echoes the password; logout clears the cookie.</done>
</task>
<task type="auto">
<name>Task 3: index.ts wiring (mounts + OIDC guard skip + /callback link branch) + de-Authelia comments</name>
<read_first>
- apps/api/src/index.ts (lines 25-110: devBypassActive, /callback handler line ~40, /api/setup mount line ~49, devAuthBypass + oidcConfigFallback + oidcAuthMiddleware + persistSessionCookie chain lines ~55-73; isMainModule boot block lines ~121-140)
- apps/api/src/auth/middleware.ts (header comment + inline 'Authelia base URL' comments to genericize — D-06)
- apps/api/src/auth/linkOidc.ts (linkOidcToUser + OidcLinkConflictError — from 19-02; called from the /callback link branch)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/index.ts (new chain) + §apps/api/src/auth/middleware.ts (comment-only de-Authelia)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Middleware Slot (skip-when-user-set wrapper) + §OIDC-Link Flow (signed state in /callback) + §BYO-Auth De-Authelia-ization
</read_first>
<files>apps/api/src/index.ts, apps/api/src/auth/middleware.ts</files>
<action>
In apps/api/src/index.ts: mount the new pre-auth routes immediately after the existing `app.route('/api/setup', setupRouter)` line — `app.route('/api/auth', authModeRouter)` and `app.route('/api/auth', localAuthRouter)` (both before any /api/* middleware). After `app.use('/api/*', devAuthBypass())`, add `app.use('/api/*', localAuthMiddleware())`. Inside the existing `if (!devBypassActive)` block, replace the bare `app.use('/api/*', oidcAuthMiddleware())` with a wrapper: `app.use('/api/*', async (c, next) => { if (c.get('user')) { await next(); return; } await oidcAuthMiddleware()(c, next); })` so a valid local (or dev) session is not 302-redirected to OIDC (RESEARCH Pitfall 1). Keep oidcConfigFallbackMiddleware and persistSessionCookie unchanged and in order.
Extend the existing `/callback` handler (registered before the OIDC guard) to support link mode: when the callback's signed `state` carries a `linkUserId`, after `processOAuthCallback` resolves the OIDC `iss+sub`, call `linkOidcToUser(linkUserId, iss, sub)`; on `OidcLinkConflictError` redirect to a generic error page/route (UI-SPEC Surface 13 409 copy) without deleting any local credential; on success continue the normal post-login redirect (the user is now OIDC-only). Do NOT alter the existing non-link callback behavior. The `assertLocalSessionSecretSet()` boot call added in 19-01 stays as-is.
In apps/api/src/auth/middleware.ts: comment-only de-Authelia-ization (D-06) — change the header comment and any inline references from Authelia-specific wording ("Authelia as the identity provider", "Authelia base URL") to generic "OIDC identity provider" / "OIDC issuer URL". No runtime behavior change. Do not rename any env var or app_config key (they are already generic).
</action>
<verify>
<automated>pnpm --filter @familysync/api test && pnpm --filter @familysync/api typecheck</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test` exits 0 (full API suite green, including the new auth suites)
- `pnpm --filter @familysync/api typecheck` exits 0
- Source assertion: `grep -c "localAuthMiddleware()" apps/api/src/index.ts` >= 1 mounted on /api/* AFTER devAuthBypass and BEFORE the OIDC guard
- Source assertion: index.ts OIDC guard is wrapped with a `if (c.get('user'))` skip (grep the wrapper)
- Source assertion: `grep -c "linkOidcToUser" apps/api/src/index.ts` >= 1 (callback link branch)
- Negative assertion (D-06): `grep -ci "authelia" apps/api/src/auth/middleware.ts` == 0 and `grep -ci "authelia" apps/api/src/index.ts` == 0
</acceptance_criteria>
<done>Auth routes mounted pre-auth; localAuthMiddleware in slot; OIDC guard skipped when a local/dev user is set; /callback handles link mode via linkOidcToUser; Authelia removed from API comments.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → POST /api/auth/local/login | unauthenticated credential submission; the brute-force surface |
| local-session cookie → c.get('user') | the request-auth boundary localAuthMiddleware enforces |
| OIDC callback state → identity binding | external-identity boundary with CSRF/conflict risk |
## STRIDE Threat Register (ASVS L1, block on high)
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-11 | Elevation of Privilege | login brute-force | mitigate | per-IP rate-limit (5→429), lockout (10→423) resolved only by admin reset (V2) |
| T-19-12 | Information Disclosure | username enumeration timing | mitigate | dummy-hash verifyPassword on unknown username; identical 401 body (RESEARCH Pitfall 2) |
| T-19-13 | Spoofing | OIDC guard 302 on valid local session | mitigate | guard wrapped to skip when c.get('user') set (RESEARCH Pitfall 1) — local sessions are honored |
| T-19-14 | Information Disclosure | password echoed in Zod error | mitigate | noEchoHook on login route (V5) |
| T-19-15 | Elevation of Privilege | account takeover via /callback link | mitigate | linkOidcToUser preflight conflict (409) + signed state (T-19-08/09 from 19-02) |
| T-19-16 | Information Disclosure | infra leak via "Authelia" copy | accept→mitigate | D-06 removes provider-specific wording from comments/UI; low severity, done for hygiene |
| T-19-17 | Spoofing | session fixation | mitigate | a fresh signed JWT is issued on every successful login; exp claim bounds lifetime (V3) |
</threat_model>
<verification>
- `pnpm --filter @familysync/api test` green (all API suites)
- `pnpm --filter @familysync/api typecheck` exits 0
- A valid local-session request reaches downstream routes without a 302 (Pitfall 1 covered by index wiring + middleware Test 2)
- No "Authelia" string remains in API source comments
</verification>
<success_criteria>
- AUTH-LOCAL-03/06: login (200/401/429/423) + logout work
- AUTH-LOCAL-04: localAuthMiddleware sets c.get('user') from cookie, passes through without
- AUTH-LOCAL-05: /api/auth/mode pre-auth, reflects oidc config
- AUTH-LOCAL-18: no Authelia copy in API comments
- AUTH-LOCAL-19/20: rate-limit + lockout + unit coverage
- D-03 coexistence: existing OIDC users unaffected (guard wrapper only skips when a user is already set)
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 03)
- Middleware: `localAuthMiddleware` (apps/api/src/auth/localAuthMiddleware.ts)
- Router: `authModeRouter``GET /api/auth/mode` (pre-auth)
- Router: `localAuthRouter``POST /api/auth/local/login`, `POST /api/auth/local/logout`, `GET /api/auth/local/logout`
- index.ts: pre-auth auth-route mounts, localAuthMiddleware slot, OIDC-guard skip-when-user-set wrapper, /callback link-mode branch
- middleware.ts: de-Authelia-ized comments (D-06)
- In-memory rate-limit/lockout state machine (login)
</artifacts_produced>
<output>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-03-SUMMARY.md` when done
</output>
@@ -0,0 +1,182 @@
---
phase: 19-local-auth-no-oidc-mode
plan: "03"
subsystem: auth
tags: [local-auth, middleware, rate-limit, lockout, session-cookie, oidc-link, de-authelia, tdd]
status: complete
dependency_graph:
requires:
- verifyLocalSessionCookie / issueLocalSessionCookie / clearLocalSessionCookie (from 19-01)
- localCredentials Drizzle table (from 19-01)
- hashPassword / verifyPassword (from 19-01)
- linkOidcToUser / OidcLinkConflictError (from 19-02)
provides:
- localAuthMiddleware: cookie → c.set('user') middleware (AUTH-LOCAL-04)
- GET /api/auth/mode: pre-auth OIDC config endpoint (AUTH-LOCAL-05)
- POST /api/auth/local/login: rate-limited + timing-safe login (AUTH-LOCAL-03, AUTH-LOCAL-19)
- POST/GET /api/auth/local/logout: session cookie clear (AUTH-LOCAL-06)
- index.ts: pre-auth mounts + localAuthMiddleware slot + OIDC guard skip-when-user-set + /callback link branch (AUTH-LOCAL-04, AUTH-LOCAL-10)
- middleware.ts: de-Authelia-ized comments (AUTH-LOCAL-18)
affects:
- apps/api/src/index.ts (route mounts, middleware chain, /callback extension)
- apps/api/src/auth/middleware.ts (comment-only D-06 changes)
tech_stack:
added: []
patterns:
- TDD RED/GREEN per task (failing test committed before implementation)
- In-memory loginAttempts Map: per-IP rate-limit (5→429) + lockout (10→423)
- DUMMY_HASH timing defense: verifyPassword always runs, even for unknown usernames (T-19-12)
- noEchoHook on login: Zod errors never echo submitted values (T-19-14)
- oidcAuthMiddleware() factory called once at construction, returned handler in skip-when-user-set wrapper (D-03)
- Jwt.verify on URL state param to extract linkUserId in /callback link branch (T-19-15)
key_files:
created:
- apps/api/src/auth/localAuthMiddleware.ts
- apps/api/src/routes/authMode.ts
- apps/api/src/routes/localAuth.ts
- apps/api/tests/auth/localAuthMiddleware.test.ts
- apps/api/tests/routes/authMode.test.ts
- apps/api/tests/routes/localAuth.test.ts
modified:
- apps/api/src/index.ts
- apps/api/src/auth/middleware.ts
decisions:
- "loginAttempts counter increments even on 429 responses — brute-force accumulates toward lockout (10→423) even during rate-window; original implementation only incremented on final auth check"
- "oidcAuthMiddleware() factory called once at app construction (not per-request) to preserve test assertion that it is called exactly once during app init"
- "localAuthMiddleware casts user value to typeof DEV_USER for ContextVariableMap compatibility — the narrow const type from devBypass.ts as const requires an explicit cast"
- "Authelia removed from 2 comments in index.ts and 2 comments in middleware.ts (D-06 / AUTH-LOCAL-18)"
metrics:
duration: "~16 minutes"
completed: "2026-06-17"
tasks_completed: 3
tasks_total: 3
files_created: 6
files_modified: 2
---
# Phase 19 Plan 03: Auth Routes + Middleware Wiring Summary
**One-liner:** localAuthMiddleware (cookie→c.set('user')), GET /api/auth/mode pre-auth endpoint, rate-limited POST /api/auth/local/login with timing-safe dummy-hash, logout, index.ts middleware chain wired with OIDC-guard skip-when-user-set and /callback link branch for linkOidcToUser, de-Authelia-ized middleware comments.
## Tasks Completed
| Task | RED Commit | GREEN Commit | Key Files |
|------|-----------|-------------|-----------|
| 1: localAuthMiddleware + GET /api/auth/mode | ac32bd4 | be7a0ae | localAuthMiddleware.ts, authMode.ts, index.ts |
| 2: POST /api/auth/local/login (rate-limit + lockout) + logout | db66295 | c437f40 | localAuth.ts |
| 3: index.ts wiring + /callback link branch + de-Authelia comments | — | 9b569ef | index.ts, middleware.ts |
## What Was Built
### Task 1: localAuthMiddleware + GET /api/auth/mode (TDD)
**`apps/api/src/auth/localAuthMiddleware.ts`** — exports `localAuthMiddleware(): MiddlewareHandler`:
- If `c.get('user')` already set (devAuthBypass ran first): no-op, calls next()
- Calls `verifyLocalSessionCookie(c)` — returns null if no cookie / invalid / expired
- On null: calls next() WITHOUT c.set('user') — Pitfall-1 guard; OIDC guard fires on absent key
- On valid userId: SELECTs users row; if found, `c.set('user', {...} as typeof DEV_USER)` with matching shape
**`apps/api/src/routes/authMode.ts`** — exports `authModeRouter` with GET /mode:
- `localEnabled: true` unconditionally (D-01)
- `oidcEnabled: Boolean(OIDC_ISSUER env)` || falls back to `app_config` oidc_issuer row
- No auth gate — pre-auth endpoint
Tests: 8 tests pass (4 middleware + 4 mode tests)
### Task 2: POST /api/auth/local/login + logout (TDD)
**`apps/api/src/routes/localAuth.ts`** — exports `localAuthRouter` and `loginAttempts`:
- `POST /local/login` — zValidator + noEchoHook + per-IP loginAttempts Map
- lockedOut check (>= LOCKOUT_FAILURES=10) → 423 `{ error: 'Account locked' }`
- Rate window check (>= RATE_WINDOW_FAILURES=5, within RATE_WINDOW_SECS=60) → increments counter + 429
- Selects local_credentials by username; ALWAYS runs verifyPassword (DUMMY_HASH on unknown username — T-19-12)
- Same 401 body for wrong-password AND unknown-username (no enumeration)
- On success: loginAttempts.delete(ip), issueLocalSessionCookie, 200 `{ ok: true }`
- `POST /local/logout` + `GET /local/logout` → clearLocalSessionCookie → 200 `{ ok: true }`
Tests: 8 tests pass (login success/failure/enumeration/rate-limit/lockout/logout/no-echo)
### Task 3: index.ts wiring + /callback link branch + de-Authelia comments
**`apps/api/src/index.ts`** changes:
- Pre-auth mounts: `app.route('/api/auth', authModeRouter)` + `app.route('/api/auth', localAuthRouter)` before devAuthBypass
- `app.use('/api/*', localAuthMiddleware())` after devAuthBypass, before OIDC guard
- OIDC guard wrapper: `oidcHandler = oidcAuthMiddleware()` stored once at construction, invoked per-request only when `c.get('user')` is falsy (D-03 coexistence seam)
- `/callback` extended: reads URL `state` param, tries Jwt.verify with LOCAL_SESSION_SECRET; if `linkUserId` in payload → call linkOidcToUser after processOAuthCallback; OidcLinkConflictError → redirect `/?error=oidc-link-conflict`
- Authelia references removed from 2 comments (D-06)
**`apps/api/src/auth/middleware.ts`** changes:
- Header: "Authelia as the identity provider" → "generic OIDC identity provider" (D-06)
- "Authelia base URL" → "OIDC issuer URL" (D-06)
- "Authelia's refresh_token_lifespan" → "the OIDC provider's refresh_token_lifespan" (D-06)
Full suite: 446/446 tests pass; `pnpm --filter @familysync/api typecheck` exits 0.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Rate-limit counter not incrementing during 429 window**
- **Found during:** Task 2 GREEN phase (Test 5 returning 429 instead of 423 for the 11th attempt)
- **Issue:** After 5 failures, subsequent attempts returned 429 (early return) without incrementing the counter. The counter never reached LOCKOUT_FAILURES=10 because the early-return prevented accumulation.
- **Fix:** Inside the rate-window 429 branch: increment counter, update lockedUntil, check if lockedOut (returns 423 if so), else return 429. This way brute-force attacks accumulate toward lockout even during the rate window.
- **Files modified:** apps/api/src/routes/localAuth.ts
- **Commit:** c437f40
**2. [Rule 1 - Bug] oidcAuthMiddleware() factory called per-request in OIDC guard wrapper**
- **Found during:** Task 3 execution — me.test.ts assertion that oidcAuthMiddleware is called exactly once during app init
- **Issue:** Original OIDC guard wrapper called `oidcAuthMiddleware()(c, next)` per-request; existing test `wires oidcAuthMiddleware on /api/* when bypass is not active` asserts `oidcMiddlewareSpy.toHaveBeenCalledTimes(1)` (factory called once at construction).
- **Fix:** Store `const oidcHandler = oidcAuthMiddleware()` at construction time; invoke `oidcHandler(c, next)` per-request inside the wrapper.
- **Files modified:** apps/api/src/index.ts
- **Commit:** 9b569ef
**3. [Rule 1 - Bug] Type incompatibility: localAuthMiddleware c.set('user') type error**
- **Found during:** Task 3 typecheck
- **Issue:** `ContextVariableMap` maps 'user' to `typeof DEV_USER` (narrow `as const` literal). The middleware constructs `{ id: number; oidcIss: string; ... }` which TypeScript rejects as incompatible.
- **Fix:** Add `import type { DEV_USER }` and cast with `as typeof DEV_USER` on the c.set call.
- **Files modified:** apps/api/src/auth/localAuthMiddleware.ts
- **Commit:** 9b569ef
## Threat Surface Scan
All new/modified routes in this plan:
- `GET /api/auth/mode` — pre-auth, no credentials, no sensitive data; reads only env/app_config
- `POST /api/auth/local/login` — new attack surface; mitigated by T-19-11 (rate-limit), T-19-12 (timing-safe dummy hash, no-enumeration 401), T-19-14 (noEchoHook)
- `POST/GET /api/auth/local/logout` — clears cookie only; no sensitive data exposed
No new trust boundaries beyond those in the plan's threat model.
## TDD Gate Compliance
**Task 1 (TDD):**
- RED commit: ac32bd4 — test(19-03): add failing tests for localAuthMiddleware and GET /api/auth/mode
- GREEN commit: be7a0ae — feat(19-03): implement localAuthMiddleware, GET /api/auth/mode...
**Task 2 (TDD):**
- RED commit: db66295 — test(19-03): add failing tests for POST /api/auth/local/login + logout
- GREEN commit: c437f40 — feat(19-03): implement POST /api/auth/local/login (rate-limit + lockout) + logout
**Task 3 (auto):** No TDD cycle required.
## Known Stubs
None. All new endpoints return real data and perform real operations.
## Self-Check: PASSED
All created files confirmed present on disk:
- FOUND: apps/api/src/auth/localAuthMiddleware.ts
- FOUND: apps/api/src/routes/authMode.ts
- FOUND: apps/api/src/routes/localAuth.ts
- FOUND: apps/api/tests/auth/localAuthMiddleware.test.ts
- FOUND: apps/api/tests/routes/authMode.test.ts
- FOUND: apps/api/tests/routes/localAuth.test.ts
All commits confirmed in git log:
- ac32bd4: test(19-03): add failing tests for localAuthMiddleware and GET /api/auth/mode
- be7a0ae: feat(19-03): implement localAuthMiddleware, GET /api/auth/mode, and pre-auth route mounts
- db66295: test(19-03): add failing tests for POST /api/auth/local/login + logout
- c437f40: feat(19-03): implement POST /api/auth/local/login (rate-limit + lockout) + logout
- 9b569ef: feat(19-03): wire /callback link branch, OIDC-guard skip, de-Authelia comments
Test results: 446/446 pass (34 test files); `pnpm --filter @familysync/api typecheck` exits 0.
@@ -0,0 +1,231 @@
---
phase: 19-local-auth-no-oidc-mode
plan: 04
type: execute
wave: 4
depends_on: ["19-02", "19-03"]
files_modified:
- apps/pwa/src/components/BrandSlot.tsx
- apps/pwa/src/routes/LoginPage.tsx
- apps/pwa/src/api/client.ts
- apps/pwa/src/App.tsx
- apps/pwa/src/routes/AdminPage.tsx
- apps/pwa/src/components/SettingsSheet.tsx
- apps/pwa/src/styles/tokens.css
autonomous: false
requirements: [AUTH-LOCAL-12, AUTH-LOCAL-13, AUTH-LOCAL-14, AUTH-LOCAL-15]
must_haves:
truths:
- "An unauthenticated user with no valid session lands on /login (when localEnabled) and sees the brand slot + username/password form"
- "Submitting valid credentials logs the user in and navigates into the app; the local-session cookie is set by the API"
- "401/429/423/5xx each render their distinct copy from the UI-SPEC; invalid-credentials does not say which field is wrong"
- "When oidcEnabled, an 'or' divider + 'Login with OIDC' button appear; the word 'Authelia' never appears"
- "An admin sees a LOCAL ACCOUNTS section to add a member and a per-member Reset-password action"
- "A local user sees Change-password (and, when oidcEnabled, Link OIDC identity) in Settings; OIDC-only users do not"
artifacts:
- path: "apps/pwa/src/routes/LoginPage.tsx"
provides: "standalone /login page (Surfaces 1-10)"
min_lines: 80
- path: "apps/pwa/src/components/BrandSlot.tsx"
provides: "Phase-17 brand seam component"
exports: ["BrandSlot"]
min_lines: 15
- path: "apps/pwa/src/App.tsx"
provides: "auth-mode fetch gate + /login route"
contains: "authMode"
key_links:
- from: "apps/pwa/src/App.tsx"
to: "apps/pwa/src/api/client.ts"
via: "fetchAuthMode() gates the /login redirect"
pattern: "authMode"
- from: "apps/pwa/src/routes/LoginPage.tsx"
to: "apps/pwa/src/api/client.ts"
via: "fetchLocalLogin posts credentials; LoginError code drives the error state"
pattern: "fetchLocalLogin"
- from: "apps/pwa/src/components/SettingsSheet.tsx"
to: "apps/pwa/src/api/client.ts"
via: "hasLocalCredential from /api/me gates Change-password / Link-OIDC rows"
pattern: "hasLocalCredential"
---
<objective>
Build the PWA local-login UI and the account-management surfaces per the approved UI-SPEC: the standalone `/login` page (brand slot + form + error states + optional OIDC button), the App.tsx auth-mode routing gate, the client.ts fetch functions + typed `LoginError`, the AdminPage LOCAL ACCOUNTS additions, and the SettingsSheet change-password / link-OIDC rows.
Purpose: This is the first real login UI in the app — end-user-facing, phone-first, must be slick for the non-technical Apple member (CLAUDE.md). It is layout/glue/state code (type: execute, not TDD). Verification leans on the project's `playwright-cli` convention for desktop/Chromium-driveable flows rather than human checkpoints.
Output: LoginPage, BrandSlot, edited App.tsx + client.ts + AdminPage + SettingsSheet + tokens.css brand-seam vars.
Derived REQ-IDs covered: AUTH-LOCAL-12 (LoginPage), AUTH-LOCAL-13 (admin UI), AUTH-LOCAL-14 (settings UI), AUTH-LOCAL-15 (routing gate). D-04 (login UI exists), D-02 (chooser), D-06 (no Authelia), D-12 (link confirm copy).
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md
@.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md
@.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
@.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md
@.planning/phases/19-local-auth-no-oidc-mode/19-02-SUMMARY.md
@.planning/phases/19-local-auth-no-oidc-mode/19-03-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: client.ts fetch fns + LoginError + MeUser.hasLocalCredential, and BrandSlot</name>
<read_first>
- apps/pwa/src/api/client.ts (fetchMe lines ~74-84; handleAuthResponse lines ~51-58; SessionExpiredError class lines ~33-39; MeUser interface lines ~62-68)
- apps/pwa/src/routes/SetupPage.tsx (ShieldCheck header block — BrandSlot analog)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/pwa/src/api/client.ts + §apps/pwa/src/components/BrandSlot.tsx (exact code patterns)
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md §Brand Slot + Surface 2 (placeholder structure, copy "FamilySync" / "Family calendar & lists")
</read_first>
<files>apps/pwa/src/api/client.ts, apps/pwa/src/components/BrandSlot.tsx, apps/pwa/src/styles/tokens.css</files>
<action>
In apps/pwa/src/api/client.ts add: `fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnabled: boolean }>` (plain GET /api/auth/mode, no credentials needed). `fetchLocalLogin({ username, password }): Promise<void>` — POST /api/auth/local/login with credentials:'include', redirect:'manual'; map status 401→`new LoginError('invalid')`, 429→`LoginError('rate-limit')`, 423→`LoginError('locked')`, other non-ok→`LoginError('server')`. `fetchLocalLogout(): Promise<void>` — POST /api/auth/local/logout. Add the typed `class LoginError extends Error` with `readonly code: 'invalid'|'rate-limit'|'locked'|'server'` (mirror the SessionExpiredError class shape incl. Object.setPrototypeOf). Add `hasLocalCredential: boolean` to the `MeUser` interface. Optionally add `fetchChangePassword`, `fetchCreateMember`, `fetchAdminResetPassword`, `fetchLinkOidc` following the same fetch+throw pattern (used by Tasks 2/3).
Create apps/pwa/src/components/BrandSlot.tsx exporting `BrandSlot()` — the placeholder structure from the UI-SPEC: a 48px circle (`var(--brand-logo-size)` / `var(--brand-logo-bg)` / `var(--brand-logo-border-radius)`) with white "FS" initials, an `<h1>FamilySync</h1>` (Display 24/600), and a tagline "Family calendar & lists" (Body 15/400, secondary). No `<img>` yet (Phase 17 seam). No props.
In apps/pwa/src/styles/tokens.css add the brand-seam custom properties under `:root` with placeholder defaults: `--brand-logo-bg: var(--color-member-0)`, `--brand-logo-text: #ffffff`, `--brand-logo-size: 48px`, `--brand-logo-border-radius: 50%`. Phase 17 overrides these values only.
</action>
<verify>
<automated>pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa test</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/pwa typecheck` exits 0
- Source assertion: `grep -c "class LoginError" apps/pwa/src/api/client.ts` == 1 with the 4 codes
- Source assertion: `grep -c "hasLocalCredential" apps/pwa/src/api/client.ts` >= 1 (MeUser extended)
- Source assertion: `grep -c "--brand-logo-size" apps/pwa/src/styles/tokens.css` == 1
- Negative assertion: `grep -ci "authelia" apps/pwa/src/components/BrandSlot.tsx` == 0
</acceptance_criteria>
<done>client.ts exposes auth-mode/login/logout fetchers + LoginError + MeUser.hasLocalCredential; BrandSlot renders the Phase-17-ready placeholder; brand-seam tokens defined.</done>
</task>
<task type="auto">
<name>Task 2: LoginPage + App.tsx routing gate</name>
<read_first>
- apps/pwa/src/routes/SetupPage.tsx (pageStyle/contentColStyle/cardStyle/primaryBtnStyle/ghostBtnStyle/inputStyle/labelStyle — copy verbatim; useMutation + error-state pattern)
- apps/pwa/src/App.tsx (setupQuery gate lines ~72-79 + /setup route lines ~156-167; AuthSplash usage; meQuery)
- apps/pwa/src/components/BrandSlot.tsx (from Task 1)
- apps/pwa/src/api/client.ts (fetchAuthMode, fetchLocalLogin, LoginError — from Task 1)
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surfaces 1-10 + Interaction Contract + Accessibility Contract + Copywriting Contract (exact copy, ids, aria, focus, tab order, show/hide toggle)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/pwa/src/routes/LoginPage.tsx + §apps/pwa/src/App.tsx (password show/hide, gate logic)
</read_first>
<files>apps/pwa/src/routes/LoginPage.tsx, apps/pwa/src/App.tsx</files>
<action>
Create apps/pwa/src/routes/LoginPage.tsx — a standalone full-page route (no AppNav/BottomTabBar/SetupBanner), copying SetupPage's page/card/input/button styles. Layout: `<BrandSlot />` above the login card (Surface 2/3). Card heading `<h2>Sign in</h2>`. Username field (Surface 4: id="login-username", label "Username", autoComplete="username", spellCheck=false, autoCapitalize="none", autoCorrect="off"). Password field with show/hide toggle (Surface 5: id="login-password", autoComplete="current-password", paddingRight 44, Eye/EyeOff button with aria-label + aria-pressed, 44px tap target; toggle resets to hidden on blur). Error/lockout banner (Surface 6: id="login-error", role="status", aria-live="polite", aria-atomic="true") with the four copy variants keyed off LoginError.code (invalid → "Incorrect username or password." and both inputs get destructive border, no field blamed; rate-limit → "Too many attempts. Please wait a moment and try again." + submit disabled; locked → "This account is temporarily locked. Contact your admin to reset access." + submit disabled; server → "Something went wrong. Please try again." + submit re-enabled). Submit button (Surface 7: full-width filled accent, "Sign in"/"Signing in…" with Loader2, minHeight 44, disabled until both fields non-empty). Forgot-password helper (Surface 10: "Forgot your password? Ask your admin." non-interactive). Method divider + OIDC button (Surfaces 8/9) rendered only when `authMode.oidcEnabled` — "or" divider then outlined "Login with OIDC" (ShieldCheck icon; NEVER "Authelia"); on click initiate the OIDC flow (top-level nav to /api/login). useMutation(fetchLocalLogin) → onSuccess `window.location.replace('/')`, onError set the LoginError code into local error state. Focus username on mount; move focus to the error heading on error; Enter in username → password, Enter in password → submit. role="main" on content column; `<h1>` is the brand-slot app name.
In apps/pwa/src/App.tsx: add an `authModeQuery` (queryKey ['authMode'], fetchAuthMode, retry false, staleTime 60_000). Add a `/login` route rendering `<LoginPage authMode={authModeQuery.data} />` as a sibling of the `*` route (standalone, outside the app shell — same structure as /setup). Gate logic, applied AFTER the existing setup gate (setup wins): if the user is unauthenticated (meQuery 401/error) AND `authMode.localEnabled` → render `<Navigate to="/login" replace />`; if unauthenticated AND `!localEnabled && oidcEnabled` → top-level redirect to /api/login (today's OIDC-only behavior). Keep AuthSplash during auth-state loading. Do not change the setup gate precedence.
</action>
<verify>
<automated>pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa build && pnpm --filter @familysync/pwa test</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/pwa typecheck` and `build` exit 0
- Source assertion: `grep -c "fetchLocalLogin" apps/pwa/src/routes/LoginPage.tsx` >= 1
- Source assertion: LoginPage renders all four error copies (grep each UI-SPEC string)
- Source assertion: `grep -c "authModeQuery" apps/pwa/src/App.tsx` >= 1 and a `/login` route is registered
- Negative assertion: `grep -ci "authelia" apps/pwa/src/routes/LoginPage.tsx` == 0
- Negative assertion: login invalid-credentials copy does not name a specific field (single shared message — UI-SPEC Surface 6 variant 1)
</acceptance_criteria>
<done>/login renders the brand slot + accessible username/password form with show/hide, four error states, optional OIDC button; App.tsx routes unauthenticated local-mode users to /login after the setup gate.</done>
</task>
<task type="auto">
<name>Task 3: AdminPage LOCAL ACCOUNTS + SettingsSheet change-password / link-OIDC</name>
<read_first>
- apps/pwa/src/routes/AdminPage.tsx (sectionLabelStyle lines ~42-49; CredentialSheet open/trigger pattern lines ~53-100; membersQuery lines ~76-81; member-row action button pattern)
- apps/pwa/src/components/SettingsSheet.tsx (bottom-sheet dialog lines ~146-178; Escape listener lines ~69-76; settings rows)
- apps/pwa/src/components/CredentialSheet.tsx (useMutation + invalidateQueries lines ~115-144; focus-on-open lines ~97-102; error-state pattern)
- apps/pwa/src/api/client.ts (fetchCreateMember / fetchAdminResetPassword / fetchChangePassword / fetchLinkOidc — from Task 1)
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surfaces 11A/11B/12/13 + Copywriting Contract + Destructive Actions (exact copy, field labels, autoComplete values, two-step link confirm)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §AdminPage.tsx + §SettingsSheet.tsx
</read_first>
<files>apps/pwa/src/routes/AdminPage.tsx, apps/pwa/src/components/SettingsSheet.tsx</files>
<action>
In apps/pwa/src/routes/AdminPage.tsx add a "LOCAL ACCOUNTS" section (sectionLabelStyle) below the existing MEMBERS / SHARED CALENDAR sections. Surface 11A — inline "Add member" form: Display name, Username (autoComplete off, spellCheck false, autoCapitalize none), Initial password + Confirm password (autoComplete new-password), filled "Add member" submit disabled until required fields filled and passwords match; on success clear the form + invalidate ['admin','members'] and ['me']; error copy: username taken → "That username is already in use. Choose a different one.", mismatch → "Passwords do not match.", short → "Password is too short. Use at least 8 characters." Surface 11B — a per-member "Reset password" action button shown only for members with `hasLocalCredential`, opening a bottom-sheet/modal (CredentialSheet dialog pattern: role=dialog, aria-modal, Escape closes, focus returns to trigger) with New password + Confirm (autoComplete new-password, no current-password field), "Reset password" submit; success closes silently.
In apps/pwa/src/components/SettingsSheet.tsx add a "Change password" row shown only when `meData.user.hasLocalCredential` (Surface 12) opening a nested sheet with Current/New/Confirm fields (correct autoComplete values), submit disabled until filled + new/confirm match; error variants: wrong current → "Current password is incorrect.", mismatch → "Passwords do not match.", generic → "Something went wrong. Please try again." Add a "Link OIDC identity" row shown only when `hasLocalCredential` AND `oidcEnabled` (Surface 13) opening a confirmation sheet (NOT a form) with the exact body copy "After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed." + secondary note "This can't be undone from the app. Contact your admin if you need to revert." + Cancel / "Continue with OIDC" (never "Authelia"); on Continue, close the sheet and initiate the OIDC link flow (fetchLinkOidc → follow the returned redirect). Reuse the existing bottom-sheet dialog + Escape + focus patterns; never echo a password; no dangerouslySetInnerHTML.
</action>
<verify>
<automated>pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa test && pnpm --filter @familysync/pwa lint</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/pwa typecheck`, `test`, `lint` exit 0
- Source assertion: AdminPage gates the Reset-password action on `hasLocalCredential` (grep)
- Source assertion: SettingsSheet gates Change-password on `hasLocalCredential` and Link-OIDC on `hasLocalCredential` + `oidcEnabled` (grep)
- Source assertion: the link-confirm body uses "your local password will be removed" and does NOT use the word "delete" (negative grep `delete` in the link copy region) — UI-SPEC copy rule
- Negative assertion: `grep -ci "authelia" apps/pwa/src/components/SettingsSheet.tsx` == 0 and in AdminPage.tsx == 0
</acceptance_criteria>
<done>Admin can add a member + reset member passwords; a local user can change their password and (when OIDC enabled) link an OIDC identity via a two-step confirmation; all gated by hasLocalCredential/oidcEnabled; no Authelia copy.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: playwright-cli walkthrough of the login + admin/settings surfaces</name>
<action>Drive the login + admin/settings flows with the playwright-cli skill against the host dev stack, then pause for human confirmation. This is a blocking checkpoint — no code change; the executor runs the browser walkthrough and waits for approval.</action>
<what-built>The full local-login UI and account-management surfaces. Drive them in a desktop Chromium browser with the project's playwright-cli skill (CLAUDE.md convention: prefer automated browser checks over manual). The dev stack is reached via the Phase-7 dev-bypass; to test the real login form, clear the local-session cookie first (Plan 05 makes this possible). iOS-Safari-standalone behavior remains a separate device-only gate, not part of this check.</what-built>
<how-to-verify>
1. Start the host-side dev stack (API + PWA dev servers, DEV_AUTH_BYPASS=true). Use the playwright-cli skill to open the PWA.
2. Clear cookies / open an incognito context so no session exists → confirm the app redirects to /login and the brand slot + "Sign in" card render with username/password fields and the show/hide toggle.
3. Submit a wrong password (dev creds from Plan 05's seed: devuser / a wrong value) → confirm the single "Incorrect username or password." message and that neither field is individually blamed.
4. Submit the correct dev creds (devuser / devpass) → confirm navigation into the calendar.
5. With OIDC configured in app_config, reload /login → confirm the "or" divider + "Login with OIDC" button appear and the word "Authelia" appears nowhere.
6. As an admin, open /admin → confirm LOCAL ACCOUNTS section with Add-member form + a per-member Reset-password action. Open Settings → confirm Change-password and (when OIDC on) Link OIDC identity rows, with the non-alarming link copy.
</how-to-verify>
<resume-signal>Type "approved" if the flows render and behave per the UI-SPEC, or describe what differs.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser DOM → password fields | password values must never persist to localStorage/sessionStorage or be echoed |
| client state → API | the PWA mirrors 401/429/423 but never derives auth; the server is authoritative |
## STRIDE Threat Register (ASVS L1, block on high)
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-18 | Information Disclosure | password in client storage | mitigate | password fields are React-controlled state only; never written to localStorage/sessionStorage (UI-SPEC Security Display Rules) |
| T-19-19 | Information Disclosure | field-level credential hint | mitigate | single "Incorrect username or password." copy; no field-specific error (timing-safe parity with the API) |
| T-19-20 | Tampering | XSS via rendered values | mitigate | plain-text JSX children; no dangerouslySetInnerHTML (project convention T-05-24) |
| T-19-21 | Information Disclosure | infra leak via provider branding | mitigate | D-06: UI never renders "Authelia"; generic "Login with OIDC" |
| T-19-22 | Elevation of Privilege | client-only admin gating | accept | client `isAdmin`/`hasLocalCredential` are UX-only; the server requireAdmin/session is the real boundary (documented prior decision) |
</threat_model>
<verification>
- `pnpm --filter @familysync/pwa typecheck && build && test && lint` all green
- playwright-cli human checkpoint confirms the login flow, error parity, OIDC chooser, and admin/settings surfaces
- No "Authelia" string in any PWA source touched by this plan
</verification>
<success_criteria>
- AUTH-LOCAL-12: /login renders + logs in + shows correct error states
- AUTH-LOCAL-13: admin add-member + reset-password surfaces work
- AUTH-LOCAL-14: settings change-password + link-OIDC surfaces work
- AUTH-LOCAL-15: App.tsx gate routes unauthenticated local-mode users to /login (after setup gate)
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 04)
- Component: `LoginPage` (apps/pwa/src/routes/LoginPage.tsx) + `/login` route
- Component: `BrandSlot` (apps/pwa/src/components/BrandSlot.tsx) — Phase-17 seam
- client.ts: `fetchAuthMode`, `fetchLocalLogin`, `fetchLocalLogout`, `LoginError`, `MeUser.hasLocalCredential` (+ create/reset/change/link fetchers)
- App.tsx: `authModeQuery` gate + `/login` route
- AdminPage LOCAL ACCOUNTS section (add member + reset password)
- SettingsSheet Change-password + Link-OIDC rows
- tokens.css brand-seam custom properties (--brand-logo-*)
</artifacts_produced>
<output>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-04-SUMMARY.md` when done
</output>
@@ -0,0 +1,197 @@
---
phase: 19-local-auth-no-oidc-mode
plan: "04"
subsystem: pwa-auth-ui
status: complete
tags: [pwa, auth, login-ui, admin, settings, local-auth]
requirements_covered: [AUTH-LOCAL-12, AUTH-LOCAL-13, AUTH-LOCAL-14, AUTH-LOCAL-15]
dependency_graph:
requires:
- 19-02 (API /api/auth/mode, /api/auth/local/login, /api/admin/members, /api/me/password)
- 19-03 (localAuthMiddleware, session cookie, /api/auth/local/logout)
provides:
- LoginPage (Surfaces 1-10): standalone /login route with brand slot, form, error states, OIDC button
- App.tsx auth-mode gate: routes unauthenticated local users to /login; OIDC-only to /api/login
- AdminPage LOCAL ACCOUNTS: add-member form (Surface 11A), per-member reset-password sheet (Surface 11B)
- SettingsSheet: change-password row + sheet (Surface 12), link-OIDC row + confirmation sheet (Surface 13)
- BrandSlot component + brand-seam CSS tokens for Phase 17 override seam
affects:
- apps/pwa/src/api/client.ts (LoginError, fetchAuthMode, fetchLocalLogin, fetchLocalLogout, fetchChangePassword, fetchCreateMember, fetchAdminResetPassword, fetchLinkOidc, hasLocalCredential on MeUser/AdminMember)
- apps/pwa/src/App.tsx (authModeQuery + auth gate + /login route)
- apps/pwa/src/routes/AdminPage.tsx (LOCAL ACCOUNTS section, ResetPasswordSheet)
- apps/pwa/src/components/SettingsSheet.tsx (change-password + link-OIDC rows + sub-sheets)
tech_stack:
added: []
patterns:
- LoginError typed class (mirrors SessionExpiredError; code union 'invalid'|'rate-limit'|'locked'|'server')
- BrandSlot component with CSS custom property seam for Phase 17 brand override
- fetchLocalLogin maps HTTP status codes to LoginError codes before surfacing to UI
- authModeQuery in App.tsx gates /login redirect and OidcRedirect rendering
- InstructionSheet.test.tsx wrapped in QueryClientProvider (Rule 1 fix: SettingsSheet now uses useQuery)
key_files:
created:
- apps/pwa/src/components/BrandSlot.tsx
- apps/pwa/src/routes/LoginPage.tsx
modified:
- apps/pwa/src/api/client.ts
- apps/pwa/src/styles/tokens.css
- apps/pwa/src/App.tsx
- apps/pwa/src/routes/AdminPage.tsx
- apps/pwa/src/components/SettingsSheet.tsx
- apps/pwa/src/components/InstructionSheet.test.tsx
- apps/pwa/src/App.test.tsx
decisions:
- "OidcRedirect rendered as a React element (not a useEffect) to avoid render-inside-render conflict; window.location.replace in render body is safe for a top-level redirect-only component"
- "SettingsSheet reads meQuery(['me']) and authModeQuery(['authMode']) with same keys as App.tsx; TanStack deduplicates the requests — no prop drilling needed"
- "ResetPasswordSheet inlined in AdminPage.tsx rather than extracted to separate file; component is only used in one place and matches CredentialSheet locality pattern"
- "fetchCreateMember throws HTTP error message so onError can detect '409' string for username-taken copy"
metrics:
duration: "~90 min (continued from previous session)"
completed: "2026-06-17"
tasks_completed: 3
files_modified: 8
files_created: 2
tests_added: 0
tests_modified: 2
test_suite_result: "263 tests passed (0 failed)"
---
# Phase 19 Plan 04: PWA Login UI + Account Management Surfaces Summary
PWA-side login UI built: standalone LoginPage with brand slot + form + 4 error states + optional OIDC button (Surfaces 1-10); App.tsx auth-mode gate added; AdminPage LOCAL ACCOUNTS section with add-member form and per-member reset-password sheet (Surfaces 11A/11B); SettingsSheet change-password and link-OIDC rows with nested bottom sheets (Surfaces 12/13).
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | client.ts fetch fns + LoginError + BrandSlot + tokens | `869cdc2` | client.ts, BrandSlot.tsx, tokens.css |
| 2 | LoginPage (Surfaces 1-10) + App.tsx gate + /login route | `32d0408` | LoginPage.tsx, App.tsx, App.test.tsx |
| 3 | AdminPage LOCAL ACCOUNTS + SettingsSheet surfaces 12/13 | `19c45eb` | AdminPage.tsx, SettingsSheet.tsx, InstructionSheet.test.tsx, client.ts, App.test.tsx |
## What Was Built
### Task 1: client.ts + BrandSlot + tokens.css
**client.ts additions:**
- `class LoginError extends Error` with `readonly code: 'invalid' | 'rate-limit' | 'locked' | 'server'` — mirrors `SessionExpiredError` pattern including `Object.setPrototypeOf` fix
- `hasLocalCredential: boolean` added to `MeUser` interface
- `hasLocalCredential: boolean` added to `AdminMember` interface
- `fetchAuthMode()` — plain GET /api/auth/mode, no credentials; returns `{ localEnabled, oidcEnabled }`
- `fetchLocalLogin({ username, password })` — POST /api/auth/local/login with credentials:'include', redirect:'manual'; maps 401 to LoginError('invalid'), 429 to LoginError('rate-limit'), 423 to LoginError('locked'), non-ok to LoginError('server')
- `fetchLocalLogout()` — POST /api/auth/local/logout
- `fetchChangePassword({ currentPassword, newPassword })` — POST /api/me/password
- `fetchCreateMember({ displayName, username, password })` — POST /api/admin/members
- `fetchAdminResetPassword(memberId, newPassword)` — POST /api/admin/members/:id/reset-password
- `fetchLinkOidc()` — POST /api/me/link-oidc; returns `{ redirectUrl: string }`
**BrandSlot.tsx:** Phase-17-ready placeholder component. 48px circle with CSS custom properties (`--brand-logo-bg`, `--brand-logo-text`, `--brand-logo-size`, `--brand-logo-border-radius`). "FS" initials. h1 "FamilySync" (24px/600). Tagline "Family calendar & lists" (15px/400, secondary). No img tag. No dangerouslySetInnerHTML. No "Authelia".
**tokens.css:** Brand-seam block added under `:root`: `--brand-logo-bg`, `--brand-logo-text`, `--brand-logo-size`, `--brand-logo-border-radius`, `--brand-app-name`. Phase 17 overrides these.
### Task 2: LoginPage + App.tsx gate
**LoginPage.tsx (465 lines):**
- Standalone full-page route (same pattern as SetupPage — no AppNav/BottomTabBar)
- Accepts `authMode?: { localEnabled: boolean; oidcEnabled: boolean }` prop
- Style: 400px max-width column, inline CSSProperties throughout (no shadcn)
- Surface 1: BrandSlot at top
- Surface 4: username field (type="text", autoFocus, autoComplete="username", spellCheck=false, autoCapitalize="none")
- Surface 5: password field with show/hide toggle (Eye/EyeOff); onBlur resets to hidden
- Surface 6 error states: invalid / rate-limit / locked / server — distinct copy per UI-SPEC
- Surface 7: "Sign in" button (Loader2 spinner while pending); disabled on empty fields, rate-limit, locked
- Surface 8: "or" divider (shown when oidcEnabled)
- Surface 9: "Login with OIDC" button with ShieldCheck icon (shown when oidcEnabled)
- Surface 10: "Forgot your password? Ask your admin." — non-interactive p tag
- Focus management: autoFocus on username, useEffect focuses error heading on error change, Enter in username navigates to password field, Enter in password submits
- Security: no "Authelia", no dangerouslySetInnerHTML, no field-level blame, password only in controlled state
**App.tsx additions:**
- `OidcRedirect` helper component: `window.location.replace('/api/login')` in render body
- `authModeQuery` with `fetchAuthMode`, `staleTime: 60_000`
- Auth gate in `*` route: `meQuery.isError + localEnabled` navigates to /login; `meQuery.isError + !localEnabled + oidcEnabled` renders OidcRedirect
- `/login` route as standalone sibling of `/setup`
### Task 3: AdminPage LOCAL ACCOUNTS + SettingsSheet surfaces 12/13
**AdminPage LOCAL ACCOUNTS section:**
- Surface 11A — "Add member" inline form: display name, username, initial password, confirm password; `useMutation(fetchCreateMember)`; client-side mismatch/short validation + server-side 409 username-taken detection; success clears form + invalidates `['admin', 'members']` and `['me']`
- Surface 11B — "Reset password" button in MemberRow: conditioned on `member.hasLocalCredential`; captures trigger button ref for focus-return; opens ResetPasswordSheet
- `ResetPasswordSheet` component: bottom sheet (role=dialog, aria-modal, Escape closes, focus heading on open); new password + confirm fields; admin-reset mutation; focus returns to trigger on close
**SettingsSheet additions:**
- `useQuery(['me'])` and `useQuery(['authMode'])` inside SettingsSheet — TanStack deduplicates with App.tsx queries
- "Account" section label + "Change password" row (gated on `hasLocalCredential`)
- "Link OIDC identity" row (gated on `hasLocalCredential && oidcEnabled`)
- `ChangePasswordSheet`: current password + new password + confirm; change-password mutation; error copies for mismatch/wrong-current/server
- `LinkOidcSheet`: confirmation dialog; body copy uses "your local password will be removed" (passive — no "delete"); "Continue with OIDC" triggers fetchLinkOidc then redirects; no "Authelia" anywhere
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] InstructionSheet.test.tsx broke after SettingsSheet gained useQuery**
- **Found during:** Task 3 verification
- **Issue:** SettingsSheet now calls useQuery for `['me']` and `['authMode']`. InstructionSheet.test.tsx rendered SettingsSheet without a QueryClientProvider, causing `Error: No QueryClient set, use QueryClientProvider to set one`.
- **Fix:** Added `renderWithQueryClient()` helper wrapping `QueryClientProvider`; added `vi.mock('../api/client.js')` with the four new fetch functions.
- **Files modified:** `apps/pwa/src/components/InstructionSheet.test.tsx`
- **Commit:** `19c45eb`
**2. [Rule 1 - Bug] Stale eslint-disable directive in App.test.tsx**
- **Found during:** Task 3 lint run
- **Issue:** `_mockFetchAuthMode` uses `_` prefix naming which already suppresses unused-vars; the explicit eslint-disable comment became an "unused disable directive" error under `--max-warnings 0`.
- **Fix:** Removed the `eslint-disable-line` comment.
- **Files modified:** `apps/pwa/src/App.test.tsx`
- **Commit:** `19c45eb`
## Playwright-CLI Walkthrough Results
The dev environment has `DEV_AUTH_BYPASS=true` which makes `/api/me` always return a valid user. The `/api/auth/mode` endpoint returns 404 (plan 19-02 routes not yet active in this dev stack). playwright-cli confirms:
- Navigating to /login when authenticated redirects to calendar shell (correct behavior)
- No React or TypeScript errors in the browser console
Full login-form visual/functional verification requires the production-mode stack (no DEV_AUTH_BYPASS, plan 19-02 deployed). This is the checkpoint:human-verify scope.
## Verification: checkpoint:human-verify Required
The following surfaces require human verification on the deployed production stack:
- Surface 1-10: /login page renders + form interaction + 4 error state variants + OIDC button gate
- Surface 11A: add-member form creates a member and it appears in the list
- Surface 11B: reset-password sheet opens per-member, submits successfully
- Surface 12: change-password sheet validates current password and updates
- Surface 13: link-OIDC confirmation shows "local password will be removed" copy then initiates redirect
## Self-Check: PASSED
Files created exist:
- apps/pwa/src/components/BrandSlot.tsx — FOUND
- apps/pwa/src/routes/LoginPage.tsx — FOUND
Commits exist:
- 869cdc2 (Task 1) — FOUND
- 32d0408 (Task 2) — FOUND
- 19c45eb (Task 3) — FOUND
Tests: 263 passed, 0 failed
Typecheck: Clean (tsc --noEmit)
Lint: Clean (0 errors, 0 warnings, --max-warnings 0)
## Known Stubs
- `BrandSlot` shows "FS" initials and no logo image — intentional Phase 17 seam, not a stub. Phase 17 will override `--brand-logo-*` CSS tokens and may add an `<img>` tag.
## Threat Flags
| Flag | File | Description |
|------|------|-------------|
| threat_flag: credential-in-controlled-state | apps/pwa/src/routes/LoginPage.tsx | Password in useState (controlled input); mitigated: never copied to localStorage/sessionStorage, cleared on success/error/blur |
| threat_flag: credential-in-controlled-state | apps/pwa/src/components/SettingsSheet.tsx | currentPassword/newPassword in useState for ChangePasswordSheet; same mitigations |
@@ -0,0 +1,214 @@
---
phase: 19-local-auth-no-oidc-mode
plan: 05
type: execute
wave: 4
depends_on: ["19-01", "19-03"]
files_modified:
- apps/api/src/auth/devBypass.ts
- apps/api/scripts/reset-admin.ts
- apps/pwa/e2e/global-setup.ts
- apps/pwa/e2e/login.spec.ts
- .gitea/workflows/ci.yml
autonomous: false
requirements: [AUTH-LOCAL-11, AUTH-LOCAL-16]
must_haves:
truths:
- "With DEV_AUTH_BYPASS=true, every request also carries a real local-session cookie for the dev user, so the PWA login gate skips to the app"
- "The Phase-7/8 harness still reaches the authed PWA without manual login (existing specs unchanged)"
- "A login-specific spec can clear the local-session cookie and exercise the real /login form against the seeded dev credential"
- "global-setup seeds a local_credentials row for the dev user (id=1) and truncates it between runs"
- "CI provides LOCAL_SESSION_SECRET to the harness job and seeds the local_credentials table"
- "The break-glass CLI creates/resets a local admin by username, runs only outside production, and is excluded from the prod image"
artifacts:
- path: "apps/api/scripts/reset-admin.ts"
provides: "break-glass create/reset local admin CLI (dev-only)"
min_lines: 30
- path: "apps/pwa/e2e/login.spec.ts"
provides: "real-login-form e2e covering the gate + form (AUTH-LOCAL-12/15)"
min_lines: 25
- path: "apps/pwa/e2e/global-setup.ts"
provides: "local_credentials dev seed + truncate"
contains: "local_credentials"
key_links:
- from: "apps/api/src/auth/devBypass.ts"
to: "apps/api/src/auth/localSession.ts"
via: "devSessionCookieMiddleware issues a real local-session cookie for DEV_USER (Option C)"
pattern: "local-session"
- from: "apps/pwa/e2e/global-setup.ts"
to: "local_credentials table"
via: "INSERT ... ON DUPLICATE KEY UPDATE seed for dev user id=1"
pattern: "local_credentials"
- from: ".gitea/workflows/ci.yml"
to: "LOCAL_SESSION_SECRET"
via: "harness job env + table seed"
pattern: "LOCAL_SESSION_SECRET"
---
<objective>
Rework the dev-bypass + Phase-7/8 Playwright harness to coexist with the new login UI (Option C: bypass issues a real `local-session` cookie), add the break-glass CLI, seed `local_credentials` for the dev user, add a real-login e2e spec, and update the CI harness job — all while preserving the D-15 dev-only/no-prod-image guarantees.
Purpose: The new login gate would otherwise break the harness, which reaches the authed PWA purely via DEV_AUTH_BYPASS (D-14). Option C is the minimal-change path: the bypass keeps setting `c.get('user')` AND now also issues the same `local-session` cookie the PWA gate expects, so existing specs pass unchanged; a dedicated login spec clears the cookie to test the real form. This is glue + CI + a CLI script (type: execute). D-15 is enforced by the existing IMG-01/02/03 gates plus the `.dockerignore apps/api/scripts/` exclusion added in 19-01.
Output: edited `devBypass.ts`, new `reset-admin.ts`, edited `global-setup.ts`, new `login.spec.ts`, edited `ci.yml`.
Derived REQ-IDs covered: AUTH-LOCAL-11 (break-glass CLI, D-13), AUTH-LOCAL-16 (dev-bypass + harness rework, D-14/D-15).
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
@.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md
@.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md
@.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md
@.planning/phases/19-local-auth-no-oidc-mode/19-03-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Option C — devSessionCookieMiddleware issues a real local-session cookie under bypass</name>
<read_first>
- apps/api/src/auth/devBypass.ts (DEV_USER shape lines ~30-36; devAuthBypass() env-guard structure lines ~58-76; the production hard-guard is the FIRST check and must stay first)
- apps/api/tests/auth/devBypass.test.ts (the test that must keep passing)
- apps/api/src/auth/localSession.ts (issueLocalSessionCookie + getCookie('local-session') — from 19-01)
- apps/api/src/index.ts (where devAuthBypass() is mounted — the companion middleware mounts just after it; from 19-03 wiring)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Dev-Bypass Rework (Option C; D-15 compliance) + Pitfall 7
</read_first>
<files>apps/api/src/auth/devBypass.ts, apps/api/src/index.ts</files>
<action>
In apps/api/src/auth/devBypass.ts add `export function devSessionCookieMiddleware(): MiddlewareHandler`. Keep the production hard-guard as the FIRST check (return no-op when NODE_ENV==='production') and a no-op when DEV_AUTH_BYPASS!=='true' — identical guard order to devAuthBypass so the IMG-01 boot guard / `assertNotDevBypassInProduction` continues to protect it. When active: on each request that does NOT already have a `local-session` cookie (getCookie), call `issueLocalSessionCookie(c, DEV_USER.id)` so the PWA login gate sees a valid session and skips /login. Then next(). devAuthBypass() itself is unchanged (still sets c.get('user')).
In apps/api/src/index.ts mount `app.use('/api/*', devSessionCookieMiddleware())` immediately AFTER `app.use('/api/*', devAuthBypass())` (it is a no-op outside bypass mode, so it is safe to mount unconditionally like devAuthBypass). Do not change the OIDC-side chain.
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/auth/devBypass.test.ts && pnpm --filter @familysync/api typecheck</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test tests/auth/devBypass.test.ts` exits 0 (existing bypass behavior intact)
- Source assertion: devSessionCookieMiddleware's FIRST conditional is `NODE_ENV === 'production'` returning a no-op (grep the guard order) — D-15
- Source assertion: `grep -c "issueLocalSessionCookie" apps/api/src/auth/devBypass.ts` >= 1
- Source assertion: `grep -c "devSessionCookieMiddleware" apps/api/src/index.ts` >= 1 mounted after devAuthBypass
</acceptance_criteria>
<done>Under DEV_AUTH_BYPASS, a real local-session cookie is issued for the dev user (production-guarded); existing bypass tests still pass.</done>
</task>
<task type="auto">
<name>Task 2: Break-glass reset-admin CLI (dev-only)</name>
<read_first>
- apps/pwa/e2e/global-setup.ts (mysql2/promise connection lines ~95-101; ON DUPLICATE KEY upsert lines ~125-129; NODE_ENV production guard lines ~34-44 — the "plain Node.js only" inline-hash constraint, Pitfall 11)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Break-Glass (script contract; tsx run via docker exec) + §Common Pitfalls 11
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/scripts/reset-admin.ts (DB connection, idempotent upsert, dev-only guard, --arg parsing, inline hashPassword)
- .dockerignore (confirm apps/api/scripts/ is excluded — added in 19-01; this CLI relies on that exclusion for D-15)
</read_first>
<files>apps/api/scripts/reset-admin.ts</files>
<action>
Create apps/api/scripts/reset-admin.ts — a standalone script runnable as `docker exec -it familysync-api node --import=tsx/esm scripts/reset-admin.ts --username admin --password '<new>'`. First statement: a dev-only guard that throws when `NODE_ENV === 'production'` (defense-in-depth; the script is also `.dockerignore`d per 19-01, IMG-02). Parse `--username` and `--password` from process.argv (no new deps). Connect via mysql2/promise using DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env (same defaults as global-setup.ts). Inline a `hashPassword` (copy the 5-line scrypt PHC implementation — cannot import compiled TS from a plain script, Pitfall 11). Upsert: find-or-insert a users row for the username with `is_admin=true, claimed=true`; then INSERT ... ON DUPLICATE KEY UPDATE the local_credentials row (user_id, username, password_hash). Print the resulting user id. Support a `--dry-run` flag that validates args + connection without writing (used by the validation command). Never log the password value.
</action>
<verify>
<automated>cd apps/api && node --import=tsx/esm scripts/reset-admin.ts --dry-run --username smoketest --password ignored; echo "exit=$?"</automated>
</verify>
<acceptance_criteria>
- The `--dry-run` invocation exits 0 and prints no password value (grep the output for the literal 'ignored' → absent)
- Source assertion: the FIRST executable statement guards `NODE_ENV === 'production'` (throws) — D-13/D-15
- Source assertion: `grep -c "scryptSync" apps/api/scripts/reset-admin.ts` >= 1 (inline hash, no TS import)
- Source assertion: `.dockerignore` excludes `apps/api/scripts/` (carried from 19-01) so this file never ships
</acceptance_criteria>
<done>reset-admin.ts creates/resets a local admin by username, refuses to run in production, is excluded from the prod image, and supports --dry-run.</done>
</task>
<task type="auto">
<name>Task 3: global-setup local_credentials seed + login.spec.ts + CI harness job env</name>
<read_first>
- apps/pwa/e2e/global-setup.ts (TRUNCATE block lines ~106-109; users seed lines ~125-129; member_credentials seed lines ~143-147; the inline-hash constraint Pitfall 11)
- apps/pwa/e2e/layout.spec.ts + apps/pwa/e2e/calendar.spec.ts (spec structure, device-profile usage, DEV_AUTH_BYPASS auth-reached precondition, serviceWorkers block)
- apps/pwa/playwright.config.ts (iphone/pixel/desktop projects; baseURL; webServer)
- .gitea/workflows/ci.yml (the harness job: DEV_AUTH_BYPASS env, dev-stack bring-up, MariaDB seed step)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Dev-Bypass Rework (global-setup change + CI env) + §PWA Routing Gate
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surfaces 1-10 (selectors/copy the spec asserts: id="login-username", "Sign in", error copy)
</read_first>
<files>apps/pwa/e2e/global-setup.ts, apps/pwa/e2e/login.spec.ts, .gitea/workflows/ci.yml</files>
<action>
In apps/pwa/e2e/global-setup.ts: add `local_credentials` to the TRUNCATE set; inline a `hashPasswordInline(password)` (scrypt PHC, Pitfall 11 — global-setup is plain Node.js); after the existing users seed, `INSERT INTO local_credentials (user_id, username, password_hash) VALUES (1, 'devuser', ?) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)` with `hashPasswordInline('devpass')`. The existing NODE_ENV/ DEV_AUTH_BYPASS guards already cover the new seed.
Create apps/pwa/e2e/login.spec.ts: in a context that clears the `local-session` cookie (so the bypass-issued cookie does not auto-skip the gate), assert: (1) navigating to the app redirects to /login and the brand slot + username/password form render (id="login-username", "Sign in"); (2) a wrong password shows the single "Incorrect username or password." message; (3) logging in as devuser/devpass navigates into the app. Follow the existing spec structure (device profiles, serviceWorkers block, no-SW-controller precondition). Keep the other specs (layout/calendar/lists) reaching the app via the bypass-issued cookie unchanged.
In .gitea/workflows/ci.yml harness job: add `LOCAL_SESSION_SECRET` to the job env (a fixed dev value >=32 chars, e.g. a documented `dev-secret-change-me-0000000000000000` length-padded) so devSessionCookieMiddleware and global-setup's hash work; if the CI step seeds tables directly, add the `local_credentials` seed there too (mirroring the member_credentials seed). The harness still runs with DEV_AUTH_BYPASS=true; LOCAL_SESSION_SECRET stays dev-only (never in the published image — IMG gates).
</action>
<verify>
<automated>pnpm --filter @familysync/pwa test:e2e --grep "login"</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/pwa test:e2e --grep "login"` passes (real-login-form spec green on at least the desktop/chromium profile)
- Source assertion: `grep -c "local_credentials" apps/pwa/e2e/global-setup.ts` >= 2 (TRUNCATE + INSERT)
- Source assertion: `grep -c "LOCAL_SESSION_SECRET" .gitea/workflows/ci.yml` >= 1 in the harness job
- Behavior: the existing layout/calendar/lists specs still reach the authed app (run `pnpm --filter @familysync/pwa test:e2e` — full harness green)
</acceptance_criteria>
<done>global-setup seeds + truncates local_credentials; a real-login e2e spec passes; existing harness specs still reach the app via the bypass cookie; CI harness job has LOCAL_SESSION_SECRET + the seed.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: Verify full harness + CI green and D-15 image boundary intact</name>
<action>Run the full harness locally + push for the CI run, then pause for human confirmation that all specs and the CI harness job are green and no dev artifact ships. Blocking checkpoint — no code change; the executor presents results and waits for approval.</action>
<what-built>The reworked dev-bypass (Option C) and CI harness. The full Playwright harness (both the new login spec and the unchanged layout/calendar/lists specs) is the automated proof. This checkpoint confirms the CI run is green end-to-end and that no dev artifact leaks into the published image — the D-15 boundary that the IMG-01/02/03 gates and the new .dockerignore exclusion enforce.</what-built>
<how-to-verify>
1. Run `pnpm --filter @familysync/pwa test:e2e` locally (host dev stack, DEV_AUTH_BYPASS=true, LOCAL_SESSION_SECRET set) → confirm all specs pass, including login.spec.ts and the unchanged layout/calendar/lists specs.
2. Push the branch and confirm the Gitea CI harness job is green (it brings up the dev stack with LOCAL_SESSION_SECRET + seeds local_credentials).
3. Confirm D-15: `.dockerignore` excludes `apps/api/scripts/` (reset-admin.ts) and `apps/pwa/e2e/` (the dev seed); the published image contains no local_credentials dev seed and no reset-admin script. Spot-check the publish.yml image-hygiene assertion still passes.
</how-to-verify>
<resume-signal>Type "approved" if the full harness + CI are green and no dev artifact ships, or describe the failure.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| dev env → published image | the D-15 boundary: dev seed, dev session secret, break-glass script must never ship |
| CI runner → dev stack | DEV_AUTH_BYPASS + LOCAL_SESSION_SECRET are dev-only CI values, never production secrets |
## STRIDE Threat Register (ASVS L1, block on high)
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-23 | Elevation of Privilege | dev local_credentials seed in prod image | mitigate | seed lives only in global-setup.ts (apps/pwa/e2e/ — .dockerignore'd) and the CI step; never in a migration or startup code (RESEARCH Pitfall 7) |
| T-19-24 | Elevation of Privilege | devSessionCookieMiddleware active in prod | mitigate | production hard-guard is the FIRST check; assertNotDevBypassInProduction (IMG-01) blocks DEV_AUTH_BYPASS in prod |
| T-19-25 | Tampering | break-glass script shipped in image | mitigate | apps/api/scripts/ excluded in .dockerignore (19-01, IMG-02); NODE_ENV=production guard in the script |
| T-19-26 | Information Disclosure | break-glass password in logs | mitigate | reset-admin never logs the password value; --dry-run validates without writing |
| T-19-SC | Tampering | npm installs | mitigate | zero new packages this plan |
</threat_model>
<verification>
- `pnpm --filter @familysync/pwa test:e2e` full harness green (login + existing specs)
- `pnpm --filter @familysync/api test` green (devBypass test intact)
- Human checkpoint confirms CI green + D-15 boundary intact (no dev artifact in the image)
</verification>
<success_criteria>
- AUTH-LOCAL-16: harness reaches the authed PWA via the bypass-issued local-session cookie; a login spec tests the real form; CI updated
- AUTH-LOCAL-11: break-glass CLI creates/resets a local admin, dev-only, image-excluded
- D-15: no dev seed, dev secret, or break-glass script ships in the published image
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 05)
- Middleware: `devSessionCookieMiddleware` (apps/api/src/auth/devBypass.ts) — Option C
- Script: `apps/api/scripts/reset-admin.ts` (break-glass CLI, dev-only, .dockerignore'd)
- e2e: `apps/pwa/e2e/login.spec.ts` (real-login-form spec)
- global-setup.ts: local_credentials dev seed (devuser/devpass) + TRUNCATE
- ci.yml: LOCAL_SESSION_SECRET in the harness job + local_credentials seed
</artifacts_produced>
<output>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-05-SUMMARY.md` when done
</output>
@@ -0,0 +1,199 @@
---
phase: 19-local-auth-no-oidc-mode
plan: "05"
subsystem: auth
tags: [local-auth, dev-bypass, playwright, e2e, ci, break-glass, option-c, d-15]
status: checkpoint
dependency_graph:
requires:
- issueLocalSessionCookie / getCookie (from 19-01)
- local_credentials Drizzle table + 0003 migration (from 19-01)
- devAuthBypass() + DEV_USER (from apps/api/src/auth/devBypass.ts)
- hashPassword / verifyPassword (from 19-01)
- localAuthMiddleware (from 19-03)
provides:
- devSessionCookieMiddleware(): issues real local-session cookie under bypass (Option C)
- apps/api/scripts/reset-admin.ts: break-glass CLI (dev-only, D-13)
- apps/pwa/e2e/login.spec.ts: real-login-form e2e spec (AUTH-LOCAL-12/15)
- global-setup.ts: local_credentials dev seed (devuser/devpass) + TRUNCATE
- ci.yml: LOCAL_SESSION_SECRET + local_credentials seed in harness job
affects:
- apps/api/src/auth/devBypass.ts (devSessionCookieMiddleware added)
- apps/api/src/index.ts (devSessionCookieMiddleware mounted after devAuthBypass)
- apps/pwa/e2e/global-setup.ts (TRUNCATE + INSERT local_credentials)
- .gitea/workflows/ci.yml (LOCAL_SESSION_SECRET + local_credentials seed step)
- apps/api/tests/routes/* (mock devBypass now exports devSessionCookieMiddleware)
tech_stack:
added: []
patterns:
- Option C: devSessionCookieMiddleware issues real JWT cookie under bypass (D-14/D-15)
- Production hard-guard FIRST check pattern (mirrors devAuthBypass, T-19-24)
- Inline scrypt PHC hashPassword (Pitfall 11 — plain Node.js scripts)
- CLI --dry-run flag: validates without writing (T-19-26)
- vitest mock update pattern: add new exports to all vi.mock(devBypass.js) blocks
key_files:
created:
- apps/api/scripts/reset-admin.ts
- apps/pwa/e2e/login.spec.ts
modified:
- apps/api/src/auth/devBypass.ts
- apps/api/src/index.ts
- apps/pwa/e2e/global-setup.ts
- .gitea/workflows/ci.yml
- apps/api/tests/lib/requireAdmin.test.ts
- apps/api/tests/routes/admin.test.ts
- apps/api/tests/routes/authMode.test.ts
- apps/api/tests/routes/lists.test.ts
- apps/api/tests/routes/localAuth.test.ts
- apps/api/tests/routes/push.test.ts
- apps/api/tests/routes/setup.test.ts
decisions:
- "Option C (devSessionCookieMiddleware): minimal-change path — bypass keeps setting c.get('user') AND issues local-session cookie, so existing specs pass unchanged"
- "devSessionCookieMiddleware degrades gracefully when LOCAL_SESSION_SECRET is absent (skip cookie issuance) rather than throwing"
- "reset-admin uses mysql2/promise createConnection (same as global-setup.ts) — no new deps"
- "CI local_credentials seed step uses inline CJS hashPassword (--input-type=commonjs) matching the existing CI seed pattern"
- "LOCAL_SESSION_SECRET CI value: 'dev-secret-change-me-0000000000000000' — 36 chars, documented as dev-only"
- "login.spec.ts scoped to desktop/Chromium only — other profiles reach the app via bypass cookie unchanged"
metrics:
duration: "~13 minutes"
completed: "2026-06-17"
tasks_completed: 3
tasks_total: 4
files_created: 2
files_modified: 11
---
# Phase 19 Plan 05: Dev-Bypass Rework + Harness + CI Summary
**One-liner:** Option C devSessionCookieMiddleware issues real local-session cookie under DEV_AUTH_BYPASS, break-glass reset-admin CLI, login.spec.ts real-form e2e, global-setup seeds local_credentials, and CI harness job gets LOCAL_SESSION_SECRET.
## Status: CHECKPOINT REACHED
Task 4 is a `type="checkpoint:human-verify"` (gate="blocking"). Tasks 1-3 are complete and committed. The plan pauses for human confirmation that the full Playwright harness + CI run are green and that no dev artifact ships in the published image (D-15 boundary).
## Tasks Completed
| Task | Name | Commit | Key Files |
|------|------|--------|-----------|
| 1 | Option C — devSessionCookieMiddleware | 3094df8 | devBypass.ts, index.ts |
| 2 | Break-glass reset-admin CLI | 8239187 | apps/api/scripts/reset-admin.ts |
| 3 | global-setup seed + login.spec.ts + CI env | 1f94dc5 | global-setup.ts, login.spec.ts, ci.yml + 7 test mocks |
## Task 4: Checkpoint (Pending Human Verification)
**Checkpoint type:** `human-verify` (blocking)
### What was verified locally
**API tests:** 446/446 tests pass (all 34 test files, including devBypass.test.ts: 3/3).
**Typecheck:** `pnpm --filter @familysync/api typecheck` and `pnpm --filter @familysync/pwa typecheck` both exit 0.
**reset-admin --dry-run:** Exit 0; no password value ("ignored") in output.
**D-15 boundary verified:**
- `.dockerignore` excludes `apps/api/scripts/` (reset-admin.ts never ships) — confirmed in file.
- `.dockerignore` excludes `apps/pwa/e2e/` (global-setup seed never ships) — confirmed in file.
- `devSessionCookieMiddleware()` production hard-guard is FIRST check (line 105 of devBypass.ts).
- `reset-admin.ts` NODE_ENV=production throw is FIRST executable statement (line 26).
- `LOCAL_SESSION_SECRET` in ci.yml is a documented dev-only value, never in the published image.
**E2E login.spec.ts:** Cannot run locally yet — `LoginPage.tsx` is being produced by the concurrent plan 04 executor in the same wave. The spec is structurally correct (matches UI-SPEC selectors `id="login-username"`, `role="heading" name="Sign in"`, etc.) and will run as part of the full harness after wave 4 merges.
### What the human needs to verify
1. **Push and run CI:** Push the branch → confirm the Gitea CI `harness` job is green. The harness job now includes `LOCAL_SESSION_SECRET` and the `local_credentials` seed step. The full Playwright suite (iphone + pixel + desktop) should pass including `login.spec.ts` on the desktop profile.
2. **D-15 image boundary:** Confirm the `publish.yml` image-hygiene assertion still passes (no `apps/api/scripts/` or `apps/pwa/e2e/` artifacts in the published image). Spot-check `.dockerignore` covers both dirs.
3. **Confirm login.spec.ts passes:** After wave 4 merges (plan 04 completes LoginPage.tsx), confirm `pnpm --filter @familysync/pwa test:e2e --grep "login"` exits 0 on the desktop profile.
**Resume signal:** Type "approved" if the full harness + CI are green and no dev artifact ships.
## What Was Built
### Task 1: devSessionCookieMiddleware (Option C)
**`apps/api/src/auth/devBypass.ts`** — new export `devSessionCookieMiddleware(): MiddlewareHandler`:
- Production hard-guard FIRST check: `NODE_ENV === 'production'` → no-op (T-19-24, D-15)
- No-op when `DEV_AUTH_BYPASS !== 'true'`
- No-op when `LOCAL_SESSION_SECRET` not set (degrades gracefully)
- When active: if no `local-session` cookie present, calls `issueLocalSessionCookie(c, DEV_USER.id)`
- Imports: `getCookie` from hono/cookie, `issueLocalSessionCookie` from localSession.ts
**`apps/api/src/index.ts`** — mounts `devSessionCookieMiddleware()` immediately after `devAuthBypass()` on `/api/*`.
### Task 2: reset-admin.ts (Break-Glass CLI)
**`apps/api/scripts/reset-admin.ts`** — standalone break-glass CLI (149 lines):
- NODE_ENV=production throw as FIRST executable statement (D-13/D-15)
- `.dockerignore apps/api/scripts/` excludes it from the prod image (IMG-02)
- Inline scrypt PHC `hashPassword()` (Pitfall 11 — cannot import compiled TS from plain script)
- Parses `--username` / `--password` / `--dry-run` from process.argv
- Upserts `users` row (is_admin=true, claimed=true) then upserts `local_credentials` row
- Never logs the password value (T-19-26)
- `--dry-run`: validates args + DB connection without writing; exit 0
### Task 3: global-setup seed + login.spec.ts + CI harness env
**`apps/pwa/e2e/global-setup.ts`**:
- Added `hashPasswordInline()` inline scrypt PHC (Pitfall 11 — plain Node.js)
- Added `TRUNCATE TABLE local_credentials` to the TRUNCATE block
- Added `INSERT INTO local_credentials (user_id, username, password_hash) VALUES (1, 'devuser', ?) ON DUPLICATE KEY UPDATE ...` after member_credentials seed
**`apps/pwa/e2e/login.spec.ts`** (new, 98 lines):
- Scoped to desktop/Chromium only (other profiles use bypass cookie)
- Uses `context.clearCookies()` before each test to strip the bypass-issued cookie
- Test 1: unauthenticated navigation → /login; brand + "Sign in" heading + form visible
- Test 2: wrong password → `role="status"` shows "Incorrect username or password."
- Test 3: devuser/devpass → navigates away from /login
**`.gitea/workflows/ci.yml`** harness job:
- Added new "Seed local_credentials for dev user (id=1)" step (CJS inline script with hashPassword)
- Added `LOCAL_SESSION_SECRET: 'dev-secret-change-me-0000000000000000'` to harness env
- LOCAL_SESSION_SECRET is a dev-only value, never in the published image (IMG gates)
**Test mock fixes (Rule 1 — Bug):** Added `devSessionCookieMiddleware: () => async (_c, next) => next()` to all 7 `vi.mock('../../src/auth/devBypass.js', ...)` blocks that used an explicit factory return object (admin, setup, push, lists, localAuth, authMode, requireAdmin tests). `events.test.ts` uses `importOriginal` + spread and already picks up the new export automatically.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] vitest mock missing devSessionCookieMiddleware export**
- **Found during:** Task 3 — running the full API test suite after Task 1's devBypass.ts change
- **Issue:** 7 test files mock `devBypass.js` with an explicit factory object. After adding `devSessionCookieMiddleware` to devBypass.ts, vitest reported "No `devSessionCookieMiddleware` export is defined on the mock" for every mock that did not include it.
- **Fix:** Added `devSessionCookieMiddleware: () => async (_c, next) => next()` to all 7 explicit mock factories: admin.test.ts, setup.test.ts, push.test.ts (both `vi.mock` and `vi.doMock`), lists.test.ts, localAuth.test.ts, authMode.test.ts, requireAdmin.test.ts.
- **Files modified:** 7 test files
- **Commit:** 1f94dc5
## D-15 Guarantee
| Artifact | Dev boundary | Enforcement |
|----------|--------------|-------------|
| `devSessionCookieMiddleware` | NODE_ENV=production hard-guard (FIRST check) + IMG-01 boot guard | T-19-24 |
| `reset-admin.ts` | NODE_ENV=production throw (FIRST statement) + .dockerignore apps/api/scripts/ | T-19-25, IMG-02 |
| `local_credentials` dev seed | Lives in apps/pwa/e2e/global-setup.ts (.dockerignore apps/pwa/e2e/) + CI step only | T-19-23 |
| `LOCAL_SESSION_SECRET` in CI | Dev-only value in harness job env; never in Dockerfile or published image | IMG-01/02/03 |
## Known Stubs
None. All new code performs real operations.
## Threat Surface Scan
No new network endpoints introduced. New surface:
- `devSessionCookieMiddleware`: internal middleware, no external exposure; guarded by NODE_ENV=production FIRST check (T-19-24).
- `reset-admin.ts`: CLI only (docker exec), guarded by NODE_ENV=production throw + .dockerignore exclusion (T-19-25).
All surfaces are within the plan's threat model (T-19-23 through T-19-26).
## Self-Check: PASSED
All created files confirmed present on disk:
- FOUND: apps/api/scripts/reset-admin.ts
- FOUND: apps/pwa/e2e/login.spec.ts
All commits confirmed in git log:
- 3094df8: feat(19-05): Option C — devSessionCookieMiddleware issues real local-session cookie under bypass
- 8239187: feat(19-05): add break-glass reset-admin CLI (dev-only, .dockerignore'd)
- 1f94dc5: feat(19-05): global-setup local_credentials seed + login.spec.ts + CI harness env
API tests: 446/446 pass (all 34 test files); typecheck: exit 0.
@@ -0,0 +1,137 @@
# Phase 19: Local Auth (No-OIDC Mode) - Context
**Gathered:** 2026-06-16
**Status:** Ready for planning
<domain>
## Phase Boundary
Let an operator run FamilySync entirely on **local DB username/password accounts with no OIDC/Authelia required**, while keeping OIDC available as an opt-in, generic (RFC-compliant, not Authelia-specific) provider that can be wired in later from the admin UI. Builds directly on the Phase-12 pre-OIDC local-user foundation (nullable `users.oidc_iss`/`oidc_sub`, the `claimed` marker, and the first-login-claims merge in `upsertUser`).
**In scope:** local credential storage (scrypt) + local login flow; a new local login UI in the PWA; a stateless local-session cookie + middleware; coexistence with the existing OIDC middleware; admin-managed local account creation + password set/change/reset; per-user OIDC-link (replacing local for that user); de-Authelia-izing OIDC config/copy to a generic OIDC provider; a lockout/break-glass recovery mechanism; reworking dev-bypass + the Phase 7/8 Playwright harness to cover the new login UI.
**Out of scope:** a full pluggable multi-auth-provider framework (LDAP, magic-link, multiple OIDC) — that is the auth-layer counterpart of backlog 999.1, a future phase. Email-based password reset (email is out of project scope). BYO-CalDAV provider abstraction (999.1, separate).
</domain>
<decisions>
## Implementation Decisions
### Mode & Coexistence
- **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.
### Session Issuance
- **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** (consistent with the app's existing storage-less-JWT approach; right for household scale). 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 and treat issuer/client as generic OIDC config. Mirrors the planned BYO-CalDAV provider abstraction (999.1).
- **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.
### Password Hashing & Storage
- **D-08:** Hash local passwords with **`node:crypto` scrypt** — zero new dependency, no native node-gyp build in the Docker image (honors the stack's deliberate no-native-dep stance, the same reason Drizzle was chosen over Prisma). Encode **algorithm + params + salt alongside the hash** so parameters can evolve. (argon2id/bcrypt native addons explicitly rejected.)
- **D-09:** Store local credentials in a **new `local_credentials` table**`user_id` (FK to `users`, UNIQUE), `username` (UNIQUE), `password_hash` (encoded), `createdAt`/`updatedAt` — mirroring the `member_credentials` pattern. Keeps the `users` row identity-method-agnostic. **Auth methods are a per-user property**: a user has local login iff a `local_credentials` row exists, and OIDC login iff an `oidc_iss+oidc_sub` binding exists. Drizzle **generate+migrate, never push** (additive migration on populated MariaDB — same rule as Phases 10/12).
### Accounts & OIDC-Link
- **D-10:** **Admin creates members** + sets an initial password; the member changes it later. **No open self-signup** (wrong trust model for a private household app exposed via Pangolin).
- **D-11:** Password lifecycle = **self-change (current + new) + admin-reset** from the admin UI. **No email reset** (email out of project scope). Reuses the admin surface that already rotates Fastmail app passwords.
- **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, per Phase-12 D-10), **delete that user's `local_credentials` row** → they become OIDC-only. OIDC-only users never receive a local credential. The returned `iss+sub` must not already belong to another user.
- **D-13 (break-glass):** Lockout recovery does **not** need to be a permanent local user account (avoids a member-vs-operator capability split — explicitly rejected). Instead, 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`. Exact form → researcher (see Open Questions).
### Testing & Dev-Bypass
- **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**. It is bound by the existing Phase-16 image-hygiene gates: the IMG-01 boot guard (`assertNotDevBypassInProduction`), `.dockerignore` (IMG-02), and the publish-time hygiene assertion (IMG-03). New dev-seed-login artifacts must be covered by those same gates.
### 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).
These were "you decide" responses; rationale captured above. Researcher/planner may refine implementation detail but should not reverse the locked choice without cause.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Auth foundation this phase extends
- `apps/api/src/auth/user.ts``upsertUser` (identity = `oidc_iss+oidc_sub`, never email; first-login-claims of the single unclaimed row; first-login-wins `is_admin` bootstrap; `claimed` semantics). The local-account + OIDC-link model generalizes this.
- `apps/api/src/auth/middleware.ts` — OIDC middleware wiring + `oidcConfigFallbackMiddleware` (env-OR-`app_config` fallback for `OIDC_ISSUER`/`OIDC_CLIENT_ID`/`app_external_url`). The generic-OIDC config path lives here.
- `apps/api/src/auth/devBypass.ts``devAuthBypass()` + `DEV_USER`; the `c.set('user', …)` pattern the new local-auth middleware mirrors. Subject of the dev-bypass rework (D-14/D-15).
- `apps/api/src/auth/persistSessionCookie.ts` — session-cookie persistence helper (referenced by the session model).
- `apps/api/src/index.ts` — middleware mount order (`/api/setup` pre-auth → `devAuthBypass``oidcConfigFallback``oidcAuthMiddleware``persistSessionCookie`); `devBypassActive` computed once at boot; `assertNotDevBypassInProduction()` boot guard. Local-login routes + middleware slot in here.
- `apps/api/src/routes/me.ts``resolveUserId` (dev-bypass `c.get('user')` first, else `getAuth`) + `needsProviderSetup`/`isAdmin` exposure. The user-resolution seam for all routes.
- `apps/api/src/db/schema.ts``users` (nullable `oidc_iss`/`oidc_sub`, `claimed`, `is_admin`, `uniq_oidc_identity`), `member_credentials` (pattern to mirror for `local_credentials`), `app_config` (k/v config; PROHIBITION list for secrets-in-DB).
- `apps/api/src/routes/admin.ts` + `apps/api/src/lib/requireAdmin.ts` — admin route surface + role guard the account-management UI and OIDC config UI extend.
- `apps/api/src/routes/setup.ts` + `apps/api/src/lib/setupGuard.ts` — Phase-12 pre-auth wizard + 423 lock; the first-local-admin bootstrap replaces the current unclaimed-user provisioning.
### Image hygiene / dev-prod boundary (constrains D-15)
- `apps/api/src/lib/bootGuards.ts``assertNotDevBypassInProduction` (IMG-01).
- `.dockerignore` (repo root) — IMG-02 dev-artifact exclusion.
- `.gitea/workflows/publish.yml` — IMG-03 publish-time image-hygiene + boot-smoke assertions.
### Test harness this phase must update
- `apps/pwa/playwright.config.ts` + `apps/pwa/e2e/` (global-setup deterministic mysql2 seed, `layout.spec.ts`, `calendar.spec.ts`, `lists.spec.ts`) — Phase 7 harness; auth reached via `DEV_AUTH_BYPASS`.
- `.gitea/workflows/ci.yml` — CI `harness` job (brings up dev stack with `DEV_AUTH_BYPASS=true`).
### Provenance / prior decisions
- `.planning/phases/12-initial-setup-wizard/12-CONTEXT.md` §Deferred Ideas — origin of this phase (full local-auth/no-OIDC mode deferred from Phase 12; D-07 local-user groundwork is the deliberate foundation).
- `.planning/ROADMAP.md` §"Phase 19" — goal, dependency on Phase 12, and the four seed open questions.
- `.planning/PROJECT.md` §Constraints / §Auth — Authelia-OIDC constraint context; MariaDB-only; no-native-dep stance.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `member_credentials` table shape + `validateEncryptAndStoreCredential` flow (`broker/credentialSync.ts`) — direct template for the `local_credentials` table and an admin-managed create/reset write path.
- `devAuthBypass()`'s `c.set('user', …)` pattern — the new local-auth middleware reuses it so downstream routes (`resolveUserId` in every router) need no change.
- `oidcConfigFallbackMiddleware` (env-OR-`app_config`) — the established pattern for admin-UI-written OIDC config taking effect.
- Phase-12 `upsertUser` claim/link machinery — the OIDC-link flow (D-12) is a generalization (bind `iss+sub` to an already-authenticated local user, then drop their local credential).
- `assertNotDevBypassInProduction` + `.dockerignore` + `publish.yml` hygiene assertions — the enforcement surface for D-15.
### Established Patterns
- **Identity = `oidc_iss+oidc_sub`, never email** (Phase-12 D-10) — local accounts are a separate per-user credential, and OIDC-link must be explicit (no email matching).
- **Drizzle generate+migrate, never push** — additive migration on populated MariaDB (Phases 10/12 precedent); applies to the new `local_credentials` table.
- **`is_admin` is the server boundary; client `isAdmin` is UX-only** — local-auth admin gating reuses `requireAdmin`.
- **Secrets stay in env, never in `app_config`/DB** (Phase-12 PROHIBITION) — the local-session signing secret and scrypt config live in env, not the DB.
- **Storage-less JWT session cookie** (CLAUDE.md, `@hono/oidc-auth`) — the local-session cookie follows the same stateless philosophy (D-05).
### Integration Points
- New local-login routes + local-auth middleware mount in `index.ts` alongside (and ordered against) `devAuthBypass`/`oidcAuthMiddleware`; the OIDC guard must not 302-redirect local-mode requests.
- The PWA gate (App.tsx setup/login routing) gains a login screen and a login-vs-OIDC chooser; `/api/me` / a new auth-mode endpoint tells the PWA which methods to offer.
- Setup wizard bootstrap shifts from "provision one unclaimed user" to "create the first local admin (username+password)".
</code_context>
<specifics>
## Specific Ideas
- "Bring Your Own Auth" framing (user's words) — explicitly do not pigeon-hole into Authelia; OIDC is one generic provider, parallel to the intended "Bring Your Own CalDAV provider" direction (999.1).
- User leans toward **"replace dev-bypass with seeded auto-login"** for the harness, but defers the final call to the researcher.
- User prefers the **break-glass to be a CLI/env override rather than a user account**, to avoid added user/capability complexity.
</specifics>
<deferred>
## Deferred Ideas
- **Full pluggable auth-provider framework** (registry/plugin for LDAP, magic-link, multiple simultaneous OIDC providers) — auth-layer counterpart of backlog 999.1; its own future phase/milestone. Phase 19 builds only a clean internal seam.
- **Member-vs-operator capability/role split** — considered for the break-glass account, explicitly rejected in favor of a CLI/env recovery mechanism + the existing single `is_admin` flag.
- **Email-based password reset** — out of project scope (no email features).
## Open Questions for Research
- **Dev-bypass rework (decide among 3):** (a) keep bypass + seed a real test login for login-specific specs; (b) replace bypass with seeded auto-login through the real local flow (user's lean); (c) bypass auto-issues a real local-session cookie. Must satisfy D-14 + the D-15 dev-only/no-prod-image constraint.
- **Break-glass recovery form:** CLI/console command vs env override (or both) for create/reset-local-admin and/or disable-OIDC; how it interacts with the boot-time mode/middleware selection.
- **OIDC-only user provisioning:** how an OIDC-only user is first created given D-10 forbids email-matching unclaimed rows — just-in-time on first OIDC login vs admin pre-creation + claim (the Phase-12 single-unclaimed-row claim can't disambiguate multiple pre-created placeholders).
- **Login-vs-OIDC mode signalling to the PWA:** reuse/extend `/api/me` or `/api/setup/status`, or a new pre-auth `/api/auth/mode` endpoint, so the login page knows which methods to render.
- **Local-login hardening:** rate-limiting / lockout / timing-safe compare on the local login endpoint (household scale, but Pangolin-exposed).
</deferred>
---
*Phase: 19-local-auth-no-oidc-mode*
*Context gathered: 2026-06-16*
@@ -0,0 +1,161 @@
# Phase 19: Local Auth (No-OIDC Mode) - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-06-16
**Phase:** 19-local-auth-no-oidc-mode
**Areas discussed:** Mode & coexistence, Session issuance, Password hashing & storage, Accounts & OIDC-link, Testing & dev-bypass
---
## Mode & Coexistence
### Q1 — How should the app decide between local-auth and OIDC?
| Option | Description | Selected |
|--------|-------------|----------|
| app_config flag (runtime) | `auth_mode` row in app_config, set by wizard; no restart | |
| Deploy-time env switch | `AUTH_MODE` env read at boot | |
| Both always live | Local form + OIDC button always shown | |
**User's choice:** Free-text — "Default to local and add the ability to wire OIDC in later if wanted."
**Notes:** Local is the always-available default; OIDC is additive/opt-in.
### Q2 — How does the app know OIDC is wired in, and what happens to local login?
| Option | Description | Selected |
|--------|-------------|----------|
| Auto-detect, local stays live | OIDC on when config present; local always available | (partial) |
| Auto-detect, OIDC takes over | Local disabled once OIDC present | |
| Explicit app_config toggle | Separate `auth_mode` controlled from admin UI | (partial) |
**User's choice:** Free-text — OIDC config is set/stored in a later step, so wire it into the **admin UI**; give users the choice of which to use at login; **no local login UI exists today** so it must be built.
**Notes:** Blend — admin-UI-configured OIDC, both methods offered at login, user chooses.
---
## Session Issuance
### Q1 — What backs a local login session?
| Option | Description | Selected |
|--------|-------------|----------|
| Stateless signed JWT cookie | userId in signed httpOnly cookie; no DB table | ✓ (Claude) |
| Server-side session table | sessions table for true revocation | |
| You decide | — | ✓ |
**User's choice:** "You decide" + "do not pigeon-hole the user into Authelia — Bring Your Own Auth and Bring Your Own CalDAV provider."
**Notes:** Claude chose stateless signed JWT cookie. User added the BYO-Auth architectural principle (generic OIDC, not Authelia-locked).
### Q2 — How far should the BYO-Auth abstraction go in Phase 19?
| Option | Description | Selected |
|--------|-------------|----------|
| Local + generic OIDC | Two concrete methods, clean seam, no framework | ✓ |
| Full pluggable framework | Provider registry/plugin (LDAP, magic-link, multi-OIDC) | |
| Local only for now | Leave Authelia OIDC as-is, defer generic OIDC | |
**User's choice:** Local + generic OIDC (recommended).
**Notes:** Clean internal seam now; full framework deferred.
---
## Password Hashing & Storage
### Q1 — Which password hashing approach?
| Option | Description | Selected |
|--------|-------------|----------|
| scrypt via node:crypto | Stdlib, zero-dep, no native build | ✓ |
| argon2id (native dep) | OWASP top pick, needs native addon | |
| bcrypt (bcryptjs) | Pure JS, older KDF | |
**User's choice:** scrypt via node:crypto (recommended).
**Notes:** Honors the stack's no-native-dep stance.
### Q2 — Where to store username + hash?
| Option | Description | Selected |
|--------|-------------|----------|
| Separate local_credentials table | Mirrors member_credentials; user-agnostic users row | ✓ (Claude) |
| Columns on users | Add username + password_hash to users | |
| You decide | — | ✓ |
**User's choice:** "You decide."
**Notes:** Claude chose a separate `local_credentials` table — best fits the BYO-Auth per-user-method seam (one row can hold both a local credential and an OIDC binding).
---
## Accounts & OIDC-Link
### Q1 — How are local accounts created?
| Option | Description | Selected |
|--------|-------------|----------|
| Admin creates members | Wizard creates first admin; admin creates rest | ✓ |
| Admin creates + invite link | One-time set-password link | |
| Open self-signup | Anyone can register | |
**User's choice:** Admin creates members.
### Q2 — Password change/reset?
| Option | Description | Selected |
|--------|-------------|----------|
| Self-change + admin reset | Member self-change; admin resets lockouts | ✓ |
| Self-change only | No admin reset | |
| Admin reset only | No self-change | |
**User's choice:** Self-change + admin reset.
**Notes:** No email reset (email out of scope).
### Q3 — After OIDC-link, what methods stay valid? (reformulated after clarification)
| Option | Description | Selected |
|--------|-------------|----------|
| Both stay valid | Row holds local + OIDC; either logs in | |
| OIDC primary, local fallback | Same data model, UI emphasis on OIDC | |
| OIDC replaces local | Linking removes local credential | ✓ (per user) |
**User's choice:** Initially requested clarification; then chose **OIDC replaces local per user** — there can/should be OIDC-only users with no local creds. Raised the need for a break-glass path.
**Notes:** Auth methods are per-user (presence of local_credentials row and/or OIDC binding). Break-glass need surfaced here.
### Q4 — Break-glass capability model?
| Option | Description | Selected |
|--------|-------------|----------|
| Protected local admin (no new role model) | Initial admin, un-removable local cred | |
| Operator-only account (member/operator split) | Strip member capability from break-glass | |
| Let researcher scope it | Lock the requirement, defer the how | ✓ (twist) |
**User's choice:** Let researcher scope it — **with a twist: break-glass can be a CLI/console command or env override instead of a user**, removing the added-user/capability complexity.
**Notes:** No new role/capability model; reuse `is_admin`. Recovery mechanism (not account) to be scoped by researcher.
---
## Testing & Dev-Bypass (added mid-discussion at user's request)
### Q1 — How should DEV_AUTH_BYPASS evolve?
| Option | Description | Selected |
|--------|-------------|----------|
| Bypass stays + seed a real test login | Fast bypass for most specs; real form for login specs | |
| Replace bypass with seeded auto-login | Harness logs in via real local flow | (user's lean) |
| Bypass auto-issues a real local session | Bypass logs in seeded user, skips form | |
**User's choice:** Defer final determination to the **research agent**; user **leans toward "replace bypass with seeded auto-login."**
**Notes:** Hard constraint — the seeded test login / dev-bypass **stays dev-only and never ships in the Docker/prod image** (Phase 16 IMG-01/02/03 gates apply).
---
## Claude's Discretion
- Local session backing → stateless signed JWT cookie (D-05).
- Credential storage location → separate `local_credentials` table (D-09).
## Deferred Ideas
- Full pluggable auth-provider framework (registry/plugin; LDAP, magic-link, multi-OIDC) — future phase, counterpart of 999.1.
- Member-vs-operator capability/role split — rejected in favor of CLI/env break-glass recovery.
- Email-based password reset — out of project scope.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
---
phase: 19-local-auth-no-oidc-mode
fixed_at: 2026-06-17T20:39:00Z
review_path: .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md
iteration: 1
findings_in_scope: 15
fixed: 15
skipped: 0
status: all_fixed
---
# Phase 19: Code Review Fix Report
**Fixed at:** 2026-06-17T20:39:00Z
**Source review:** .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md
**Iteration:** 1
**Summary:**
- Findings in scope: 15 (4 critical, 4 blocker, 7 warning, 4 info — fix_scope: all)
- Fixed: 15
- Skipped: 0
**Verification:** Full API suite **452/452** (34 files, live MariaDB) and full PWA suite **266/266** (22 files) pass; both `tsc --noEmit` clean. Findings classified as security/availability logic (CR-04, BL-03, WR-06, IN-04) are flagged "requires human verification" below — syntax/tests pass but a human should confirm the threat-model intent.
## Fixed Issues
### CR-01: OIDC-link flow broken — client/server response-shape mismatch
**Files modified:** `apps/pwa/src/api/client.ts`, `apps/pwa/src/components/SettingsSheet.tsx`
**Commit:** 1688f22
**Applied fix:** Changed `fetchLinkOidc` to return `{ authorizationUrl: string | null }` matching the server's `{ signedState, authorizationUrl }` contract, and updated `LinkOidcSheet` to navigate to `authorizationUrl` (handling the `null`/unconfigured case by surfacing an error instead of navigating to `undefined`).
### CR-02: Admin "create member" always fails — request field-name mismatch
**Files modified:** `apps/pwa/src/api/client.ts`
**Commit:** 93c47b3
**Applied fix:** `fetchCreateMember` now sends `initialPassword` (the field `createMemberSchema` requires) instead of `password`, and maps HTTP 409 to `Error('conflict')` so the AdminPage's existing conflict branch renders the right banner.
### CR-03: Self-service password change logs user out on wrong current password
**Files modified:** `apps/api/src/routes/me.ts`, `apps/pwa/src/api/client.ts`, `apps/api/tests/routes/me.test.ts`
**Commit:** 6ef8e03
**Applied fix:** Server returns **403** (not 401) for an incorrect current password; client `fetchChangePassword` branches on 403 → `Error('wrong-current')` before the 401→`SessionExpiredError` path, so a mistyped password no longer triggers the global session-expiry logout. Updated me.test Test 2 to expect 403.
### CR-04: Login lockout per-IP, global, permanent, unrecoverable — *requires human verification*
**Files modified:** `apps/api/src/routes/localAuth.ts`, `apps/api/src/routes/admin.ts`, `apps/pwa/src/routes/LoginPage.tsx`, `apps/api/tests/routes/localAuth.test.ts`
**Commit:** b083cb7
**Applied fix:** Re-scoped the rate limiter from client IP to the **validated username**; added a **15-minute TTL** so a 423 lockout auto-expires (self-healing, no restart); wired admin password-reset to call `resetLoginAttempts(username)` for an immediate unlock; corrected the LoginPage banner copy. Added Test 5b asserting TTL auto-expiry. *Human verification: confirm the username-scoping + TTL behaviour matches the intended threat model for the tunnel deployment.*
### BL-01: devSessionCookieMiddleware issues a session without verifying secret strength
**Files modified:** `apps/api/src/auth/devBypass.ts`
**Commit:** 3674b25
**Applied fix:** Apply the same `secret.length >= 32` floor used by the boot guard inside `devSessionCookieMiddleware` before minting the dev cookie (degrade to no-op if too short), and emit a loud warning when the secret is the well-known dev placeholder.
### BL-02: Logout cannot clear the cookie in non-production (Secure attribute mismatch)
**Files modified:** `apps/api/src/auth/localSession.ts`
**Commit:** cd095e5
**Applied fix:** `clearLocalSessionCookie` now mirrors the issue-time `secure: process.env.NODE_ENV === 'production'` logic instead of hard-coding `secure: true`, so the deletion cookie is accepted over plain HTTP and logout actually clears the session in HTTP-only self-hosts.
### BL-03: OIDC-link binding swallows failure / binds on stale/blank identity — *requires human verification*
**Files modified:** `apps/api/src/index.ts`
**Commit:** 7153760
**Applied fix:** In the `/callback` link path, reject the bind unless the current local session (`verifyLocalSessionCookie`) matches `linkUserId` (account-takeover guard), and reject when `iss`/`sub` are empty (never call `linkOidcToUser` with blank identity, which would corrupt identity and delete the user's local credential). *Human verification: confirm the session cross-check closes the replay-takeover path described in the review.*
### BL-04: localAuthMiddleware fabricates oidcSub collisions for local users
**Files modified:** `apps/api/src/auth/devBypass.ts`, `apps/api/src/auth/localAuthMiddleware.ts`, `apps/api/tests/auth/localAuthMiddleware.test.ts`
**Commit:** 40666e1
**Applied fix:** Introduced a `ContextUser` interface with nullable `oidcIss`/`oidcSub`; the middleware now stores `null` for local users instead of the `'local'`/`String(id)` sentinels that shared the `uniq_oidc_identity` uniqueness domain. Added Test 1c asserting null context for a null-OIDC local user.
### WR-01: reset-admin.ts arg parsing trusts `--password ''` and echoes username
**Files modified:** `apps/api/scripts/reset-admin.ts`
**Commit:** c4d8d76
**Applied fix:** Rewrote `parseArgs` to support `--key=value` and to treat `--username`/`--password` as value-taking (consuming the next token verbatim, so a `--`-prefixed or empty password is preserved) and `--dry-run` as boolean; removed username interpolation from log lines.
### WR-02 + WR-04: hardcoded Authelia auth path / OIDC-config detection divergence
**Files modified:** `apps/api/src/auth/oidcConfig.ts` (new), `apps/api/src/routes/me.ts`
**Commit:** 322929a
**Applied fix:** New `oidcConfig.ts` centralizes the env-OR-app_config resolution (`resolveOidcConfig`) and discovers the `authorization_endpoint` from the provider's `/.well-known/openid-configuration` (`discoverAuthorizationEndpoint`). me.ts link-oidc now uses both, so a wizard-configured instance no longer reports `oidcEnabled:true` while returning `authorizationUrl:null`, and the URL is no longer Authelia-path-specific. (Two findings fixed in one commit — they share the same handler/lines and are inseparable.)
### WR-03: scryptSync blocks the event loop on the login hot path
**Files modified:** `apps/api/src/auth/localCredentials.ts`, `apps/api/src/routes/localAuth.ts`, `apps/api/src/routes/me.ts`, `apps/api/src/routes/admin.ts`, plus their tests
**Commit:** 30ad25c
**Applied fix:** Converted `hashPassword`/`verifyPassword` to async (threadpool scrypt via a typed Promise wrapper), awaited at all call sites, made the login DUMMY_HASH a module-level promise, and moved create-member hashing outside the DB transaction. Updated all test call sites to await. Preserves the timing-defense property while keeping the event loop responsive.
### WR-05: noEchoHook pass-through is correct-but-untested
**Files modified:** `apps/api/tests/routes/admin.test.ts`, `apps/api/tests/routes/me.test.ts`
**Commit:** 4bd6b2c
**Applied fix:** Added focused no-echo tests for the admin create-member and me password hook sites asserting a malformed body never includes the submitted password or Zod's `received`/`issues`. `@hono/zod-validator` is already pinned to exact `0.8.0` in package.json.
### WR-06: rate-limit lockedUntil refreshed on every blocked attempt — *requires human verification*
**Files modified:** `apps/api/src/routes/localAuth.ts`
**Commit:** 4cf2ad4
**Applied fix:** The 429 (already-rejected) branch no longer re-arms `lockedUntil`; the cooldown window stays anchored to when it was first armed, so sustained attacker traffic can no longer slide the window forward indefinitely. *Human verification: confirm the window now expires on schedule for a legitimate user behind the same identity.*
### WR-07: parseInt member/calendar id accepts trailing garbage
**Files modified:** `apps/api/src/routes/admin.ts`, `apps/api/tests/routes/admin.test.ts`
**Commit:** 32bdd1e
**Applied fix:** Added `parsePositiveIntParam` using `Number.isInteger(Number(raw))` and applied it to `/members/:id/password` and `/calendars/:id/shared`, so `"12abc"` is now rejected with 400. Added a test for the calendar route.
### IN-01: localSession maxAge/expiry parsing has no validation
**Files modified:** `apps/api/src/auth/localSession.ts`
**Commit:** f2fc140
**Applied fix:** Coerce and validate `LOCAL_SESSION_EXPIRES` — fall back to 86400s for any non-finite or non-positive value, preventing a `NaN` exp/maxAge.
### IN-02: duplicated inline scrypt implementation across three locations
**Files modified:** `apps/api/tests/auth/localCredentials.test.ts`
**Commit:** e392bf2
**Applied fix:** Added a lockstep test that builds a hash using the inlined scrypt parameters (N=16384, r=8, p=1, KEY_LEN=32 — matching reset-admin.ts and ci.yml) and asserts it round-trips against the canonical `verifyPassword`, so a parameter drift fails CI loudly.
### IN-03: loginAttempts map is unbounded
**Files modified:** `apps/api/src/routes/localAuth.ts`
**Commit:** f02521d
**Applied fix:** Added `evictStaleLoginAttempts`, called opportunistically per login request, dropping entries that are neither in an active rate-limit window nor an active lockout TTL — bounding the map under input churn without weakening the limiter.
### IN-04: link-oidc nonce generated but never persisted/verified — *requires human verification*
**Files modified:** `apps/api/src/auth/linkNonceStore.ts` (new), `apps/api/src/routes/me.ts`, `apps/api/src/index.ts`
**Commit:** 2691dd0
**Applied fix:** New in-memory single-use nonce store: me.ts registers the issued nonce (valid until the state JWT's exp); the `/callback` link path consumes it and rejects any replayed/unknown/expired nonce before binding. Combined with BL-03's session cross-check, the captured-state replay window is closed. *Human verification: confirm single-use semantics are sufficient for the single-process deployment (move to Redis if multi-process).*
## Skipped Issues
None — all 15 in-scope findings were fixed.
---
_Fixed: 2026-06-17T20:39:00Z_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 1_
@@ -0,0 +1,125 @@
---
phase: 19-local-auth-no-oidc-mode
fixed_at: 2026-06-17T20:39:00Z
review_path: .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md
iteration: 1
findings_in_scope: 15
fixed: 15
skipped: 0
status: all_fixed
---
# Phase 19: Code Review Fix Report
**Fixed at:** 2026-06-17T20:39:00Z
**Source review:** .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md
**Iteration:** 1
**Summary:**
- Findings in scope: 15 (4 critical, 4 blocker, 7 warning, 4 info — fix_scope: all)
- Fixed: 15
- Skipped: 0
**Verification:** Full API suite **452/452** (34 files, live MariaDB) and full PWA suite **266/266** (22 files) pass; both `tsc --noEmit` clean. Findings classified as security/availability logic (CR-04, BL-03, WR-06, IN-04) are flagged "requires human verification" below — syntax/tests pass but a human should confirm the threat-model intent.
## Fixed Issues
### CR-01: OIDC-link flow broken — client/server response-shape mismatch
**Files modified:** `apps/pwa/src/api/client.ts`, `apps/pwa/src/components/SettingsSheet.tsx`
**Commit:** 1688f22
**Applied fix:** Changed `fetchLinkOidc` to return `{ authorizationUrl: string | null }` matching the server's `{ signedState, authorizationUrl }` contract, and updated `LinkOidcSheet` to navigate to `authorizationUrl` (handling the `null`/unconfigured case by surfacing an error instead of navigating to `undefined`).
### CR-02: Admin "create member" always fails — request field-name mismatch
**Files modified:** `apps/pwa/src/api/client.ts`
**Commit:** 93c47b3
**Applied fix:** `fetchCreateMember` now sends `initialPassword` (the field `createMemberSchema` requires) instead of `password`, and maps HTTP 409 to `Error('conflict')` so the AdminPage's existing conflict branch renders the right banner.
### CR-03: Self-service password change logs user out on wrong current password
**Files modified:** `apps/api/src/routes/me.ts`, `apps/pwa/src/api/client.ts`, `apps/api/tests/routes/me.test.ts`
**Commit:** 6ef8e03
**Applied fix:** Server returns **403** (not 401) for an incorrect current password; client `fetchChangePassword` branches on 403 → `Error('wrong-current')` before the 401→`SessionExpiredError` path, so a mistyped password no longer triggers the global session-expiry logout. Updated me.test Test 2 to expect 403.
### CR-04: Login lockout per-IP, global, permanent, unrecoverable — *requires human verification*
**Files modified:** `apps/api/src/routes/localAuth.ts`, `apps/api/src/routes/admin.ts`, `apps/pwa/src/routes/LoginPage.tsx`, `apps/api/tests/routes/localAuth.test.ts`
**Commit:** b083cb7
**Applied fix:** Re-scoped the rate limiter from client IP to the **validated username**; added a **15-minute TTL** so a 423 lockout auto-expires (self-healing, no restart); wired admin password-reset to call `resetLoginAttempts(username)` for an immediate unlock; corrected the LoginPage banner copy. Added Test 5b asserting TTL auto-expiry. *Human verification: confirm the username-scoping + TTL behaviour matches the intended threat model for the tunnel deployment.*
### BL-01: devSessionCookieMiddleware issues a session without verifying secret strength
**Files modified:** `apps/api/src/auth/devBypass.ts`
**Commit:** 3674b25
**Applied fix:** Apply the same `secret.length >= 32` floor used by the boot guard inside `devSessionCookieMiddleware` before minting the dev cookie (degrade to no-op if too short), and emit a loud warning when the secret is the well-known dev placeholder.
### BL-02: Logout cannot clear the cookie in non-production (Secure attribute mismatch)
**Files modified:** `apps/api/src/auth/localSession.ts`
**Commit:** cd095e5
**Applied fix:** `clearLocalSessionCookie` now mirrors the issue-time `secure: process.env.NODE_ENV === 'production'` logic instead of hard-coding `secure: true`, so the deletion cookie is accepted over plain HTTP and logout actually clears the session in HTTP-only self-hosts.
### BL-03: OIDC-link binding swallows failure / binds on stale/blank identity — *requires human verification*
**Files modified:** `apps/api/src/index.ts`
**Commit:** 7153760
**Applied fix:** In the `/callback` link path, reject the bind unless the current local session (`verifyLocalSessionCookie`) matches `linkUserId` (account-takeover guard), and reject when `iss`/`sub` are empty (never call `linkOidcToUser` with blank identity, which would corrupt identity and delete the user's local credential). *Human verification: confirm the session cross-check closes the replay-takeover path described in the review.*
### BL-04: localAuthMiddleware fabricates oidcSub collisions for local users
**Files modified:** `apps/api/src/auth/devBypass.ts`, `apps/api/src/auth/localAuthMiddleware.ts`, `apps/api/tests/auth/localAuthMiddleware.test.ts`
**Commit:** 40666e1
**Applied fix:** Introduced a `ContextUser` interface with nullable `oidcIss`/`oidcSub`; the middleware now stores `null` for local users instead of the `'local'`/`String(id)` sentinels that shared the `uniq_oidc_identity` uniqueness domain. Added Test 1c asserting null context for a null-OIDC local user.
### WR-01: reset-admin.ts arg parsing trusts `--password ''` and echoes username
**Files modified:** `apps/api/scripts/reset-admin.ts`
**Commit:** c4d8d76
**Applied fix:** Rewrote `parseArgs` to support `--key=value` and to treat `--username`/`--password` as value-taking (consuming the next token verbatim, so a `--`-prefixed or empty password is preserved) and `--dry-run` as boolean; removed username interpolation from log lines.
### WR-02 + WR-04: hardcoded Authelia auth path / OIDC-config detection divergence
**Files modified:** `apps/api/src/auth/oidcConfig.ts` (new), `apps/api/src/routes/me.ts`
**Commit:** 322929a
**Applied fix:** New `oidcConfig.ts` centralizes the env-OR-app_config resolution (`resolveOidcConfig`) and discovers the `authorization_endpoint` from the provider's `/.well-known/openid-configuration` (`discoverAuthorizationEndpoint`). me.ts link-oidc now uses both, so a wizard-configured instance no longer reports `oidcEnabled:true` while returning `authorizationUrl:null`, and the URL is no longer Authelia-path-specific. (Two findings fixed in one commit — they share the same handler/lines and are inseparable.)
### WR-03: scryptSync blocks the event loop on the login hot path
**Files modified:** `apps/api/src/auth/localCredentials.ts`, `apps/api/src/routes/localAuth.ts`, `apps/api/src/routes/me.ts`, `apps/api/src/routes/admin.ts`, plus their tests
**Commit:** 30ad25c
**Applied fix:** Converted `hashPassword`/`verifyPassword` to async (threadpool scrypt via a typed Promise wrapper), awaited at all call sites, made the login DUMMY_HASH a module-level promise, and moved create-member hashing outside the DB transaction. Updated all test call sites to await. Preserves the timing-defense property while keeping the event loop responsive.
### WR-05: noEchoHook pass-through is correct-but-untested
**Files modified:** `apps/api/tests/routes/admin.test.ts`, `apps/api/tests/routes/me.test.ts`
**Commit:** 4bd6b2c
**Applied fix:** Added focused no-echo tests for the admin create-member and me password hook sites asserting a malformed body never includes the submitted password or Zod's `received`/`issues`. `@hono/zod-validator` is already pinned to exact `0.8.0` in package.json.
### WR-06: rate-limit lockedUntil refreshed on every blocked attempt — *requires human verification*
**Files modified:** `apps/api/src/routes/localAuth.ts`
**Commit:** 4cf2ad4
**Applied fix:** The 429 (already-rejected) branch no longer re-arms `lockedUntil`; the cooldown window stays anchored to when it was first armed, so sustained attacker traffic can no longer slide the window forward indefinitely. *Human verification: confirm the window now expires on schedule for a legitimate user behind the same identity.*
### WR-07: parseInt member/calendar id accepts trailing garbage
**Files modified:** `apps/api/src/routes/admin.ts`, `apps/api/tests/routes/admin.test.ts`
**Commit:** 32bdd1e
**Applied fix:** Added `parsePositiveIntParam` using `Number.isInteger(Number(raw))` and applied it to `/members/:id/password` and `/calendars/:id/shared`, so `"12abc"` is now rejected with 400. Added a test for the calendar route.
### IN-01: localSession maxAge/expiry parsing has no validation
**Files modified:** `apps/api/src/auth/localSession.ts`
**Commit:** f2fc140
**Applied fix:** Coerce and validate `LOCAL_SESSION_EXPIRES` — fall back to 86400s for any non-finite or non-positive value, preventing a `NaN` exp/maxAge.
### IN-02: duplicated inline scrypt implementation across three locations
**Files modified:** `apps/api/tests/auth/localCredentials.test.ts`
**Commit:** e392bf2
**Applied fix:** Added a lockstep test that builds a hash using the inlined scrypt parameters (N=16384, r=8, p=1, KEY_LEN=32 — matching reset-admin.ts and ci.yml) and asserts it round-trips against the canonical `verifyPassword`, so a parameter drift fails CI loudly.
### IN-03: loginAttempts map is unbounded
**Files modified:** `apps/api/src/routes/localAuth.ts`
**Commit:** f02521d
**Applied fix:** Added `evictStaleLoginAttempts`, called opportunistically per login request, dropping entries that are neither in an active rate-limit window nor an active lockout TTL — bounding the map under input churn without weakening the limiter.
### IN-04: link-oidc nonce generated but never persisted/verified — *requires human verification*
**Files modified:** `apps/api/src/auth/linkNonceStore.ts` (new), `apps/api/src/routes/me.ts`, `apps/api/src/index.ts`
**Commit:** 2691dd0
**Applied fix:** New in-memory single-use nonce store: me.ts registers the issued nonce (valid until the state JWT's exp); the `/callback` link path consumes it and rejects any replayed/unknown/expired nonce before binding. Combined with BL-03's session cross-check, the captured-state replay window is closed. *Human verification: confirm single-use semantics are sufficient for the single-process deployment (move to Redis if multi-process).*
## Skipped Issues
None — all 15 in-scope findings were fixed.
---
_Fixed: 2026-06-17T20:39:00Z_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 1_
@@ -0,0 +1,387 @@
---
phase: 19-local-auth-no-oidc-mode
reviewed: 2026-06-17T00:00:00Z
depth: deep
files_reviewed: 39
files_reviewed_list:
- apps/api/scripts/reset-admin.ts
- apps/api/src/auth/devBypass.ts
- apps/api/src/auth/linkOidc.ts
- apps/api/src/auth/localAuthMiddleware.ts
- apps/api/src/auth/localCredentials.ts
- apps/api/src/auth/localSession.ts
- apps/api/src/auth/middleware.ts
- apps/api/src/db/migrations/0003_warm_deathstrike.sql
- apps/api/src/db/schema.ts
- apps/api/src/index.ts
- apps/api/src/lib/bootGuards.ts
- apps/api/src/routes/admin.ts
- apps/api/src/routes/authMode.ts
- apps/api/src/routes/localAuth.ts
- apps/api/src/routes/me.ts
- apps/api/tests/auth/localAuthMiddleware.test.ts
- apps/api/tests/auth/localCredentials.test.ts
- apps/api/tests/auth/localSession.test.ts
- apps/api/test/setup.ts
- apps/api/tests/lib/requireAdmin.test.ts
- apps/api/tests/routes/admin.test.ts
- apps/api/tests/routes/authMode.test.ts
- apps/api/tests/routes/lists.test.ts
- apps/api/tests/routes/localAuth.test.ts
- apps/api/tests/routes/me.test.ts
- apps/api/tests/routes/push.test.ts
- apps/api/tests/routes/setup.test.ts
- apps/pwa/e2e/global-setup.ts
- apps/pwa/e2e/login.spec.ts
- apps/pwa/src/api/client.ts
- apps/pwa/src/App.test.tsx
- apps/pwa/src/App.tsx
- apps/pwa/src/components/BrandSlot.tsx
- apps/pwa/src/components/InstructionSheet.test.tsx
- apps/pwa/src/components/SettingsSheet.tsx
- apps/pwa/src/routes/AdminPage.tsx
- apps/pwa/src/routes/LoginPage.tsx
- apps/pwa/src/styles/tokens.css
- .gitea/workflows/ci.yml
- scripts/generate-secrets.mjs
findings:
critical: 4
blocker: 4
warning: 7
info: 4
total: 15
status: issues_found
---
# Phase 19: Code Review Report
**Reviewed:** 2026-06-17
**Depth:** deep
**Files Reviewed:** 39 (auth source + routes + PWA + CI)
**Status:** issues_found
## Summary
Phase 19 adds a local username/password authentication mode alongside the existing
OIDC flow. The cryptographic core (scrypt PHC hashing, constant-time compare, dummy-hash
timing defense, HS256 session JWT with boot guards) is implemented carefully and is sound.
The middleware chain (`devAuthBypass → devSessionCookie → localAuthMiddleware → OIDC guard`)
and the admin-privilege boundary (`requireAdmin` DB-enforced on every `/api/admin/*` request)
are correct.
The serious problems are at the **client↔server API contract boundary** — the exact place
a deep cross-file review is meant to catch. Three Phase-19 client functions in
`apps/pwa/src/api/client.ts` disagree with their server routes on field names, response
shape, or status-code handling, so the corresponding features (OIDC-link, admin
create-member, and self-service password change error handling) are broken end-to-end
despite each side individually passing its own unit tests. There is also an
authentication availability defect: the per-IP login lockout is mislabeled as
"account locked," is global, and never expires — a single attacker IP can permanently
deny login for the whole household with no self-recovery path.
## Critical Issues
### CR-01: OIDC-link flow is broken — client/server response-shape mismatch
**File:** `apps/pwa/src/api/client.ts:226-238` and `apps/api/src/routes/me.ts:301-341`
**Issue:** `fetchLinkOidc()` reads `result.redirectUrl` and its return type is
`{ redirectUrl: string }`. The server's `POST /api/me/link-oidc` returns
`{ signedState, authorizationUrl }` — there is no `redirectUrl` key. The caller
(Surface 13 "Link OIDC") will navigate to `undefined`, so the entire OIDC-link feature
(AUTH-LOCAL-10) cannot work in the browser. Each side's own unit tests pass because
neither test crosses the boundary. Additionally `authorizationUrl` can legitimately be
`null` (OIDC unconfigured), which the client type does not model.
**Fix:** Make the contract agree. Either return `redirectUrl` from the server, or read
`authorizationUrl` on the client and handle `null`:
```ts
export async function fetchLinkOidc(): Promise<{ authorizationUrl: string | null }> {
// ...
return res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>;
}
// caller:
const { authorizationUrl } = await fetchLinkOidc();
if (authorizationUrl) window.location.href = authorizationUrl;
```
### CR-02: Admin "create member" always fails — request field-name mismatch
**File:** `apps/pwa/src/api/client.ts:176-194` and `apps/api/src/routes/admin.ts:123-133`
**Issue:** `fetchCreateMember` POSTs `{ displayName, username, password }`. The server's
`createMemberSchema` requires `{ displayName, username, initialPassword }`. The `password`
field is ignored and `initialPassword` is missing, so Zod validation fails and the route
returns `400 { error: 'Invalid request' }` (via `noEchoHook`) for *every* valid admin
attempt to create a local member (AUTH-LOCAL-07). The Admin UI maps a non-409 error to
the generic "Something went wrong" banner, so the admin can never create an account.
**Fix:** Send the field the server expects:
```ts
body: JSON.stringify({
displayName: body.displayName,
username: body.username,
initialPassword: body.password,
}),
```
### CR-03: Self-service password change logs the user out on a wrong current password
**File:** `apps/pwa/src/api/client.ts:148-165` and `apps/api/src/routes/me.ts:233-261`
**Issue:** `fetchChangePassword` treats `res.status === 401` as `SessionExpiredError`,
which the global MutationCache handler interprets as "session expired → arm the
re-auth interstitial / redirect to login." But `POST /api/me/password` returns **401**
`{ error: 'Current password incorrect' }` when the supplied current password is wrong
(me.ts:259-260). So a user who simply mistypes their current password is forcibly logged
out instead of seeing "current password incorrect." The client doc comment even claims
"401 → wrong current password" while the code routes 401 to `SessionExpiredError`. The
documented 422 validation branch also never fires — the server returns 400 (noEchoHook)
or 404, not 422.
**Fix:** Distinguish auth-expiry from an in-app 401. Have the route return a distinct
status for "wrong current password" (e.g. 403 or a body code), and branch on it client-side
before treating 401 as session expiry:
```ts
if (res.status === 401) {
const body = await res.json().catch(() => ({}));
if (body?.error === 'Current password incorrect') throw new Error('wrong-current');
throw new SessionExpiredError();
}
```
### CR-04: Login lockout is per-IP, global, permanent, and unrecoverable
**File:** `apps/api/src/routes/localAuth.ts:55-140`
**Issue:** Multiple correctness/availability defects in one mechanism:
1. The `loginAttempts` map is keyed by **IP**, yet the 423 response and the PWA banner say
"This account is temporarily locked." It is neither account-scoped nor temporary.
2. Once `count >= LOCKOUT_FAILURES (10)`, `lockedOut` is set permanently. The only documented
clear path is "admin password reset" — but no admin route ever clears `loginAttempts`
(admin.ts reset-password updates the hash, not the in-memory map). So **the lockout is
genuinely unrecoverable without a process restart**.
3. Because it is per-IP and all household traffic arrives via the Pangolin tunnel with the
same `X-Forwarded-For` first hop, one bad actor (or one user fat-fingering 10 times) can
lock out **every** member at that egress IP. This is a self-inflicted DoS on a 2-person
household whose entire reason for existing is low-friction access.
4. `X-Forwarded-For` is attacker-controllable on any request that does not pass through the
trusted proxy; an attacker can rotate the header to get unlimited fresh rate-limit
buckets, defeating the brute-force defense entirely while still being able to lock
*other* identities by spoofing their IP if it were ever known.
**Fix:** Re-scope the limiter to the submitted username (not IP), make the 423 lockout
expire on a timer (or actually wire admin reset to clear it), correct the banner copy, and
only trust `X-Forwarded-For` when the request demonstrably came from the known proxy
(or use the leftmost-trusted hop). At minimum, give the lockout a TTL so a restart is not
required:
```ts
// derive key from the validated username, and expire lockout after N minutes
const LOCKOUT_TTL_MS = 15 * 60 * 1000;
if (attempt?.lockedOut && Date.now() < attempt.lockedUntil) { /* 423 */ }
```
## Blockers
### BL-01: `devSessionCookieMiddleware` issues a session for user id=1 without verifying the user exists or the secret is strong
**File:** `apps/api/src/auth/devBypass.ts:102-131` and `apps/api/src/lib/bootGuards.ts:53-66`
**Issue:** In dev-bypass mode the boot guard `assertLocalSessionSecretSet()` is *skipped*
entirely (returns early when `DEV_AUTH_BYPASS==='true'`). `devSessionCookieMiddleware` then
only checks that `LOCAL_SESSION_SECRET` is *present* (truthy), not that it is ≥32 chars, and
mints a real, fully-valid `local-session` JWT for `DEV_USER.id (=1)`. The CI sets a weak
fixed secret `'dev-secret-change-me-0000000000000000'`. Any cookie minted under bypass is a
genuine, signature-valid session token for user 1 — if that same weak/known secret is ever
present in a non-bypass environment (e.g. an operator copies the dev compose), forged
sessions are trivial. The hard `NODE_ENV==='production'` guard mitigates the worst case, but
this is a latent footgun: the boot guard's length check is the documented defense and it is
bypassed here.
**Fix:** Apply the same `secret.length >= 32` floor inside `devSessionCookieMiddleware`
before issuing, and emit a loud warning if the dev secret is the placeholder value. Do not
treat "present" as "safe."
### BL-02: Logout cannot clear the cookie in non-production (attribute mismatch)
**File:** `apps/api/src/auth/localSession.ts:53-59` vs `97-106`
**Issue:** `issueLocalSessionCookie` sets `secure: process.env.NODE_ENV === 'production'`
(i.e. `secure:false` in dev/test over HTTP). `clearLocalSessionCookie` hard-codes
`secure: true`. Browsers require the `Secure` attribute on a deletion cookie to match the
context: over plain HTTP a `Secure` delete-cookie is rejected, so `POST /local/logout`
returns 200 but the `local-session` cookie is **not actually cleared** in any non-HTTPS
deployment (local dev, and any HTTP-only self-host). The user appears logged in after
"logout." The inline comment acknowledges the mismatch but waves it away with "logout should
happen over HTTPS" — that is an unsafe assumption for a self-hosted app that explicitly
supports private-IP/HTTP internal access.
**Fix:** Mirror the issue-time logic on delete:
```ts
deleteCookie(c, COOKIE_NAME, {
path: '/', httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
});
```
### BL-03: OIDC-link binding swallows `processOAuthCallback` failure and can bind on a stale/blank identity
**File:** `apps/api/src/index.ts:57-101`
**Issue:** In the `/callback` link path the code calls `processOAuthCallback(c)` then
`getAuth(c)` and binds whatever `iss`/`sub` it finds to `linkUserId`. Two problems:
(1) `iss`/`sub` fall back to `''` (`auth.iss ?? ''`, `auth.sub ?? ''`); if `getAuth` returns
a partially-populated session, `linkOidcToUser(linkUserId, '', '')` will write
`oidc_iss=''`/`oidc_sub=''` onto the user and **delete their local_credentials** — locking
them out of both auth methods. (2) `linkUserId` comes from a JWT signed with
`LOCAL_SESSION_SECRET`, but nothing verifies the *currently authenticated session* matches
`linkUserId`; the signed-state CSRF defense assumes the state can only have been produced by
`POST /api/me/link-oidc`, but the callback binds to `linkUserId` regardless of who completes
the OIDC login. An attacker who can get a victim to complete an OIDC login while replaying a
captured (still-valid, 10-min) link state binds the *attacker's* OIDC identity to the
*victim's* account — account takeover.
**Fix:** Reject the bind unless `iss` and `sub` are both non-empty, and cross-check that the
OIDC identity being bound is the one the initiating user intended (e.g. require the
post-callback session's subject to be confirmed by the user, or bind only when the
initiating local session is still present and matches `linkUserId`). Never call
`linkOidcToUser` with empty iss/sub.
### BL-04: `localAuthMiddleware` fabricates `oidcSub` collisions for local users
**File:** `apps/api/src/auth/localAuthMiddleware.ts:88-94` and `apps/api/src/db/schema.ts:60-64`
**Issue:** When populating context for a local user with null OIDC fields, the middleware
substitutes `oidcIss: 'local'` and `oidcSub: String(row.id)`. This is only a context shape
and is not persisted by the middleware — but `me.ts:resolveUserId` (the OIDC path) and
`upsertUser` key identity on `iss+sub`, and the `users` table has
`unique('uniq_oidc_identity').on(oidcIss, oidcSub)`. If any code path ever upserts using the
context's `('local', String(id))` pair (e.g. a future call to `upsertUser` with these
values), two local users would deterministically collide or a local user could shadow a real
OIDC identity whose `(iss,sub)` happened to equal `('local','<n>')`. The fabricated values
leak a synthetic identity namespace that overlaps the real one.
**Fix:** Keep the context `oidcIss/oidcSub` as `null` for local users (widen the
`ContextVariableMap` `user` type to allow null) rather than inventing `'local'`/`String(id)`
sentinels that share a uniqueness domain with real OIDC identities.
## Warnings
### WR-01: `reset-admin.ts` interpolates the username into a log line and trusts `--password ''`
**File:** `apps/api/scripts/reset-admin.ts:60-66, 123, 133`
**Issue:** Two issues. (1) The arg parser treats any token starting with `--` as a new flag,
so `--password --foo` yields `password=''`; combined with the dry-run branch the validation
is loose. More importantly a password that legitimately begins with `--` (or is the empty
string) is silently coerced to `''`. (2) The username is interpolated directly into
`console.log(... username="${username}")`; while not an injection into SQL (queries are
parameterized — good), logging the username is a minor info disclosure for a break-glass tool
and inconsistent with the password-never-logged contract.
**Fix:** Parse `--password=value` and `--password value` explicitly; do not infer empty
strings from a following flag. Avoid echoing the username, or document it as acceptable.
### WR-02: `me.ts` builds the OIDC authorization URL with a hardcoded Authelia path
**File:** `apps/api/src/routes/me.ts:331`
**Issue:** `new URL(`${issuer}/api/oidc/authorization`)` hardcodes Authelia's authorization
endpoint path. The project's stated design is to discover endpoints via
`/.well-known/openid-configuration` (the whole reason `@hono/oidc-auth` is used). Any
non-Authelia or differently-mounted provider will get a wrong URL. This is a latent
correctness bug that compounds CR-01.
**Fix:** Resolve the authorization endpoint from the discovery document rather than assuming
`/api/oidc/authorization`.
### WR-03: `scryptSync` blocks the event loop on the login hot path
**File:** `apps/api/src/auth/localCredentials.ts:42-53, 71-90` and `localAuth.ts:127-129`
**Issue:** `verifyPassword` always runs `scryptSync` (N=16384) synchronously, including the
dummy-hash branch on every failed/unknown login. The file comment justifies this for a
2-person household, but combined with the per-IP rate limiter and the always-run dummy hash,
a burst of unauthenticated `POST /local/login` requests can pin the single Node event loop
(each scrypt is ~tens of ms of blocking CPU) and stall *all* other API traffic — a cheap
unauthenticated DoS. The rate limiter does not protect this because the scrypt runs *before*
the failure counter is consulted on the dummy path for new IPs.
**Fix:** Use `promisify(scrypt)` (async) so hashing does not block the loop, as the comment
itself suggests. This keeps the timing-defense property while preventing loop starvation.
### WR-04: `authMode` / OIDC-enabled detection diverges across three files
**File:** `apps/api/src/routes/authMode.ts:33-49`, `apps/api/src/auth/middleware.ts:62-114`,
`apps/api/src/routes/me.ts:323-328`
**Issue:** Three independent notions of "is OIDC configured": `authMode` checks
`OIDC_ISSUER` env OR `app_config.oidc_issuer`; the fallback middleware injects
issuer/client-id/external-url from app_config; but `me.ts` link-oidc only builds a URL when
`OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_REDIRECT_URI` are all in **env** (it never consults
app_config). So a wizard-configured-but-not-restarted instance reports `oidcEnabled:true`
from `/api/auth/mode`, shows the "Link OIDC" button, but `link-oidc` returns
`authorizationUrl:null` — inconsistent state surfaced to the user.
**Fix:** Centralize the "OIDC configured" resolution (env-or-app_config) in one helper and
use it in all three sites.
### WR-05: `noEchoHook` return value is ignored by `@hono/zod-validator` in one of two styles
**File:** `apps/api/src/routes/localAuth.ts:39-43`, `apps/api/src/routes/admin.ts:74-78`,
`apps/api/src/routes/me.ts:178-182`
**Issue:** The hook signature is `(result, c)` and returns `c.json(...)` only on failure. This
relies on zValidator short-circuiting when the hook returns a Response. That contract holds
for current `@hono/zod-validator`, but the hook does not `return` anything on success and does
not assert `result.success` narrows the type, so a future validator version that requires an
explicit early-return-on-success, or that passes through when the hook returns `undefined`,
would silently start echoing Zod errors (the exact T-19-14 leak this guards against). It is
correct today but fragile and untested for the pass-through case.
**Fix:** Add a focused test asserting that a malformed body never includes `received`/the
submitted value for each hook site (localAuth has one; admin/me password routes should too),
and pin the `@hono/zod-validator` version.
### WR-06: Rate-limit `lockedUntil` is refreshed on every blocked attempt, extending the window indefinitely
**File:** `apps/api/src/routes/localAuth.ts:97-106, 133-137`
**Issue:** On a 429 the code sets `attempt.lockedUntil = Date.now() + RATE_WINDOW_SECS*1000`
again, so an attacker who keeps hitting the endpoint perpetually slides the cooldown forward —
a legitimate user behind the same IP can never get back in even after pausing, because every
attacker request re-arms the window. Coupled with CR-04 this makes the household-wide lock
effectively permanent under sustained traffic.
**Fix:** Do not extend `lockedUntil` on requests that are themselves rejected by the window;
only set it when transitioning from below-threshold to at-threshold.
### WR-07: `parseInt` member/calendar id accepts trailing garbage
**File:** `apps/api/src/routes/admin.ts:215-218, 307-311`
**Issue:** `parseInt(c.req.param('id'), 10)` returns `12` for `"12abc"` and the `isNaN`
guard passes. Not exploitable here (the value is used only in a parameterized `eq`), but it
silently accepts malformed ids and could mask client bugs. The `/members/:id/password` and
`/calendars/:id/shared` routes both use this pattern.
**Fix:** Validate with `Number.isInteger(Number(raw))` or a Zod param schema so `"12abc"` is
rejected with 400.
## Info
### IN-01: `localSession` `maxAge`/expiry parsing has no validation
**File:** `apps/api/src/auth/localSession.ts:31`
**Issue:** `Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400)` yields `NaN` for a malformed
value, producing a JWT with `exp = now + NaN` (→ `NaN`) and a cookie `maxAge: NaN`. Verify
behavior is then "always expired" or "never expires" depending on the JWT lib's NaN handling.
**Fix:** Coerce and validate: `const n = Number(env); SESSION_MAX_AGE = Number.isFinite(n) && n > 0 ? n : 86400;`
### IN-02: Duplicated inline scrypt implementation across three locations
**File:** `apps/api/scripts/reset-admin.ts:45-51`, `.gitea/workflows/ci.yml:307-311`,
`apps/api/src/auth/localCredentials.ts:42-53`
**Issue:** The PHC scrypt hash is copy-pasted in the reset-admin script, the CI seed step,
and the canonical module. If the parameters ever change (the file comment advertises
parameter evolution as a feature), these three drift and produce incompatible hashes. The
duplication is documented as necessary (cannot import compiled TS from a plain script), but
there is no test asserting the three stay in lockstep.
**Fix:** Add a test that imports `hashPassword` and asserts a known input round-trips against
a hash produced by the inlined parameters, so a parameter change fails CI loudly.
### IN-03: `loginAttempts` map is unbounded (memory growth)
**File:** `apps/api/src/routes/localAuth.ts:66`
**Issue:** Entries are only removed on a *successful* login for that IP. Spoofed/rotated
`X-Forwarded-For` values (see CR-04) accumulate map entries with no eviction, a slow memory
leak. Out of strict v1 perf scope, noted because it is reachable by unauthenticated input.
**Fix:** Add periodic eviction of entries whose `lockedUntil` is far in the past.
### IN-04: `me.ts` link-oidc nonce is generated but never persisted/verified
**File:** `apps/api/src/routes/me.ts:312-320` and `apps/api/src/index.ts:60-74`
**Issue:** The signed state carries a `nonce` "to prevent replay," but the `/callback`
handler never records or checks the nonce — it only verifies the JWT signature and reads
`linkUserId`. A captured state JWT is fully replayable within its 10-minute window (the
signature stays valid), so the nonce provides no actual replay protection. This underlies
the takeover concern in BL-03.
**Fix:** Persist issued nonces (or a single-use jti) and reject a state whose nonce was
already consumed, or shorten the window and bind the state to the initiating session cookie.
---
_Reviewed: 2026-06-17_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: deep_
@@ -0,0 +1,189 @@
---
phase: 19-local-auth-no-oidc-mode
reviewed: 2026-06-17T00:00:00Z
depth: deep
files_reviewed: 41
files_reviewed_list:
- apps/api/scripts/reset-admin.ts
- apps/api/src/auth/devBypass.ts
- apps/api/src/auth/linkOidc.ts
- apps/api/src/auth/localAuthMiddleware.ts
- apps/api/src/auth/localCredentials.ts
- apps/api/src/auth/localSession.ts
- apps/api/src/auth/middleware.ts
- apps/api/src/db/migrations/0003_warm_deathstrike.sql
- apps/api/src/db/schema.ts
- apps/api/src/index.ts
- apps/api/src/lib/bootGuards.ts
- apps/api/src/routes/admin.ts
- apps/api/src/routes/authMode.ts
- apps/api/src/routes/localAuth.ts
- apps/api/src/routes/me.ts
- apps/api/tests/auth/localAuthMiddleware.test.ts
- apps/api/tests/auth/localCredentials.test.ts
- apps/api/tests/auth/localSession.test.ts
- apps/api/test/setup.ts
- apps/api/tests/lib/requireAdmin.test.ts
- apps/api/tests/routes/admin.test.ts
- apps/api/tests/routes/authMode.test.ts
- apps/api/tests/routes/lists.test.ts
- apps/api/tests/routes/localAuth.test.ts
- apps/api/tests/routes/me.test.ts
- apps/api/tests/routes/push.test.ts
- apps/api/tests/routes/setup.test.ts
- apps/pwa/e2e/global-setup.ts
- apps/pwa/e2e/login.spec.ts
- apps/pwa/src/api/client.ts
- apps/pwa/src/App.test.tsx
- apps/pwa/src/App.tsx
- apps/pwa/src/components/BrandSlot.tsx
- apps/pwa/src/components/InstructionSheet.test.tsx
- apps/pwa/src/components/SettingsSheet.tsx
- apps/pwa/src/routes/AdminPage.tsx
- apps/pwa/src/routes/LoginPage.tsx
- apps/pwa/src/styles/tokens.css
- .gitea/workflows/ci.yml
- scripts/generate-secrets.mjs
findings:
critical: 0
blocker: 0
warning: 0
info: 2
total: 2
status: clean
---
# Phase 19: Code Review Report (Iteration-2 Re-Review)
**Reviewed:** 2026-06-17
**Depth:** deep
**Files Reviewed:** 41
**Status:** clean
## Summary
This is the iteration-2 re-review confirming the fixer correctly applied all 15
findings from the prior review (4 critical, 4 blocker, 7 warning, 4 info). I read
every listed source file, traced the high-judgment fixes through their full call
chains across module boundaries, ran `tsc --noEmit` on both `@familysync/api` and
`@familysync/pwa` (both clean, exit 0), and confirmed the CI seed parameters and
inlined scrypt copies all agree. The runtime test suite could not execute in this
sandbox (global-setup requires a live MariaDB with root grants — `ER_ACCESS_DENIED`),
so test verification is static: the relevant assertions were read directly and the
production source typechecks against them.
**Verdict: all 15 prior findings are correctly and completely resolved. No
regressions, no re-occurrence at other call sites, and no new critical/blocker/warning
issues.** Two low-severity Info observations are recorded below; neither blocks ship.
### Confirmation of high-judgment fixes (verified by tracing, not just diff)
- **CR-01/02/03 (client↔server contracts):**
- CR-01 shared-calendar: `admin.ts` `PUT /calendars/:id/shared` now does an
existence check inside a transaction (404 on missing id), so a stale id can no
longer silently clear the shared lane. Client `setSharedCalendar` agrees.
- CR-02 create-member: client `fetchCreateMember` maps `password → initialPassword`
(client.ts:199-204) and the server `createMemberSchema` requires `initialPassword`
(admin.ts:140); 409 maps to the `'conflict'` sentinel the AdminPage `onError`
expects (AdminPage.tsx:229). Server returns 409 on `ER_DUP_ENTRY` (admin.ts:202).
Both ends agree; admin.test.ts Tests 12 cover the round-trip + 409 rollback.
- CR-03 wrong-current-password: server returns **403** (`me.ts:267`), client checks
403 **before** the 401 session-expiry branch (client.ts:166) and maps it to
`'wrong-current'`, which `ChangePasswordSheet.onError` surfaces without dropping
the session (SettingsSheet.tsx:569). me.test.ts Test 2 asserts 403 + update-not-called.
- **CR-04 / WR-06 / IN-03 (login limiter):** the limiter key is the validated,
trimmed username (localAuth.ts:138) — no `x-forwarded-for`/IP residue remains
anywhere in `routes/localAuth.ts` or `src/auth/*` (grep clean). 423 lockout
auto-expires after `LOCKOUT_TTL_MS` (15 min, localAuth.ts:149-155); admin reset
calls `resetLoginAttempts(credRow.username)` for an instant unlock (admin.ts:262).
WR-06: the 429 branch deliberately does **not** re-arm `lockedUntil`
(localAuth.ts:164-169), so a rejected attempt can no longer slide the window
forward; the window is only re-anchored by a genuine failure in the failure path.
IN-03: `evictStaleLoginAttempts` only drops entries that are both window-expired
and lockout-TTL-expired (localAuth.ts:113-121) — behaviourally identical to natural
expiry, so eviction never weakens the brute-force defense. Tests 4, 5, 5b cover it.
- **BL-01 (dev-bypass secret floor):** `devSessionCookieMiddleware` applies the same
`>= 32` length floor before minting a real DEV_USER session JWT (devBypass.ts:144-151)
and warns on the well-known placeholder. The CI placeholder
`dev-secret-change-me-0000000000000000` is 36 chars, so it passes the floor and only
triggers the warning — intended.
- **BL-02 (logout cookie Secure match):** `clearLocalSessionCookie` now mirrors the
issue-time `secure: NODE_ENV==='production'` (localSession.ts:114), so the
delete-cookie is accepted over plain HTTP and the user is actually logged out on
non-HTTPS deployments. `sameSite`/`path`/`httpOnly` also match issue-time.
- **BL-03 (OIDC-link takeover guard):** `/callback` (index.ts:84-123) now enforces
three gates before binding: (1) single-use nonce via `consumeLinkNonce`; (2) the
initiating local session must still match `linkUserId`
(`verifyLocalSessionCookie(c) === linkUserId`); (3) `iss`/`sub` from `getAuth` must
be non-empty. `/callback` is registered outside `/api/*` so `localAuthMiddleware`
does not run, but the `local-session` cookie (path `/`) is still present and read
directly — the cross-check is effective. All three gates fail-closed to
`/?error=oidc-link-conflict`. The preflight conflict check in `linkOidcToUser`
(linkOidc.ts:65-74) remains the backstop.
- **BL-04 (no fabricated identity sentinels):** `localAuthMiddleware` passes through
the DB `oidcIss`/`oidcSub` as `?? null` (localAuthMiddleware.ts:91-97); `ContextUser`
widens both to `string | null` (devBypass.ts:56-62). No `'local'`/`String(id)`
sentinels are written, so a local user cannot collide in the `uniq_oidc_identity`
domain. localAuthMiddleware.test.ts Test 1c pins null.
- **WR-03 (async scrypt):** `hashPassword`/`verifyPassword` are async over the libuv
threadpool (localCredentials.ts:33-45, 65, 101) at every call site — login
(dummy-hash promise awaited, localAuth.ts:128/199), create-member (hash before the
transaction, admin.ts:159), admin reset, and self-change. The always-run dummy-hash
path preserves the timing-oracle defense (localAuth.ts:197-199). reset-admin.ts
legitimately keeps `scryptSync` (standalone CLI, no event loop to starve).
- **IN-04 (single-use nonce):** `linkNonceStore.ts` records the nonce at issue
(me.ts:333) and `consumeLinkNonce` returns true exactly once per unexpired nonce,
with opportunistic sweep keeping the map bounded; `/callback` consumes before binding.
### Cross-cutting checks
- Inlined PHC scrypt parameters agree across all three copies: canonical module
(N=16384,r=8,p=1,keylen=32), `reset-admin.ts`, and `.gitea/workflows/ci.yml`
(seed step lines 309-310). localCredentials.test.ts Test 6 pins this round-trip.
- `oidcConfig.ts` (`resolveOidcConfig` + `discoverAuthorizationEndpoint`) is the single
env-OR-app_config source now shared by `/api/auth/mode`, the fallback middleware, and
`me.ts` link-oidc, closing the WR-04 divergence where link-oidc could return
`authorizationUrl:null` while `/mode` reported `oidcEnabled:true`.
- `LOCAL_SESSION_EXPIRES` NaN-coercion guard (localSession.ts:35-38) and boot guards
(`assertLocalSessionSecretSet` >= 32, exempt under bypass) are correct and wired
first in the `isMainModule()` block (index.ts:262-265).
- Migration `0003_warm_deathstrike.sql` matches the `localCredentials` Drizzle schema
(unique on user_id and username, FK cascade, varchar(256) hash).
## Info
### IN-01: `fetchLinkOidc` declared return type is narrower than the cast it returns
**File:** `apps/pwa/src/api/client.ts:250,261`
**Issue:** The function signature declares `Promise<{ authorizationUrl: string | null }>`
but the body returns `res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>`.
The widening cast is harmless (the only consumer, `SettingsSheet.tsx` `LinkOidcSheet`,
reads `data.authorizationUrl` only and never `signedState`), and `tsc` is clean. It is a
minor contract-doc inconsistency: the declared type drops a field the server actually
sends. Not a defect — recorded only so the next editor does not "fix" the cast and
accidentally start relying on the absent field.
**Fix:** Align the declared return type with the cast for clarity:
```ts
export async function fetchLinkOidc(): Promise<{ signedState: string; authorizationUrl: string | null }> {
```
### IN-02: 429 rate-limit branch short-circuits before the dummy-hash work
**File:** `apps/api/src/routes/localAuth.ts:162-177`
**Issue:** Once an identity is in the 429 window, the handler returns before the DB
lookup and the always-run `verifyPassword`/dummy-hash. This is a deliberate and correct
DoS/throughput tradeoff (a rate-limited identity should not pay scrypt cost), and it does
NOT leak username existence because the 429 path is reached identically for valid and
invalid usernames (the limiter is keyed on the submitted username regardless of whether a
credential row exists). The timing-oracle defense is only required on the *credential-check*
path, which still always runs the dummy hash. Recorded for completeness; no change needed.
**Fix:** None required. If a future reviewer wants strict constant-time even under
rate-limiting, the dummy-hash could be awaited before the 429 return — but that would
re-introduce the exact event-loop-starvation cost WR-03 removed, so leaving it as-is is
the right call.
---
_Reviewed: 2026-06-17_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: deep_
@@ -0,0 +1,113 @@
---
phase: 19-local-auth-no-oidc-mode
audited: 2026-06-17
status: secured
asvs_level: 1
block_on: high
register_authored_at_plan_time: true
threats_total: 28
threats_closed: 28
threats_open: 0
threats_accepted: 2
supply_chain_checks: 2
---
# Phase 19 — Local Auth (No-OIDC Mode): Security Audit
**Audited:** 2026-06-17
**ASVS Level:** 1
**block_on:** high
**Compared against:** main..HEAD
**Audit type:** Retroactive threat-mitigation verification (declared register, no net-new scan)
**Branch:** `gsd/phase-19-local-auth-no-oidc-mode`
**Verdict:** SECURED — 28/28 threats closed (26 mitigate + 2 accept), 0 open, 0 unregistered flags
Implementation files were treated as READ-ONLY. No implementation file was modified by this audit.
---
## Threat Verification
| Threat ID | Category | Disposition | Status | Evidence (file:line) |
|-----------|----------|-------------|--------|----------------------|
| T-19-01 | Information Disclosure | mitigate | CLOSED | `apps/api/src/auth/localCredentials.ts:66` (16-byte randomBytes salt), `:114` timingSafeEqual, `:115-118` verify never throws; no password logged |
| T-19-02 | Spoofing | mitigate | CLOSED | `apps/api/src/auth/localSession.ts:58` Jwt.sign HS256 w/ LOCAL_SESSION_SECRET; `:90-94` verify returns null on tamper/expiry |
| T-19-03 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/lib/bootGuards.ts:53-66` assertLocalSessionSecretSet (exit 1 when unset/<32, exempt in bypass); wired `apps/api/src/index.ts:265` |
| T-19-04 | Tampering | mitigate | CLOSED | `.dockerignore:7` `apps/api/scripts/`, `:21` `apps/api/tests/`, `:23` `apps/pwa/e2e/` |
| T-19-SC(01) | Tampering | mitigate | CLOSED | `git diff main...HEAD` shows zero dependency-line changes in any package.json |
| T-19-05 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/routes/admin.ts:47` `adminRouter.use('*', requireAdmin)` is first statement; test asserts 403 |
| T-19-06 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/admin.ts:75-79` noEchoHook on create/reset; `:148,:239` no-log comments honored |
| T-19-07 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/routes/me.ts:240` resolveUserId from session, `:260-268` verifyPassword(current) before update |
| T-19-08 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/auth/linkOidc.ts:65-74` preflight conflict before any write; backstop `uniq_oidc_identity` in `migrations/0000_baseline.sql` |
| T-19-09 | Tampering | mitigate | CLOSED | `apps/api/src/routes/me.ts:321-333` per-request nonce in signed HS256 state; `linkNonceStore.ts:41-48` single-use consume |
| T-19-10 | Tampering | mitigate | CLOSED | `apps/api/src/routes/admin.ts:165-185` db.transaction wraps users + local_credentials; `:202` 409 rolls back |
| T-19-11 | Elevation of Privilege | mitigate | CLOSED (deviation noted) | `apps/api/src/routes/localAuth.ts:162-176` 5→429 / 10→423; `:96-98`/admin.ts:262 admin reset clears. Keyed on **username** not IP (CR-04, documented) |
| T-19-12 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:128` dummyHashPromise, `:197-199` verifyPassword always run, `:210` identical 401 body |
| T-19-13 | Spoofing | mitigate | CLOSED | `apps/api/src/index.ts:191-197` OIDC guard wrapped to skip when `c.get('user')` set; `localAuthMiddleware.ts:45-101` populates it |
| T-19-14 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:47-51` noEchoHook on login route |
| T-19-15 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/index.ts:84-133` callback: nonce consume + BL-03 session-match + empty-iss/sub guard + linkOidcToUser conflict (409) |
| T-19-16 | Information Disclosure | accept→mitigate | CLOSED | D-06 applied; Phase-19-touched PWA files render no "Authelia" (see T-19-21) |
| T-19-17 | Spoofing | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:216` fresh issueLocalSessionCookie every success; `localSession.ts:50-55` exp claim bounds lifetime |
| T-19-18 | Information Disclosure | mitigate | CLOSED | `apps/pwa/src/routes/LoginPage.tsx` + `SettingsSheet.tsx` password in useState only; no localStorage/sessionStorage write for password fields |
| T-19-19 | Information Disclosure | mitigate | CLOSED | `apps/pwa/src/routes/LoginPage.tsx:291` single "Incorrect username or password." — no field-level blame |
| T-19-20 | Tampering | mitigate | CLOSED | No `dangerouslySetInnerHTML` in any PWA src (grep across `apps/pwa/src/` = 0 usages; only prohibition comments) |
| T-19-21 | Information Disclosure | mitigate | CLOSED | `grep -ci authelia` == 0 in LoginPage/AdminPage/BrandSlot; SettingsSheet's 1 hit is a copywriting-rule comment (line 828), not rendered |
| T-19-22 | Elevation of Privilege | accept | CLOSED | Documented accepted risk (below); server boundary verified at `admin.ts:47` requireAdmin |
| T-19-23 | Elevation of Privilege | mitigate | CLOSED | Seed only in `apps/pwa/e2e/global-setup.ts` (.dockerignore'd); zero seed in `migrations/` or `index.ts` |
| T-19-24 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/auth/devBypass.ts:87,:122` NODE_ENV==='production' is FIRST check; `bootGuards.ts:26-34` assertNotDevBypassInProduction |
| T-19-25 | Tampering | mitigate | CLOSED | `.dockerignore:7` excludes `apps/api/scripts/`; `reset-admin.ts:26-32` NODE_ENV=production throw is first executable statement |
| T-19-26 | Information Disclosure | mitigate | CLOSED | `reset-admin.ts` logs only user id / status; no console statement emits the password value; `--dry-run` validates without writing (`:129-133`) |
| T-19-SC(05) | Tampering | mitigate | CLOSED | Zero new packages (same as T-19-SC(01)) |
---
## Deviation Note — T-19-11 (rate-limit key)
The register declares "per-IP rate-limit". The implementation (`localAuth.ts`, CR-04) keys the
limiter on the **submitted username**, not the client IP. This is a deliberate, documented
deviation: in this Pangolin-tunnel deployment all household traffic shares one X-Forwarded-For
first hop (so IP-keying let one actor lock out every member) and X-Forwarded-For is spoofable.
The declared security property — brute-force resistance via 5→429 and 10→423 with admin-reset
recovery and a self-healing TTL — is fully present. Treated as CLOSED. The register wording is
stale relative to the shipped (stronger-for-this-topology) mechanism.
---
## Accepted Risks Log
| Threat ID | Risk | Rationale |
|-----------|------|-----------|
| T-19-22 | Client `isAdmin` / `hasLocalCredential` flags are UX-only and trivially editable in the browser. | Accepted: these flags only gate PWA nav/affordances. The real authorization boundary is server-side `requireAdmin` on every `/api/admin/*` request (`admin.ts:47`) and session-derived `resolveUserId` on `/api/me/*`. Client gating is never the security boundary. Documented prior decision. |
| T-19-16 | "Authelia" provider name could leak infrastructure detail in UI/comments. | Low-severity hygiene (accept→mitigate). D-06 applied across Phase-19-touched surfaces; remaining occurrences are in the out-of-scope Phase 12 `SetupPage.tsx` wizard and in source comments, not on the local-auth surfaces this phase introduced. |
---
## Unregistered Flags
The two `## Threat Flags` entries in `19-04-SUMMARY.md`
(`threat_flag: credential-in-controlled-state` for `LoginPage.tsx` and `SettingsSheet.tsx`)
both map to existing register threats **T-19-18** (password in client storage). Informational
only — no unregistered attack surface. No WARNING raised.
---
## Out-of-Scope Observation (non-blocking, not a Phase 19 gap)
`apps/pwa/src/routes/SetupPage.tsx` (lines 379, 498, 627, 645, 695) renders the literal string
"Authelia" in the first-run setup wizard. This file was **not** modified in Phase 19
(`git diff main...HEAD` = no changes) — it is the pre-existing Phase 12 wizard, outside the
T-19-21 mitigation scope ("any PWA source touched by this plan"). It does not affect the local-auth
login/admin/settings surfaces. Flagged here for a future D-06 sweep of the setup wizard; it is
**not** an open Phase 19 threat.
---
## Security Audit 2026-06-17
| Metric | Count |
|--------|-------|
| Threats found | 28 |
| Closed | 28 |
| Open | 0 |
| Accepted | 2 |
| Supply-chain checks | 2 |
@@ -0,0 +1,131 @@
---
phase: 19-local-auth-no-oidc-mode
created: 2026-06-17T18:25:00Z
updated: 2026-06-17T21:20:00Z
status: complete
source: verification + plan-checkpoints
gaps: []
findings_routed_to_phase_17: [F-01, F-02, F-03, F-04]
---
## Live UAT Session (resumed 2026-06-17, post code-review-fix)
**Stack configured for local-auth / no-OIDC mode** (Phase 19's canonical deployment):
- API rebuilt from the phase-19 branch (all 18 code-review fixes live; verified `initialPassword` + 403 present in running `dist`).
- `DEV_AUTH_BYPASS=false` and `OIDC_ISSUER=""` via throwaway `docker-compose.uat.yml` override
(tracked files untouched; restore the normal bypass stack after UAT).
- Seeded local admin: **username `uatadmin` / password `UATtest1234!`** (user id 2, is_admin=1).
- PWA on host Vite at **http://localhost:5173**.
**Automated API smoke (pre-checks):**
- ✅ `POST /api/auth/local/login` (uatadmin) → 200 + `local-session` cookie.
- ✅ Wrong password → 401 `{"error":"Invalid credentials"}` (generic, no field blame).
- ✅ Authenticated `GET /api/me``{id:2, isAdmin:true, hasLocalCredential:true}`.
- ⚠️ Unauth `GET /api/me`**500 `Invalid session`** (not 401) in no-OIDC mode: the OIDC guard
is mounted whenever bypass is off and errors trying to redirect with a blank issuer. **Cosmetic**
— PWA gates on `meQuery.isError && localEnabled` (App.tsx:213) so it still redirects to `/login`.
Candidate follow-up: short-circuit the OIDC guard to a clean 401 when no issuer is configured.
**Note on Item 1 OIDC button:** the "OIDC button appears when oidcEnabled" sub-check can't be
exercised on this box (no reachable Authelia → blanked). Covered at unit/e2e level. This session
verifies the no-OIDC local-auth surface, which is the phase's primary deliverable.
# Phase 19: Local Auth (No-OIDC Mode) — User Acceptance Tests
All automated verification passed (API 446/446, PWA 266/266, e2e desktop 42 passed
/ 3 skipped, typecheck clean; VERIFICATION.md status: passed, 21/21 must-haves).
The single blocker found during verification (admin reset-password URL mismatch) was
fixed and confirmed live (commit `53da4be`).
The items below are the remaining **human / live-stack** checks that cannot be driven
from the dev `DEV_AUTH_BYPASS` harness or a headless box. They do not block automated
goal achievement but should be confirmed before shipping.
## UAT Items
### 1. Login page visual + flow (real, non-bypass stack)
- **Test:** Run the stack with OIDC/Authelia configured and `DEV_AUTH_BYPASS` **off**.
Visit the app unauthenticated → confirm redirect to `/login`. Verify the brand slot
("FS" mark, "FamilySync", "Family calendar & lists"), the form (username auto-focus,
password show/hide), wrong-creds single error ("Incorrect username or password."),
correct-creds navigation into the app, and the OIDC button only when `oidcEnabled`.
- **Expected:** All surfaces per 19-UI-SPEC; no "Authelia" text anywhere; error copy
never blames a specific field.
- **Why human:** The unauth login-gate redirect is unreachable under the bypass-only
harness (covered at unit level in `App.test.tsx`); the full visual flow needs a real
browser against a non-bypass deployment. (Desktop/Chromium portions are already e2e-
covered via `login.spec.ts`.)
- **result: pass** (2026-06-17, live no-OIDC stack, operator-confirmed — all steps:
redirect to /login, brand slot, username autofocus, password show/hide, generic
wrong-creds error, successful login into the app). Setup-gate precedence confirmed:
`setupComplete===true` so /login is reachable (App.tsx checks `/setup` redirect first).
### 2. Admin Reset-password sheet (live, end-to-end)
- **Test:** As an admin, open Admin → Local Accounts → Reset password for a member;
submit a new password; confirm the member can then log in with it.
- **Expected:** `POST /api/admin/members/:id/password` returns 200; new password works.
- **Why human:** Needs a live stack with an admin session and a real local member.
(URL fix already verified: route reachable, 404 eliminated.)
- **result: pass (functional) — with UX gap.** Operator created `testmember` + reset its
password via the admin UI. Verified at DB/login level: member exists (user 3, non-admin);
login with the **reset** pw (`MemberPass456!`) → 200; login with the **original**
(`MemberPass123!`) → 401. So **CR-02 create-member + reset both work and persist.**
BUT neither action showed a success confirmation (see Finding F-01).
### 3. Settings Change-password sheet (live, local user)
- **Test:** As a local user, Settings → Account → Change password; verify wrong current
password shows "Current password is incorrect.", correct current updates, and the new
password works on next login.
- **Expected:** Current-password verification enforced; update succeeds; re-login works.
- **Why human:** Needs a live stack with a local-user session.
- **result: pass** (2026-06-17, verified functionally via API on the live no-OIDC stack as
`testmember`). CR-03 confirmed: wrong current password → **403 (not 401)** and the session
stays valid (`/me`→200, no force-logout); correct current → 200; new password logs in (200),
old password rejected (401).
### 4. Rate-limit / lockout test flakiness (harden)
- **Test:** Run `apps/api/tests/routes/localAuth.test.ts` Test 5 (10 failures → 423)
~10 times; characterize the intermittent failure the orchestrator observed (1 failure
across 3 runs, then stable).
- **Expected:** Stable pass; if timing-dependent, harden the in-memory rate-limit test
(e.g. fake timers / deterministic clock).
- **Why human:** Timing-dependent in-memory test; needs repeated runs to characterize.
- **result: resolved-by-fix.** The code-review fix (CR-04/WR-06/IN-03) rewrote the limiter and
made the lockout test **deterministic** — it back-dates `lockedAt` instead of using wall-clock
timers (`localAuth.test.ts:280`), structurally removing the timing flakiness. The fixer ran the
full API suite **452/452** (incl. Test 5/5b). Couldn't be re-run in this session's shell (no
test-DB root creds — `ER_ACCESS_DENIED`); confirm via CI or `set -a; . ./.env; set +a;
DB_HOST=127.0.0.1 pnpm --filter @familysync/api test`.
### 5. CI harness green + D-15 image boundary (push, outward-facing)
- **Test:** Push the branch and open the PR so Gitea CI runs. Confirm the `harness` job
(iphone + pixel + desktop, incl. `login.spec.ts`) is green, the `api` job is green,
and the published-image hygiene checks (no `apps/api/scripts/` or `apps/pwa/e2e/` in
the prod image) pass.
- **Expected:** CI all green; no dev artifact in the shipped image (D-14/D-15).
- **Why human:** Pushing to the remote / triggering CI is an outward-facing action the
operator owns. (Plan 19-05's blocking checkpoint.)
- **result: deferred to `/gsd-ship`** (operator decision 2026-06-17). The push/PR/CI run +
image-hygiene gate is owned by the ship workflow, not this UAT session.
## Live Session Findings (2026-06-17)
Surfaced by the operator during Test 2. **Operator decision (2026-06-17): route ALL four UI
findings — including the logout button — to Phase 17 (UI Optimization & Polish), which has not yet
kicked off. None block Phase 19**, whose auth machinery is functionally complete and verified.
All four added to `17-CONTEXT.md` (Phase-17 branch):
- **F-02 — No logout button in the UI (functional-UI).** Logout is fully plumbed — endpoint
`POST/GET /api/auth/local/logout` returns 200 and clears the cookie (BL-02 verified live), and
`fetchLocalLogout()` exists in `apps/pwa/src/api/client.ts:127` — but **no component calls it**
(grep of `apps/pwa/src` finds zero logout buttons/handlers). Phase 17 wires a logout control to
the existing client function (no backend work). Per operator: a UI concern, not a Phase-19 blocker.
- **F-01 — Admin create/reset give no success feedback.** Both succeed (verified at DB/login level)
but show no success toast/confirmation. Add success feedback to the admin local-account flows.
- **F-03 — Dialogs/popups render at bottom-center instead of properly centered (cosmetic).** Fits
Phase 17's fixed-chrome/sheet-positioning sweep.
- **F-04 — Admin UI navigation is clunky and needs a rework.** UX-polish item for Phase 17.
**Status:** Phase 19 UAT **complete (functional)** — Tests 13 pass (live), Test 4 resolved-by-fix,
Test 5 deferred to `/gsd-ship`. No Phase-19 blockers. F-01F-04 carried to Phase 17.
@@ -0,0 +1,687 @@
---
phase: 19
slug: local-auth-no-oidc-mode
status: approved
shadcn_initialized: false
preset: none
created: 2026-06-16
approved: 2026-06-16
---
# Phase 19 — UI Design Contract: Local Auth (No-OIDC Mode)
> Visual and interaction contract for the local login screen, login-method chooser,
> and admin-surface additions for local account management.
> Generated by gsd-ui-researcher. Verified by gsd-ui-checker.
---
## Context & Audience
This phase introduces the **first real login UI** in the FamilySync PWA. Today the PWA boots
straight into the authed app (OIDC redirect) or via dev-bypass — there is no login form. Phase 19
builds:
1. A **local login screen** (username + password) — full-viewport, pre-auth, the first surface an
unauthenticated user sees. This is the highest-value branding surface in the app.
2. A **login-method chooser** rendered when OIDC is also configured (D-02) — local form OR
"Login with OIDC" (generic, never says "Authelia" — D-06).
3. Admin-surface additions (in-app shell `/admin` route, extending Phase 10): local member
creation + initial password; self password-change; admin password-reset; per-user
"Link OIDC identity" action.
The login screen is **end-user-facing**, not operator-facing. The non-technical Apple household
member is the primary user — UX must be slick and low-friction (CLAUDE.md hard constraint).
The login screen is a **standalone full-page route**, most closely analogous to the Phase 12
setup wizard (`/setup`). It renders none of the AppNav / BottomTabBar / SetupBanner chrome.
All design tokens are inherited from `apps/pwa/src/styles/tokens.css`. No new tokens are
introduced.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | none (existing CSS custom properties) |
| Preset | not applicable |
| Component library | none (hand-rolled inline `React.CSSProperties`, project convention) |
| Icon library | lucide-react (already installed — `Lock`, `User`, `Eye`, `EyeOff`, `Loader2`, `AlertCircle`, `LogIn`, `ShieldCheck`) |
| Font | system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif (var(--font-family-base)) |
Source: `apps/pwa/src/styles/tokens.css` — pre-populated from existing codebase scan.
Pattern baseline: `apps/pwa/src/routes/SetupPage.tsx` (full-viewport standalone page),
`apps/pwa/src/routes/AdminPage.tsx` (admin-surface additions).
---
## Spacing Scale
Uses the existing 4px-based scale. No new tokens.
| Token | Value | Usage in this phase |
|-------|-------|---------------------|
| --space-1 | 4px | Icon gaps, label-to-input gap, helper-text margin-top |
| --space-2 | 8px | Compact element spacing, password show/hide button gap, form field gap within a group |
| --space-3 | 12px | Input padding (vertical), row gaps |
| --space-4 | 16px | Between form fields, button horizontal padding, card horizontal padding |
| --space-6 | 24px | Card padding, section gap, brand slot bottom margin |
| --space-8 | 32px | Between the brand slot and the login card, between major sections |
| --space-12 | 48px | Page top/bottom padding (matches SetupPage pattern) |
Exceptions:
- Login card max-width: 400px (narrower than wizard 540px; a two-field login needs less width).
- All interactive elements: `minHeight: 44px; minWidth: 44px` (WCAG 2.5.5 Touch Target).
- Password show/hide toggle: 44px tap target embedded inside the input row (right-side icon button).
- Brand logo slot: reserved 48px height (aspect-ratio box 1:1); see Brand Slot section.
---
## Typography
All values from `tokens.css`. No new sizes or weights.
| Role | Size | Weight | Line Height | Variable |
|------|------|--------|-------------|----------|
| Body | 15px | 400 | 1.5 | var(--text-body-size) / var(--text-body-weight) / var(--text-body-line-height) |
| Label | 13px | 400 | 1.4 | var(--text-label-size) / var(--text-label-weight) / var(--text-label-line-height) |
| Heading | 18px | 600 | 1.25 | var(--text-heading-size) / var(--text-heading-weight) / var(--text-heading-line-height) |
| Display | 24px | 600 | 1.2 | var(--text-display-size) / var(--text-display-weight) / var(--text-display-line-height) |
Usage in this phase:
- App name "FamilySync" in brand slot: Display (24px/600/1.2) — `var(--color-text-primary)`
- App tagline "Family calendar & lists" in brand slot: Body (15px/400/1.5) — `var(--color-text-secondary)`
- Login card heading ("Sign in"): Heading (18px/600/1.25) — `var(--color-text-primary)`
- Field labels, helper text, divider label ("or"): Label (13px/400/1.4)
- Field labels use weight 600, helper text uses weight 400
- Section labels in admin additions ("LOCAL ACCOUNTS", "OIDC LINK"):
13px/600/uppercase/0.06em letter-spacing (AdminPage `sectionLabelStyle` pattern)
- Error messages: Body (15px/400/1.5) — `var(--color-destructive)`
- Primary CTA label: Label (13px/600)
---
## Color
All values from `tokens.css`. No new hex values.
| Role | Value | Variable | Usage |
|------|-------|----------|-------|
| Dominant (60%) | #ffffff | var(--color-surface) | Page background, card background, input background |
| Secondary (30%) | #f7f7f8 | var(--color-surface-dim) | Divider area between form methods, info banners, rate-limit notice background |
| Accent (10%) | #4a90d9 | var(--color-member-0) | Primary CTA button ("Sign in"), spinner, focus ring, "Login with OIDC" button border |
| Destructive | #dc2626 | var(--color-destructive) | Error message text, error-state input border, lockout notice, rate-limit warning |
Accent reserved for:
- "Sign in" button (filled background)
- "Login with OIDC" button (outlined, `1px solid var(--color-member-0)`, accent text)
- `Loader2` spinner during login submit
- Focus ring on all inputs and buttons (`var(--color-focus-ring)`, 2px outline, 2px offset)
- Text links (e.g., "Forgot password? Ask your admin.")
Additional semantic colors (not new — already in tokens.css):
- `var(--color-border)` #e2e4e9 — card border, input border (default), divider line
- `var(--color-border-subtle)` #eceef2 — section dividers in admin additions
- `var(--color-text-primary)` #111318 — headings, field values, app name
- `var(--color-text-secondary)` #6b7280 — descriptions, helper text, tagline, divider label
- `var(--color-text-muted)` #9ca3af — placeholder text, inactive admin rows
- `var(--color-overlay)` rgba(0,0,0,0.32) — modal backdrop for confirmation dialogs
---
## Brand Slot — Phase 17 Readiness
The login screen is the **highest-value branding surface** in the app — full-viewport,
unauthenticated, the first thing any user sees. A reserved brand slot sits above the login
card and is designed as a **theming/asset seam**: Phase 19 ships a minimal shippable
placeholder; Phase 17 drops in real assets without restructuring the layout.
### Brand slot structure (Phase 19 ships this)
```
[brand-slot]
[--brand-logo placeholder] — 48×48px box, aspect-ratio 1/1, reserved intrinsic dimensions
Placeholder: a 48px circle, background var(--color-member-0),
initials "FS" in white Display (24px/600).
No broken image ref. No layout shift when replaced.
[--brand-app-name] — "FamilySync" text (Display 24px/600, var(--color-text-primary))
Rendered from a CSS custom property / named slot; not hardcoded.
[--brand-tagline] — "Family calendar & lists" (Body 15px/400, var(--color-text-secondary))
```
Layout:
- Centered column, `textAlign: center`
- Logo mark: `width: 48px; height: 48px; borderRadius: 50%; margin: 0 auto var(--space-2)`
- App name: `marginTop: var(--space-2); marginBottom: var(--space-1)`
- Tagline: `marginBottom: var(--space-8)` (32px gap before the login card)
### Asset seam tokens
Define in `tokens.css` (Phase 19 sets placeholder defaults; Phase 17 overrides):
```css
:root {
/* Phase 17 replaces these values — never the component structure */
--brand-logo-bg: var(--color-member-0); /* placeholder circle background */
--brand-logo-text: #ffffff; /* placeholder initials color */
--brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */
--brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */
--brand-app-name: 'FamilySync'; /* not used as CSS content — drives doc only */
}
```
The logo slot renders via a React component `<BrandSlot />` in the login page — not inline JSX.
This isolates the seam: Phase 17 replaces `<BrandSlot>` internals (swap placeholder div for
`<img src="...">`) without touching `<LoginPage>` layout.
### Phase 17 readiness subsection
**Phase 17 contract — what Phase 17 must honor:**
| Slot | Asset Phase 17 provides | Constraints Phase 17 must respect |
|------|-------------------------|-----------------------------------|
| Logo mark | SVG or PNG, favicon-derived | Must fit in 48×48px box at 1x; provide 2x/3x for retina. `alt=""` (decorative — app name already in text) |
| App name text | Same string "FamilySync" or updated display name | Rendered as text, not image — screen readers read it |
| Tagline | Optional; may be removed | If removed, set `--brand-tagline-display: none` — no layout reflow |
| Background hero | Optional — if added, must go behind the entire page, not just the brand slot | `var(--brand-bg): none` default; Phase 17 sets to a CSS gradient or subtle image |
| Aspect-ratio box | Phase 17 MUST keep the 48px height reserve | Prevents layout shift; use `aspect-ratio: 1/1; width: var(--brand-logo-size)` |
Phase 17 asset swap is: update `<BrandSlot>` internals (image src) + set CSS custom property
values. No changes to `<LoginPage>` layout, spacing, or card structure are permitted by this
contract.
---
## Surface Architecture
### Surface 1 — Login Page Shell (`/login`)
A standalone full-page route. No AppNav, no BottomTabBar, no SetupBanner, no
PermissionDeniedBanner at any breakpoint.
- Background: `var(--color-surface)` (#ffffff)
- Layout: `minHeight: 100dvh; display: flex; flexDirection: column; alignItems: center; justifyContent: flex-start`
- Content column: `maxWidth: 400px; width: 100%; margin: 0 auto; padding: var(--space-12) var(--space-6)`
Routing gate:
1. On app load, `GET /api/auth/mode` (pre-auth endpoint — no session required) returns
`{ localEnabled: true, oidcEnabled: boolean }`.
2. If the user already has a valid session (local JWT cookie or OIDC session), they are
redirected to `/calendar` before the login page renders.
3. The `/login` route renders the `<LoginPage>` (full-viewport, no shell).
4. After successful login, navigate to `/` (which redirects to `/calendar`).
### Surface 2 — Brand Slot
Sits at the top of the content column, above the login card. Detailed in "Brand Slot" section.
Not inside the login card — floats above it in the flow.
### Surface 3 — Login Card
The primary login interaction area.
- Background: `var(--color-surface)` (#ffffff)
- Border: `1px solid var(--color-border)` (#e2e4e9)
- Border-radius: 8px
- Padding: `var(--space-6)` (24px) all sides
- Box-shadow: `0 1px 4px rgba(0,0,0,0.06)` (matches SetupPage cardStyle)
- Card heading "Sign in": Heading (18px/600/1.25), `var(--color-text-primary)`,
`marginBottom: var(--space-6)` (24px)
### Surface 4 — Username Field
- Label: "Username" — 13px/600, `var(--color-text-primary)`, `marginBottom: var(--space-1)` (4px)
- Input: `type="text"`, `autoComplete="username"`, `id="login-username"`
- Style: full-width, `padding: var(--space-3) var(--space-4)`, `border: 1px solid var(--color-border)`,
`borderRadius: var(--space-1)`, 15px/400, `var(--color-text-primary)`, `background: var(--color-surface)`
- Error state border: `1px solid var(--color-destructive)`
- `aria-describedby="login-error"` when error state is active
- `spellCheck={false}`, `autoCapitalize="none"`, `autoCorrect="off"`
### Surface 5 — Password Field with Show/Hide Toggle
- Label: "Password" — 13px/600, `var(--color-text-primary)`, `marginBottom: var(--space-1)` (4px)
- Input wrapper: `position: relative`
- Input: `type="password"` (toggled to `"text"` by show/hide button), `autoComplete="current-password"`,
`id="login-password"`, `paddingRight: 44px` (space for toggle)
- Error state border: `1px solid var(--color-destructive)`
- Show/hide toggle button: `position: absolute; right: 0; top: 0; height: 100%; minWidth: 44px;
background: none; border: none; cursor: pointer; color: var(--color-text-muted)` —
renders lucide `Eye` (show) or `EyeOff` (hide), 16px, `aria-label="Show password"` /
`"Hide password"`, `aria-pressed` reflects current state
- Field container `marginBottom: var(--space-4)` (16px)
### Surface 6 — Form Error / Lockout Banner
Shown below the password field, above the submit button. Uses `role="status"` + `aria-live="polite"`.
**Error states in order of severity:**
1. **Invalid credentials** (incorrect username or password):
- Icon: `AlertCircle` (16px, `var(--color-destructive)`) inline
- Copy: "Incorrect username or password." — Body (15px/400), `var(--color-destructive)`
- Both fields remain editable; no field is specifically blamed (timing-safe: do not indicate
which field is wrong)
- Input borders: both switch to `var(--color-destructive)`
2. **Rate limit** (too many attempts, not yet locked):
- Background: `var(--color-surface-dim)` pill/banner, `border-radius: var(--space-1)`,
`padding: var(--space-3) var(--space-4)`
- Icon: `AlertCircle` (16px, `var(--color-destructive)`) inline
- Copy: "Too many attempts. Please wait a moment and try again." — 13px/400,
`var(--color-destructive)`
- Submit button: disabled during rate-limit window
3. **Account locked** (persistent lockout — household scale break-glass is CLI only, D-13):
- Same banner style as rate-limit
- Copy: "This account is temporarily locked. Contact your admin to reset access."
- Submit button: disabled
4. **Generic server error** (5xx / network):
- Copy: "Something went wrong. Please try again." — Body (15px/400), `var(--color-destructive)`
- Submit button: re-enabled after error
### Surface 7 — Primary Submit Button ("Sign in")
- Filled: `background: var(--color-member-0)`, `color: #ffffff`
- Width: 100% (full-width login button — D-04 low-friction for non-technical user)
- Label: 13px/600, `fontFamily: var(--font-family-base)`
- `minHeight: 44px`, `borderRadius: var(--space-1)` (4px), `border: none`
- `transition: background 0.15s ease`
- Disabled state: `background: var(--color-border)`, `cursor: default` (during submission or lockout)
- Loading state: `Loader2` icon (16px, #ffffff, `animation: spin 1s linear infinite`) inline before
label text; label changes to "Signing in…"
- Enabled only when both username and password fields are non-empty
### Surface 8 — Method Divider (OIDC mode only)
Rendered between the local login card and the OIDC button when `oidcEnabled === true` from
`/api/auth/mode`. Not rendered when OIDC is not configured.
- A horizontal rule with centered label "or":
- `display: flex; alignItems: center; gap: var(--space-3); marginTop: var(--space-4); marginBottom: var(--space-4)`
- Left/right lines: `flex: 1; height: 1px; background: var(--color-border)`
- "or" label: 13px/400, `var(--color-text-secondary)`, `flexShrink: 0`
### Surface 9 — OIDC Login Button (OIDC mode only)
Rendered below the method divider when `oidcEnabled === true`. Not rendered when OIDC is not
configured. This is NOT inside the login card — it sits below the card, after the divider.
- Outlined style: `background: transparent; border: 1px solid var(--color-member-0); color: var(--color-member-0)`
- Width: 100% (matches Surface 7 width)
- Label: "Login with OIDC" — 13px/600 (never says "Authelia" — D-06 BYO-Auth principle)
- `minHeight: 44px`, `borderRadius: var(--space-1)`, `cursor: pointer`
- On click: initiates the OIDC authorization-code flow (same as today's redirect)
- `lucide ShieldCheck` (16px) inline before label text — represents "your SSO provider"
- No loading state needed (redirect is instant)
### Surface 10 — Forgot Password Helper
Below Surface 7 (sign-in button), inside the login card.
- A single-line text: "Forgot your password? Ask your admin." — 13px/400,
`var(--color-text-secondary)`, `textAlign: center; marginTop: var(--space-4)`
- No link — password reset is admin-only (D-11), no self-service email reset (D-11, email
out of project scope). The text is informational only; not interactive.
- This copy is non-alarming for the non-technical user: frames it as a quick admin action,
not a problem.
### Surface 11 — Admin Additions: Local Accounts Section
Extends the existing `/admin` route (AdminPage.tsx), below the "MEMBERS" section and "SHARED
CALENDAR" section. New section labeled "LOCAL ACCOUNTS" (section-label style: 13px/600/uppercase/
0.06em letter-spacing, `var(--color-text-muted)`).
**Sub-surface 11A — Create Member / Set Initial Password**
A card/form within the LOCAL ACCOUNTS section:
- Heading (inline, not a card): "Add member" — Body (15px/600/`var(--color-text-primary)`)
- Fields (same input style as CredentialSheet):
- Display name — `type="text"`, label "Display name"
- Username — `type="text"`, label "Username", `autoComplete="off"`, `spellCheck={false}`, `autoCapitalize="none"`
- Initial password — `type="password"`, label "Initial password", `autoComplete="new-password"`
- Confirm password — `type="password"`, label "Confirm password", `autoComplete="new-password"`
- Field error: inline below the specific field, 13px/400, `var(--color-destructive)`, same style as
CredentialSheet validation failure
- Submit: "Add member" — filled accent button (same style as admin Save Credential button),
`minHeight: 44px`, right-aligned in action row. Disabled when any required field is empty or
passwords do not match.
- Success: form clears; member appears in the MEMBERS section above.
- Error copy variants:
- Username already taken: "That username is already in use. Choose a different one."
- Passwords do not match: "Passwords do not match."
- Weak password (if enforced): "Password is too short. Use at least 8 characters."
**Sub-surface 11B — Admin Password Reset (per-member)**
Accessible from each member row in the MEMBERS section via a new "Reset password" action button
(alongside existing "Rotate credential"/"Add credential" buttons — shown only for members who have
a local credential row).
Opens a bottom sheet (mobile) / centered modal (desktop), identical pattern to CredentialSheet
(role="dialog", aria-modal, Escape closes, focus returns to trigger):
- Heading: "Reset password" — 18px/600
- Member subtitle: "{DisplayName}" — 15px/400, `var(--color-text-secondary)`
- Fields:
- New password — `type="password"`, `autoComplete="new-password"`, label "New password"
- Confirm new password — `type="password"`, `autoComplete="new-password"`, label "Confirm new password"
- No current-password field — admin reset does not require knowing the old password
- Action row (right-aligned, gap `var(--space-3)`):
- Cancel: ghost button (same ghostBtnStyle as CredentialSheet)
- "Reset password": filled accent button, disabled while fields empty or mismatch
- Success: sheet closes; no toast (the action is silent — admin-only, not user-visible)
- Error: inline below confirm field in `var(--color-destructive)`, 13px/400
### Surface 12 — Self Password-Change (member self-service)
Accessible from the SettingsSheet (existing Settings bottom sheet the user opens from the avatar
button). A new "Change password" row in SettingsSheet, shown only when the current user has a
local credential (`hasLocalCredential: true` from `/api/me`). Tapping opens a bottom sheet
(same pattern as CredentialSheet):
- Heading: "Change password" — 18px/600
- Fields:
- Current password — `type="password"`, `autoComplete="current-password"`, label "Current password"
- New password — `type="password"`, `autoComplete="new-password"`, label "New password"
- Confirm new password — `type="password"`, `autoComplete="new-password"`, label "Confirm"
- Action row:
- Cancel: ghost button
- "Change password": filled accent, disabled while any field empty or new/confirm mismatch
- Success: sheet closes; no toast (self-service action is low-stakes confirmation)
- Error variants:
- Wrong current password: "Current password is incorrect."
- Passwords do not match: "Passwords do not match."
- Generic error: "Something went wrong. Please try again."
- `aria-describedby` on each field pointing to the specific inline error
### Surface 13 — Link OIDC Identity (per-user action)
Shown in SettingsSheet for the currently authenticated user, only when:
- The user has a local credential (is a local user, not already OIDC-only)
- OIDC is enabled (`oidcEnabled === true` from app state)
Entry point: a "Link OIDC identity" row in SettingsSheet, below "Change password" (if shown).
Tapping opens a **confirmation bottom sheet** (not a form — the actual linking happens via OIDC
redirect, so the sheet just explains consequences):
- Heading: "Link OIDC identity" — 18px/600
- Body (15px/400, `var(--color-text-secondary)`, `lineHeight: 1.5`):
"After linking, you'll sign in with your OIDC provider instead of a username and password.
Your local password will be removed."
- This is informational, not alarming: frame as an upgrade, not a removal.
- Do NOT use the word "delete" or "remove" in the primary copy.
- A secondary note in `var(--color-text-muted)` 13px/400:
"This can't be undone from the app. Contact your admin if you need to revert."
- Action row:
- "Cancel" ghost button
- "Continue with OIDC" filled accent button (D-06: never "Continue with Authelia")
- On "Continue with OIDC": sheet closes; OIDC authorization-code flow initiates.
On callback, backend binds `iss+sub` to the user and deletes the `local_credentials` row (D-12).
User is then redirected to `/calendar` as a now-OIDC-only user.
- If the OIDC `iss+sub` already belongs to another user: the callback returns a 409 error.
The PWA shows a generic error page: "This OIDC identity is already linked to another account.
Please contact your admin." (not shown in the sheet — occurs post-redirect)
---
## Routing & App-Level Gate
1. On app load, `GET /api/auth/mode` is fetched pre-auth (before OIDC middleware, no session
required). Returns: `{ localEnabled: true, oidcEnabled: boolean }`.
2. If the user has a valid session (any method): skip `/login`, proceed to normal app routes.
3. If no valid session AND `localEnabled === true`: render `/login` (Surface 1).
4. If no valid session AND `localEnabled === false` AND `oidcEnabled === true`: initiate OIDC
redirect directly (no login page shown — OIDC-only mode, today's behavior).
5. The `/login` route does NOT render inside the normal App shell — no AppNav, no BottomTabBar.
The existing `AuthSplash` component (spinner + "Signing you in") continues to be shown during
any auth-state loading before the login page is reached.
The Phase 12 setup gate (`/api/setup/status`) takes priority: if `setupComplete === false`, the
app redirects to `/setup` before reaching the login gate.
---
## Interaction Contract
### Login form state machine
```
fields empty → Submit disabled
username OR password empty → Submit disabled
both fields non-empty → Submit enabled
submit tapped → loading state (Loader2 spinner, "Signing in…", submit disabled)
success → navigate to /calendar (cookie set by API)
401 invalid credentials → error state (Surface 6, variant 1); fields remain editable; reset loading
429 rate limit → error state (Surface 6, variant 2); submit temporarily disabled
423 locked → error state (Surface 6, variant 3); submit disabled
5xx / network → error state (Surface 6, variant 4); submit re-enabled
```
### Password show/hide
Toggle button (Surface 5): clicking switches `type` between `"password"` and `"text"`.
The toggle state resets to hidden (`type="password"`) when the field loses focus.
`aria-pressed` reflects current show state.
### OIDC button (Surface 9)
Rendered only when `oidcEnabled === true`. Clicking initiates OIDC authorization-code flow
(same redirect as today). No loading state — the redirect is immediate.
### Focus management
- On page mount, focus moves to the username field (autofocus — login form is the only content)
- On submit error, focus moves to the heading of Surface 6 (`tabIndex={-1}`, `ref` + `.focus()`)
- On Enter key in username field: focus moves to password field
- On Enter key in password field: submit fires (if button not disabled)
### Keyboard-only login
The entire login form is keyboard-navigable. Tab order: username → password → show/hide toggle →
"Sign in" button → "Login with OIDC" button (if shown). No tab traps outside the OIDC
confirmation sheet.
---
## Copywriting Contract
### Login Screen (Surface 110)
| Element | Copy |
|---------|------|
| App name in brand slot | "FamilySync" |
| App tagline in brand slot | "Family calendar & lists" |
| Login card heading | "Sign in" |
| Username field label | "Username" |
| Password field label | "Password" |
| Show password toggle aria-label | "Show password" |
| Hide password toggle aria-label | "Hide password" |
| Primary CTA | "Sign in" |
| Primary CTA loading state | "Signing in…" |
| Forgot password helper | "Forgot your password? Ask your admin." |
| Method divider label | "or" |
| OIDC button label | "Login with OIDC" |
| Error — invalid credentials | "Incorrect username or password." |
| Error — rate limit | "Too many attempts. Please wait a moment and try again." |
| Error — account locked | "This account is temporarily locked. Contact your admin to reset access." |
| Error — server/network | "Something went wrong. Please try again." |
| Empty state | N/A — login form always has explicit content |
### Admin Additions (Surfaces 1113)
| Element | Copy |
|---------|------|
| Section label | "LOCAL ACCOUNTS" |
| Add member form heading | "Add member" |
| Display name field label | "Display name" |
| Username field label | "Username" |
| Initial password field label | "Initial password" |
| Confirm password field label | "Confirm password" |
| Add member submit button | "Add member" |
| Error — username taken | "That username is already in use. Choose a different one." |
| Error — passwords mismatch (create) | "Passwords do not match." |
| Error — password too short | "Password is too short. Use at least 8 characters." |
| Admin reset sheet heading | "Reset password" |
| Admin reset new password label | "New password" |
| Admin reset confirm label | "Confirm new password" |
| Admin reset submit button | "Reset password" |
| SettingsSheet — change password row | "Change password" |
| Self-change sheet heading | "Change password" |
| Self-change current password label | "Current password" |
| Self-change new password label | "New password" |
| Self-change confirm label | "Confirm" |
| Self-change submit button | "Change password" |
| Self-change error — wrong current | "Current password is incorrect." |
| Self-change error — passwords mismatch | "Passwords do not match." |
| SettingsSheet — link OIDC row | "Link OIDC identity" |
| Link OIDC sheet heading | "Link OIDC identity" |
| Link OIDC sheet body | "After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed." |
| Link OIDC secondary note | "This can't be undone from the app. Contact your admin if you need to revert." |
| Link OIDC cancel button | "Cancel" |
| Link OIDC confirm button | "Continue with OIDC" |
| Link OIDC post-redirect error (409) | "This OIDC identity is already linked to another account. Please contact your admin." |
| Admin member row CTA — reset (local user) | "Reset password" |
| Generic admin error | "Something went wrong. Please try again." |
### Copywriting rules (D-06 BYO-Auth principle)
- Never use the word "Authelia" in any user-facing copy. Use "your OIDC provider" or
"Login with OIDC" everywhere.
- Never say "delete" or "remove" when describing the OIDC-link consequence — use
"your local password will be removed" (passive, factual, non-alarming).
- Admin copy ("Reset password") is direct — admins are comfortable with technical vocabulary.
- End-user copy ("Sign in", "Forgot your password? Ask your admin.") is warm and low-friction —
optimized for the non-technical Apple household member.
---
## Destructive Actions
| Action | Trigger | Confirmation approach |
|--------|---------|----------------------|
| Link OIDC identity (removes local credential for that user) | "Link OIDC identity" in SettingsSheet → "Continue with OIDC" tap | Two-step: open confirmation sheet (step 1, explains consequence) + explicit "Continue with OIDC" tap (step 2). The confirmation sheet clearly states "your local password will be removed." No additional modal/dialog beyond this sheet. |
| Admin password reset | "Reset password" in admin member row → sheet submit | Two-step: open reset sheet (step 1) + explicit "Reset password" tap with filled-in new password (step 2). No separate confirmation dialog — the act of filling and submitting a new value is the acknowledgement. |
No hard-delete of local accounts in this phase. Account removal is out of scope.
---
## Accessibility Contract
### Login page (Surfaces 110)
- `role="main"` on the content column
- `<h1>` is the app name "FamilySync" in the brand slot (page-level heading);
`<h2>` is "Sign in" (login card heading)
- Username input: `id="login-username"`, `<label htmlFor="login-username">`, `spellCheck={false}`,
`autoCapitalize="none"`, `autoCorrect="off"`
- Password input: `id="login-password"`, `<label htmlFor="login-password">`, `aria-describedby="login-error"` (when error active)
- Error container: `id="login-error"`, `role="status"`, `aria-live="polite"`, `aria-atomic="true"`
screen readers announce errors without focus movement
- Show/hide toggle: `aria-pressed`, `aria-label="Show password"` / `"Hide password"`, 44px tap target
- Submit button: `disabled` attribute (not just `pointer-events: none`) when disabled
- Focus on mount: `autoFocus` on username field
- Focus management on error: move focus to error heading (`tabIndex={-1}`, `.focus()`)
- OIDC button: `type="button"`, descriptive label (no ambiguous icon-only)
- Focus ring: `var(--color-focus-ring)` (#4a90d9), 2px outline, 2px offset on all focusable elements
### Admin additions (Surfaces 1113)
- All sheets: `role="dialog"`, `aria-modal="true"`, `aria-label` matching heading, Escape closes,
focus returns to trigger element on close
- All password fields: `type="password"`, correct `autoComplete` values (never cross-contaminate
new-password / current-password)
- Field errors: `aria-describedby` from input to its specific inline error element
- Sheet heading: `<h2>` (heading hierarchy under page `<h1>`)
- Minimum touch targets: `minHeight: 44px; minWidth: 44px` on all buttons
---
## Responsive Behavior
The login page is **phone-first** (the primary user is on mobile — CLAUDE.md hard UX constraint).
- Phone (<768px): card fills viewport minus `var(--space-6)` horizontal padding (12px each side);
brand slot centered; no bottom tab bar; no AppNav
- Desktop (≥768px): card centered at maxWidth 400px; brand slot centered above it
- At all breakpoints: no AppNav, no BottomTabBar rendered on the login page
Admin additions (Surfaces 1113) follow the existing AdminPage responsive pattern:
- Mobile: bottom sheet for all sheets (borderRadius 12px top corners, slides up)
- Desktop: centered modal (maxWidth 480px, same as CredentialSheet)
- Add-member form (Surface 11A) is inline within `/admin` content, not a sheet
---
## Security Display Rules
Hard UI rules — not implementation notes:
- Password fields always render as `type="password"` initially — show/hide is explicit user action
- No password is ever pre-filled, echoed, or returned to the UI after save
- Password values are never written to localStorage, sessionStorage, or any client-side store
- Error messages for invalid credentials do NOT indicate which field is wrong
(timing-safe: same copy for "wrong username" and "wrong password")
- No `dangerouslySetInnerHTML` anywhere on the login page (project convention T-05-24)
- The OIDC button label never contains provider-specific branding that would leak infrastructure
details (D-06)
- The "Link OIDC identity" flow is only accessible to an already-authenticated local user —
never from the unauthenticated login page
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none — not initialized | not applicable |
| third-party | none | not applicable |
No third-party component registries. All components hand-rolled following existing project
convention. No new npm dependencies for UI are required beyond lucide-react (already installed;
new icons needed: `Lock`, `User`, `Eye`, `EyeOff`, `LogIn` — all available in lucide-react).
---
## Pre-Population Sources
| Decision | Source | Value |
|----------|--------|-------|
| Spacing scale | apps/pwa/src/styles/tokens.css | --space-1 through --space-12; no new tokens |
| Typography scale | apps/pwa/src/styles/tokens.css | 4 sizes (13/15/18/24px), 2 weights (400/600) |
| Color palette | apps/pwa/src/styles/tokens.css | All hex values; no new colors |
| Component library | apps/pwa convention | Hand-rolled inline React.CSSProperties; no shadcn |
| Icon library | apps/pwa imports | lucide-react (already installed) |
| Full-page shell layout | SetupPage.tsx | pageStyle, contentColStyle, cardStyle, primaryBtnStyle, ghostBtnStyle, inputStyle, labelStyle, helperStyle |
| Validation row pattern | SetupPage.tsx | ValidationRow component (idle/pending/success/failure) |
| Admin section label style | AdminPage.tsx | sectionLabelStyle (13px/600/uppercase/0.06em) |
| Bottom sheet pattern | CredentialSheet.tsx | role="dialog", aria-modal, Escape, focus-return, borderRadius 12px top |
| Button styles | SetupPage.tsx / AdminPage.tsx | Filled accent + ghost button — exact match |
| Input style | SetupPage.tsx | Same inputStyle(hasError) — border switches to destructive on error |
| No OIDC-specific branding | CONTEXT.md D-06 | Never "Authelia"; use "Login with OIDC" / "your OIDC provider" |
| Local-only + OIDC-optional coexistence | CONTEXT.md D-01/D-02 | localEnabled always true; oidcEnabled from /api/auth/mode |
| OIDC link removes local credential | CONTEXT.md D-12 | Confirmation sheet required; copy non-alarming |
| No email password reset | CONTEXT.md D-11 | "Ask your admin" copy only |
| Admin creates accounts only (no self-signup) | CONTEXT.md D-10 | Add member form is admin-only |
| Stateless JWT session cookie | CONTEXT.md D-05 | No session table UI; logout = clear cookie |
| Break-glass is CLI/env only | CONTEXT.md D-13 | No break-glass UI in scope |
| Phase 17 brand slot seam | cross-phase directive | BrandSlot component + CSS asset tokens defined |
---
## Checker Sign-Off
- [x] Dimension 1 Copywriting: PASS
- [x] Dimension 2 Visuals: PASS
- [x] Dimension 3 Color: PASS
- [x] Dimension 4 Typography: PASS
- [x] Dimension 5 Spacing: PASS
- [x] Dimension 6 Registry Safety: PASS
- [x] Phase 17 Brand-Slot Readiness: PASS
**Approval:** approved 2026-06-16
@@ -0,0 +1,76 @@
---
phase: 19
slug: local-auth-no-oidc-mode
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-06-17
---
# Phase 19 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | {pytest 7.x / jest 29.x / vitest / go test / other} |
| **Config file** | {path or "none — Wave 0 installs"} |
| **Quick run command** | `{quick command}` |
| **Full suite command** | `{full command}` |
| **Estimated runtime** | ~{N} seconds |
---
## Sampling Rate
- **After every task commit:** Run `{quick run command}`
- **After every plan wave:** Run `{full suite command}`
- **Before `/gsd-verify-work`:** Full suite must be green
- **Max feedback latency:** {N} seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| {N}-01-01 | 01 | 1 | REQ-{XX} | T-{N}-01 / — | {expected secure behavior or "N/A"} | unit | `{command}` | ✅ / ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `{tests/test_file.py}` — stubs for REQ-{XX}
- [ ] `{tests/conftest.py}` — shared fixtures
- [ ] `{framework install}` — if no framework detected
*If none: "Existing infrastructure covers all phase requirements."*
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| {behavior} | REQ-{XX} | {reason} | {steps} |
*If none: "All phase behaviors have automated verification."*
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < {N}s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** {pending / approved YYYY-MM-DD}
@@ -0,0 +1,239 @@
---
phase: 19-local-auth-no-oidc-mode
verified: 2026-06-17T17:51:00Z
status: passed
score: 21/21 must-haves verified
behavior_unverified: 0
overrides_applied: 0
reverified: 2026-06-17T18:20:00Z
reverification_note: "Orchestrator closed the single BLOCKER gap post-verification (commit 53da4be). Both gaps below shared one root cause — a client/server URL mismatch — now fixed and confirmed live (old path /members/:id/reset-password -> 404, correct path /members/:id/password -> 400 route-reached) plus a URL-contract regression test (client.test.ts). PWA 266/266, API 446/446, typecheck clean. Remaining items are genuine human/live-stack UAT (see human_verification) and do not block automated goal achievement."
gaps:
- truth: "An admin can reset any local member's password without knowing the current one"
status: resolved
reason: "FIXED (commit 53da4be): client.ts fetchAdminResetPassword now POSTs /api/admin/members/:id/password, matching the API route. Confirmed live (correct path returns 400 route-reached, not 404) + URL-contract regression test added."
artifacts:
- path: "apps/pwa/src/api/client.ts"
issue: "RESOLVED — URL corrected to `/api/admin/members/${memberId}/password`"
- truth: "An admin sees a LOCAL ACCOUNTS section to add a member and a per-member Reset-password action"
status: resolved
reason: "FIXED (commit 53da4be): same root-cause URL fix; the Reset-password sheet now targets the correct route. UI rendering was already verified."
artifacts:
- path: "apps/pwa/src/routes/AdminPage.tsx"
issue: "RESOLVED — fetchAdminResetPassword now calls the correct URL"
deferred: []
behavior_unverified_items: []
human_verification:
- test: "Drive /login with playwright-cli against the real non-bypass stack: verify brand slot + form render, wrong-password single error message, correct creds navigate into app, OIDC button gate when oidcEnabled"
expected: "All 4 error states render correctly per UI-SPEC; no Authelia branding visible; OIDC button only shown when oidcEnabled"
why_human: "LoginPage renders in a real browser; DEV_AUTH_BYPASS prevents real login-gate testing from playwright-cli under dev harness"
- test: "After fixing the fetchAdminResetPassword URL: drive the admin Reset-password sheet and confirm admin can reset a member password without knowing current"
expected: "POST to /api/admin/members/:id/password returns 200; member can then log in with the new password"
why_human: "Depends on fixing the gap first; then requires end-to-end stack with admin session"
- test: "Drive the SettingsSheet Change-password sheet with a local user: verify current-password verification and successful update"
expected: "Wrong current password shows 'Current password is incorrect.'; correct current allows update; subsequent login with new password succeeds"
why_human: "Requires end-to-end stack with a local user session"
- test: "Verify rate-limit/lockout flakiness: run localAuth.test.ts Test 5 (10 failures -> 423) 10 times and confirm stable pass rate"
expected: "Test 5 passes all 10 runs (orchestrator noted one intermittent failure across 3 runs)"
why_human: "Timing-dependent in-memory test; could be environment-dependent flakiness"
---
# Phase 19: Local Auth (No-OIDC Mode) Verification Report
**Phase Goal:** Let an operator run FamilySync entirely on local DB users with no OIDC — username/password accounts and a local login flow that coexists with the Authelia OIDC path — and optionally wire OIDC in later by claiming/linking an existing local user to an OIDC identity. Removes the hard dependency on a deployed Authelia for small/solo self-hosters.
**Verified:** 2026-06-17T17:51:00Z
**Status:** gaps_found
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | A password can be hashed and the same password verifies true; a wrong password verifies false | VERIFIED | `localCredentials.ts` exports `hashPassword`/`verifyPassword`; 5/5 tests pass in `tests/auth/localCredentials.test.ts` |
| 2 | `verifyPassword` returns false (never throws) on a malformed stored hash | VERIFIED | Test 4 in `localCredentials.test.ts` confirms try/catch wraps all crypto errors |
| 3 | A signed local-session JWT round-trips: issue then verify returns the same userId | VERIFIED | Test 1 in `localSession.test.ts` passes; `Jwt.sign`/`Jwt.verify` HS256 with `LOCAL_SESSION_SECRET` |
| 4 | An expired or tampered local-session token verifies to null, never throws | VERIFIED | Test 3 in `localSession.test.ts` passes; `verifyLocalSessionCookie` wraps `Jwt.verify` in try/catch |
| 5 | The API process refuses to boot (exit 1) when `LOCAL_SESSION_SECRET` is missing/short and dev-bypass is off | VERIFIED | `assertLocalSessionSecretSet()` in `bootGuards.ts` (line 53); called from `index.ts` line 230; test confirms exemption for DEV_AUTH_BYPASS=true |
| 6 | The `local_credentials` table exists after migration with unique user_id and unique username | VERIFIED | `0003_warm_deathstrike.sql` has `UNIQUE(user_id)` + `UNIQUE(username)` + FK; migration applied; schema exports `localCredentials` |
| 7 | An admin can create a local member (users row + local_credentials row with a hashed initial password) in one transaction | VERIFIED | `POST /api/admin/members` in `admin.ts` uses `db.transaction`; Test 1 + Test 2 in `admin.test.ts` pass (transaction rollback on dup username) |
| 8 | Creating a member with an already-used username returns 409, not a 500 or a partial insert | VERIFIED | ER_DUP_ENTRY detection in `admin.ts` returns 409; Test 2 confirms no users row created |
| 9 | An admin can reset any local member's password without knowing the current one | FAILED | API route `POST /api/admin/members/:id/password` is correctly implemented and tested, but **`client.ts` `fetchAdminResetPassword` calls `/api/admin/members/${memberId}/reset-password`** — path mismatch causes 404 in production |
| 10 | A user can change their own password only after verifying their current password | VERIFIED | `POST /api/me/password` calls `verifyPassword(current)` before `hashPassword(new)`; Test 2 confirms wrong current → 401, hash unchanged |
| 11 | `GET /api/me` returns `hasLocalCredential` so the PWA knows whether to show Change-password / Link-OIDC | VERIFIED | `resolveAdminAndSetupStatus` selects from `local_credentials` and returns `hasLocalCredential: Boolean(localCred)`; Tests 4 + 5 in `me.test.ts` pass |
| 12 | Linking an OIDC identity binds iss+sub to the current user and deletes their local_credentials row; a conflicting iss+sub is rejected (409) and no local row is deleted | VERIFIED | `linkOidcToUser` in `linkOidc.ts`: preflight SELECT + db.transaction(UPDATE users + DELETE local_credentials); Tests 1 + 2 in `me.test.ts` pass |
| 13 | A valid username+password POST to `/api/auth/local/login` returns 200 and sets a local-session cookie | VERIFIED | `localAuth.ts` Test 1 in `localAuth.test.ts` passes (200 + Set-Cookie) |
| 14 | A wrong password and an unknown username both return the same 401 with the same body (no enumeration, no field discrimination) | VERIFIED | `DUMMY_HASH` timing defense; Test 3 in `localAuth.test.ts` confirms identical 401 body; test passes |
| 15 | After 5 failed attempts the endpoint returns 429; after 10 it returns 423 until an admin reset | VERIFIED | `loginAttempts` Map; counter increments on 429 path too; Tests 4 + 5 in `localAuth.test.ts` pass |
| 16 | A request carrying a valid local-session cookie resolves `c.get('user')` and is NOT 302-redirected to OIDC | VERIFIED | `localAuthMiddleware` sets `c.get('user')`; OIDC guard wrapped with `if (c.get('user')) { next(); return; }` in `index.ts` line 157; middleware Tests 1 + 4 pass |
| 17 | `GET /api/auth/mode` is reachable pre-auth and returns `{ localEnabled:true, oidcEnabled }` reflecting app_config/env | VERIFIED | `authModeRouter` mounted before devAuthBypass (line 114 of index.ts); Tests 5/6/6b in `authMode.test.ts` pass |
| 18 | Logout clears the local-session cookie | VERIFIED | `clearLocalSessionCookie` called on `POST /api/auth/local/logout` and `GET` alias; Test 6 + 6b pass |
| 19 | An unauthenticated user with no valid session lands on `/login` (when localEnabled) and sees the brand slot + username/password form | VERIFIED | App.tsx `authModeQuery` gate; `App.test.tsx` Phase 19 describe block passes; `LoginPage.tsx` has id="login-username", "Sign in" heading; `BrandSlot` component renders |
| 20 | No user-facing string or config comment says "Authelia" | VERIFIED | All modified API and PWA source files return 0 case-insensitive matches for "authelia" in user-facing code (one occurrence in SettingsSheet.tsx is a comment prohibiting the word, not user-facing) |
| 21 | An admin sees a LOCAL ACCOUNTS section to add a member and a per-member Reset-password action | PARTIAL | LOCAL ACCOUNTS section exists in `AdminPage.tsx` (line 691); add-member form is wired to `fetchCreateMember` (correct URL `/api/admin/members`); Reset-password button exists gated on `hasLocalCredential`, but `fetchAdminResetPassword` calls wrong URL — see gap for truth #9 |
**Score:** 19/21 truths verified (1 FAILED, 1 PARTIAL)
### Deferred Items
None.
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|---------|--------|---------|
| `apps/api/src/auth/localCredentials.ts` | hashPassword + verifyPassword (scrypt, PHC-encoded) | VERIFIED | 91 lines, node:crypto only, timingSafeEqual |
| `apps/api/src/auth/localSession.ts` | issueLocalSessionCookie + verifyLocalSessionCookie + clearLocalSessionCookie | VERIFIED | 107 lines, Jwt namespace import, local-session cookie |
| `apps/api/src/db/migrations/0003_warm_deathstrike.sql` | CREATE TABLE local_credentials | VERIFIED | Purely additive; UNIQUE(user_id), UNIQUE(username), FK→users cascade |
| `apps/api/src/db/schema.ts` | localCredentials Drizzle table export | VERIFIED | Lines 320-349 |
| `apps/api/src/lib/bootGuards.ts` | assertLocalSessionSecretSet | VERIFIED | Line 53; exits(1) when secret missing/<32 chars outside bypass |
| `apps/api/src/auth/localAuthMiddleware.ts` | localAuthMiddleware | VERIFIED | 99 lines; Pitfall-1 guard; c.set('user') only when cookie valid |
| `apps/api/src/routes/authMode.ts` | GET /api/auth/mode pre-auth | VERIFIED | 50 lines; localEnabled always true; oidcEnabled from env/app_config |
| `apps/api/src/routes/localAuth.ts` | POST /login (rate-limited) + logout | VERIFIED | 165 lines; DUMMY_HASH; noEchoHook; loginAttempts Map |
| `apps/api/src/auth/linkOidc.ts` | linkOidcToUser + OidcLinkConflictError | VERIFIED | 89 lines; preflight SELECT; db.transaction; no email field |
| `apps/pwa/src/routes/LoginPage.tsx` | Standalone /login page (Surfaces 1-10) | VERIFIED | 465 lines; BrandSlot; 4 error states; show/hide; OIDC gate |
| `apps/pwa/src/components/BrandSlot.tsx` | Phase-17 brand seam component | VERIFIED | 84 lines; CSS custom properties; no img; no Authelia |
| `apps/api/src/routes/admin.ts` | POST /members + POST /members/:id/password + hasLocalCredential in GET /members | VERIFIED (API); PARTIAL (client wiring) | Routes exist and are tested; client URL mismatch for reset |
| `apps/api/src/routes/me.ts` | POST /password + POST /link-oidc + hasLocalCredential in GET / | VERIFIED | All three additions present and tested |
| `apps/api/scripts/reset-admin.ts` | Break-glass CLI, dev-only | VERIFIED | 149 lines; NODE_ENV=production guard FIRST; inline scrypt; --dry-run exits 0 |
| `apps/pwa/e2e/login.spec.ts` | Real-login-form e2e | VERIFIED | 148 lines; clearCookies; 3 tests covering brand/form/error/login |
### Key Link Verification
| From | To | Via | Status | Details |
|------|-----|-----|--------|---------|
| `localSession.ts` | `process.env.LOCAL_SESSION_SECRET` | Jwt.sign/Jwt.verify HS256 | WIRED | Line 41 + 83; throws if unset |
| `index.ts` | `bootGuards.ts` | `assertLocalSessionSecretSet()` at boot | WIRED | Line 230 of index.ts |
| `schema.ts` | `0003_warm_deathstrike.sql` | drizzle-kit generate emits SQL | WIRED | Migration applied and confirmed additive |
| `localAuthMiddleware.ts` | `localSession.ts` | `verifyLocalSessionCookie → c.set('user')` | WIRED | Line 55 of middleware |
| `index.ts` | `localAuthMiddleware.ts` | `app.use('/api/*', localAuthMiddleware())` after devAuthBypass, before OIDC | WIRED | Line 132 of index.ts |
| `index.ts` | `linkOidc.ts` | `/callback` reads link-state and calls `linkOidcToUser` | WIRED | Lines 66-86 of index.ts |
| `localAuth.ts` | `localSession.ts` | `issueLocalSessionCookie` on success / `clearLocalSessionCookie` on logout | WIRED | Lines 145, 159 |
| `admin.ts` | `localCredentials.ts` | `hashPassword` on create-member and reset-password | WIRED | Lines 165, 237 |
| `me.ts` | `localCredentials.ts` | `verifyPassword(current)` then `hashPassword(new)` | WIRED | Lines 258, 264 |
| `client.ts` (PWA) | `/api/admin/members/:id/password` (API) | `fetchAdminResetPassword` for admin reset | NOT_WIRED | `client.ts` line 204 calls `/api/admin/members/${memberId}/reset-password` but API path is `/api/admin/members/:id/password` |
| `devBypass.ts` | `localSession.ts` | `devSessionCookieMiddleware` issues real local-session cookie | WIRED | Line 127 of devBypass.ts |
| `global-setup.ts` | `local_credentials table` | INSERT devuser/devpass ON DUPLICATE KEY UPDATE | WIRED | Lines 166-169 of global-setup.ts |
| `.gitea/workflows/ci.yml` | `LOCAL_SESSION_SECRET` | harness job env | WIRED | Line 349; value `dev-secret-change-me-0000000000000000` |
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|--------------|--------|-------------------|--------|
| `LoginPage.tsx` | `loginError` state | `fetchLocalLogin` -> API 401/429/423 | Yes — API returns real status codes | FLOWING |
| `SettingsSheet.tsx` | `hasLocalCredential` | `useQuery(['me'])` -> `GET /api/me` -> DB SELECT local_credentials | Yes — real DB query | FLOWING |
| `AdminPage.tsx` | `member.hasLocalCredential` | `membersQuery` -> `GET /api/admin/members` -> LEFT JOIN local_credentials | Yes — real DB query | FLOWING |
| `App.tsx` | `authModeQuery.data` | `fetchAuthMode` -> `GET /api/auth/mode` -> env/app_config | Yes — real env/DB check | FLOWING |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| hashPassword/verifyPassword round-trip | `pnpm --filter @familysync/api test tests/auth/localCredentials.test.ts` | 5/5 pass | PASS |
| JWT session cookie round-trip | `pnpm --filter @familysync/api test tests/auth/localSession.test.ts` | 5/5 pass | PASS |
| localAuthMiddleware Pitfall-1 guard | `pnpm --filter @familysync/api test tests/auth/localAuthMiddleware.test.ts` | 4/4 pass | PASS |
| Login rate-limit/lockout | `pnpm --filter @familysync/api test tests/routes/localAuth.test.ts` | 8/8 pass | PASS |
| App.tsx auth gate (Phase 19) | `pnpm --filter @familysync/pwa test src/App.test.tsx` | 2/2 Phase-19 tests pass | PASS |
| Full API suite | `pnpm --filter @familysync/api test` (446 tests) | 446/446 pass (34 files) | PASS |
| Full PWA unit suite | `pnpm --filter @familysync/pwa test` (265 tests) | 265/265 pass (22 files) | PASS |
### Probe Execution
No conventional `scripts/*/tests/probe-*.sh` probes defined for this phase. N/A.
### Requirements Coverage
| Requirement | Plan | Description | Status | Evidence |
|-------------|------|-------------|--------|---------|
| AUTH-LOCAL-01 | 19-01 | local_credentials schema + migration | SATISFIED | Table in schema.ts + 0003 migration applied |
| AUTH-LOCAL-02 | 19-01 | scrypt hash/verify (node:crypto) | SATISFIED | localCredentials.ts; 5 tests pass |
| AUTH-LOCAL-03 | 19-03 | Login route (rate-limited) | SATISFIED | localAuth.ts POST /local/login; 8 tests pass |
| AUTH-LOCAL-04 | 19-03 | localAuthMiddleware | SATISFIED | localAuthMiddleware.ts; 4 tests pass |
| AUTH-LOCAL-05 | 19-03 | Auth-mode endpoint | SATISFIED | authMode.ts GET /mode; 3 tests pass |
| AUTH-LOCAL-06 | 19-03 | Logout | SATISFIED | POST+GET /local/logout; Tests 6+6b pass |
| AUTH-LOCAL-07 | 19-02 | Admin create-member | SATISFIED | admin.ts POST /members; db.transaction; 409 on dup |
| AUTH-LOCAL-08 | 19-02 | Admin reset password | PARTIAL | API route correct (`/members/:id/password`); client.ts calls wrong URL (`/reset-password`) — gap |
| AUTH-LOCAL-09 | 19-02 | Self-change password | SATISFIED | me.ts POST /password; verifyPassword(current) required |
| AUTH-LOCAL-10 | 19-02 | OIDC-link | SATISFIED | linkOidc.ts + /callback link branch; preflight SELECT |
| AUTH-LOCAL-11 | 19-05 | Break-glass CLI | SATISFIED | reset-admin.ts; production guard FIRST; --dry-run exits 0 |
| AUTH-LOCAL-12 | 19-04 | LoginPage | SATISFIED | LoginPage.tsx 465 lines; 4 error states; id="login-username" |
| AUTH-LOCAL-13 | 19-04 | Admin UI (LOCAL ACCOUNTS section) | PARTIAL | Section exists; add-member wired correctly; reset-password UI exists but client URL wrong |
| AUTH-LOCAL-14 | 19-04 | Settings UI (Change-password + Link-OIDC) | SATISFIED | SettingsSheet.tsx; both gated on hasLocalCredential; link-OIDC also gates on oidcEnabled |
| AUTH-LOCAL-15 | 19-04 | Routing gate | SATISFIED | App.tsx authModeQuery gate; App.test.tsx Phase 19 tests pass |
| AUTH-LOCAL-16 | 19-05 | Dev-bypass/harness rework | SATISFIED | devSessionCookieMiddleware; global-setup seed; login.spec.ts; CI updated |
| AUTH-LOCAL-17 | 19-02 | hasLocalCredential | SATISFIED | GET /api/me + GET /api/admin/members both return hasLocalCredential |
| AUTH-LOCAL-18 | 19-03 | De-Authelia copy | SATISFIED | 0 occurrences of "authelia" (case-insensitive) in all modified source/template files |
| AUTH-LOCAL-19 | 19-03 | Rate-limit/lockout | SATISFIED | loginAttempts Map; 5→429; 10→423; tests pass |
| AUTH-LOCAL-20 | 19-03 | Auth unit tests | SATISFIED | 8 new test files; 446/446 API + 265/265 PWA pass |
| D-05 / LOCAL_SESSION_SECRET | 19-01 | Env var + boot assertion | SATISFIED | generate-secrets.mjs emits it; assertLocalSessionSecretSet in bootGuards.ts; docker-compose.dev.yml has value |
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `apps/pwa/src/api/client.ts` | 204 | Wrong URL in `fetchAdminResetPassword` — calls `/reset-password` instead of `/password` | Blocker | Admin password reset 404s in production |
| `apps/pwa/src/components/SettingsSheet.tsx` | 828 | Comment says "Never use 'Authelia'" — this is a code comment prohibiting the word, not a violation | Info | Not a problem; serves as documentation |
No unreferenced TBD/FIXME/XXX debt markers found in Phase 19 files.
### Human Verification Required
#### 1. Login Page Visual + Flow Verification
**Test:** Using playwright-cli against the non-bypass stack (or deploy stack), navigate to the PWA without a session. Confirm /login renders with BrandSlot ("FamilySync", "Family calendar & lists"), Sign in heading, username + password fields with show/hide toggle. Test all 4 error states.
**Expected:** BrandSlot visible at top; form renders with id="login-username" and id="login-password"; submitting wrong credentials shows exactly "Incorrect username or password." (no field blame); submitting correct credentials navigates into the app. When oidcEnabled, "or" divider + "Login with OIDC" button appear (no "Authelia").
**Why human:** LoginPage renders in a real browser; DEV_AUTH_BYPASS prevents real login-gate testing under dev harness. playwright-cli can drive /login directly (not the gate redirect) in Chromium.
#### 2. Admin Reset Password (After Fixing Gap)
**Test:** After fixing the `fetchAdminResetPassword` URL in `client.ts`, log in as admin, open /admin, expand LOCAL ACCOUNTS for a member with `hasLocalCredential:true`, click Reset-password, enter a new password, submit.
**Expected:** 200 from `POST /api/admin/members/:id/password`; the member can then log in with the new password.
**Why human:** The gap (URL mismatch) must be fixed first; then requires a live admin session with real local credential data.
#### 3. Settings Change-Password Flow
**Test:** Log in as a local user. Open Settings. Confirm "Account" section with "Change password" row visible (only when hasLocalCredential). Click, enter wrong current password — confirm "Current password is incorrect." error. Enter correct current and new password — confirm success.
**Expected:** Current password validation works server-side (401 on wrong current); hash updated on success; new password works on next login.
**Why human:** Requires live stack with a local user session; involves stateful password update.
#### 4. Rate-Limit Flakiness Characterization
**Test:** Run `pnpm --filter @familysync/api test tests/routes/localAuth.test.ts` 10 times in sequence and note pass rate for Test 5 (10 failures → 423).
**Expected:** 10/10 pass. Orchestrator noted one intermittent failure across 3 run history.
**Why human:** Timing-dependent in-memory state machine; could be flaky under CI load; needs repeated observation to characterize.
---
## Gaps Summary
### 1. `fetchAdminResetPassword` URL Mismatch (AUTH-LOCAL-08 / AUTH-LOCAL-13)
**Root cause:** `apps/pwa/src/api/client.ts` line 204 calls `/api/admin/members/${memberId}/reset-password` but the API registers the route as `POST /api/admin/members/:id/password` (in `apps/api/src/routes/admin.ts` line 212). These paths are different — `/reset-password` vs `/password`.
**Impact:** The Admin page "Reset password" sheet (`AdminPage.tsx``ResetPasswordSheet``fetchAdminResetPassword`) will receive a 404 response on every submit in a production (non-mocked) environment. The underlying API endpoint is correctly implemented and tested; only the client URL is wrong.
**API tests pass** because `admin.test.ts` calls the correct `/api/admin/members/${newMemberId}/password` path directly via `jsonRequest`, not via `client.ts`.
**Fix:** Change line 204 of `client.ts`:
```typescript
// Wrong:
const res = await fetch(`/api/admin/members/${memberId}/reset-password`, {
// Correct:
const res = await fetch(`/api/admin/members/${memberId}/password`, {
```
This is a one-line fix. No API change needed.
---
_Verified: 2026-06-17T17:51:00Z_
_Verifier: Claude (gsd-verifier)_
+185
View File
@@ -0,0 +1,185 @@
/**
* reset-admin.ts Break-glass CLI: create or reset a local admin account (D-13).
*
* Usage (dev only):
* docker exec -it familysync-api node --import=tsx/esm scripts/reset-admin.ts \
* --username admin --password '<new-password>'
*
* Flags:
* --username <name> Required. Username to create/reset.
* --password <pass> Required. New password (never logged).
* --dry-run Validate args + DB connection without writing.
*
* Security (T-19-25, T-19-26, D-13, D-15):
* - FIRST statement: dev-only guard throws when NODE_ENV=production (defense-in-depth).
* - This script is also excluded from the production image via .dockerignore apps/api/scripts/ (IMG-02).
* - The password value is NEVER logged or printed.
* - hashPassword is inlined (scrypt PHC) cannot import compiled TS from a plain script (Pitfall 11).
*
* DB:
* Reads DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env (same defaults as global-setup.ts).
* Upserts users row (is_admin=true, claimed=true) then upserts local_credentials row.
* Idempotent: safe to run multiple times with the same username.
*/
// ── DEV-ONLY GUARD — must be the FIRST executable statement (T-19-25 / D-15) ────────────
if (process.env.NODE_ENV === 'production') {
throw new Error(
'reset-admin refused: NODE_ENV=production. ' +
'This CLI creates/resets local admin credentials and must NEVER run in production. ' +
'The script is also excluded from the production image via .dockerignore apps/api/scripts/ (IMG-02).',
);
}
import { createConnection } from 'mysql2/promise';
import { scryptSync, randomBytes } from 'node:crypto';
// ── Inline hashPassword (PHC-style scrypt) ───────────────────────────────────────────────
// Cannot import compiled TS from a plain Node.js script at runtime (Pitfall 11).
// Copy of the 5-line implementation from apps/api/src/auth/localCredentials.ts.
const SCRYPT_N = 16384;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
const KEY_LEN = 32;
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('$');
}
// ── CLI arg parsing (no new deps — process.argv only) ───────────────────────────────────
// WR-01: support both `--key=value` and `--key value`, and parse values EXPLICITLY rather
// than inferring an empty string whenever the next token starts with '--'. The old heuristic
// coerced `--password --foo` (and a legitimately `--`-prefixed or empty password) silently to
// ''. Here, known value-taking flags (--username, --password) always consume the next token
// verbatim as their value; the only boolean flag (--dry-run) takes no value. This keeps a
// password that begins with '--', or an intentionally empty password, intact.
const VALUE_FLAGS = new Set(['username', 'password']);
const BOOLEAN_FLAGS = new Set(['dry-run']);
function parseArgs(argv: string[]): Record<string, string | undefined> {
const result: Record<string, string | undefined> = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (!arg.startsWith('--')) continue;
const eq = arg.indexOf('=');
if (eq !== -1) {
// `--key=value` form — value is everything after the first '=', taken verbatim
// (so `--password=--weird` and `--password=` both work correctly).
result[arg.slice(2, eq)] = arg.slice(eq + 1);
continue;
}
const key = arg.slice(2);
if (BOOLEAN_FLAGS.has(key)) {
result[key] = ''; // presence-only flag; detected via hasOwnProperty
continue;
}
if (VALUE_FLAGS.has(key)) {
// Consume the NEXT token verbatim as the value — even if it starts with '--' or is
// empty. If there is no next token, record undefined (genuinely absent, not '').
result[key] = argv[i + 1];
if (i + 1 < argv.length) i++; // skip the consumed value token
continue;
}
// Unknown flag — record presence with no value (forward-compatible, no crash).
result[key] = '';
}
return result;
}
const args = parseArgs(process.argv.slice(2));
// ── Validate required args ────────────────────────────────────────────────────────────────
const username = args['username'];
const password = args['password'];
const dryRun = Object.prototype.hasOwnProperty.call(args, 'dry-run');
if (!username || username.trim() === '') {
console.error('reset-admin: --username is required');
process.exit(1);
}
if (!dryRun && (!password || password.trim() === '')) {
console.error('reset-admin: --password is required (use --dry-run to test without writing)');
process.exit(1);
}
if (dryRun && !password) {
// In dry-run mode a placeholder password is acceptable — skip real validation
console.log(
'[dry-run] Args validated: --username present, --dry-run active (no write will occur)',
);
}
// ── DB connection ─────────────────────────────────────────────────────────────────────────
const conn = await createConnection({
host: process.env.DB_HOST ?? '127.0.0.1',
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER ?? 'familysync',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'familysync',
});
try {
// Verify DB connectivity (used by --dry-run to confirm connection works)
await conn.query('SELECT 1');
console.log('[reset-admin] DB connection OK');
if (dryRun) {
console.log('[dry-run] Connection verified. Exiting without writing.');
await conn.end();
process.exit(0);
}
// ── Upsert users row ─────────────────────────────────────────────────────────────────
// Find existing user by username (via local_credentials join) or create a new one.
// is_admin=true + claimed=true for break-glass recovery (D-13).
// Never logs the password value (T-19-26).
const [lcRows] = await conn.execute<{ user_id: number }[]>(
'SELECT user_id FROM local_credentials WHERE username = ? LIMIT 1',
[username],
);
let userId: number;
if (lcRows.length > 0) {
// Existing local_credentials row — update password and ensure is_admin
userId = lcRows[0].user_id;
await conn.execute('UPDATE users SET is_admin = true, claimed = true WHERE id = ?', [userId]);
// WR-01: do not echo the username — log only the resolved user id (no credential data).
console.log(`[reset-admin] Found existing user id=${userId}`);
} else {
// No existing row — insert a new user
const displayName = username;
const [insertResult] = await conn.execute<{ insertId: number }>(
`INSERT INTO users (oidc_iss, oidc_sub, display_name, color, is_admin, claimed)
VALUES (NULL, NULL, ?, '#4A90D9', true, true)`,
[displayName],
);
userId = (insertResult as unknown as { insertId: number }).insertId;
// WR-01: do not echo the username — log only the resolved user id.
console.log(`[reset-admin] Created new user id=${userId}`);
}
// ── Upsert local_credentials row ─────────────────────────────────────────────────────
const passwordHash = hashPassword(password!);
await conn.execute(
`INSERT INTO local_credentials (user_id, username, password_hash)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), username = VALUES(username)`,
[userId, username, passwordHash],
);
console.log(`[reset-admin] Local credential upserted for user id=${userId}`);
console.log(`[reset-admin] Done. User id=${userId} is now a local admin.`);
} finally {
await conn.end();
}
+106 -5
View File
@@ -16,15 +16,24 @@
* skipping the DB upsert and getAuth path entirely. Other routes (e.g. events) * skipping the DB upsert and getAuth path entirely. Other routes (e.g. events)
* also read c.get('user') directly same pattern, no change needed there. * also read c.get('user') directly same pattern, no change needed there.
* *
* Phase 19 Option C (AUTH-LOCAL-16, D-14/D-15):
* devSessionCookieMiddleware() complements devAuthBypass() by issuing a real
* local-session JWT cookie for DEV_USER on each request that lacks one. This lets
* the PWA login gate (which checks the local-session cookie) see a valid session and
* skip to the app, so existing Phase 7/8 Playwright specs still reach the authed PWA
* without manual login. Mount AFTER devAuthBypass() in index.ts.
*
* Security: * Security:
* - The FIRST conditional is always `NODE_ENV === 'production'` checked before reading * - The FIRST conditional is always `NODE_ENV === 'production'` checked before reading
* any other env var. This is the hard guard (T-02-01). Even if DEV_AUTH_BYPASS is * any other env var. This is the hard guard (T-02-01 / T-19-24). Even if DEV_AUTH_BYPASS is
* accidentally set in production config, the guard fires and returns a no-op. * accidentally set in production config, the guard fires and returns a no-op.
* - The production Docker Compose MUST NOT set DEV_AUTH_BYPASS. See docs/deployment.md. * - The production Docker Compose MUST NOT set DEV_AUTH_BYPASS. See docs/deployment.md.
* - This file must never be removed the pattern is referenced by Plan 02 routes. * - This file must never be removed the pattern is referenced by Plan 02 routes.
*/ */
import type { MiddlewareHandler } from 'hono'; import type { MiddlewareHandler } from 'hono';
import { getCookie } from 'hono/cookie';
import { issueLocalSessionCookie } from './localSession.js';
import { COLOR_PALETTE } from './user.js'; import { COLOR_PALETTE } from './user.js';
export const DEV_USER = { export const DEV_USER = {
@@ -35,15 +44,32 @@ export const DEV_USER = {
color: COLOR_PALETTE[0], // '#4A90D9' — first palette slot color: COLOR_PALETTE[0], // '#4A90D9' — first palette slot
} as const; } as const;
/**
* The shape stored on c.get('user') across the bypass, local-session, and OIDC paths.
*
* BL-04: oidcIss/oidcSub are NULLABLE. Local users have null OIDC fields, and
* localAuthMiddleware must NOT fabricate sentinel ('local'/String(id)) values those
* share the uniqueness domain (uniq_oidc_identity) with real OIDC identities and could
* collide with a genuine (iss,sub) pair if ever persisted. DEV_USER carries non-null
* 'dev'/'dev-user' values and remains assignable to this widened shape.
*/
export interface ContextUser {
id: number;
oidcIss: string | null;
oidcSub: string | null;
displayName: string | null;
color: string;
}
/** /**
* Extend Hono's ContextVariableMap so that c.get('user') / c.set('user', ...) * Extend Hono's ContextVariableMap so that c.get('user') / c.set('user', ...)
* are statically typed throughout the app. The value type is the DEV_USER shape, * are statically typed throughout the app. The value type is ContextUser the shape
* which is compatible with both the bypass path and any future app-level user object * shared by the dev-bypass path, the local-session path (nullable oidc fields), and any
* stored on context (they share the same id/displayName/color subset). * future app-level user object stored on context.
*/ */
declare module 'hono' { declare module 'hono' {
interface ContextVariableMap { interface ContextVariableMap {
user: typeof DEV_USER; user: ContextUser;
} }
} }
@@ -74,3 +100,78 @@ export function devAuthBypass(): MiddlewareHandler {
await next(); await next();
}; };
} }
/**
* Phase 19 Option C (AUTH-LOCAL-16): issues a real local-session JWT cookie for DEV_USER
* so the PWA login gate sees a valid session and skips /login during dev-bypass runs.
*
* Mount AFTER devAuthBypass() on /api/* in index.ts. This middleware is a pure no-op
* passthrough in all non-bypass contexts:
* 1. NODE_ENV === 'production' immediate no-op (hard guard, T-19-24 / D-15)
* 2. DEV_AUTH_BYPASS !== 'true' immediate no-op (inactive outside bypass mode)
* 3. LOCAL_SESSION_SECRET not set no-op (issueLocalSessionCookie will throw, but
* in bypass mode the boot guard exempts the secret check skip gracefully)
* 4. 'local-session' cookie already present no-op (avoids re-signing on every request)
*
* Security: the production hard-guard is the FIRST check identical guard order to
* devAuthBypass() so assertNotDevBypassInProduction (IMG-01) catches both at boot.
*/
export function devSessionCookieMiddleware(): MiddlewareHandler {
// Hard production guard — FIRST check, before reading any other env var.
// Ensures this middleware can never issue a session cookie in production.
if (process.env.NODE_ENV === 'production') {
return async (_c, next) => next();
}
// Bypass flag not set — passthrough; no cookie is issued.
if (process.env.DEV_AUTH_BYPASS !== 'true') {
return async (_c, next) => next();
}
// LOCAL_SESSION_SECRET not set — bypass mode exempts the secret requirement
// (assertLocalSessionSecretSet skips when DEV_AUTH_BYPASS=true), but we cannot
// issue a cookie without it. Degrade gracefully so devAuthBypass still works.
const secret = process.env.LOCAL_SESSION_SECRET;
if (!secret) {
return async (_c, next) => next();
}
// BL-01: do not treat "present" as "safe". The boot guard's length floor
// (assertLocalSessionSecretSet, >= 32 chars) is SKIPPED in bypass mode, so apply the
// same floor here before minting a real, signature-valid local-session JWT for DEV_USER
// (id=1). A short/forgeable secret must NOT issue a genuine session token. Degrade to a
// no-op so the cookie is never signed with a weak key.
if (secret.length < 32) {
console.warn(
'[devSessionCookieMiddleware] LOCAL_SESSION_SECRET is shorter than 32 characters — ' +
'refusing to issue a dev local-session cookie. Generate a strong value with ' +
'node scripts/generate-secrets.mjs.',
);
return async (_c, next) => next();
}
// BL-01: warn loudly if the secret is the well-known dev placeholder. A genuine,
// signature-valid session token minted under this known value is trivially forgeable
// if the same secret ever leaks into a non-bypass environment.
// Not a security comparison — this matches against a PUBLIC well-known placeholder to
// emit a warning, so constant-time equality is irrelevant here.
// eslint-disable-next-line security/detect-possible-timing-attacks
if (secret === 'dev-secret-change-me-0000000000000000') {
console.warn(
'[devSessionCookieMiddleware] LOCAL_SESSION_SECRET is the well-known dev placeholder. ' +
'This is acceptable ONLY for local dev/CI under DEV_AUTH_BYPASS — never reuse this ' +
'value in any non-bypass or shared environment.',
);
}
// Bypass active + secret set: issue a real local-session cookie for DEV_USER
// on each request that does not already carry one.
return async (c, next) => {
const existing = getCookie(c, 'local-session');
if (!existing) {
// issueLocalSessionCookie is async (JWT sign) — await before next()
await issueLocalSessionCookie(c, DEV_USER.id);
}
await next();
};
}
+55
View File
@@ -0,0 +1,55 @@
/**
* linkNonceStore.ts single-use nonce store for the OIDC-link state (IN-04).
*
* POST /api/me/link-oidc mints a signed `state` JWT carrying a random `nonce` "to prevent
* replay". Previously the /callback handler never recorded or checked that nonce, so a
* captured state JWT was fully replayable within its 10-minute signature window the nonce
* provided no actual protection (this underlies the BL-03 takeover concern).
*
* This in-memory store makes the nonce genuinely single-use:
* - registerLinkNonce(nonce, expEpochSeconds): called when the state is issued.
* - consumeLinkNonce(nonce): called on /callback; returns true exactly ONCE per nonce
* (and only while unexpired), false on replay / unknown / expired.
*
* In-memory is sufficient for a single-process household deployment (same scope as the
* loginAttempts limiter). Expired entries are swept opportunistically on each access so the
* map stays bounded. If this app ever runs multi-process, move this to Redis.
*/
// nonce → expiry (epoch ms). Presence means "issued and not yet consumed".
const issuedNonces = new Map<string, number>();
function sweepExpired(now: number): void {
for (const [nonce, expiresAt] of issuedNonces) {
if (now >= expiresAt) issuedNonces.delete(nonce);
}
}
/**
* Record a freshly-issued link nonce as valid until expEpochSeconds (the state JWT's exp).
*/
export function registerLinkNonce(nonce: string, expEpochSeconds: number): void {
const now = Date.now();
sweepExpired(now);
issuedNonces.set(nonce, expEpochSeconds * 1000);
}
/**
* Consume a link nonce. Returns true exactly once for a known, unexpired nonce; false for
* any replay, unknown, or expired nonce. Single-use: the entry is deleted on first success.
*/
export function consumeLinkNonce(nonce: string): boolean {
const now = Date.now();
sweepExpired(now);
const expiresAt = issuedNonces.get(nonce);
if (expiresAt === undefined || now >= expiresAt) return false;
issuedNonces.delete(nonce); // single-use
return true;
}
/**
* Test-only: clear all issued nonces.
*/
export function _clearLinkNonces(): void {
issuedNonces.clear();
}
+88
View File
@@ -0,0 +1,88 @@
/**
* linkOidc.ts OIDC-link binding helper (AUTH-LOCAL-10, D-12).
*
* Exports:
* - OidcLinkConflictError: thrown when iss+sub already belongs to a DIFFERENT user.
* - linkOidcToUser(userId, iss, sub): binds oidc_iss+oidc_sub to the user row and
* deletes their local_credentials row in an atomic transaction.
*
* Security contract (T-19-08):
* - Preflight SELECT checks iss+sub uniqueness BEFORE any write.
* - If a different user already owns iss+sub: throw OidcLinkConflictError; NO write occurs.
* - db.transaction wraps the UPDATE users + DELETE local_credentials so both succeed or
* neither does no partial state where oidc is bound but local cred survives or vice versa.
* - Identity binding uses iss+sub ONLY never the user's address field (D-10).
* - The uniq_oidc_identity DB constraint on users is the backstop behind the preflight
* (RESEARCH Pitfall 6 preflight prevents the race-case before hitting the constraint).
*
* Called by:
* - apps/api/src/routes/me.ts POST /link-oidc (initiates OIDC flow, state carries userId)
* - apps/api/src/routes/localAuth.ts /callback (19-03) reads linkUserId from state,
* calls linkOidcToUser after verifying the OIDC token.
*/
import { and, eq, ne } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users, localCredentials } from '../db/schema.js';
/**
* Thrown by linkOidcToUser when iss+sub already belongs to a DIFFERENT user.
*
* Callers should translate this to a 409 response or error-redirect to the PWA.
* The thrown error deliberately carries no raw iss/sub values to avoid leaking
* identity correlation info in error logs (T-19-08).
*/
export class OidcLinkConflictError extends Error {
readonly name = 'OidcLinkConflictError';
constructor() {
super('OIDC identity already linked to a different account');
Object.setPrototypeOf(this, OidcLinkConflictError.prototype);
}
}
/**
* Bind an OIDC identity (iss+sub) to the given userId and delete that user's
* local_credentials row (D-12: OIDC-link replaces local credential).
*
* Steps:
* 1. Preflight SELECT: check if iss+sub belongs to a user with id userId.
* If so: throw OidcLinkConflictError (NO writes).
* 2. db.transaction:
* a. UPDATE users SET oidc_iss=iss, oidc_sub=sub, claimed=true WHERE id=userId
* b. DELETE FROM local_credentials WHERE user_id=userId
* (user becomes OIDC-only; no local credential remains)
*
* D-10 constraint: binding is strictly iss+sub no address claim or contact field used.
* T-19-08: abort before any write on conflict; uniq_oidc_identity constraint is backstop.
*
* @throws OidcLinkConflictError if iss+sub is already owned by a DIFFERENT userId.
*/
export async function linkOidcToUser(userId: number, iss: string, sub: string): Promise<void> {
// Preflight: check if another user already holds this iss+sub (T-19-08 / RESEARCH Pitfall 6)
// We SELECT WHERE oidc_iss=iss AND oidc_sub=sub AND id ≠ userId — only a DIFFERENT user is a conflict.
// If the same userId already has iss+sub (idempotent re-link): allow the UPDATE to proceed.
const [conflicting] = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.oidcIss, iss), eq(users.oidcSub, sub), ne(users.id, userId)))
.limit(1);
if (conflicting) {
// iss+sub belongs to a DIFFERENT user — abort before any write (T-19-08)
throw new OidcLinkConflictError();
}
// Atomic: UPDATE users + DELETE local_credentials — both or neither (D-12)
await db.transaction(async (tx) => {
// a. Bind the OIDC identity and mark the user as claimed
await tx
.update(users)
.set({ oidcIss: iss, oidcSub: sub, claimed: true })
.where(eq(users.id, userId));
// b. Delete the local_credentials row — user is now OIDC-only (D-12)
// Silently succeeds even if no local_credentials row exists (DELETE 0 rows is fine)
await tx.delete(localCredentials).where(eq(localCredentials.userId, userId));
});
}
+101
View File
@@ -0,0 +1,101 @@
/**
* localAuthMiddleware.ts local-session cookie c.set('user') middleware (AUTH-LOCAL-04).
*
* Reads the `local-session` cookie, validates the JWT, fetches the users row,
* and populates `c.get('user')` with the same shape as devAuthBypass so that all
* downstream routes work unchanged.
*
* Mount in index.ts AFTER devAuthBypass() and BEFORE the OIDC guard:
* app.use('/api/*', devAuthBypass());
* app.use('/api/*', localAuthMiddleware()); HERE
* if (!devBypassActive) {
* app.use('/api/*', oidcAuthMiddleware()); skip if c.get('user') set
* }
*
* Security contract:
* - If c.get('user') is already set (devAuthBypass ran first): no-op passthrough.
* This is Test 4 dev user is not overwritten.
* - If no 'local-session' cookie is present: pure passthrough WITHOUT calling
* c.set('user', undefined). The OIDC guard fires on falsy c.get('user') only when
* the value was never set calling c.set('user', undefined) would suppress it.
* This is Pitfall-1 / Test 2.
* - If cookie is valid but the users row is gone: passthrough (no crash).
* This is Test 3.
*
* ContextVariableMap: the `user` shape is declared in devBypass.ts. Import it as a
* side-effect so c.set('user', ...) is typed correctly throughout this file.
*/
// Side-effect import: extends ContextVariableMap with the `user` key (Pitfall — shared shape).
import './devBypass.js';
import type { MiddlewareHandler } from 'hono';
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users } from '../db/schema.js';
import { verifyLocalSessionCookie } from './localSession.js';
import type { ContextUser } from './devBypass.js';
/**
* Returns a Hono MiddlewareHandler that:
* 1. Checks if c.get('user') is already set no-op if so (devAuthBypass-first).
* 2. Calls verifyLocalSessionCookie(c) returns null if no cookie / invalid / expired.
* 3. On null: calls next() WITHOUT setting user (pure passthrough OIDC guard can fire).
* 4. On valid userId: SELECTs the users row; if found, c.set('user', {...}); always next().
*/
export function localAuthMiddleware(): MiddlewareHandler {
return async (c, next) => {
// If a prior middleware (devAuthBypass) already set the user, do not overwrite.
if (c.get('user')) {
await next();
return;
}
// Verify the local-session JWT cookie — returns userId or null.
// verifyLocalSessionCookie returns null (never throws) on any error (Pitfall 9 guard).
const userId = await verifyLocalSessionCookie(c);
if (userId === null) {
// No valid local session — pass through WITHOUT setting c.get('user').
// CRITICAL: Do NOT call c.set('user', undefined) — that sets the key to undefined
// which is falsy but "set", breaking the OIDC guard's c.get('user') check (Pitfall 1).
await next();
return;
}
// Load the users row to populate the same shape as DEV_USER.
const [row] = await db
.select({
id: users.id,
oidcIss: users.oidcIss,
oidcSub: users.oidcSub,
displayName: users.displayName,
color: users.color,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!row) {
// userId from JWT but no users row (deleted user) — passthrough without setting user.
await next();
return;
}
// Populate c.get('user') with the ContextUser shape (devBypass.ts ContextVariableMap).
// BL-04: keep oidcIss/oidcSub as NULL for local users — do NOT fabricate
// 'local'/String(id) sentinels. Those values share the uniq_oidc_identity uniqueness
// domain with real OIDC identities, so persisting them (e.g. a future upsertUser call
// using these context values) would let two local users collide or a local user shadow
// a genuine OIDC identity. ContextUser widens oidcIss/oidcSub to string | null so no
// cast is needed.
c.set('user', {
id: row.id,
oidcIss: row.oidcIss ?? null,
oidcSub: row.oidcSub ?? null,
displayName: row.displayName ?? null,
color: row.color ?? '#4A90D9',
} satisfies ContextUser);
await next();
};
}
+120
View File
@@ -0,0 +1,120 @@
/**
* localCredentials.ts password hashing and verification using node:crypto scrypt.
*
* D-08: Uses node:crypto scrypt zero new npm dependencies; no native node-gyp build
* in the Docker image. PHC-style encoded format allows parameter evolution without
* a separate DB migration.
*
* Security properties:
* - 16-byte per-hash random salt unique salt per password prevents rainbow table attacks
* - scrypt parameters: N=16384 (2^14), r=8, p=1 OWASP-compatible
* - 32-byte (256-bit) output key
* - timingSafeEqual for constant-time comparison prevents timing oracle attacks
* - verifyPassword never throws returns false on any parse/format/crypto error
* - Passwords are never logged
*
* Encoded format: scrypt$N$r$p$<salt_base64url>$<hash_base64url>
* Example: scrypt$16384$8$1$<22-char-b64url>$<43-char-b64url>
* Max length: ~83 chars fits in varchar(256) password_hash column
*/
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import type { BinaryLike, ScryptOptions } from 'node:crypto';
// WR-03: use the ASYNC scrypt (libuv threadpool) so password hashing does NOT block the
// single Node event loop. scryptSync ran on the main thread, so a burst of unauthenticated
// POST /local/login requests (each running scrypt N=16384, ~tens of ms of CPU, including the
// always-run dummy-hash path) could pin the loop and stall ALL other API traffic — a cheap
// unauthenticated DoS. This async wrapper offloads the CPU to the threadpool, preserving the
// timing-defense property while keeping the loop responsive.
//
// A hand-rolled Promise wrapper is used (rather than promisify) because promisify's typings
// do not cover the options-carrying scrypt overload (N/r/p).
function scryptAsync(
password: BinaryLike,
salt: BinaryLike,
keylen: number,
options: ScryptOptions,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, keylen, options, (err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey);
});
});
}
// OWASP-compatible scrypt parameters for password hashing
const SCRYPT_N = 16384; // CPU/memory cost factor (2^14)
const SCRYPT_R = 8; // block size
const SCRYPT_P = 1; // parallelization factor
const KEY_LEN = 32; // 256-bit derived key output
/**
* Hash a password using scrypt with a random 16-byte salt.
*
* Returns a self-describing PHC-style encoded string:
* scrypt$N$r$p$<salt_base64url>$<hash_base64url>
*
* The encoded format embeds all parameters so verifyPassword can re-derive
* the hash without relying on hardcoded constants supports future parameter
* migration without a DB schema change.
*
* WR-03: async scrypt runs on the libuv threadpool, not the event loop.
*/
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const hash = await scryptAsync(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 PHC-encoded hash.
*
* Parses the algorithm parameters from the stored string, re-derives the
* candidate hash using scryptSync, and compares with timingSafeEqual to
* prevent timing-oracle attacks.
*
* Security:
* - timingSafeEqual: requires equal-length buffers; storedHash.length as keylen
* ensures this regardless of the stored KEY_LEN at hash time.
* - Returns false (never throws) on any parse error, invalid base64url, or
* scrypt parameter error safe to call with untrusted input.
* - Never logs the candidate password.
*
* WR-03: async scrypt runs on the libuv threadpool, not the event loop.
*
* @returns Promise<true> if candidate matches the stored hash; Promise<false> otherwise
* (including all parse/format/crypto errors never rejects).
*/
export async function verifyPassword(storedEncoded: string, candidate: string): Promise<boolean> {
try {
const parts = storedEncoded.split('$');
if (parts.length !== 6) return false;
const [, n, r, p, saltB64, hashB64] = parts;
const salt = Buffer.from(saltB64, 'base64url');
const storedHash = Buffer.from(hashB64, 'base64url');
if (salt.length === 0 || storedHash.length === 0) return false;
const candidateHash = await scryptAsync(candidate, salt, storedHash.length, {
N: Number(n),
r: Number(r),
p: Number(p),
});
return timingSafeEqual(storedHash, candidateHash);
} catch {
// Catch any scrypt parameter errors, buffer errors, or other crypto exceptions.
// Never propagate — return false for all error cases.
return false;
}
}
+117
View File
@@ -0,0 +1,117 @@
/**
* localSession.ts stateless JWT session-cookie helpers for local auth (D-05).
*
* Issues and verifies a signed httpOnly JWT cookie named 'local-session',
* distinct from the OIDC cookie 'oidc-auth' (Pitfall 4).
*
* Uses Hono's built-in Jwt from 'hono/utils/jwt' via the { Jwt } namespace import
* (Pitfall 8 named sign/verify do not exist; Jwt.sign/Jwt.verify is correct).
*
* Security properties (T-19-02):
* - HS256 signed with LOCAL_SESSION_SECRET from env (never stored in DB SC-3)
* - httpOnly: true not accessible from client JavaScript
* - secure: true in production, false in non-production (allows local dev over HTTP)
* - sameSite: 'Lax' CSRF mitigation for browser navigation
* - Jwt.verify throws on expiry (Pitfall 9) verifyLocalSessionCookie wraps in try/catch
* - verifyLocalSessionCookie returns null (never throws) on any error
*
* Boot guard:
* assertLocalSessionSecretSet() in lib/bootGuards.ts refuses to start if secret
* is missing or < 32 chars when not in dev-bypass mode (Pitfall 10 / T-19-03).
*/
import { Jwt } from 'hono/utils/jwt';
import { setCookie, getCookie, deleteCookie } from 'hono/cookie';
import type { Context } from 'hono';
// Cookie name must be distinct from the OIDC cookie 'oidc-auth' (Pitfall 4)
const COOKIE_NAME = 'local-session';
// Session max age: default 1 day (86400s); configurable via LOCAL_SESSION_EXPIRES env.
// IN-01: validate the coercion. A malformed value yields NaN, which would produce a JWT
// with exp = now + NaN (→ NaN) and a cookie maxAge: NaN — making verify behaviour
// "always expired" or "never expires" depending on the lib's NaN handling. Fall back to
// the 86400s default for any non-finite or non-positive value.
const SESSION_MAX_AGE_SECONDS = (() => {
const n = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400);
return Number.isFinite(n) && n > 0 ? n : 86400;
})();
/**
* Issue a signed local-session JWT cookie for the given userId.
*
* The JWT payload carries: { userId, iat, exp } (HS256, signed with LOCAL_SESSION_SECRET).
* Throws if LOCAL_SESSION_SECRET is not set the boot guard should have caught this.
*/
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,
};
// Pitfall 8: use Jwt.sign (namespace import), NOT named sign from hono/utils/jwt
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,
});
}
/**
* Verify the local-session JWT cookie and return the userId, or null.
*
* Returns null (never throws) when:
* - LOCAL_SESSION_SECRET is not set
* - No 'local-session' cookie is present
* - The JWT is expired (Jwt.verify throws JwtTokenExpired caught here, Pitfall 9)
* - The JWT has been tampered with
* - The payload.userId is not a number
* - Any other crypto/parse error
*/
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 {
// Pitfall 8: Jwt.sign/Jwt.verify (namespace import — NOT named exports)
// Pitfall 9: Jwt.verify throws on expiry — must catch all errors and return null
const payload = await Jwt.verify(token, secret, 'HS256');
return typeof payload.userId === 'number' ? payload.userId : null;
} catch {
// Includes JwtTokenExpired, tampered signature, malformed token, etc.
return null;
}
}
/**
* Clear the local-session cookie.
*
* Cookie attributes must match the ones set on issue so the browser correctly
* expires the cookie (path, httpOnly, sameSite all must match).
*/
export function clearLocalSessionCookie(c: Context): void {
deleteCookie(c, COOKIE_NAME, {
path: '/',
httpOnly: true,
// BL-02: mirror the issue-time `secure` logic. issueLocalSessionCookie sets
// secure:false over plain HTTP (non-production), and a browser will REJECT a
// Secure delete-cookie sent over HTTP — so a hard-coded secure:true left the
// local-session cookie uncleared on every non-HTTPS deployment (local dev and any
// HTTP-only self-host), leaving the user "logged in" after logout. Match the
// context so the deletion cookie is accepted.
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
});
}
+4 -4
View File
@@ -1,12 +1,12 @@
/** /**
* OIDC authentication middleware wiring. * OIDC authentication middleware wiring.
* *
* Configures @hono/oidc-auth for Authelia as the identity provider. * Configures @hono/oidc-auth for the generic OIDC identity provider (D-06).
* *
* Required env vars: * Required env vars:
* OIDC_AUTH_SECRET 32+ char random string for JWT cookie signing (T-02-03) * OIDC_AUTH_SECRET 32+ char random string for JWT cookie signing (T-02-03)
* OIDC_ISSUER Authelia base URL (middleware fetches /.well-known/openid-configuration) * OIDC_ISSUER OIDC issuer URL (middleware fetches /.well-known/openid-configuration)
* OIDC_CLIENT_ID registered client ID in Authelia * OIDC_CLIENT_ID registered client ID at the OIDC provider
* OIDC_CLIENT_SECRET PLAIN text secret (NOT the pbkdf2 hash see Pitfall 7) * OIDC_CLIENT_SECRET PLAIN text secret (NOT the pbkdf2 hash see Pitfall 7)
* OIDC_REDIRECT_URI https://familysync.<domain>/callback * OIDC_REDIRECT_URI https://familysync.<domain>/callback
* OIDC_AUTH_EXTERNAL_URL https://familysync.<domain> — MANDATORY behind Pangolin (Pitfall 1) * OIDC_AUTH_EXTERNAL_URL https://familysync.<domain> — MANDATORY behind Pangolin (Pitfall 1)
@@ -30,7 +30,7 @@
* Every OIDC_AUTH_REFRESH_INTERVAL (default 15 min) the middleware calls * Every OIDC_AUTH_REFRESH_INTERVAL (default 15 min) the middleware calls
* the token endpoint with the stored refresh token no iframe required (D-12). * the token endpoint with the stored refresh token no iframe required (D-12).
* Session lifespan is governed by OIDC_AUTH_EXPIRES (default 1 day) and * Session lifespan is governed by OIDC_AUTH_EXPIRES (default 1 day) and
* Authelia's refresh_token_lifespan. * the OIDC provider's refresh_token_lifespan.
* *
* Scopes: openid, profile, email only no 'groups' scope (D-11). * Scopes: openid, profile, email only no 'groups' scope (D-11).
* *
+91
View File
@@ -0,0 +1,91 @@
/**
* oidcConfig.ts centralized "is OIDC configured" resolution + endpoint discovery.
*
* WR-04: three sites previously had independent notions of whether OIDC is configured:
* - routes/authMode.ts OIDC_ISSUER env OR app_config.oidc_issuer
* - auth/middleware.ts injects issuer/client-id/external-url from app_config
* - routes/me.ts (link-oidc) ONLY env (OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_REDIRECT_URI)
*
* The me.ts divergence meant a wizard-configured-but-not-restarted instance reported
* oidcEnabled:true from /api/auth/mode (and showed the "Link OIDC" button) but link-oidc
* returned authorizationUrl:null. This helper makes the env-OR-app_config resolution a
* single source of truth.
*
* WR-02: me.ts also hardcoded Authelia's `/api/oidc/authorization` path. The project's
* design is to discover endpoints from the issuer's /.well-known/openid-configuration
* document (the whole reason @hono/oidc-auth is used). discoverAuthorizationEndpoint()
* resolves the real authorization_endpoint so any RFC-compliant provider works.
*/
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { appConfig } from '../db/schema.js';
export interface ResolvedOidcConfig {
issuer: string;
clientId: string;
redirectUri: string;
}
/**
* Read a single app_config value (or null when absent).
*/
async function readAppConfig(key: string): Promise<string | null> {
const [row] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
return row?.value ?? null;
}
/**
* Resolve issuer + clientId + redirectUri using env first, then app_config (WR-04).
*
* Returns null when any of the three cannot be resolved from EITHER source i.e. OIDC is
* not (fully) configured, so no authorization URL can be built. The redirect URI is taken
* from OIDC_REDIRECT_URI when present, else derived from the external app URL
* (OIDC_AUTH_EXTERNAL_URL env or app_config.app_external_url) as `${externalUrl}/callback`,
* matching the middleware's redirect-uri construction.
*/
export async function resolveOidcConfig(): Promise<ResolvedOidcConfig | null> {
const issuer = process.env.OIDC_ISSUER ?? (await readAppConfig('oidc_issuer'));
const clientId = process.env.OIDC_CLIENT_ID ?? (await readAppConfig('oidc_client_id'));
let redirectUri = process.env.OIDC_REDIRECT_URI ?? null;
if (!redirectUri) {
const externalUrl =
process.env.OIDC_AUTH_EXTERNAL_URL ?? (await readAppConfig('app_external_url'));
if (externalUrl) {
redirectUri = `${externalUrl.replace(/\/$/, '')}/callback`;
}
}
if (!issuer || !clientId || !redirectUri) return null;
return { issuer, clientId, redirectUri };
}
/**
* Discover the provider's authorization_endpoint from its OIDC discovery document (WR-02).
*
* Fetches `${issuer}/.well-known/openid-configuration` (5s timeout, matching the setup
* wizard's validate/oidc step) and returns the `authorization_endpoint` URL. Returns null
* on any network error, non-2xx, or missing field callers treat null as "cannot build
* the authorization URL" and degrade gracefully (the PWA disables the Link button).
*/
export async function discoverAuthorizationEndpoint(issuer: string): Promise<string | null> {
try {
const res = await fetch(`${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const doc = (await res.json()) as { authorization_endpoint?: unknown };
return typeof doc.authorization_endpoint === 'string' ? doc.authorization_endpoint : null;
} catch (err) {
console.error(
'[oidcConfig/discoverAuthorizationEndpoint]',
err instanceof Error ? err.message : String(err),
);
return null;
}
}
@@ -0,0 +1,14 @@
CREATE TABLE `local_credentials` (
`id` int AUTO_INCREMENT NOT NULL,
`user_id` int NOT NULL,
`username` varchar(128) NOT NULL,
`password_hash` varchar(256) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `local_credentials_id` PRIMARY KEY(`id`),
CONSTRAINT `uniq_local_cred_user` UNIQUE(`user_id`),
CONSTRAINT `uniq_local_cred_username` UNIQUE(`username`)
);
--> statement-breakpoint
ALTER TABLE `local_credentials` ADD CONSTRAINT `local_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_local_credentials_user_id` ON `local_credentials` (`user_id`);
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,13 @@
"when": 1781545048917, "when": 1781545048917,
"tag": "0002_lethal_millenium_guard", "tag": "0002_lethal_millenium_guard",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1781727317172,
"tag": "0003_warm_deathstrike",
"breakpoints": true
} }
] ]
} }
+41
View File
@@ -309,6 +309,47 @@ export const appConfig = mysqlTable('app_config', {
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
}); });
/**
* Local authentication credentials per member (Phase 19 D-09).
*
* Stores username + PHC-encoded scrypt password hash for members who authenticate
* via local username/password rather than (or before) OIDC.
*
* Design decisions:
* - Separate table from `users` to keep the users row identity-method-agnostic (D-09).
* - A user has a local login iff a `local_credentials` row exists (UNIQUE on user_id).
* - OIDC-link flow (D-12): when a local user links OIDC, their `local_credentials`
* row is deleted they become OIDC-only.
* - CASCADE DELETE on users.id keeps credentials clean when a member is removed.
* - username is globally unique (login identifier, separate from displayName).
* - password_hash is PHC-encoded: scrypt$N$r$p$<salt_b64url>$<hash_b64url> (varchar 256).
*
* PROHIBITION: LOCAL_SESSION_SECRET (the signing key for this table's sessions) is an
* env-only secret and must NEVER be stored in this table or app_config (SC-3).
*/
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_base64url>$<hash_base64url> — max ~83 chars
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 — user_id is unique (D-09: auth method is per-user property)
unique('uniq_local_cred_user').on(t.userId),
// Username is globally unique (login identifier; case-sensitive per MariaDB default)
unique('uniq_local_cred_username').on(t.username),
// Index for fast lookup by user_id (e.g., on middleware / self-change-password)
index('idx_local_credentials_user_id').on(t.userId),
],
);
/** /**
* Items within a list. * Items within a list.
* *
+143 -8
View File
@@ -11,17 +11,25 @@ import { listsRouter, listItemsRouter } from './routes/lists.js';
import { pushRouter } from './routes/push.js'; import { pushRouter } from './routes/push.js';
import { adminRouter } from './routes/admin.js'; import { adminRouter } from './routes/admin.js';
import { setupRouter } from './routes/setup.js'; import { setupRouter } from './routes/setup.js';
import { authModeRouter } from './routes/authMode.js';
import { localAuthRouter } from './routes/localAuth.js';
import { import {
oidcAuthMiddleware, oidcAuthMiddleware,
processOAuthCallback, processOAuthCallback,
oidcConfigFallbackMiddleware, oidcConfigFallbackMiddleware,
} from './auth/middleware.js'; } from './auth/middleware.js';
import { devAuthBypass } from './auth/devBypass.js'; import { devAuthBypass, devSessionCookieMiddleware } from './auth/devBypass.js';
import { localAuthMiddleware } from './auth/localAuthMiddleware.js';
import { verifyLocalSessionCookie } from './auth/localSession.js';
import { consumeLinkNonce } from './auth/linkNonceStore.js';
import { persistSessionCookie } from './auth/persistSessionCookie.js'; import { persistSessionCookie } from './auth/persistSessionCookie.js';
import { startBrokerPoller } from './broker/poller.js'; import { startBrokerPoller } from './broker/poller.js';
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js'; import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
import { startReminderScheduler } from './broker/reminderScheduler.js'; import { startReminderScheduler } from './broker/reminderScheduler.js';
import { assertNotDevBypassInProduction } from './lib/bootGuards.js'; import { assertNotDevBypassInProduction, assertLocalSessionSecretSet } from './lib/bootGuards.js';
import { getAuth } from './auth/middleware.js';
import { Jwt } from 'hono/utils/jwt';
import { linkOidcToUser, OidcLinkConflictError } from './auth/linkOidc.js';
import webpush from 'web-push'; import webpush from 'web-push';
export const app = new Hono(); export const app = new Hono();
@@ -36,8 +44,99 @@ if (devBypassActive) {
} }
// OIDC callback — must be registered BEFORE oidcAuthMiddleware so the // OIDC callback — must be registered BEFORE oidcAuthMiddleware so the
// authorization-code exchange is not itself intercepted by the auth check (T-02-02) // authorization-code exchange is not itself intercepted by the auth check (T-02-02).
app.get('/callback', (c) => processOAuthCallback(c)); //
// Phase 19 — link mode (AUTH-LOCAL-10, T-19-15):
// When POST /api/me/link-oidc initiates a link flow, a signed JWT state is included
// in the authorization URL as the `state` parameter. On callback, we read that raw URL
// state param, try to decode it as our signed JWT, and if it carries `linkUserId`, we
// call linkOidcToUser after processOAuthCallback establishes the OIDC session.
//
// Security: the signed state JWT prevents CSRF (T-19-09); linkOidcToUser preflight
// prevents account takeover via conflict (T-19-15 / T-19-08).
//
// Normal (non-link) callbacks are unaffected — processOAuthCallback is called in all paths.
app.get('/callback', async (c) => {
// Attempt to extract linkUserId from the signed state param BEFORE processOAuthCallback
// consumes it. The state param may be our signed JWT (link mode) or a random string (normal).
let linkUserId: number | null = null;
let linkNonce: string | null = null;
const rawState = c.req.query('state');
if (rawState) {
const secret = process.env.LOCAL_SESSION_SECRET;
if (secret) {
try {
const payload = await Jwt.verify(rawState, secret, 'HS256');
if (typeof payload.linkUserId === 'number') {
linkUserId = payload.linkUserId;
linkNonce = typeof payload.nonce === 'string' ? payload.nonce : null;
}
} catch {
// Not our signed link state — normal OIDC callback, proceed normally.
}
}
}
// Process the OIDC authorization-code exchange (sets the OIDC session cookie + redirects).
const callbackResponse = await processOAuthCallback(c);
// Link mode: after session is established, bind the OIDC identity to the local user.
if (linkUserId !== null) {
try {
// IN-04: enforce SINGLE USE of the link nonce. The signed state JWT is otherwise
// replayable for its full 10-minute signature lifetime; consuming the nonce here means
// a captured state can be used at most once. A replay (already-consumed), unknown, or
// expired nonce is rejected before any binding occurs.
if (!linkNonce || !consumeLinkNonce(linkNonce)) {
console.warn('[callback] OIDC-link rejected: link nonce missing, replayed, or expired.');
return c.redirect('/?error=oidc-link-conflict');
}
// BL-03: cross-check that the local session completing this callback is the SAME
// user the link flow was initiated for. The signed `state` JWT proves the state was
// minted by POST /api/me/link-oidc, but NOT that the person finishing the OIDC login
// is that user. Without this check, an attacker who gets a victim to complete an OIDC
// login while replaying a still-valid (10-min) captured link state would bind the
// ATTACKER's OIDC identity onto the VICTIM's account (account takeover). Require the
// initiating local session to still be present and to match linkUserId.
const sessionUserId = await verifyLocalSessionCookie(c);
if (sessionUserId !== linkUserId) {
console.warn(
'[callback] OIDC-link rejected: local session does not match link state (possible replay).',
);
return c.redirect('/?error=oidc-link-conflict');
}
const auth = await getAuth(c);
if (auth) {
const iss = (auth.iss as string | undefined) ?? '';
const sub = auth.sub ?? '';
// BL-03: never bind on a blank/partial identity. linkOidcToUser writes oidc_iss/
// oidc_sub AND deletes the user's local_credentials — binding empty iss/sub would
// both corrupt identity and lock the user out of BOTH auth methods. Reject instead.
if (!iss || !sub) {
console.warn('[callback] OIDC-link rejected: empty iss/sub from getAuth.');
return c.redirect('/?error=oidc-link-conflict');
}
await linkOidcToUser(linkUserId, iss, sub);
// On success: user is now OIDC-only; normal redirect via callbackResponse proceeds.
}
} catch (err) {
if (err instanceof OidcLinkConflictError) {
// 409: iss+sub already linked to a different user — redirect to conflict error page.
// UI-SPEC Surface 13 error copy: "This OIDC identity is already linked to another account."
return c.redirect('/?error=oidc-link-conflict');
}
// Unexpected error during link binding — log and continue with normal redirect.
console.error(
'[callback] linkOidcToUser unexpected error:',
err instanceof Error ? err.message : String(err),
);
}
}
return callbackResponse;
});
// GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05) // GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05)
app.route('/health', healthRouter); app.route('/health', healthRouter);
@@ -48,15 +147,32 @@ app.route('/health', healthRouter);
// 423 lock after setup is complete (SETUP-04 / D-10). // 423 lock after setup is complete (SETUP-04 / D-10).
app.route('/api/setup', setupRouter); app.route('/api/setup', setupRouter);
// Phase 19 — pre-auth auth routes: GET /api/auth/mode and POST /api/auth/local/login, /logout.
// Mounted BEFORE devAuthBypass so they are reachable without a session (D-01 / AUTH-LOCAL-05).
app.route('/api/auth', authModeRouter);
app.route('/api/auth', localAuthRouter);
// Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'. // Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'.
// When active, injects DEV_USER into the Hono context and the OIDC guard is NOT mounted. // When active, injects DEV_USER into the Hono context and the OIDC guard is NOT mounted.
// Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts). // Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts).
app.use('/api/*', devAuthBypass()); app.use('/api/*', devAuthBypass());
// Phase 19 Option C (AUTH-LOCAL-16, D-14/D-15): issue a real local-session cookie for DEV_USER
// under bypass so the PWA login gate sees a valid session and skips /login. Pure no-op outside
// bypass mode (production guard is FIRST check — T-19-24; see auth/devBypass.ts).
// Mount AFTER devAuthBypass() so DEV_USER is already in context; BEFORE localAuthMiddleware.
app.use('/api/*', devSessionCookieMiddleware());
// Phase 19 — local-session middleware: sets c.get('user') from 'local-session' JWT cookie.
// No-op passthrough when no cookie is present — the OIDC guard fires for unauthenticated.
// Runs AFTER devAuthBypass (which may set c.get('user') first) and BEFORE the OIDC guard.
// The OIDC guard below is wrapped to skip when c.get('user') is already set (Pitfall 1 guard).
app.use('/api/*', localAuthMiddleware());
// Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05). // Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05).
// Skipped entirely when devBypassActive so that local dev works without Authelia. // Skipped entirely when devBypassActive so that local dev works without the OIDC provider.
// In production devBypassActive is always false — OIDC is unconditionally mounted. // In production devBypassActive is always false — OIDC is unconditionally mounted.
// Unauthenticated requests receive a 302 redirect to Authelia's authorize endpoint. // Unauthenticated requests receive a 302 redirect to the OIDC authorize endpoint.
// OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct // OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct
// redirect_uri (Pitfall 1). Set it to https://familysync.<domain>. // redirect_uri (Pitfall 1). Set it to https://familysync.<domain>.
if (!devBypassActive) { if (!devBypassActive) {
@@ -67,7 +183,24 @@ if (!devBypassActive) {
// A2 CONFIRMED: oidcAuthMiddleware() reads process.env per-request (call time), so // A2 CONFIRMED: oidcAuthMiddleware() reads process.env per-request (call time), so
// injecting into process.env here is safe and effective (see auth/middleware.ts A2 note). // injecting into process.env here is safe and effective (see auth/middleware.ts A2 note).
app.use('/api/*', oidcConfigFallbackMiddleware); app.use('/api/*', oidcConfigFallbackMiddleware);
app.use('/api/*', oidcAuthMiddleware()); // Phase 19 / D-03: OIDC guard wrapped to skip when c.get('user') is already set.
// A valid local-session (or dev-bypass) user must NOT be 302-redirected to the OIDC
// provider — the skip-when-set wrapper is the coexistence seam (D-03 / RESEARCH Pitfall 1).
//
// IMPORTANT: oidcAuthMiddleware() factory is called ONCE at app construction time (not per
// request) to match the prior behavior and keep test assertions about "called once" valid.
// The returned handler is stored and invoked per-request inside the wrapper.
const oidcHandler = oidcAuthMiddleware();
app.use('/api/*', async (c, next) => {
if (c.get('user')) {
await next();
return;
}
// oidcHandler's parameter is typed as the generic Hono Context; our wrapper's c is the
// same runtime Context narrowed to '/api/*' — the structural mismatch is type-only.
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
await oidcHandler(c, next);
});
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02). // Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
app.use('/api/*', persistSessionCookie()); app.use('/api/*', persistSessionCookie());
} }
@@ -76,7 +209,7 @@ if (!devBypassActive) {
// GET /api/login — login entry point for the PWA. // GET /api/login — login entry point for the PWA.
// Flow (production): unauthenticated top-level nav hits the OIDC guard above, // Flow (production): unauthenticated top-level nav hits the OIDC guard above,
// which 302-redirects to Authelia. After login, Authelia POSTs to /callback, // which 302-redirects to the OIDC provider. After login, the provider POSTs to /callback,
// the middleware sets a `continue` cookie pointing back to /api/login, and the // the middleware sets a `continue` cookie pointing back to /api/login, and the
// browser follows it here — now authenticated. The handler then redirects to / // browser follows it here — now authenticated. The handler then redirects to /
// so the SPA boots with a valid session. Under DEV_AUTH_BYPASS the guard is not // so the SPA boots with a valid session. Under DEV_AUTH_BYPASS the guard is not
@@ -134,6 +267,8 @@ function isMainModule(): boolean {
if (isMainModule()) { if (isMainModule()) {
// D-08: Production safety guard — must be FIRST, before VAPID config, workers, or serve(). // D-08: Production safety guard — must be FIRST, before VAPID config, workers, or serve().
assertNotDevBypassInProduction(); assertNotDevBypassInProduction();
// D-05 / T-19-03: Refuse to start if LOCAL_SESSION_SECRET is missing/short in non-bypass mode.
assertLocalSessionSecretSet();
// Configure VAPID credentials for web-push before starting background workers. // Configure VAPID credentials for web-push before starting background workers.
// VAPID_SUBJECT must be a mailto: or https: URL identifying the operator. // VAPID_SUBJECT must be a mailto: or https: URL identifying the operator.
+32
View File
@@ -32,3 +32,35 @@ export function assertNotDevBypassInProduction(): void {
process.exit(1); process.exit(1);
} }
} }
/**
* Refuses to start the process when LOCAL_SESSION_SECRET is absent or shorter
* than 32 characters, UNLESS dev-bypass mode is active.
*
* Rationale (D-05 / T-19-03 / Pitfall 10):
* LOCAL_SESSION_SECRET signs the local-session JWT cookie. A missing or weak
* secret means any issued session cookie can be trivially forged. This boot
* guard converts a silent misconfiguration into an immediate loud failure
* instead of letting the API start and issue insecure JWTs.
*
* DEV_AUTH_BYPASS=true is exempt: bypass mode never issues local-session cookies
* (the OIDC/dev-bypass path handles auth), so the secret is not required there.
* This mirrors assertNotDevBypassInProduction's exempt logic.
*
* Call immediately after assertNotDevBypassInProduction() in the isMainModule()
* boot block in index.ts.
*/
export function assertLocalSessionSecretSet(): void {
// Exempt when dev-bypass is active — bypass mode doesn't issue local-session JWTs
if (process.env.DEV_AUTH_BYPASS === 'true') return;
const secret = process.env.LOCAL_SESSION_SECRET;
if (!secret || secret.length < 32) {
console.error(
'[FATAL] LOCAL_SESSION_SECRET is not set or is shorter than 32 characters. ' +
'This secret signs local-session JWT cookies. Refusing to start. ' +
'Run: node scripts/generate-secrets.mjs to generate a value.',
);
process.exit(1);
}
}
+175 -8
View File
@@ -11,10 +11,12 @@
* - No console.log of request bodies or passwords in any handler (T-10-10). * - No console.log of request bodies or passwords in any handler (T-10-10).
* *
* Routes: * Routes:
* GET /api/admin/members list members + credential status (UI-SPEC Surface 2) * GET /api/admin/members list members + credential status (UI-SPEC Surface 2)
* POST /api/admin/credentials validate+encrypt+store for any member (ADMIN-01) * POST /api/admin/members create local member: users row + local_credentials (AUTH-LOCAL-07)
* GET /api/admin/calendars list synced calendars (UI-SPEC Surface 5) * POST /api/admin/members/:id/password admin reset local member password (AUTH-LOCAL-08)
* PUT /api/admin/calendars/:id/shared exclusive is_shared designation (ADMIN-02) * POST /api/admin/credentials validate+encrypt+store for any member (ADMIN-01)
* GET /api/admin/calendars list synced calendars (UI-SPEC Surface 5)
* PUT /api/admin/calendars/:id/shared exclusive is_shared designation (ADMIN-02)
* *
* Mounted in index.ts: app.route('/api/admin', adminRouter) * Mounted in index.ts: app.route('/api/admin', adminRouter)
*/ */
@@ -25,13 +27,16 @@ import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { db } from '../db/client.js'; import { db } from '../db/client.js';
import { users, memberCredentials, calendars, appConfig } from '../db/schema.js'; import { users, memberCredentials, calendars, appConfig, localCredentials } from '../db/schema.js';
import { requireAdmin } from '../lib/requireAdmin.js'; import { requireAdmin } from '../lib/requireAdmin.js';
import { isValidIanaTimezone, resolveHouseholdTimezone } from '../lib/householdTimezone.js'; import { isValidIanaTimezone, resolveHouseholdTimezone } from '../lib/householdTimezone.js';
import { import {
validateEncryptAndStoreCredential, validateEncryptAndStoreCredential,
CredentialValidationError, CredentialValidationError,
} from '../broker/credentialSync.js'; } from '../broker/credentialSync.js';
import { hashPassword } from '../auth/localCredentials.js';
import { resetLoginAttempts } from './localAuth.js';
import { COLOR_PALETTE } from '../auth/user.js';
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'; import '../auth/devBypass.js';
@@ -73,11 +78,25 @@ const noEchoHook = (result: { success: boolean }, c: Context) => {
} }
}; };
/**
* WR-07: strictly parse a positive-integer route param. parseInt('12abc', 10) returns 12
* and passes an isNaN guard, silently accepting malformed ids. Number('12abc') is NaN, so
* Number.isInteger(Number(raw)) rejects trailing garbage. Returns null for anything that is
* not a whole positive integer (empty, '12abc', '1.5', '-3', '0', etc.) so the caller can 400.
*/
function parsePositiveIntParam(raw: string | undefined): number | null {
if (raw === undefined || raw.trim() === '') return null;
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) return null;
return n;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /api/admin/members // GET /api/admin/members
// //
// Returns all household members with their credential status. // Returns all household members with their credential status.
// Feeds UI-SPEC Surface 2 (member list with rotate-credential affordance). // Feeds UI-SPEC Surface 2 (member list with rotate-credential affordance).
// Includes hasLocalCredential (AUTH-LOCAL-17) alongside existing hasCredential.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
adminRouter.get('/members', async (c) => { adminRouter.get('/members', async (c) => {
@@ -87,20 +106,168 @@ adminRouter.get('/members', async (c) => {
displayName: users.displayName, displayName: users.displayName,
color: users.color, color: users.color,
credentialId: memberCredentials.id, credentialId: memberCredentials.id,
localCredId: localCredentials.id, // LEFT JOIN — null when no local_credentials row
}) })
.from(users) .from(users)
.leftJoin(memberCredentials, eq(memberCredentials.userId, users.id)); .leftJoin(memberCredentials, eq(memberCredentials.userId, users.id))
.leftJoin(localCredentials, eq(localCredentials.userId, users.id)); // AUTH-LOCAL-17
const members = rows.map((row) => ({ const members = rows.map((row) => ({
id: row.id, id: row.id,
displayName: row.displayName, displayName: row.displayName,
color: row.color, color: row.color,
hasCredential: row.credentialId !== null, hasCredential: row.credentialId !== null,
hasLocalCredential: row.localCredId !== null, // AUTH-LOCAL-17
})); }));
return c.json({ members }); return c.json({ members });
}); });
// ---------------------------------------------------------------------------
// POST /api/admin/members
//
// Admin creates a new local member: inserts a users row and a local_credentials
// row with a hashed initial password in a single transaction (AUTH-LOCAL-07).
// Security:
// - noEchoHook: never echoes Zod errors containing the submitted password (T-19-06)
// - requireAdmin: already enforced by adminRouter.use('*', requireAdmin) (T-19-05)
// - db.transaction: rolls back both inserts on username conflict (T-19-10)
// ---------------------------------------------------------------------------
const createMemberSchema = z.object({
displayName: z.string().min(1).max(256),
username: z.string().min(1).max(128),
initialPassword: z.string().min(8),
});
adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook), async (c) => {
const { displayName, username, initialPassword } = c.req.valid('json');
// T-19-06: NEVER log request body, displayName, username, or initialPassword here
// Assign the first palette color not already in use (mirrors upsertUser color logic)
const usedRows = await db.select({ color: users.color }).from(users);
const usedColors = new Set(usedRows.map((r) => r.color));
const color =
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
// WR-03: hash the initial password BEFORE opening the transaction so the (now async,
// threadpool) scrypt work does not hold the DB transaction open for its duration.
const initialPasswordHash = await hashPassword(initialPassword);
try {
let newUserId: number;
// T-19-10: atomic transaction — both inserts succeed or both roll back
await db.transaction(async (tx) => {
// Insert the new users row (no oidcIss/oidcSub — local-only member)
const [inserted] = await tx
.insert(users)
.values({
displayName,
color,
isAdmin: false,
claimed: false, // no OIDC identity bound yet
})
.$returningId();
newUserId = inserted.id;
// Insert local_credentials row with hashed initial password
// If username is already in use, the UNIQUE constraint fires here and rolls back
await tx.insert(localCredentials).values({
userId: newUserId,
username,
passwordHash: initialPasswordHash,
});
});
// Return the new user's id (the PWA uses it to navigate to the member)
return c.json({ id: newUserId! }, 201);
} catch (err) {
// Username uniqueness violation — UNIQUE constraint on local_credentials.username.
// Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY.
const isDup =
(err instanceof Error && err.message.includes('ER_DUP_ENTRY')) ||
(err != null &&
typeof err === 'object' &&
'code' in err &&
(err as { code?: string }).code === 'ER_DUP_ENTRY') ||
(err != null &&
typeof err === 'object' &&
'cause' in err &&
(err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY');
if (isDup) {
return c.json({ error: 'Username already in use' }, 409);
}
// Unexpected errors — log message only, never the body or password
console.error(
'[admin/POST /members] Unexpected error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503);
}
});
// ---------------------------------------------------------------------------
// POST /api/admin/members/:id/password
//
// Admin resets a local member's password without knowing the current one (AUTH-LOCAL-08).
// Security:
// - noEchoHook: never echoes Zod errors (T-19-06)
// - requireAdmin: enforced by adminRouter.use('*', requireAdmin) (T-19-05)
// - No current password required — admin-only capability
// ---------------------------------------------------------------------------
const resetPasswordSchema = z.object({
newPassword: z.string().min(8),
});
adminRouter.post(
'/members/:id/password',
zValidator('json', resetPasswordSchema, noEchoHook),
async (c) => {
const targetId = parsePositiveIntParam(c.req.param('id'));
if (targetId === null) {
return c.json({ error: 'Invalid member id' }, 400);
}
const { newPassword } = c.req.valid('json');
// T-19-06: NEVER log newPassword or the request body
// Verify the target user has a local_credentials row (404 if not).
// Also read the username so we can clear any login lockout for it (CR-04).
const [credRow] = await db
.select({ id: localCredentials.id, username: localCredentials.username })
.from(localCredentials)
.where(eq(localCredentials.userId, targetId))
.limit(1);
if (!credRow) {
return c.json({ error: 'Member not found or has no local credential' }, 404);
}
try {
await db
.update(localCredentials)
.set({ passwordHash: await hashPassword(newPassword) })
.where(eq(localCredentials.userId, targetId));
// CR-04: an admin password reset must immediately clear any rate-limit / lockout
// state for this username, so a locked-out member regains access at once rather than
// waiting for the TTL. The lockout is keyed on username (not member id).
resetLoginAttempts(credRow.username);
return c.json({ ok: true }, 200);
} catch (err) {
console.error(
'[admin/POST /members/:id/password] Unexpected error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503);
}
},
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /api/admin/credentials // POST /api/admin/credentials
// //
@@ -158,8 +325,8 @@ adminRouter.get('/calendars', async (c) => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
adminRouter.put('/calendars/:id/shared', async (c) => { adminRouter.put('/calendars/:id/shared', async (c) => {
const targetId = parseInt(c.req.param('id'), 10); const targetId = parsePositiveIntParam(c.req.param('id'));
if (isNaN(targetId)) { if (targetId === null) {
return c.json({ error: 'Invalid calendar id' }, 400); return c.json({ error: 'Invalid calendar id' }, 400);
} }
+50
View File
@@ -0,0 +1,50 @@
/**
* authMode.ts GET /api/auth/mode pre-auth endpoint (AUTH-LOCAL-05).
*
* Returns { localEnabled: boolean, oidcEnabled: boolean } reflecting the current
* authentication configuration. This endpoint is intentionally reachable before
* authentication (same pre-auth pattern as GET /api/setup/status).
*
* Mount in index.ts BEFORE devAuthBypass and OIDC guard:
* app.route('/api/auth', authModeRouter); pre-auth
*
* Response contract:
* - localEnabled: always true local auth is the default, always available (D-01).
* - oidcEnabled: true when OIDC_ISSUER env var is set, OR when app_config has
* an oidc_issuer row (supports wizard-configured OIDC before container restart).
*
* No auth gate, no isSetupLocked() check the PWA fetches this on app load before
* knowing if the user is authenticated.
*/
import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { appConfig } from '../db/schema.js';
export const authModeRouter = new Hono();
/**
* GET / (mounted as /api/auth, so effective path is GET /api/auth/mode)
*
* Checks OIDC_ISSUER env var first (process.env is cheapest); falls back to
* app_config DB row if env var is absent (wizard-configured OIDC).
*/
authModeRouter.get('/mode', async (c) => {
// localEnabled: always true (D-01)
// oidcEnabled: env var first, then app_config fallback
const issuerFromEnv = process.env.OIDC_ISSUER;
let oidcEnabled = Boolean(issuerFromEnv);
if (!oidcEnabled) {
// Check app_config for oidc_issuer (wizard-written, pre-restart-fallback pattern from middleware.ts)
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 });
});
+246
View File
@@ -0,0 +1,246 @@
/**
* localAuth.ts local authentication routes (AUTH-LOCAL-03, AUTH-LOCAL-06).
*
* Routes:
* POST /api/auth/local/login rate-limited credential verify + session cookie issue
* POST /api/auth/local/logout clear local-session cookie
* GET /api/auth/local/logout alias for POST /logout (some browsers prefer GET)
*
* Mounted in index.ts as app.route('/api/auth', localAuthRouter) BEFORE any auth middleware.
* This means POST /api/auth/local/login is reachable without a session (pre-auth, per D-01).
*
* Security (T-19-11, T-19-12, T-19-14):
* - noEchoHook on login: Zod errors NEVER return received values (T-19-14 / Pitfall 7).
* - Dummy-hash timing defense: verifyPassword is always called, even for unknown usernames,
* to prevent timing-oracle username enumeration attacks (T-19-12 / RESEARCH Pitfall 2).
* - Same 401 body for wrong password AND unknown username no field discrimination (T-19-12).
* - Per-USERNAME in-memory rate-limiting: 5 failures 429; 10 failures 423 (T-19-11).
* - Lockout (423) auto-expires after LOCKOUT_TTL_MS (CR-04) so a single attacker cannot
* permanently deny login for the whole household, and no process restart is needed to
* recover. Admin password reset still clears it immediately (resetLoginAttempts).
*
* CR-04: the limiter is keyed on the submitted username, NOT the client IP. In this
* deployment all household traffic egresses the Pangolin tunnel with the same
* X-Forwarded-For first hop, so IP-keying made one bad actor (or one fat-fingered user)
* able to lock out every member, and X-Forwarded-For is attacker-spoofable. Username-keying
* scopes the lockout to the identity actually under attack and the TTL makes it self-healing.
*/
import { Hono } from 'hono';
import type { Context } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { localCredentials } from '../db/schema.js';
import { verifyPassword, hashPassword } from '../auth/localCredentials.js';
import { issueLocalSessionCookie, clearLocalSessionCookie } from '../auth/localSession.js';
export const localAuthRouter = new Hono();
// ---------------------------------------------------------------------------
// noEchoHook — NEVER return Zod validation error details for the login route.
// Zod's error object contains issues[].received which may echo the submitted password.
// Always return { error: 'Invalid request' } 400, no other fields (T-19-14).
// ---------------------------------------------------------------------------
const noEchoHook = (result: { success: boolean }, c: Context) => {
if (!result.success) {
return c.json({ error: 'Invalid request' }, 400);
}
};
// ---------------------------------------------------------------------------
// Zod schema for login body
// ---------------------------------------------------------------------------
const loginSchema = z.object({
username: z.string().min(1).max(128).trim(),
password: z.string().min(1).max(1000),
});
// ---------------------------------------------------------------------------
// Per-USERNAME rate-limiting state (in-memory Map — household scale, no Redis needed).
//
// State shape per username: { count, lockedUntil (epoch ms), lockedOut (bool), lockedAt (epoch ms) }
// count >= RATE_WINDOW_FAILURES AND Date.now() < lockedUntil → 429
// count >= LOCKOUT_FAILURES → 423 (until LOCKOUT_TTL_MS elapses OR admin reset)
// Success → delete entry (clears counter)
//
// RATE_WINDOW_FAILURES: 5 failures → 60s cooldown (429)
// LOCKOUT_FAILURES: 10 failures → account locked (423)
// LOCKOUT_TTL_MS: 15 min after which a 423 lockout auto-expires (CR-04)
//
// CR-04: keyed on the validated username (not the client IP) so a lockout is scoped to
// the identity under attack, and self-heals after LOCKOUT_TTL_MS without a restart.
// ---------------------------------------------------------------------------
export const loginAttempts = new Map<
string,
{ count: number; lockedUntil: number; lockedOut: boolean; lockedAt: number }
>();
const RATE_WINDOW_FAILURES = 5;
const RATE_WINDOW_SECS = 60;
const LOCKOUT_FAILURES = 10;
const LOCKOUT_TTL_MS = 15 * 60 * 1000; // CR-04: 423 lockout auto-expires after 15 minutes
/**
* Immediately clear any rate-limit / lockout state for a username (CR-04).
*
* Called by the admin password-reset path so a reset is an instant unlock and the
* lockout is not "unrecoverable without a process restart". Safe to call for an
* unknown username (no-op). Normalizes the username the same way the login schema
* does (trim) so the key matches what the limiter stored.
*/
export function resetLoginAttempts(username: string): void {
loginAttempts.delete(username.trim());
}
/**
* IN-03: evict stale rate-limit entries to bound the in-memory map size.
*
* An entry is stale (and safe to drop) when it is neither inside an active rate-limit
* window nor inside an active lockout TTL:
* - locked-out entries expire LOCKOUT_TTL_MS after lockedAt
* - non-locked entries expire once their lockedUntil window has passed
*
* Called opportunistically at the top of each login request. Dropping an entry is
* behaviourally identical to the entry having naturally expired, so eviction never
* weakens the brute-force defense it only reclaims memory for identities no longer
* under active rate-limiting.
*/
function evictStaleLoginAttempts(now: number): void {
for (const [k, v] of loginAttempts) {
const lockoutExpired = v.lockedOut ? now - v.lockedAt >= LOCKOUT_TTL_MS : true;
const windowExpired = now >= v.lockedUntil;
if (lockoutExpired && windowExpired) {
loginAttempts.delete(k);
}
}
}
// Pre-computed dummy hash used to run verifyPassword on unknown-username paths
// (timing defense — prevents timing-oracle username enumeration, RESEARCH Pitfall 2).
// WR-03: hashPassword is now async. Kick off the computation once at module load and keep
// the PROMISE; the login handler awaits it. The value is never used for auth — only to make
// the unknown-username path perform the same scrypt work as the known-username path.
const dummyHashPromise: Promise<string> = hashPassword('dummy-constant-time-filler-xyzzy');
// ---------------------------------------------------------------------------
// POST /local/login → POST /api/auth/local/login
// ---------------------------------------------------------------------------
localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook), async (c) => {
// CR-04: the rate-limit / lockout key is the validated, normalized username — NOT the
// client IP. zValidator has already run, so c.req.valid('json') is available here.
const { username, password } = c.req.valid('json');
const key = username; // loginSchema .trim()s the username; the map key matches resetLoginAttempts
const now = Date.now();
// IN-03: opportunistically reclaim memory from entries that are no longer actively
// rate-limited or locked out. Cheap at household scale; bounds the map under input churn.
evictStaleLoginAttempts(now);
const attempt = loginAttempts.get(key);
// 423: account locked (>= LOCKOUT_FAILURES failures). CR-04: the lockout auto-expires
// after LOCKOUT_TTL_MS so a single attacker cannot deny login indefinitely and no restart
// is required to recover. On expiry, drop the entry so the next attempt starts clean.
if (attempt?.lockedOut) {
if (now - attempt.lockedAt >= LOCKOUT_TTL_MS) {
loginAttempts.delete(key);
} else {
return c.json({ error: 'Account locked' }, 423);
}
}
// Re-read after a possible expiry-delete above.
const live = loginAttempts.get(key);
// 429: rate window — >= RATE_WINDOW_FAILURES failures within the cooldown window.
// Increment the counter even on 429 so continued brute-force accumulates toward lockout.
if (live && live.count >= RATE_WINDOW_FAILURES && now < live.lockedUntil) {
live.count += 1;
// WR-06: do NOT extend lockedUntil here. This request was itself REJECTED by the window;
// re-arming the cooldown on every blocked attempt let an attacker who keeps hammering the
// endpoint slide the window forward forever, so a legitimate user behind the same identity
// could never get back in even after pausing. The window stays anchored to when it was
// first armed (in the failure path below); it expires on schedule regardless of rejected
// traffic. The lockout (423) still triggers once the failure count crosses the threshold.
live.lockedOut = live.count >= LOCKOUT_FAILURES;
if (live.lockedOut && live.lockedAt === 0) live.lockedAt = now;
loginAttempts.set(key, live);
if (live.lockedOut) {
return c.json({ error: 'Account locked' }, 423);
}
return c.json({ error: 'Too many attempts' }, 429);
}
// Look up local_credentials by username
let cred: { userId: number; passwordHash: string } | undefined;
try {
const [found] = await db
.select({ userId: localCredentials.userId, passwordHash: localCredentials.passwordHash })
.from(localCredentials)
.where(eq(localCredentials.username, username))
.limit(1);
cred = found;
} catch (err) {
console.error(
'[localAuth/POST /local/login] DB error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503);
}
// ALWAYS run verifyPassword — even for unknown usernames — to prevent timing-oracle
// username enumeration (T-19-12 / RESEARCH Pitfall 2). Use a pre-computed dummy hash
// so the scrypt work is always performed regardless of whether username was found.
// WR-03: verifyPassword is async (threadpool scrypt) — await it.
const valid = cred
? await verifyPassword(cred.passwordHash, password)
: await verifyPassword(await dummyHashPromise, password);
if (!valid || !cred) {
// Increment failure counter (keyed on username)
const cur = loginAttempts.get(key) ?? {
count: 0,
lockedUntil: 0,
lockedOut: false,
lockedAt: 0,
};
cur.count += 1;
cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000;
cur.lockedOut = cur.count >= LOCKOUT_FAILURES;
if (cur.lockedOut && cur.lockedAt === 0) cur.lockedAt = Date.now();
loginAttempts.set(key, cur);
// Same 401 body for wrong password AND unknown username — no field discrimination (T-19-12)
return c.json({ error: 'Invalid credentials' }, 401);
}
// Success: clear failure counter, issue the local-session JWT cookie, return ok
loginAttempts.delete(key);
try {
await issueLocalSessionCookie(c, cred.userId);
} catch (err) {
console.error(
'[localAuth/POST /local/login] Cookie issue error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503);
}
return c.json({ ok: true }, 200);
});
// ---------------------------------------------------------------------------
// POST /local/logout → POST /api/auth/local/logout
// GET /local/logout → GET /api/auth/local/logout (browser-redirect alias)
// ---------------------------------------------------------------------------
function handleLogout(c: Context) {
clearLocalSessionCookie(c);
return c.json({ ok: true }, 200);
}
localAuthRouter.post('/local/logout', handleLogout);
localAuthRouter.get('/local/logout', handleLogout);
+163 -8
View File
@@ -7,15 +7,15 @@
* 2. Derives displayName from OIDC claims (name preferred_username email sub fallback) * 2. Derives displayName from OIDC claims (name preferred_username email sub fallback)
* then calls upsertUser(iss, sub, displayName) writes on first visit, corrects a * then calls upsertUser(iss, sub, displayName) writes on first visit, corrects a
* previously blank displayName on subsequent visits (idempotent, keyed on iss+sub, D-10) * previously blank displayName on subsequent visits (idempotent, keyed on iss+sub, D-10)
* 3. Queries users.isAdmin and member_credentials existence for the resolved user * 3. Queries users.isAdmin, member_credentials existence, and local_credentials existence
* 4. Returns { user: { id, displayName, color, isAdmin, needsProviderSetup } } * 4. Returns { user: { id, displayName, color, isAdmin, needsProviderSetup, hasLocalCredential } }
* *
* Flow (dev bypass DEV_AUTH_BYPASS=true, non-production): * Flow (dev bypass DEV_AUTH_BYPASS=true, non-production):
* devAuthBypass() injects DEV_USER via c.set('user', DEV_USER). oidcAuthMiddleware * devAuthBypass() injects DEV_USER via c.set('user', DEV_USER). oidcAuthMiddleware
* is NOT mounted in index.ts when the bypass is active, so getAuth(c) is never called. * is NOT mounted in index.ts when the bypass is active, so getAuth(c) is never called.
* This handler reads c.get('user') first and short-circuits using the dev user's id, * This handler reads c.get('user') first and short-circuits using the dev user's id,
* but STILL queries the DB for isAdmin (T-10-05: bypass skips OIDC, not the DB check) * but STILL queries the DB for isAdmin (T-10-05: bypass skips OIDC, not the DB check)
* and member_credentials existence. * and member_credentials/local_credentials existence.
* *
* Security (D-03, T-10-06): * Security (D-03, T-10-06):
* isAdmin is exposed for UX-only PWA nav gating it is NOT the security boundary. * isAdmin is exposed for UX-only PWA nav gating it is NOT the security boundary.
@@ -30,22 +30,28 @@ import type { Context } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { Jwt } from 'hono/utils/jwt';
import { randomBytes } from 'node:crypto';
import { getAuth } from '../auth/middleware.js'; import { getAuth } from '../auth/middleware.js';
import { upsertUser, deriveDisplayName } from '../auth/user.js'; import { upsertUser, deriveDisplayName } from '../auth/user.js';
import { db } from '../db/client.js'; import { db } from '../db/client.js';
import { users, memberCredentials } from '../db/schema.js'; import { users, memberCredentials, localCredentials } from '../db/schema.js';
import { import {
validateEncryptAndStoreCredential, validateEncryptAndStoreCredential,
CredentialValidationError, CredentialValidationError,
} from '../broker/credentialSync.js'; } from '../broker/credentialSync.js';
import { hashPassword, verifyPassword } from '../auth/localCredentials.js';
import { resolveOidcConfig, discoverAuthorizationEndpoint } from '../auth/oidcConfig.js';
import { registerLinkNonce } from '../auth/linkNonceStore.js';
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'; import '../auth/devBypass.js';
export const meRouter = new Hono(); export const meRouter = new Hono();
/** /**
* Looks up isAdmin and needsProviderSetup for a given userId. * Looks up isAdmin, needsProviderSetup, and hasLocalCredential for a given userId.
* Always reads from the DB bypass only skips OIDC, not this check (T-10-05). * Always reads from the DB bypass only skips OIDC, not this check (T-10-05).
* hasLocalCredential (AUTH-LOCAL-17): true when a local_credentials row exists for userId.
*/ */
async function resolveAdminAndSetupStatus(userId: number) { async function resolveAdminAndSetupStatus(userId: number) {
const [userRow] = await db const [userRow] = await db
@@ -60,9 +66,17 @@ async function resolveAdminAndSetupStatus(userId: number) {
.where(eq(memberCredentials.userId, userId)) .where(eq(memberCredentials.userId, userId))
.limit(1); .limit(1);
// AUTH-LOCAL-17: expose whether the user has a local username/password credential
const [localCred] = await db
.select({ id: localCredentials.id })
.from(localCredentials)
.where(eq(localCredentials.userId, userId))
.limit(1);
return { return {
isAdmin: userRow?.isAdmin ?? false, isAdmin: userRow?.isAdmin ?? false,
needsProviderSetup: !cred, needsProviderSetup: !cred,
hasLocalCredential: Boolean(localCred), // AUTH-LOCAL-17
}; };
} }
@@ -88,10 +102,12 @@ async function resolveUserId(c: Context): Promise<number | null> {
meRouter.get('/', async (c) => { meRouter.get('/', async (c) => {
// Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active. // Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active.
// Use the injected dev identity's id for DB lookups — no OIDC session needed, // Use the injected dev identity's id for DB lookups — no OIDC session needed,
// but isAdmin and needsProviderSetup are still resolved from the DB (T-10-05). // but isAdmin, needsProviderSetup, and hasLocalCredential are still resolved from the DB (T-10-05).
const devUser = c.get('user'); const devUser = c.get('user');
if (devUser) { if (devUser) {
const { isAdmin, needsProviderSetup } = await resolveAdminAndSetupStatus(devUser.id); const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(
devUser.id,
);
return c.json({ return c.json({
user: { user: {
id: devUser.id, id: devUser.id,
@@ -99,6 +115,7 @@ meRouter.get('/', async (c) => {
color: devUser.color, color: devUser.color,
isAdmin, isAdmin,
needsProviderSetup, needsProviderSetup,
hasLocalCredential, // AUTH-LOCAL-17
}, },
}); });
} }
@@ -126,7 +143,9 @@ meRouter.get('/', async (c) => {
return c.json({ error: 'Could not resolve user' }, 500); return c.json({ error: 'Could not resolve user' }, 500);
} }
const { isAdmin, needsProviderSetup } = await resolveAdminAndSetupStatus(user.id); const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(
user.id,
);
return c.json({ return c.json({
user: { user: {
@@ -135,6 +154,7 @@ meRouter.get('/', async (c) => {
color: user.color, color: user.color,
isAdmin, isAdmin,
needsProviderSetup, needsProviderSetup,
hasLocalCredential, // AUTH-LOCAL-17
}, },
}); });
}); });
@@ -200,3 +220,138 @@ meRouter.post('/credential', zValidator('json', meCredentialSchema, meNoEchoHook
return c.json({ ok: true }, 200); return c.json({ ok: true }, 200);
}); });
// ---------------------------------------------------------------------------
// POST /api/me/password — self-change password (AUTH-LOCAL-09, T-19-07)
//
// Security contract:
// - T-19-07: verifyPassword(current) required before any update; resolveUserId from
// session (not body). User can only change their OWN password.
// - meNoEchoHook: NEVER return Zod error details (contains submitted passwords, T-19-06).
// - Never log currentPassword, newPassword, or c.req.valid('json') (T-19-06).
// ---------------------------------------------------------------------------
const mePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8),
});
meRouter.post('/password', zValidator('json', mePasswordSchema, meNoEchoHook), async (c) => {
// T-19-07: ALWAYS resolve userId from session — never from body
const currentUserId = await resolveUserId(c);
if (!currentUserId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { currentPassword, newPassword } = c.req.valid('json');
// T-19-06: NEVER log currentPassword, newPassword, or the request body
// Look up the user's local_credentials row (404 if none — no local credential to change)
const [credRow] = await db
.select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId })
.from(localCredentials)
.where(eq(localCredentials.userId, currentUserId))
.limit(1);
if (!credRow) {
return c.json({ error: 'No local credential found' }, 404);
}
// T-19-07: verify current password before any update (WR-03: async scrypt)
const isCorrect = await verifyPassword(credRow.passwordHash, currentPassword);
if (!isCorrect) {
// CR-03: return 403 (NOT 401) for a wrong current password. The PWA's global
// MutationCache treats any 401 as "session expired" and arms the re-auth
// interstitial / login redirect — so a 401 here would force-log-out a user who
// merely mistyped their current password. 403 is in-app authorization-failure and
// lets the client surface "current password incorrect" without dropping the session.
return c.json({ error: 'Current password incorrect' }, 403);
}
try {
await db
.update(localCredentials)
.set({ passwordHash: await hashPassword(newPassword) })
.where(eq(localCredentials.userId, currentUserId));
return c.json({ ok: true }, 200);
} catch (err) {
console.error(
'[me/POST /password] Unexpected error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503);
}
});
// ---------------------------------------------------------------------------
// POST /api/me/link-oidc — initiate OIDC identity-link flow (AUTH-LOCAL-10, T-19-09)
//
// Purpose: initiates the OIDC authorization-code flow for the currently-authenticated
// local user. The current userId is encoded in a signed OIDC `state` parameter so that
// the /callback handler (plan 19-03) can bind the returned iss+sub to this user.
//
// Security contract (T-19-09 — OIDC-link CSRF):
// - state payload: { linkUserId, nonce } signed with LOCAL_SESSION_SECRET (HS256)
// - nonce: 16-byte random hex string per request — prevents state replay
// - Only the user encoded in `state.linkUserId` is bound on callback (T-19-09)
//
// Returns:
// { signedState: string, authorizationUrl: string | null }
// signedState: JWT for the OIDC state parameter (plan 19-03 /callback reads this)
// authorizationUrl: OIDC authorization endpoint URL with state, or null if OIDC not configured
//
// The PWA (Surface 13) redirects the user to authorizationUrl. The actual binding
// (UPDATE users + DELETE local_credentials) happens in the /callback handler (19-03).
// ---------------------------------------------------------------------------
meRouter.post('/link-oidc', async (c) => {
const currentUserId = await resolveUserId(c);
if (!currentUserId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const secret = process.env.LOCAL_SESSION_SECRET;
if (!secret) {
return c.json({ error: 'Service unavailable' }, 503);
}
// Produce a signed state token encoding the current userId + a per-request nonce
// T-19-09: nonce prevents state replay attacks (each link attempt gets a fresh nonce)
const nonce = randomBytes(16).toString('hex');
const now = Math.floor(Date.now() / 1000);
const exp = now + 600; // 10-minute window
const signedState = await Jwt.sign(
{ linkUserId: currentUserId, nonce, iat: now, exp },
secret,
'HS256',
);
// IN-04: record the nonce so /callback can enforce SINGLE USE. Without this the signed
// state JWT is fully replayable for its 10-minute signature lifetime and the nonce is
// decorative. registerLinkNonce keeps it valid only until the state's own exp.
registerLinkNonce(nonce, exp);
// Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button).
// WR-04: resolve issuer/clientId/redirectUri from env-OR-app_config (single source of truth,
// consistent with /api/auth/mode and the OIDC fallback middleware) so a wizard-configured
// instance does not report oidcEnabled:true while returning authorizationUrl:null here.
// WR-02: discover the authorization_endpoint from the provider's discovery document instead
// of hardcoding Authelia's /api/oidc/authorization path.
let authorizationUrl: string | null = null;
const oidc = await resolveOidcConfig();
if (oidc) {
const authEndpoint = await discoverAuthorizationEndpoint(oidc.issuer);
if (authEndpoint) {
const url = new URL(authEndpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', oidc.clientId);
url.searchParams.set('redirect_uri', oidc.redirectUri);
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('state', signedState);
authorizationUrl = url.toString();
}
}
return c.json({ signedState, authorizationUrl }, 200);
});
+10 -1
View File
@@ -24,7 +24,13 @@
import { afterEach } from 'vitest'; import { afterEach } from 'vitest';
import { db } from '../src/db/client.js'; import { db } from '../src/db/client.js';
import { lists, listItems, listShares, pushSubscriptions } from '../src/db/schema.js'; import {
lists,
listItems,
listShares,
pushSubscriptions,
localCredentials,
} from '../src/db/schema.js';
/** /**
* Truncate list and push tables in FK-safe order after each test. * Truncate list and push tables in FK-safe order after each test.
@@ -39,6 +45,9 @@ afterEach(async () => {
await db.delete(listShares); await db.delete(listShares);
await db.delete(pushSubscriptions); await db.delete(pushSubscriptions);
await db.delete(lists); await db.delete(lists);
// Phase 19: local_credentials has FK to users (cascade delete via users); truncate here
// so each test starts with a clean credential slate. users intentionally left intact.
await db.delete(localCredentials);
} catch { } catch {
// DB may not be available in pure-unit test runs (no DB_HOST configured). // DB may not be available in pure-unit test runs (no DB_HOST configured).
// Swallow the error — pure-logic tests do not need cleanup. // Swallow the error — pure-logic tests do not need cleanup.
@@ -0,0 +1,281 @@
/**
* localAuthMiddleware() unit tests (Plan 19-03, TDD RED GREEN).
*
* Covers:
* Test 1: valid local-session cookie for an existing user c.get('user') set; next() called
* Test 2: no cookie pure passthrough; c.get('user') NOT set (OIDC guard fall-through intact)
* Test 3: cookie with valid JWT but userId has no users row passthrough (no crash)
* Test 4: c.get('user') already set (devAuthBypass ran first) not overwritten; next() called
*
* Security:
* - Test 2 is the Pitfall-1 guard: middleware must NEVER call c.set('user', undefined).
* The OIDC guard only fires when c.get('user') is falsy; setting it to undefined
* would suppress the OIDC guard for unauthenticated requests.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Hono } from 'hono';
// ---------------------------------------------------------------------------
// Mock DB — avoids real DB connections in this middleware unit-test
// ---------------------------------------------------------------------------
const mockDbSelectResult: Array<{
id: number;
oidcIss: string | null;
oidcSub: string | null;
displayName: string | null;
color: string;
}> = [];
vi.mock('../../src/db/client.js', () => ({
db: {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockImplementation(() => Promise.resolve(mockDbSelectResult)),
}),
}),
}),
},
}));
// ---------------------------------------------------------------------------
// Mock verifyLocalSessionCookie — controls what userId the cookie yields
// ---------------------------------------------------------------------------
let mockVerifyResult: number | null = null;
vi.mock('../../src/auth/localSession.js', () => ({
verifyLocalSessionCookie: vi.fn().mockImplementation(() => Promise.resolve(mockVerifyResult)),
issueLocalSessionCookie: vi.fn(),
clearLocalSessionCookie: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function getMiddleware() {
const { localAuthMiddleware } = await import('../../src/auth/localAuthMiddleware.js');
return localAuthMiddleware;
}
function makeApp(middleware: ReturnType<typeof vi.fn>, presetUser?: unknown) {
const app = new Hono();
if (presetUser !== undefined) {
// Simulate devAuthBypass having already set c.get('user')
app.use('/api/*', async (c, next) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
c.set('user', presetUser as any);
await next();
});
}
app.use('/api/*', middleware());
let capturedUser: unknown = 'NOT_SET_SENTINEL';
let nextCalled = false;
app.get('/api/test', (c) => {
capturedUser = c.get('user');
nextCalled = true;
return c.json({ ok: true });
});
return { app, getCapturedUser: () => capturedUser, wasNextCalled: () => nextCalled };
}
// ---------------------------------------------------------------------------
describe('localAuthMiddleware', () => {
beforeEach(() => {
mockVerifyResult = null;
mockDbSelectResult.splice(0);
vi.resetModules();
});
afterEach(() => {
vi.resetModules();
});
it('Test 1: valid local-session cookie for existing user → sets c.get("user") and calls next', async () => {
mockVerifyResult = 42;
mockDbSelectResult.push({
id: 42,
oidcIss: 'local',
oidcSub: '42',
displayName: 'Test User',
color: '#4A90D9',
});
const localAuthMiddleware = await getMiddleware();
const { app } = makeApp(localAuthMiddleware);
const res = await app.request('/api/test');
expect(res.status).toBe(200);
// Re-import to inspect captured value via the route handler's closure
// We verify by checking response — route returns 200 only if next() was called
// The actual user value is verified via the app route handler
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
});
it('Test 1b: user shape from DB row is correct (id, oidcIss, oidcSub, displayName, color)', async () => {
mockVerifyResult = 7;
mockDbSelectResult.push({
id: 7,
oidcIss: 'https://auth.example.com',
oidcSub: 'sub-abc',
displayName: 'Alice',
color: '#FF5733',
});
const localAuthMiddleware = await getMiddleware();
const app = new Hono();
let capturedUser: unknown;
app.use('/api/*', localAuthMiddleware());
app.get('/api/test', (c) => {
capturedUser = c.get('user');
return c.json({ ok: true });
});
const res = await app.request('/api/test');
expect(res.status).toBe(200);
expect(capturedUser).toBeDefined();
const u = capturedUser as {
id: number;
oidcIss: string;
oidcSub: string;
displayName: string | null;
color: string;
};
expect(u.id).toBe(7);
expect(u.oidcIss).toBe('https://auth.example.com');
expect(u.oidcSub).toBe('sub-abc');
expect(u.displayName).toBe('Alice');
expect(u.color).toBe('#FF5733');
});
it('Test 1c (BL-04): local user with null DB oidc fields → context oidcIss/oidcSub are NULL (no fabricated sentinels)', async () => {
mockVerifyResult = 9;
mockDbSelectResult.push({
id: 9,
oidcIss: null, // local user — no OIDC identity bound
oidcSub: null,
displayName: 'Local Member',
color: '#22AA88',
});
const localAuthMiddleware = await getMiddleware();
const app = new Hono();
let capturedUser: unknown;
app.use('/api/*', localAuthMiddleware());
app.get('/api/test', (c) => {
capturedUser = c.get('user');
return c.json({ ok: true });
});
const res = await app.request('/api/test');
expect(res.status).toBe(200);
const u = capturedUser as { id: number; oidcIss: string | null; oidcSub: string | null };
expect(u.id).toBe(9);
// BL-04: must be null — NOT 'local' / String(id) sentinels that share the
// uniq_oidc_identity domain with real OIDC identities.
expect(u.oidcIss).toBeNull();
expect(u.oidcSub).toBeNull();
});
it('Test 2: no cookie → pure passthrough; c.get("user") remains unset (Pitfall-1 guard)', async () => {
mockVerifyResult = null; // No cookie / invalid
const localAuthMiddleware = await getMiddleware();
const app = new Hono();
let capturedUser: unknown = 'NOT_SET_SENTINEL';
let nextCalled = false;
app.use('/api/*', localAuthMiddleware());
app.get('/api/test', (c) => {
capturedUser = c.get('user');
nextCalled = true;
return c.json({ ok: true });
});
const res = await app.request('/api/test');
expect(res.status).toBe(200);
expect(nextCalled).toBe(true);
// CRITICAL: user must remain UNSET (undefined), NOT set to undefined explicitly.
// The OIDC guard checks c.get('user') — if it's undefined (not set), the guard fires.
// The middleware must call next() without c.set('user') on the no-cookie path.
expect(capturedUser).toBeUndefined();
});
it('Test 3: valid cookie but userId has no users row → passthrough (no crash)', async () => {
mockVerifyResult = 999; // Valid JWT payload, userId=999
mockDbSelectResult.splice(0); // No DB row for userId 999
const localAuthMiddleware = await getMiddleware();
const app = new Hono();
let capturedUser: unknown = 'NOT_SET_SENTINEL';
let nextCalled = false;
app.use('/api/*', localAuthMiddleware());
app.get('/api/test', (c) => {
capturedUser = c.get('user');
nextCalled = true;
return c.json({ ok: true });
});
const res = await app.request('/api/test');
expect(res.status).toBe(200);
expect(nextCalled).toBe(true);
// No row found — should passthrough without setting user
expect(capturedUser).toBeUndefined();
});
it('Test 4: c.get("user") already set (devAuthBypass ran first) → not overwritten; next() called', async () => {
const preExistingUser = {
id: 1,
oidcIss: 'dev',
oidcSub: 'dev-user',
displayName: 'Dev User',
color: '#4A90D9',
};
// Even if verifyLocalSessionCookie would succeed, the existing user must not be overwritten
mockVerifyResult = 99; // A DIFFERENT userId
mockDbSelectResult.push({
id: 99,
oidcIss: 'local',
oidcSub: '99',
displayName: 'Another User',
color: '#FF0000',
});
const localAuthMiddleware = await getMiddleware();
const app = new Hono();
let capturedUser: unknown;
// Simulate devAuthBypass having set the user first
app.use('/api/*', async (c, next) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
c.set('user', preExistingUser as any);
await next();
});
app.use('/api/*', localAuthMiddleware());
app.get('/api/test', (c) => {
capturedUser = c.get('user');
return c.json({ ok: true });
});
const res = await app.request('/api/test');
expect(res.status).toBe(200);
// User must remain the dev-bypass user, not overwritten
expect(capturedUser).toEqual(preExistingUser);
expect((capturedUser as typeof preExistingUser).id).toBe(1);
});
});
@@ -0,0 +1,96 @@
/**
* localCredentials.ts unit tests for hashPassword / verifyPassword.
*
* Uses node:crypto scrypt under the hood; no external dependencies.
* All tests run without MariaDB or any external service.
*
* WR-03: hashPassword / verifyPassword are now async (promisify(scrypt), threadpool)
* all assertions await them.
*
* Test suite (TDD RED GREEN Plan 19-01 Task 1):
* Test 1: correct password verifies true
* Test 2: wrong password verifies false
* Test 3: two hashes of the same input produce different encoded strings (unique salt)
* Test 4: verifyPassword never throws on a malformed hash (returns false)
* Test 5: encoded string has the PHC shape: scrypt$N$r$p$<salt_b64url>$<hash_b64url> (6 segments)
*/
import { describe, it, expect } from 'vitest';
import { scryptSync, randomBytes } from 'node:crypto';
import { hashPassword, verifyPassword } from '../../src/auth/localCredentials.js';
describe('hashPassword / verifyPassword', () => {
it('Test 1: verifyPassword(hashPassword(pw), pw) === true (round-trip)', async () => {
const encoded = await hashPassword('hunter2');
const result = await verifyPassword(encoded, 'hunter2');
expect(result).toBe(true);
});
it('Test 2: verifyPassword(hashPassword(pw), wrong) === false', async () => {
const encoded = await hashPassword('hunter2');
const result = await verifyPassword(encoded, 'wrong-password');
expect(result).toBe(false);
});
it('Test 3: two hashPassword calls on same input produce different encoded strings (unique salt)', async () => {
const encoded1 = await hashPassword('x');
const encoded2 = await hashPassword('x');
expect(encoded1).not.toBe(encoded2);
});
it('Test 4: verifyPassword returns false (never throws) on a malformed stored hash', async () => {
await expect(verifyPassword('not-a-valid-hash', 'x')).resolves.toBe(false);
await expect(verifyPassword('', 'x')).resolves.toBe(false);
await expect(verifyPassword('scrypt$bad$data', 'x')).resolves.toBe(false);
});
it('Test 6 (IN-02): a hash produced by the INLINED scrypt parameters round-trips against the canonical verifyPassword', async () => {
// IN-02: the PHC scrypt hash is copy-pasted in three places — the canonical module
// (src/auth/localCredentials.ts), the break-glass CLI (scripts/reset-admin.ts), and the
// CI seed step (.gitea/workflows/ci.yml). If the parameters ever drift, those inlined
// copies would produce hashes the canonical verifyPassword cannot validate, silently
// breaking login for seeded/reset accounts. This test pins the inlined parameter set:
// if anyone changes N/r/p/KEY_LEN in the canonical module without updating the inlined
// copies (or vice versa), this round-trip fails loudly in CI.
//
// These constants MUST match scripts/reset-admin.ts and .gitea/workflows/ci.yml exactly.
const INLINE_N = 16384;
const INLINE_R = 8;
const INLINE_P = 1;
const INLINE_KEY_LEN = 32;
const password = 'inline-roundtrip-pw';
const salt = randomBytes(16);
const hash = scryptSync(password, salt, INLINE_KEY_LEN, {
N: INLINE_N,
r: INLINE_R,
p: INLINE_P,
});
const inlineEncoded = [
'scrypt',
INLINE_N,
INLINE_R,
INLINE_P,
salt.toString('base64url'),
hash.toString('base64url'),
].join('$');
// The canonical verifyPassword must validate a hash produced by the inlined params.
expect(await verifyPassword(inlineEncoded, password)).toBe(true);
expect(await verifyPassword(inlineEncoded, 'wrong')).toBe(false);
});
it('Test 5: encoded string has scrypt$N$r$p$salt$hash shape (6 $-delimited segments)', async () => {
const encoded = await hashPassword('testpassword');
const segments = encoded.split('$');
expect(segments).toHaveLength(6);
expect(segments[0]).toBe('scrypt');
// N, r, p are numeric
expect(Number(segments[1])).toBeGreaterThan(0); // N
expect(Number(segments[2])).toBeGreaterThan(0); // r
expect(Number(segments[3])).toBeGreaterThan(0); // p
// salt and hash are non-empty base64url strings
expect(segments[4].length).toBeGreaterThan(0);
expect(segments[5].length).toBeGreaterThan(0);
});
});
+140
View File
@@ -0,0 +1,140 @@
/**
* localSession.ts + bootGuards.ts unit tests for JWT session cookie helpers
* and assertLocalSessionSecretSet boot guard.
*
* Uses Hono test app for cookie round-trips. All tests run without MariaDB.
*
* Test suite (TDD RED GREEN Plan 19-01 Task 2):
* Test 1: issue then verify round-trips userId
* Test 2: verifyLocalSessionCookie returns null when no local-session cookie present (no throw)
* Test 3: verifyLocalSessionCookie returns null for a tampered/garbage token (no throw)
* Test 4: assertLocalSessionSecretSet does NOT exit when DEV_AUTH_BYPASS='true' even if secret unset
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Hono } from 'hono';
// ── Test constants ─────────────────────────────────────────────────────────────
const TEST_SECRET = 'test-secret-that-is-at-least-32-characters-long-for-jwt';
const TEST_USER_ID = 42;
describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = { ...process.env };
process.env.LOCAL_SESSION_SECRET = TEST_SECRET;
vi.resetModules(); // ensure fresh imports pick up env changes
});
afterEach(() => {
process.env = originalEnv;
vi.resetModules();
});
it('Test 1: issue then verify round-trips userId', async () => {
const { issueLocalSessionCookie, verifyLocalSessionCookie } =
await import('../../src/auth/localSession.js');
const app = new Hono();
app.post('/issue', async (c) => {
await issueLocalSessionCookie(c, TEST_USER_ID);
return c.json({ ok: true });
});
app.get('/verify', async (c) => {
const userId = await verifyLocalSessionCookie(c);
return c.json({ userId });
});
// Issue cookie
const issueRes = await app.request('/issue', { method: 'POST' });
expect(issueRes.status).toBe(200);
const setCookieHeader = issueRes.headers.get('set-cookie');
expect(setCookieHeader).not.toBeNull();
expect(setCookieHeader).toContain('local-session=');
// Extract cookie value and forward it for verify
const cookieHeader = setCookieHeader?.split(';')[0]; // just name=value
const verifyRes = await app.request('/verify', {
headers: { cookie: cookieHeader ?? '' },
});
expect(verifyRes.status).toBe(200);
const body = (await verifyRes.json()) as { userId: number | null };
expect(body.userId).toBe(TEST_USER_ID);
});
it('Test 2: verifyLocalSessionCookie returns null when no local-session cookie present (no throw)', async () => {
const { verifyLocalSessionCookie } = await import('../../src/auth/localSession.js');
const app = new Hono();
app.get('/verify', async (c) => {
const userId = await verifyLocalSessionCookie(c);
return c.json({ userId });
});
const res = await app.request('/verify');
expect(res.status).toBe(200);
const body = (await res.json()) as { userId: number | null };
expect(body.userId).toBeNull();
});
it('Test 3: verifyLocalSessionCookie returns null for a tampered/garbage token (no throw)', async () => {
const { verifyLocalSessionCookie } = await import('../../src/auth/localSession.js');
const app = new Hono();
app.get('/verify', async (c) => {
const userId = await verifyLocalSessionCookie(c);
return c.json({ userId });
});
// Send a garbage token — should return null without throwing
const res = await app.request('/verify', {
headers: { cookie: 'local-session=garbage.token.value' },
});
expect(res.status).toBe(200);
const body = (await res.json()) as { userId: number | null };
expect(body.userId).toBeNull();
});
});
describe('assertLocalSessionSecretSet (bootGuards)', () => {
let originalEnv: NodeJS.ProcessEnv;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
originalEnv = { ...process.env };
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called');
}) as never);
vi.resetModules();
});
afterEach(() => {
process.env = originalEnv;
exitSpy.mockRestore();
vi.resetModules();
});
it('Test 4: assertLocalSessionSecretSet does NOT exit when DEV_AUTH_BYPASS=true even if secret unset', async () => {
process.env.DEV_AUTH_BYPASS = 'true';
delete process.env.LOCAL_SESSION_SECRET;
const { assertLocalSessionSecretSet } = await import('../../src/lib/bootGuards.js');
// Must not throw / must not call process.exit
expect(() => assertLocalSessionSecretSet()).not.toThrow();
expect(exitSpy).not.toHaveBeenCalled();
});
it('assertLocalSessionSecretSet is exported from bootGuards', async () => {
process.env.LOCAL_SESSION_SECRET = TEST_SECRET;
delete process.env.DEV_AUTH_BYPASS;
const bootGuards = await import('../../src/lib/bootGuards.js');
// Must export the function
expect(typeof bootGuards.assertLocalSessionSecretSet).toBe('function');
});
});
+2
View File
@@ -32,6 +32,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
color: '#4A90D9', color: '#4A90D9',
}, },
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(), devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
COLOR_PALETTE: ['#4A90D9'], COLOR_PALETTE: ['#4A90D9'],
})); }));
+227 -1
View File
@@ -30,7 +30,14 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db } from '../../src/db/client.js'; import { db } from '../../src/db/client.js';
import { users, memberCredentials, calendars, appConfig } from '../../src/db/schema.js'; import {
users,
memberCredentials,
calendars,
appConfig,
localCredentials,
} from '../../src/db/schema.js';
import { verifyPassword } from '../../src/auth/localCredentials.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CalDAV mock — intercept createFastmailClient so tests do not hit live Fastmail. // CalDAV mock — intercept createFastmailClient so tests do not hit live Fastmail.
@@ -92,6 +99,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
c.set('user', { id: currentDevUserId }); c.set('user', { id: currentDevUserId });
await next(); await next();
}, },
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests (cookie not needed)
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
})); }));
vi.mock('@hono/oidc-auth', () => ({ vi.mock('@hono/oidc-auth', () => ({
@@ -169,9 +178,12 @@ beforeEach(async () => {
afterEach(async () => { afterEach(async () => {
// Clean up seeded users and credentials between tests // Clean up seeded users and credentials between tests
await db.delete(localCredentials);
await db.delete(memberCredentials); await db.delete(memberCredentials);
await db.delete(calendars); await db.delete(calendars);
await db.delete(users).where(eq(users.oidcIss, 'https://auth.test')); await db.delete(users).where(eq(users.oidcIss, 'https://auth.test'));
// Also clean up users created by POST /api/admin/members (no oidcIss)
await db.delete(users).where(eq(users.oidcIss, ''));
}); });
// =========================================================================== // ===========================================================================
@@ -477,6 +489,19 @@ describe('PUT /api/admin/calendars/:id/shared', () => {
.limit(1); .limit(1);
expect(rowA.isShared).toBe(true); expect(rowA.isShared).toBe(true);
}); });
it('WR-07: rejects a calendar id with trailing garbage (e.g. "1abc") with 400', async () => {
const adminId = await seedUser('admin-shared-badid', true);
currentDevUserId = adminId;
const app = await getApp();
// parseInt('1abc', 10) === 1 would have silently accepted this; the strict
// Number.isInteger parse must reject it as a malformed id.
const res = await app.fetch(jsonRequest('PUT', '/api/admin/calendars/1abc/shared'));
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe('Invalid calendar id');
});
}); });
// =========================================================================== // ===========================================================================
@@ -845,3 +870,204 @@ describe('admin timezone config', () => {
expect(row?.value).toBe('America/Denver'); expect(row?.value).toBe('America/Denver');
}); });
}); });
// ===========================================================================
// POST /api/admin/members — admin create local member (AUTH-LOCAL-07, T-19-05, T-19-06, T-19-10)
// ===========================================================================
describe('POST /api/admin/members', () => {
it('Test 1: creates a users row + local_credentials row, hash verifies against initialPassword', async () => {
const adminId = await seedUser('admin-create-member', true);
currentDevUserId = adminId;
const app = await getApp();
const initialPassword = 'correct-horse-battery-staple1!';
const res = await app.fetch(
jsonRequest('POST', '/api/admin/members', {
displayName: 'New Member',
username: `newmember-${randomUUID()}`,
initialPassword,
}),
);
expect(res.status).toBe(201);
const body = (await res.json()) as { id: number };
expect(typeof body.id).toBe('number');
// Verify users row was created
const [userRow] = await db
.select({ id: users.id, displayName: users.displayName })
.from(users)
.where(eq(users.id, body.id))
.limit(1);
expect(userRow).toBeDefined();
expect(userRow.displayName).toBe('New Member');
// Verify local_credentials row was created with a verifiable hash
const [credRow] = await db
.select({ passwordHash: localCredentials.passwordHash })
.from(localCredentials)
.where(eq(localCredentials.userId, body.id))
.limit(1);
expect(credRow).toBeDefined();
expect(await verifyPassword(credRow.passwordHash, initialPassword)).toBe(true);
});
it('Test 2: duplicate username returns 409 — transaction rolls back (no orphaned users row)', async () => {
const adminId = await seedUser('admin-dup-username', true);
currentDevUserId = adminId;
const app = await getApp();
const uniqueUsername = `dupuser-${randomUUID()}`;
// Create the first member successfully
const firstRes = await app.fetch(
jsonRequest('POST', '/api/admin/members', {
displayName: 'First Member',
username: uniqueUsername,
initialPassword: 'first-password-abc123',
}),
);
expect(firstRes.status).toBe(201);
const firstBody = (await firstRes.json()) as { id: number };
const countBefore = (await db.select({ id: users.id }).from(users)).length;
// Try to create a second member with the same username
const dupRes = await app.fetch(
jsonRequest('POST', '/api/admin/members', {
displayName: 'Duplicate Member',
username: uniqueUsername,
initialPassword: 'second-password-xyz789',
}),
);
expect(dupRes.status).toBe(409);
// No new users row should have been created (transaction rolled back)
const countAfter = (await db.select({ id: users.id }).from(users)).length;
expect(countAfter).toBe(countBefore);
// The first member's local_credentials must still exist
const [credRow] = await db
.select({ id: localCredentials.id })
.from(localCredentials)
.where(eq(localCredentials.userId, firstBody.id))
.limit(1);
expect(credRow).toBeDefined();
});
it('Test 3: admin can reset any member password without knowing the current one', async () => {
const adminId = await seedUser('admin-reset-pw', true);
// Seeded for DB-state parity; this test creates its own member via the admin API below.
await seedUser('member-reset-target', false);
currentDevUserId = adminId;
const app = await getApp();
// First create a local_credentials row for the member
const createRes = await app.fetch(
jsonRequest('POST', '/api/admin/members', {
displayName: 'Reset Target',
username: `reset-target-${randomUUID()}`,
initialPassword: 'old-password-123',
}),
);
expect(createRes.status).toBe(201);
const { id: newMemberId } = (await createRes.json()) as { id: number };
// Admin resets the password
const newPassword = 'new-password-xyz789-secure';
const resetRes = await app.fetch(
jsonRequest('POST', `/api/admin/members/${newMemberId}/password`, {
newPassword,
}),
);
expect(resetRes.status).toBe(200);
// Verify the stored hash now verifies against the new password
const [credRow] = await db
.select({ passwordHash: localCredentials.passwordHash })
.from(localCredentials)
.where(eq(localCredentials.userId, newMemberId))
.limit(1);
expect(credRow).toBeDefined();
expect(await verifyPassword(credRow.passwordHash, newPassword)).toBe(true);
expect(await verifyPassword(credRow.passwordHash, 'old-password-123')).toBe(false);
});
it('Test 4: non-admin gets 403 on POST /members and POST /members/:id/password', async () => {
const nonAdminId = await seedUser('non-admin-member-create', false);
currentDevUserId = nonAdminId;
const app = await getApp();
const createRes = await app.fetch(
jsonRequest('POST', '/api/admin/members', {
displayName: 'Should Fail',
username: `fail-${randomUUID()}`,
initialPassword: 'password-fail-123',
}),
);
expect(createRes.status).toBe(403);
const resetRes = await app.fetch(
jsonRequest('POST', '/api/admin/members/1/password', {
newPassword: 'fail-new-password',
}),
);
expect(resetRes.status).toBe(403);
});
it('Test 5: GET /api/admin/members returns hasLocalCredential:true for member with local_credentials row', async () => {
const adminId = await seedUser('admin-haslocalcred', true);
currentDevUserId = adminId;
const app = await getApp();
// Create a member via the API (which creates a local_credentials row)
const createRes = await app.fetch(
jsonRequest('POST', '/api/admin/members', {
displayName: 'Has Local Cred',
username: `has-cred-${randomUUID()}`,
initialPassword: 'has-cred-password-123',
}),
);
expect(createRes.status).toBe(201);
const { id: newMemberId } = (await createRes.json()) as { id: number };
// GET /members should show hasLocalCredential:true for this member
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
expect(getRes.status).toBe(200);
const body = (await getRes.json()) as {
members: Array<{ id: number; hasLocalCredential: boolean }>;
};
const memberRow = body.members.find((m) => m.id === newMemberId);
expect(memberRow).toBeDefined();
expect(memberRow!.hasLocalCredential).toBe(true);
// The admin user (no local_credentials row) should have hasLocalCredential:false
const adminRow = body.members.find((m) => m.id === adminId);
expect(adminRow).toBeDefined();
expect(adminRow!.hasLocalCredential).toBe(false);
});
it('WR-05 (no-echo): malformed create-member body never echoes the submitted password or Zod received field', async () => {
const adminId = await seedUser('admin-create-noecho', true);
currentDevUserId = adminId;
const app = await getApp();
// initialPassword too short (< 8) → Zod rejects. The noEchoHook must return only
// { error: 'Invalid request' } and NEVER leak the submitted password or Zod's
// issues[].received field (T-19-06 / the T-19-14 leak this guards against).
const submittedPassword = 'shortpw-secret';
const res = await app.fetch(
jsonRequest('POST', '/api/admin/members', {
displayName: 'No Echo',
username: `noecho-${randomUUID()}`,
initialPassword: submittedPassword.slice(0, 3), // 3 chars — fails min(8)
}),
);
expect(res.status).toBe(400);
const bodyText = await res.text();
expect(bodyText).not.toContain('received');
expect(bodyText).not.toContain('issues');
expect(bodyText).not.toContain(submittedPassword.slice(0, 3));
const parsed = JSON.parse(bodyText) as { error: string };
expect(parsed.error).toBe('Invalid request');
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* GET /api/auth/mode unit tests (Plan 19-03, TDD RED GREEN).
*
* Covers:
* Test 5: returns { localEnabled:true, oidcEnabled:false } when no oidc_issuer in env or app_config
* Test 6: returns { oidcEnabled:true } when OIDC_ISSUER env var is set
* Test 6b: returns { oidcEnabled:true } when oidc_issuer is in app_config (no env var)
*
* Pre-auth surface: reachable without OIDC session (same as /api/setup/status).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ---------------------------------------------------------------------------
// DB mock — controls app_config rows returned for oidc_issuer
// ---------------------------------------------------------------------------
let mockAppConfigOidcIssuer: string | null = null;
vi.mock('../../src/db/client.js', () => ({
db: {
execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]),
select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockImplementation(() => {
if (mockAppConfigOidcIssuer) {
return Promise.resolve([{ value: mockAppConfigOidcIssuer }]);
}
return Promise.resolve([]);
}),
}),
}),
})),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
onDuplicateKeyUpdate: vi.fn().mockResolvedValue(undefined),
}),
}),
},
}));
vi.mock('@hono/oidc-auth', () => ({
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
getAuth: vi.fn().mockResolvedValue(null),
}));
vi.mock('../../src/auth/devBypass.js', () => ({
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
localAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
// ---------------------------------------------------------------------------
// Env snapshot
// ---------------------------------------------------------------------------
const originalOidcIssuer = process.env.OIDC_ISSUER;
beforeEach(() => {
mockAppConfigOidcIssuer = null;
delete process.env.OIDC_ISSUER;
vi.resetModules();
});
afterEach(() => {
if (originalOidcIssuer === undefined) {
delete process.env.OIDC_ISSUER;
} else {
process.env.OIDC_ISSUER = originalOidcIssuer;
}
vi.resetModules();
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('GET /api/auth/mode', () => {
it('Test 5: returns { localEnabled:true, oidcEnabled:false } when no oidc_issuer in env or app_config', async () => {
delete process.env.OIDC_ISSUER;
mockAppConfigOidcIssuer = null;
const { app } = await import('../../src/index.js');
const res = await app.request('/api/auth/mode');
expect(res.status).toBe(200);
const body = (await res.json()) as { localEnabled: boolean; oidcEnabled: boolean };
expect(body.localEnabled).toBe(true);
expect(body.oidcEnabled).toBe(false);
});
it('Test 6: returns { oidcEnabled:true } when OIDC_ISSUER env var is set', async () => {
process.env.OIDC_ISSUER = 'https://auth.example.com';
mockAppConfigOidcIssuer = null;
const { app } = await import('../../src/index.js');
const res = await app.request('/api/auth/mode');
expect(res.status).toBe(200);
const body = (await res.json()) as { localEnabled: boolean; oidcEnabled: boolean };
expect(body.localEnabled).toBe(true);
expect(body.oidcEnabled).toBe(true);
});
it('Test 6b: returns { oidcEnabled:true } when oidc_issuer is in app_config (no env var)', async () => {
delete process.env.OIDC_ISSUER;
mockAppConfigOidcIssuer = 'https://auth-from-config.example.com';
const { app } = await import('../../src/index.js');
const res = await app.request('/api/auth/mode');
expect(res.status).toBe(200);
const body = (await res.json()) as { localEnabled: boolean; oidcEnabled: boolean };
expect(body.localEnabled).toBe(true);
expect(body.oidcEnabled).toBe(true);
});
});
+2
View File
@@ -35,6 +35,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
c.set('user', { id: currentDevUserId }); c.set('user', { id: currentDevUserId });
await next(); await next();
}, },
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
})); }));
// Also mock the oidcAuthMiddleware so the OIDC guard is a no-op in tests. // Also mock the oidcAuthMiddleware so the OIDC guard is a no-op in tests.
+343
View File
@@ -0,0 +1,343 @@
/**
* POST /api/auth/local/login + POST/GET /api/auth/local/logout tests (Plan 19-03, TDD RED GREEN).
*
* Covers:
* Test 1: valid username+password 200 { ok:true } + Set-Cookie for local-session
* Test 2: wrong password 401 { error: 'Invalid credentials' }
* Test 3: unknown username 401 with SAME body as Test 2 (no enumeration / no field discrimination)
* Test 4: 5 consecutive failures for one username 6th returns 429
* Test 5: 10 failures 423 (lockedOut); a cleared map resets the counter
* Test 5b (CR-04): a 423 lockout auto-expires after LOCKOUT_TTL_MS (no admin reset needed)
* Test 6: POST /api/auth/local/logout clears the local-session cookie (expired Set-Cookie)
* Test 6b: GET /api/auth/local/logout (alias) also clears the local-session cookie
* Test 7 (no-echo): malformed body (missing password) 400 { error: 'Invalid request' };
* body must NOT contain submitted value or Zod 'received' field
*
* Architecture:
* Tests mock DB client and issueLocalSessionCookie/clearLocalSessionCookie.
* loginAttempts Map is imported directly and cleared between tests.
* IP is derived from x-forwarded-for header.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ---------------------------------------------------------------------------
// Mock DB client
// ---------------------------------------------------------------------------
type LocalCredRow = { userId: number; passwordHash: string } | undefined;
let mockCredRow: LocalCredRow;
vi.mock('../../src/db/client.js', () => ({
db: {
execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]),
select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi
.fn()
.mockImplementation(() => Promise.resolve(mockCredRow ? [mockCredRow] : [])),
}),
}),
})),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
onDuplicateKeyUpdate: vi.fn().mockResolvedValue(undefined),
}),
}),
},
}));
// ---------------------------------------------------------------------------
// Mock localSession helpers — track calls and control cookie behavior
// ---------------------------------------------------------------------------
let issueSessionCalled = false;
let issuedUserId: number | null = null;
let clearSessionCalled = false;
vi.mock('../../src/auth/localSession.js', () => ({
issueLocalSessionCookie: vi.fn().mockImplementation((_c: unknown, userId: number) => {
issueSessionCalled = true;
issuedUserId = userId;
// Simulate setting a cookie on the context
return Promise.resolve();
}),
clearLocalSessionCookie: vi.fn().mockImplementation((_c: unknown) => {
clearSessionCalled = true;
}),
verifyLocalSessionCookie: vi.fn().mockResolvedValue(null),
}));
// ---------------------------------------------------------------------------
// Mock devAuthBypass and OIDC — standard passthrough for route tests
// ---------------------------------------------------------------------------
vi.mock('../../src/auth/devBypass.js', () => ({
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
localAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
vi.mock('@hono/oidc-auth', () => ({
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
getAuth: vi.fn().mockResolvedValue(null),
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeLoginRequest(body: Record<string, string>, ip = '1.2.3.4'): Request {
return new Request('http://localhost/api/auth/local/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-forwarded-for': ip,
},
body: JSON.stringify(body),
});
}
async function getApp() {
const { app } = await import('../../src/index.js');
return app;
}
// ---------------------------------------------------------------------------
// Test state
// ---------------------------------------------------------------------------
const originalNodeEnv = process.env.NODE_ENV;
const originalBypass = process.env.DEV_AUTH_BYPASS;
const originalSecret = process.env.LOCAL_SESSION_SECRET;
beforeEach(async () => {
// Use dev-bypass mode so no OIDC redirect occurs
process.env.NODE_ENV = 'test';
process.env.DEV_AUTH_BYPASS = 'true';
// Provide a valid LOCAL_SESSION_SECRET for the session helpers
process.env.LOCAL_SESSION_SECRET = 'test-secret-that-is-at-least-32-chars!!';
issueSessionCalled = false;
issuedUserId = null;
clearSessionCalled = false;
mockCredRow = undefined;
vi.resetModules();
// Clear the rate-limit map between tests
const { loginAttempts } = await import('../../src/routes/localAuth.js');
loginAttempts.clear();
});
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
if (originalBypass === undefined) delete process.env.DEV_AUTH_BYPASS;
else process.env.DEV_AUTH_BYPASS = originalBypass;
if (originalSecret === undefined) delete process.env.LOCAL_SESSION_SECRET;
else process.env.LOCAL_SESSION_SECRET = originalSecret;
vi.resetModules();
});
// ---------------------------------------------------------------------------
// Import verifyPassword/hashPassword for test credential setup
// ---------------------------------------------------------------------------
async function getLocalCredentials() {
return import('../../src/auth/localCredentials.js');
}
// ===========================================================================
// POST /api/auth/local/login
// ===========================================================================
describe('POST /api/auth/local/login', () => {
it('Test 1: valid username+password → 200 { ok:true } and issueLocalSessionCookie called', async () => {
const { hashPassword } = await getLocalCredentials();
const hash = await hashPassword('correcthorse');
mockCredRow = { userId: 5, passwordHash: hash };
const app = await getApp();
const res = await app.fetch(makeLoginRequest({ username: 'alice', password: 'correcthorse' }));
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
expect(issueSessionCalled).toBe(true);
expect(issuedUserId).toBe(5);
});
it('Test 2: wrong password → 401 { error: "Invalid credentials" }', async () => {
const { hashPassword } = await getLocalCredentials();
const hash = await hashPassword('correcthorse');
mockCredRow = { userId: 5, passwordHash: hash };
const app = await getApp();
const res = await app.fetch(makeLoginRequest({ username: 'alice', password: 'wrongpassword' }));
expect(res.status).toBe(401);
const body = (await res.json()) as { error: string };
expect(body.error).toBe('Invalid credentials');
expect(issueSessionCalled).toBe(false);
});
it('Test 3: unknown username → 401 with SAME body as wrong password (no enumeration)', async () => {
mockCredRow = undefined; // No credential row found
const app = await getApp();
const res = await app.fetch(
makeLoginRequest({ username: 'unknown-user', password: 'anypassword' }),
);
expect(res.status).toBe(401);
const body = (await res.json()) as { error: string };
// CRITICAL: same body as wrong-password case (Test 2) — no field discrimination
expect(body.error).toBe('Invalid credentials');
expect(issueSessionCalled).toBe(false);
});
it('Test 4: 5 consecutive failures → 6th attempt returns 429', async () => {
mockCredRow = undefined; // Always unknown — every attempt fails
const app = await getApp();
// 5 failures to trigger the rate window
for (let i = 0; i < 5; i++) {
const res = await app.fetch(
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'),
);
expect(res.status).toBe(401);
}
// 6th attempt from same IP → 429
const res6 = await app.fetch(
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'),
);
expect(res6.status).toBe(429);
const body = (await res6.json()) as { error: string };
expect(body.error).toBe('Too many attempts');
});
it('Test 5: 10 failures → 423 (account locked); cleared map resets counter', async () => {
mockCredRow = undefined;
const app = await getApp();
// 10 failures from same IP → lockout
for (let i = 0; i < 10; i++) {
await app.fetch(makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'));
}
// 11th attempt → 423 (locked)
const res11 = await app.fetch(
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'),
);
expect(res11.status).toBe(423);
const body = (await res11.json()) as { error: string };
expect(body.error).toBe('Account locked');
// Clear the map (simulates admin reset) → counter gone → next attempt is 401 again (not locked)
// CR-04: the limiter is keyed on the USERNAME ('alice'), not the IP.
const { loginAttempts } = await import('../../src/routes/localAuth.js');
loginAttempts.delete('alice');
const resAfterReset = await app.fetch(
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'),
);
expect(resAfterReset.status).toBe(401);
});
it('Test 5b (CR-04): a 423 lockout auto-expires after the TTL — no admin reset needed', async () => {
mockCredRow = undefined;
const app = await getApp();
// 10 failures → lockout for username 'bob'
for (let i = 0; i < 10; i++) {
await app.fetch(makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'));
}
// Confirm locked (423)
const resLocked = await app.fetch(
makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'),
);
expect(resLocked.status).toBe(423);
// Simulate the TTL elapsing by back-dating lockedAt well past LOCKOUT_TTL_MS (15 min).
const { loginAttempts } = await import('../../src/routes/localAuth.js');
const entry = loginAttempts.get('bob');
expect(entry?.lockedOut).toBe(true);
if (entry) entry.lockedAt = Date.now() - 16 * 60 * 1000;
// Next attempt: the lockout has expired → handler drops the entry and processes the
// login normally, so a wrong password is a fresh 401 (not a 423). CR-04: self-healing.
const resAfterTtl = await app.fetch(
makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'),
);
expect(resAfterTtl.status).toBe(401);
});
it('Test 7 (no-echo): malformed body (missing password) → 400 { error: "Invalid request" }; body has no echoed value or Zod received field', async () => {
const app = await getApp();
// Body with username but missing password (Zod will reject)
const res = await app.fetch(
new Request('http://localhost/api/auth/local/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' },
body: JSON.stringify({ username: 'mysecretusername' }),
}),
);
expect(res.status).toBe(400);
const bodyText = await res.text();
const parsed = JSON.parse(bodyText) as { error: string };
expect(parsed.error).toBe('Invalid request');
// CRITICAL (no-echo): the response must NOT contain the submitted value or Zod 'received' field
expect(bodyText).not.toContain('mysecretusername');
expect(bodyText).not.toContain('received');
});
});
// ===========================================================================
// POST /api/auth/local/logout + GET /api/auth/local/logout
// ===========================================================================
describe('POST /api/auth/local/logout', () => {
it('Test 6: POST /logout → 200 { ok:true } and clearLocalSessionCookie called', async () => {
const app = await getApp();
const res = await app.fetch(
new Request('http://localhost/api/auth/local/logout', {
method: 'POST',
headers: { 'x-forwarded-for': '1.2.3.4' },
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
expect(clearSessionCalled).toBe(true);
});
});
describe('GET /api/auth/local/logout', () => {
it('Test 6b: GET /logout (alias) → 200 { ok:true } and clearLocalSessionCookie called', async () => {
const app = await getApp();
const res = await app.fetch(
new Request('http://localhost/api/auth/local/logout', {
method: 'GET',
headers: { 'x-forwarded-for': '1.2.3.4' },
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
expect(clearSessionCalled).toBe(true);
});
});
+420
View File
@@ -12,6 +12,9 @@
* - dev-bypass path returns isAdmin (DB-backed, not hardcoded) + needsProviderSetup * - dev-bypass path returns isAdmin (DB-backed, not hardcoded) + needsProviderSetup
* - OIDC path returns isAdmin + needsProviderSetup * - OIDC path returns isAdmin + needsProviderSetup
* - needsProviderSetup=true when no member_credentials row exists; false when one exists * - needsProviderSetup=true when no member_credentials row exists; false when one exists
* 4. Plan 19-02 additions:
* - POST /api/me/password: self-change with correct/wrong current-password
* - GET /api/me: hasLocalCredential field
* *
* Architecture note: * Architecture note:
* devAuthBypass() and devBypassActive in index.ts both evaluate env vars at module * devAuthBypass() and devBypassActive in index.ts both evaluate env vars at module
@@ -20,6 +23,7 @@
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { hashPassword } from '../../src/auth/localCredentials.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared mock: DB — avoids real DB connections across all tests in this file. // Shared mock: DB — avoids real DB connections across all tests in this file.
@@ -253,3 +257,419 @@ describe('GET /api/me — isAdmin + needsProviderSetup (Plan 10-02, D-03)', () =
expect(body.user.needsProviderSetup).toBe(false); expect(body.user.needsProviderSetup).toBe(false);
}); });
}); });
// ---------------------------------------------------------------------------
// Plan 19-02: POST /api/me/password — self-change password (AUTH-LOCAL-09, T-19-07)
// ---------------------------------------------------------------------------
describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () => {
beforeEach(() => {
process.env.NODE_ENV = 'test';
process.env.DEV_AUTH_BYPASS = 'true';
process.env.LOCAL_SESSION_SECRET = 'test-secret-that-is-long-enough-32chars!!';
});
it('Test 1: correct currentPassword → 200 and stored hash verifies newPassword', async () => {
const { db } = await import('../../src/db/client.js');
const oldPassword = 'old-password-correct-123';
const newPassword = 'new-password-secure-456';
const storedHash = await hashPassword(oldPassword);
let updatedHash: string | null = null;
// Mock sequence: resolveUserId (devBypass sets user), then:
// 1. SELECT local_credentials WHERE user_id (returns row with stored hash)
// 2. UPDATE local_credentials SET password_hash (capture the new hash)
let callCount = 0;
vi.mocked(db.select).mockImplementation(() => {
callCount++;
if (callCount === 1) {
// local_credentials lookup
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]),
}),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
// fallback for other selects
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
});
// Mock the UPDATE call — capture what hash it sets
vi.mocked(db).update = vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation((values: { passwordHash?: string }) => {
if (values.passwordHash) updatedHash = values.passwordHash;
return {
where: vi.fn().mockResolvedValue({ rowsAffected: 1 }),
};
}),
}));
const { app } = await import('../../src/index.js');
const res = await app.request('/api/me/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: oldPassword, newPassword }),
});
expect(res.status).toBe(200);
// The updatedHash must verify the new password
expect(updatedHash).not.toBeNull();
const { verifyPassword } = await import('../../src/auth/localCredentials.js');
expect(await verifyPassword(updatedHash!, newPassword)).toBe(true);
expect(await verifyPassword(updatedHash!, oldPassword)).toBe(false);
});
it('Test 2: wrong currentPassword → 403 and update is NOT called', async () => {
const { db } = await import('../../src/db/client.js');
const realPassword = 'real-password-correct-789';
const storedHash = await hashPassword(realPassword);
let updateWasCalled = false;
let callCount = 0;
vi.mocked(db.select).mockImplementation(() => {
callCount++;
if (callCount === 1) {
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]),
}),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
});
vi.mocked(db).update = vi.fn().mockImplementation(() => {
updateWasCalled = true;
return {
set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue({ rowsAffected: 1 }) }),
};
});
const { app } = await import('../../src/index.js');
const res = await app.request('/api/me/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: 'WRONG-password', newPassword: 'new-pass-12345678' }),
});
// CR-03: wrong current password returns 403 (in-app authz failure), NOT 401.
// A 401 would be interpreted by the PWA as session expiry and log the user out.
expect(res.status).toBe(403);
const body = (await res.json()) as { error: string };
expect(body.error).toBe('Current password incorrect');
// Update must NOT have been called
expect(updateWasCalled).toBe(false);
});
it('Test 3: user with no local_credentials row → 404', async () => {
const { db } = await import('../../src/db/client.js');
vi.mocked(db.select).mockImplementation(
() =>
({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
const { app } = await import('../../src/index.js');
const res = await app.request('/api/me/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: 'any', newPassword: 'new-pass-12345678' }),
});
expect(res.status).toBe(404);
});
it('Test 4 (WR-05 no-echo): malformed body never echoes the submitted password or Zod received field', async () => {
// newPassword too short (< 8) → Zod rejects via meNoEchoHook. The response must be
// ONLY { error: 'Invalid request' } and must NOT leak the submitted password or the
// Zod issues[].received field (T-19-06).
const { app } = await import('../../src/index.js');
const submitted = 'my-secret-current-pw';
const res = await app.request('/api/me/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: submitted, newPassword: 'short' }),
});
expect(res.status).toBe(400);
const bodyText = await res.text();
expect(bodyText).not.toContain(submitted);
expect(bodyText).not.toContain('received');
expect(bodyText).not.toContain('issues');
const parsed = JSON.parse(bodyText) as { error: string };
expect(parsed.error).toBe('Invalid request');
});
});
// ---------------------------------------------------------------------------
// Plan 19-02: GET /api/me — hasLocalCredential field (AUTH-LOCAL-17)
// ---------------------------------------------------------------------------
describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => {
beforeEach(() => {
process.env.NODE_ENV = 'test';
process.env.DEV_AUTH_BYPASS = 'true';
});
it('Test 4: includes hasLocalCredential:true when local_credentials row exists', async () => {
const { db } = await import('../../src/db/client.js');
let callCount = 0;
vi.mocked(db.select).mockImplementation(() => {
callCount++;
// Call order in resolveAdminAndSetupStatus:
// 1 → users.isAdmin lookup
// 2 → memberCredentials lookup
// 3 → localCredentials lookup (new, AUTH-LOCAL-17)
let limitResult: object[];
if (callCount === 1) {
limitResult = [{ isAdmin: false }]; // users row
} else if (callCount === 2) {
limitResult = []; // no member_credentials (needsProviderSetup=true)
} else {
limitResult = [{ id: 42 }]; // has local_credentials row
}
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
});
const { app } = await import('../../src/index.js');
const res = await app.request('/api/me');
expect(res.status).toBe(200);
const body = (await res.json()) as { user: { hasLocalCredential: boolean } };
expect(body.user).toHaveProperty('hasLocalCredential');
expect(body.user.hasLocalCredential).toBe(true);
});
it('hasLocalCredential:false when no local_credentials row', async () => {
const { db } = await import('../../src/db/client.js');
let callCount = 0;
vi.mocked(db.select).mockImplementation(() => {
callCount++;
// All 3 selects return empty/minimal
const limitResult = callCount === 1 ? [{ isAdmin: false }] : [];
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
});
const { app } = await import('../../src/index.js');
const res = await app.request('/api/me');
expect(res.status).toBe(200);
const body = (await res.json()) as { user: { hasLocalCredential: boolean } };
expect(body.user).toHaveProperty('hasLocalCredential');
expect(body.user.hasLocalCredential).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Plan 19-02: linkOidcToUser helper + POST /api/me/link-oidc (AUTH-LOCAL-10)
// ---------------------------------------------------------------------------
describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
beforeEach(() => {
process.env.NODE_ENV = 'test';
process.env.DEV_AUTH_BYPASS = 'true';
process.env.LOCAL_SESSION_SECRET = 'test-secret-that-is-long-enough-32chars!!';
});
it('Test 1: linkOidcToUser updates users.oidc_iss/sub and deletes local_credentials for userId', async () => {
const { db } = await import('../../src/db/client.js');
const { linkOidcToUser } = await import('../../src/auth/linkOidc.js');
const iss = 'https://auth.example.com';
const sub = 'user-sub-abc-123';
let updatedUsers = false;
let deletedLocalCreds = false;
// Mock: SELECT users WHERE oidc_iss = iss AND oidc_sub = sub → no conflict (empty)
const mockTx = {
select: vi.fn().mockImplementation(() => {
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // no conflict
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
};
}),
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => {
updatedUsers = true;
return { where: vi.fn().mockResolvedValue({ rowsAffected: 1 }) };
}),
})),
delete: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => {
deletedLocalCreds = true;
return Promise.resolve({ rowsAffected: 1 });
}),
})),
};
vi.mocked(db.select).mockImplementation(
() =>
({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
vi.mocked(db).transaction = vi
.fn()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockImplementation(async (fn: (tx: any) => Promise<void>) => {
await fn(mockTx);
});
await linkOidcToUser(42, iss, sub);
expect(updatedUsers).toBe(true);
expect(deletedLocalCreds).toBe(true);
});
it('Test 2: linkOidcToUser throws OidcLinkConflictError and does NOT delete local_credentials when iss+sub belongs to different user', async () => {
const { db } = await import('../../src/db/client.js');
const { linkOidcToUser, OidcLinkConflictError } = await import('../../src/auth/linkOidc.js');
const iss = 'https://auth.example.com';
const sub = 'already-taken-sub';
const conflictingUserId = 99; // different from target userId 42
let deletedLocalCreds = false;
// Preflight SELECT finds a conflicting user (id=99, different from target=42)
vi.mocked(db.select).mockImplementation(
() =>
({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict!
}),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
// Transaction should NEVER be called on conflict
const mockTx = {
delete: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => {
deletedLocalCreds = true;
return Promise.resolve({ rowsAffected: 1 });
}),
})),
};
vi.mocked(db).transaction = vi
.fn()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockImplementation(async (fn: (tx: any) => Promise<void>) => {
await fn(mockTx);
});
// Should throw OidcLinkConflictError, not proceed to transaction
await expect(linkOidcToUser(42, iss, sub)).rejects.toThrow(OidcLinkConflictError);
// local_credentials row must NOT have been deleted (binding aborted before any write)
expect(deletedLocalCreds).toBe(false);
// Transaction must not have been called
expect(vi.mocked(db).transaction).not.toHaveBeenCalled();
});
it('Test 3: POST /api/me/link-oidc returns response shape with authorization URL / initiation payload', async () => {
const { db } = await import('../../src/db/client.js');
// Mock db — not needed for route shape test but avoids errors
vi.mocked(db.select).mockImplementation(
() =>
({
from: vi.fn().mockReturnValue({
where: vi
.fn()
.mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
const { app } = await import('../../src/index.js');
const res = await app.request('/api/me/link-oidc', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
// Must be 200 with an initiation payload (not the actual OIDC redirect — that's 19-03)
expect(res.status).toBe(200);
const body = (await res.json()) as { authorizationUrl?: string; state?: string };
// The response must have at minimum a signedState field (or authorizationUrl)
// — the exact shape depends on implementation; assert it's an object with a useful field
const hasInitiationPayload =
'authorizationUrl' in body || 'state' in body || 'signedState' in body;
expect(hasInitiationPayload).toBe(true);
});
});
+4
View File
@@ -30,6 +30,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
c.set('user', { id: currentDevUserId }); c.set('user', { id: currentDevUserId });
await next(); await next();
}, },
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
})); }));
vi.mock('@hono/oidc-auth', () => ({ vi.mock('@hono/oidc-auth', () => ({
@@ -111,6 +113,8 @@ describe('POST /api/push/subscription', () => {
// and OIDC getAuth returns null — so resolveUserId returns null → 401. // and OIDC getAuth returns null — so resolveUserId returns null → 401.
vi.doMock('../../src/auth/devBypass.js', () => ({ vi.doMock('../../src/auth/devBypass.js', () => ({
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(), devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
})); }));
vi.doMock('../../src/auth/middleware.js', () => ({ vi.doMock('../../src/auth/middleware.js', () => ({
getAuth: () => null, getAuth: () => null,
+2
View File
@@ -101,6 +101,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
// No user injection for setup routes — pre-auth surface // No user injection for setup routes — pre-auth surface
await next(); await next();
}, },
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
})); }));
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+23
View File
@@ -21,6 +21,16 @@
*/ */
import mysql from 'mysql2/promise'; import mysql from 'mysql2/promise';
import { scryptSync, randomBytes } from 'node:crypto';
// ── Inline scrypt PHC hashPassword ───────────────────────────────────────────
// global-setup is plain Node.js (no @playwright/test, cannot import compiled TS).
// Copy of apps/api/src/auth/localCredentials.ts hashPassword (Pitfall 11).
function hashPasswordInline(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 default async function globalSetup(): Promise<void> { export default async function globalSetup(): Promise<void> {
// ── Step 0: Fail-closed environment guard (CR-01 — data-loss prevention) ───── // ── Step 0: Fail-closed environment guard (CR-01 — data-loss prevention) ─────
@@ -107,6 +117,7 @@ export default async function globalSetup(): Promise<void> {
await conn.execute('TRUNCATE TABLE list_shares'); await conn.execute('TRUNCATE TABLE list_shares');
await conn.execute('TRUNCATE TABLE lists'); await conn.execute('TRUNCATE TABLE lists');
await conn.execute('TRUNCATE TABLE calendar_events'); await conn.execute('TRUNCATE TABLE calendar_events');
await conn.execute('TRUNCATE TABLE local_credentials');
await conn.execute('SET FOREIGN_KEY_CHECKS=1'); await conn.execute('SET FOREIGN_KEY_CHECKS=1');
// Phase 18: clear any stored household timezone so the timezone spec always // Phase 18: clear any stored household timezone so the timezone spec always
@@ -146,6 +157,18 @@ export default async function globalSetup(): Promise<void> {
ON DUPLICATE KEY UPDATE fastmail_email='dev@e2e.local'`, ON DUPLICATE KEY UPDATE fastmail_email='dev@e2e.local'`,
); );
// Phase 19 — Option C (AUTH-LOCAL-16): seed local_credentials for the dev user (id=1).
// devSessionCookieMiddleware issues a local-session cookie so the PWA login gate skips
// /login and the existing harness specs still reach the authed app unchanged.
// A dedicated login.spec.ts clears the cookie to test the real login form.
// hashPasswordInline is inlined (Pitfall 11 — plain Node.js, cannot import compiled TS).
await conn.execute(
`INSERT INTO local_credentials (user_id, username, password_hash)
VALUES (1, 'devuser', ?)
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)`,
[hashPasswordInline('devpass')],
);
// CI guard (Pitfall 4): ensure calendar row id=10 exists before inserting events. // CI guard (Pitfall 4): ensure calendar row id=10 exists before inserting events.
// INSERT IGNORE is a no-op if the row already exists (dev DB), creates it if not (CI fresh DB). // INSERT IGNORE is a no-op if the row already exists (dev DB), creates it if not (CI fresh DB).
await conn.execute( await conn.execute(
+117
View File
@@ -0,0 +1,117 @@
/**
* login.spec.ts Phase 19 AUTH-LOCAL-12/15/16
*
* Real-login-form e2e tests covering the PWA login gate + form interaction.
*
* Strategy (Option C):
* The global-setup seeds 'devuser'/'devpass' into local_credentials and the API's
* devSessionCookieMiddleware issues a local-session cookie on every /api/* request
* under DEV_AUTH_BYPASS=true. The OTHER specs (layout, calendar, lists) rely on that
* cookie being present and do NOT clear it they still reach the authed app unchanged.
*
* IMPORTANT bypass constraint: this harness is DEV_AUTH_BYPASS-only (global-setup
* refuses a non-bypass DB). Under the bypass, devAuthBypass() injects DEV_USER into
* every /api/* request, so /api/me is authed regardless of the local-session cookie
* clearing the cookie does NOT produce a logged-out state in the browser. We therefore
* exercise the /login page DIRECTLY (the /login route always renders the form) for the
* form + real-login round-trip, and cover the unauthenticated root/login redirect gate
* at the unit level in src/App.test.tsx (where meQuery.isError is controllable).
*
* Specs covered:
* 1. /login renders all brand + form surfaces (real browser, real CSS/tokens)
* 2. Wrong password single "Incorrect username or password." error message
* 3. Correct devuser/devpass navigates into the app (out of /login)
*
* Only runs on the desktop/chromium project (Chromium handles local-session cookies
* consistently; WebKit PWA restrictions are irrelevant here since the login form is
* a normal web page, not a Home Screen PWA). Other profiles inherit the bypass cookie.
*
* Run:
* pnpm --filter @familysync/pwa test:e2e --grep "login"
* pnpm --filter @familysync/pwa exec playwright test --project=desktop login.spec.ts
*/
import { test, expect } from '@playwright/test';
// Selectors derived from 19-UI-SPEC.md Surfaces 3-7 (locked by plan 04 implementation)
const SELECTORS = {
usernameInput: '#login-username',
// password input has id="login-password" (UI-SPEC Surface 5)
passwordInput: '#login-password',
// Primary submit: role=button with name "Sign in" (UI-SPEC Surface 7)
submitBtn: 'button[type="submit"]',
// Error message is in a role="status" element (UI-SPEC Surface 6)
errorMessage: '[role="status"]',
};
// Only run these specs on the desktop profile. The login form is a standard web
// page (not PWA-specific) and Chromium handles cookies most consistently for this test.
// iphone/pixel still reach the authed app via the bypass-issued cookie (unchanged behavior).
test.describe('Login form — real auth round-trip (desktop/Chromium only)', () => {
test.skip(
({ browserName }) => browserName !== 'chromium',
'Login form tests only run on Chromium (desktop profile) — other profiles use the bypass cookie',
);
test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', async ({ page }) => {
// Navigate DIRECTLY to /login rather than asserting an unauthenticated root→/login
// redirect: under the always-on DEV_AUTH_BYPASS, /api/me is authed via DEV_USER
// injection regardless of the cookie, so visiting / lands on /calendar and a
// logged-out state is unreachable here. The redirect gate is unit-tested in
// src/App.test.tsx; this e2e proves /login renders every surface in a real browser.
await page.goto('/login', { waitUntil: 'domcontentloaded' });
// Assert we are on the /login route
await expect(page).toHaveURL(/\/login/);
// Brand slot: "FamilySync" text should be visible (UI-SPEC Surface 2)
await expect(page.getByText('FamilySync', { exact: true })).toBeVisible();
// Login card heading "Sign in" (UI-SPEC Surface 3)
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
// Username field (UI-SPEC Surface 4)
await expect(page.locator(SELECTORS.usernameInput)).toBeVisible();
// Password field (UI-SPEC Surface 5)
await expect(page.locator(SELECTORS.passwordInput)).toBeVisible();
// Submit button (UI-SPEC Surface 7)
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
});
test('wrong password shows single "Incorrect username or password." error', async ({
page,
context,
}) => {
await context.clearCookies();
await page.goto('/login', { waitUntil: 'domcontentloaded' });
// Fill in wrong credentials
await page.locator(SELECTORS.usernameInput).fill('devuser');
await page.locator(SELECTORS.passwordInput).fill('wrongpassword');
await page.getByRole('button', { name: 'Sign in' }).click();
// Error message appears (UI-SPEC Surface 6 — "Incorrect username or password.")
const errorEl = page.locator(SELECTORS.errorMessage);
await expect(errorEl).toBeVisible({ timeout: 5_000 });
await expect(errorEl).toContainText('Incorrect username or password.');
// Still on /login
await expect(page).toHaveURL(/\/login/);
});
test('correct devuser/devpass logs in and navigates out of /login', async ({ page, context }) => {
await context.clearCookies();
await page.goto('/login', { waitUntil: 'domcontentloaded' });
// Fill in the seeded dev credentials (global-setup seeds devuser/devpass)
await page.locator(SELECTORS.usernameInput).fill('devuser');
await page.locator(SELECTORS.passwordInput).fill('devpass');
await page.getByRole('button', { name: 'Sign in' }).click();
// After login, the page navigates away from /login (to / or /calendar)
await expect(page).not.toHaveURL(/\/login/, { timeout: 10_000 });
});
});
+67 -1
View File
@@ -74,21 +74,33 @@ vi.mock('./routes/ListDetail.js', () => ({
ListDetail: () => <div data-testid="list-detail">ListDetail</div>, ListDetail: () => <div data-testid="list-detail">ListDetail</div>,
})); }));
vi.mock('./routes/LoginPage.js', () => ({
LoginPage: () => <div data-testid="login-page">LoginPage</div>,
}));
// Mock the API client — this is the key mock for the gate // Mock the API client — this is the key mock for the gate
vi.mock('./api/client.js', () => ({ vi.mock('./api/client.js', () => ({
fetchSetupStatus: vi.fn(), fetchSetupStatus: vi.fn(),
fetchMe: vi.fn(), fetchMe: vi.fn(),
// Phase 19: fetchAuthMode is queried in App.tsx for the /login gate
fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }),
SetupAlreadyLockedError: class SetupAlreadyLockedError extends Error { SetupAlreadyLockedError: class SetupAlreadyLockedError extends Error {
readonly name = 'SetupAlreadyLockedError'; readonly name = 'SetupAlreadyLockedError';
}, },
SessionExpiredError: class SessionExpiredError extends Error { SessionExpiredError: class SessionExpiredError extends Error {
readonly name = 'SessionExpiredError'; readonly name = 'SessionExpiredError';
}, },
LoginError: class LoginError extends Error {
readonly name = 'LoginError';
constructor(public readonly code: string) {
super(`Login failed: ${code}`);
}
},
})); }));
// ── Imports (after mocks) ──────────────────────────────────────────────────── // ── Imports (after mocks) ────────────────────────────────────────────────────
import { fetchSetupStatus, fetchMe } from './api/client.js'; import { fetchSetupStatus, fetchMe, fetchAuthMode } from './api/client.js';
import type { Mock } from 'vitest'; import type { Mock } from 'vitest';
import App from './App.js'; import App from './App.js';
@@ -113,6 +125,7 @@ function renderApp(queryClient: QueryClient) {
const mockFetchSetupStatus = fetchSetupStatus as Mock; const mockFetchSetupStatus = fetchSetupStatus as Mock;
const mockFetchMe = fetchMe as Mock; const mockFetchMe = fetchMe as Mock;
const _mockFetchAuthMode = fetchAuthMode as Mock;
// ── Tests ───────────────────────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────────────────────
@@ -129,6 +142,7 @@ describe('App — setup-status gate', () => {
color: '#4a90d9', color: '#4a90d9',
isAdmin: false, isAdmin: false,
needsProviderSetup: false, needsProviderSetup: false,
hasLocalCredential: false,
}, },
}); });
}); });
@@ -223,6 +237,58 @@ describe('App — setup-status gate', () => {
}); });
}); });
// Phase 19 (AUTH-LOCAL-15): the unauthenticated → /login redirect gate. This lives
// here at the unit level because the e2e harness runs DEV_AUTH_BYPASS-only (global-setup
// refuses a non-bypass DB), and under the always-on bypass /api/me is authed via DEV_USER
// injection regardless of any cookie — so a logged-out state (meQuery.isError) is
// architecturally unreachable in the browser harness. The redirect logic is controllable
// here by rejecting fetchMe.
describe('App — auth gate (Phase 19)', () => {
beforeEach(() => {
vi.clearAllMocks();
window.history.pushState({}, '', '/');
mockFetchSetupStatus.mockResolvedValue({ setupComplete: true });
_mockFetchAuthMode.mockResolvedValue({ localEnabled: true, oidcEnabled: false });
});
it('redirects to /login when fetchMe errors (unauthenticated) and localEnabled', async () => {
mockFetchMe.mockRejectedValue(new Error('401 Unauthorized'));
const queryClient = makeQueryClient();
renderApp(queryClient);
await waitFor(() => {
expect(screen.getByTestId('login-page')).toBeInTheDocument();
});
// The authenticated app shell must NOT render for an unauthenticated user.
expect(screen.queryByTestId('calendar-shell')).toBeNull();
expect(screen.queryByTestId('app-nav')).toBeNull();
});
it('renders the app shell (not /login) when fetchMe succeeds', async () => {
mockFetchMe.mockResolvedValue({
user: {
id: 1,
displayName: 'Test User',
color: '#4a90d9',
isAdmin: false,
needsProviderSetup: false,
hasLocalCredential: false,
},
});
const queryClient = makeQueryClient();
renderApp(queryClient);
await waitFor(() => {
expect(screen.getByTestId('calendar-shell')).toBeInTheDocument();
});
expect(screen.queryByTestId('login-page')).toBeNull();
});
});
describe('App — setupStatus and route presence', () => { describe('App — setupStatus and route presence', () => {
it('App.tsx references setupStatus queryKey', () => { it('App.tsx references setupStatus queryKey', () => {
// This test verifies the source-level contract via module inspection. // This test verifies the source-level contract via module inspection.
+42 -1
View File
@@ -52,18 +52,33 @@ import { ListsIndex } from './routes/ListsIndex.js';
import { ListDetail } from './routes/ListDetail.js'; import { ListDetail } from './routes/ListDetail.js';
import { AdminPage } from './routes/AdminPage.js'; import { AdminPage } from './routes/AdminPage.js';
import { SetupPage } from './routes/SetupPage.js'; import { SetupPage } from './routes/SetupPage.js';
import { LoginPage } from './routes/LoginPage.js';
import { BottomTabBar } from './components/BottomTabBar.js'; import { BottomTabBar } from './components/BottomTabBar.js';
import { AppNav } from './components/AppNav.js'; import { AppNav } from './components/AppNav.js';
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js'; import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js'; import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
import { SetupBanner } from './components/SetupBanner.js'; import { SetupBanner } from './components/SetupBanner.js';
import { SettingsSheet } from './components/SettingsSheet.js'; import { SettingsSheet } from './components/SettingsSheet.js';
import { fetchMe, fetchSetupStatus } from './api/client.js'; import { fetchMe, fetchSetupStatus, fetchAuthMode } from './api/client.js';
function isPhone(): boolean { function isPhone(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
} }
/**
* OidcRedirect tiny helper that triggers a top-level navigation to /api/login.
*
* Used in the auth gate when localEnabled === false and oidcEnabled === true
* the OIDC-only mode that was the app's only auth path before Phase 19.
* A top-level navigation (not a React Router navigate) is required because
* /api/login responds with a 302 redirect to the external OIDC provider,
* which browsers cannot follow as a fetch/XHR (T-07-04).
*/
function OidcRedirect() {
window.location.replace('/api/login');
return <div aria-hidden="true" />;
}
export default function App() { export default function App() {
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const phone = isPhone(); const phone = isPhone();
@@ -98,6 +113,16 @@ export default function App() {
staleTime: 0, staleTime: 0,
}); });
// Auth mode query — fetched pre-auth (no session required).
// Determines whether to show /login (localEnabled) or OIDC redirect (!localEnabled && oidcEnabled).
// staleTime 60s: auth mode changes rarely; re-fetches on new tab/focus.
const authModeQuery = useQuery({
queryKey: ['authMode'],
queryFn: fetchAuthMode,
retry: false,
staleTime: 60_000,
});
// isAdmin from /api/me — used for UX gating only (D-03). Server enforces 403. // isAdmin from /api/me — used for UX gating only (D-03). Server enforces 403.
// While meQuery is loading, isAdmin is false/undefined → admin route redirects // While meQuery is loading, isAdmin is false/undefined → admin route redirects
// (loading gate: no flash of admin content for non-admins). // (loading gate: no flash of admin content for non-admins).
@@ -166,6 +191,12 @@ export default function App() {
} }
/> />
{/* /login route standalone login page, no AppNav/BottomTabBar shell (UI-SPEC §Surface 1).
Phase 19: shown when the user is unauthenticated AND localEnabled === true.
The route itself always renders LoginPage (authMode gating is in the `*` route gate below).
LoginPage receives authMode so it can show the optional OIDC button when oidcEnabled. */}
<Route path="/login" element={<LoginPage authMode={authModeQuery.data} />} />
{/* All other routes are gated on setup completion */} {/* All other routes are gated on setup completion */}
<Route <Route
path="*" path="*"
@@ -176,6 +207,16 @@ export default function App() {
) : setupComplete === false ? ( ) : setupComplete === false ? (
// Not configured: full-app redirect to /setup (no nav shell rendered) // Not configured: full-app redirect to /setup (no nav shell rendered)
<Navigate to="/setup" replace /> <Navigate to="/setup" replace />
) : meQuery.isError && !meQuery.isLoading && authModeQuery.data?.localEnabled ? (
// Unauthenticated + localEnabled: redirect to /login
<Navigate to="/login" replace />
) : meQuery.isError &&
!meQuery.isLoading &&
!authModeQuery.data?.localEnabled &&
authModeQuery.data?.oidcEnabled ? (
// Unauthenticated + OIDC-only mode: top-level redirect to /api/login (today's behavior)
// Use a render side-effect via useEffect isn't available here; use a helper element
<OidcRedirect />
) : ( ) : (
// Setup complete: render the normal authenticated app shell // Setup complete: render the normal authenticated app shell
<> <>
+30
View File
@@ -654,3 +654,33 @@ describe('calendarStore — delete/sync keys', () => {
expect(state.lastSyncedUid).toBeNull(); expect(state.lastSyncedUid).toBeNull();
}); });
}); });
// ── Phase 19 (AUTH-LOCAL-08): admin reset-password URL contract ───────────────
// Regression guard for the post-merge blocker: client.ts targeted
// /members/:id/reset-password but the API registers /members/:id/password, so the
// Admin reset sheet 404'd in production. Unit tests on both sides missed it (API
// tests hit the real path directly; PWA tests mock the fetcher). Pin the exact URL.
describe('fetchAdminResetPassword — URL contract (Phase 19, AUTH-LOCAL-08)', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('POSTs to /api/admin/members/:id/password (must match admin.ts route)', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
type: 'basic',
status: 200,
} as unknown as Response);
const { fetchAdminResetPassword } = await import('./client.js');
await fetchAdminResetPassword(7, 'new-password-123');
expect(fetch).toHaveBeenCalledWith(
'/api/admin/members/7/password',
expect.objectContaining({ method: 'POST' }),
);
});
});
+203
View File
@@ -57,6 +57,207 @@ function handleAuthResponse(res: Response, label: string): void {
} }
} }
// ── /api/auth/* (Phase 19 — local auth) ───────────────────────────────────
/**
* Typed error thrown by fetchLocalLogin when the server returns 401/429/423/5xx.
*
* Codes:
* 'invalid' 401: incorrect username or password
* 'rate-limit' 429: too many attempts within the rate window
* 'locked' 423: account locked (persistent lockout)
* 'server' 5xx or network: transient server error
*
* Object.setPrototypeOf is required so instanceof checks work correctly after
* TypeScript compilation to ES5 / CommonJS (mirrors SessionExpiredError).
*/
export class LoginError extends Error {
readonly name = 'LoginError';
constructor(public readonly code: 'invalid' | 'rate-limit' | 'locked' | 'server') {
super(`Login failed: ${code}`);
Object.setPrototypeOf(this, LoginError.prototype);
}
}
/**
* GET /api/auth/mode pre-auth endpoint, no session required.
* Returns whether local-auth and/or OIDC are enabled.
* staleTime: 60_000 in App.tsx authModeQuery.
*/
export async function fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnabled: boolean }> {
const res = await fetch('/api/auth/mode');
if (!res.ok) {
throw new Error(`fetchAuthMode failed: ${res.status}`);
}
return res.json() as Promise<{ localEnabled: boolean; oidcEnabled: boolean }>;
}
/**
* POST /api/auth/local/login submit username/password credentials.
*
* Maps status codes to typed LoginError:
* 401 LoginError('invalid') incorrect username or password
* 429 LoginError('rate-limit') too many attempts
* 423 LoginError('locked') account locked
* other non-ok LoginError('server')
*
* Throws nothing on 200 OK the local-session cookie is set by the server.
*/
export async function fetchLocalLogin(body: { username: string; password: string }): Promise<void> {
const res = await fetch('/api/auth/local/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify(body),
});
if (res.status === 401) throw new LoginError('invalid');
if (res.status === 429) throw new LoginError('rate-limit');
if (res.status === 423) throw new LoginError('locked');
if (!res.ok) throw new LoginError('server');
}
/**
* POST /api/auth/local/logout clear the local-session cookie.
*/
export async function fetchLocalLogout(): Promise<void> {
const res = await fetch('/api/auth/local/logout', {
method: 'POST',
credentials: 'include',
redirect: 'manual',
});
if (!res.ok && res.type !== 'opaqueredirect') {
throw new Error(`fetchLocalLogout failed: ${res.status}`);
}
}
/**
* POST /api/me/password self-service password change (Phase 19, Surface 12).
* Requires the user's current password and a new password (min 8 chars).
*
* Status codes:
* 403 wrong current password (throws Error('wrong-current')) NOT a session expiry
* 401 / opaqueredirect genuine session expiry (throws SessionExpiredError)
* other non-ok generic error (throws Error('server'))
*
* CR-03: the server returns 403 (not 401) for an incorrect current password so this
* client can distinguish an in-app authorization failure from a real session expiry.
* Treating that case as 401 would route it to the global MutationCache session-expiry
* handler and forcibly log the user out for a simple mistyped password.
*/
export async function fetchChangePassword(body: {
currentPassword: string;
newPassword: string;
}): Promise<void> {
const res = await fetch('/api/me/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify(body),
});
// 403 → wrong current password (in-app). Check BEFORE the 401 session-expiry branch.
if (res.status === 403) throw new Error('wrong-current');
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
if (!res.ok) {
const detail = (await res.json().catch(() => ({}))) as { code?: string };
throw new Error(detail.code ?? 'server');
}
}
/**
* POST /api/admin/members create a new local member account (Phase 19, Surface 11A).
* Admin-only; server enforces requireAdmin.
*
* Request contract: the server's createMemberSchema requires
* { displayName, username, initialPassword }
* (see apps/api/src/routes/admin.ts). The caller-facing `password` field is mapped to
* `initialPassword` here so the request validates server-side.
*
* Status codes:
* 409 username already taken (throws Error with message 'conflict')
* other non-ok generic error (throws Error('server'))
*/
export async function fetchCreateMember(body: {
displayName: string;
username: string;
password: string;
}): Promise<void> {
const res = await fetch('/api/admin/members', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
// CR-02: the server expects `initialPassword`, not `password`. Send the field it
// validates against — otherwise Zod rejects every create with a generic 400.
body: JSON.stringify({
displayName: body.displayName,
username: body.username,
initialPassword: body.password,
}),
});
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
// 409 → username conflict. The server returns { error: 'Username already in use' } (no
// `code` field), so map the status to the 'conflict' sentinel the AdminPage handler expects.
if (res.status === 409) throw new Error('conflict');
if (!res.ok) {
const detail = (await res.json().catch(() => ({}))) as { code?: string };
throw new Error(detail.code ?? 'server');
}
}
/**
* POST /api/admin/members/:id/password admin reset of a member's password (Surface 11B).
* Admin-only; server enforces requireAdmin. (Route is registered as `/members/:id/password`
* in apps/api/src/routes/admin.ts must match exactly or the sheet 404s.)
*/
export async function fetchAdminResetPassword(
memberId: number,
newPassword: string,
): Promise<void> {
const res = await fetch(`/api/admin/members/${memberId}/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify({ newPassword }),
});
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
if (!res.ok) {
throw new Error(`fetchAdminResetPassword failed: ${res.status}`);
}
}
/**
* POST /api/me/link-oidc initiate the OIDC-link flow for the current local user (Surface 13).
*
* The server returns the OIDC authorization endpoint URL (with a signed `state` parameter
* encoding the linkUserId claim) to begin the authorization-code flow. The caller should
* follow it via top-level navigation (window.location.href = authorizationUrl) when present.
*
* authorizationUrl is null when OIDC is not configured in env (the server cannot build the
* URL); callers MUST handle that case and surface an error instead of navigating to null.
* The server contract is { signedState, authorizationUrl } (see apps/api/src/routes/me.ts).
*/
export async function fetchLinkOidc(): Promise<{ authorizationUrl: string | null }> {
const res = await fetch('/api/me/link-oidc', {
method: 'POST',
credentials: 'include',
redirect: 'manual',
});
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
if (!res.ok) {
throw new Error(`fetchLinkOidc failed: ${res.status}`);
}
return res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>;
}
// ── /api/me ──────────────────────────────────────────────────────────────── // ── /api/me ────────────────────────────────────────────────────────────────
export interface MeUser { export interface MeUser {
@@ -65,6 +266,7 @@ export interface MeUser {
color: string; color: string;
isAdmin: boolean; // from users.is_admin — UX gating only (D-03); server enforces 403 on /api/admin/* isAdmin: boolean; // from users.is_admin — UX gating only (D-03); server enforces 403 on /api/admin/*
needsProviderSetup: boolean; // true when no member_credentials row exists for this user needsProviderSetup: boolean; // true when no member_credentials row exists for this user
hasLocalCredential: boolean; // true when a local_credentials row exists for this user (Phase 19)
} }
export interface MeResponse { export interface MeResponse {
@@ -363,6 +565,7 @@ export interface AdminMember {
displayName: string | null; displayName: string | null;
color: string; color: string;
hasCredential: boolean; hasCredential: boolean;
hasLocalCredential: boolean; // true when a local_credentials row exists for this member (Phase 19)
} }
export interface AdminMembersResponse { export interface AdminMembersResponse {
+84
View File
@@ -0,0 +1,84 @@
/**
* BrandSlot Phase 17 seam component for the login page brand area.
*
* Phase 19 ships a minimal shippable placeholder: a 48px circle with "FS"
* initials, the app name "FamilySync", and the tagline "Family calendar & lists".
*
* Phase 17 replaces the internals of this component (swap the placeholder div for
* an <img> with a real logo) without touching LoginPage's layout. This isolates
* the branding seam see 19-UI-SPEC.md §Brand Slot section.
*
* CSS custom properties used (all set in tokens.css with placeholder defaults;
* Phase 17 overrides these values):
* --brand-logo-bg logo circle background (default: var(--color-member-0))
* --brand-logo-text initials color (default: #ffffff)
* --brand-logo-size circle diameter (default: 48px)
* --brand-logo-border-radius circle shape (default: 50%)
*
* Accessibility:
* <h1> contains the app name screen readers read "FamilySync" as the page title.
* The logo circle is aria-hidden (the text is the accessible label).
* No <img> today no broken image ref no layout shift when Phase 17 replaces it.
*
* Security: all copy is plain-text JSX children no dangerouslySetInnerHTML (T-05-24).
*/
export function BrandSlot() {
return (
<div style={{ textAlign: 'center' }}>
{/* Phase 17 replaces this div with <img src="..." alt="" /> */}
<div
aria-hidden="true"
style={{
width: 'var(--brand-logo-size, 48px)',
height: 'var(--brand-logo-size, 48px)',
borderRadius: 'var(--brand-logo-border-radius, 50%)',
background: 'var(--brand-logo-bg, var(--color-member-0, #4a90d9))',
color: 'var(--brand-logo-text, #ffffff)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto var(--space-2, 8px)',
fontSize: 'var(--text-display-size, 24px)',
fontWeight: 600,
fontFamily: 'var(--font-family-base)',
flexShrink: 0,
aspectRatio: '1 / 1',
}}
>
FS
</div>
{/* App name — <h1> so screen readers identify the page (UI-SPEC §Accessibility) */}
<h1
style={{
margin: 0,
marginTop: 'var(--space-2, 8px)',
marginBottom: 'var(--space-1, 4px)',
fontSize: 'var(--text-display-size, 24px)',
fontWeight: 600,
lineHeight: 'var(--text-display-line-height, 1.2)',
color: 'var(--color-text-primary, #111318)',
fontFamily: 'var(--font-family-base)',
}}
>
FamilySync
</h1>
{/* Tagline */}
<p
style={{
margin: 0,
marginBottom: 'var(--space-8, 32px)',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
lineHeight: 'var(--text-body-line-height, 1.5)',
color: 'var(--color-text-secondary, #6b7280)',
fontFamily: 'var(--font-family-base)',
}}
>
Family calendar &amp; lists
</p>
</div>
);
}
@@ -7,8 +7,10 @@
* - onClose (the sheet-close prop) is NOT called when the dialog opens * - onClose (the sheet-close prop) is NOT called when the dialog opens
*/ */
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// ── Module mocks ────────────────────────────────────────────────────────────── // ── Module mocks ──────────────────────────────────────────────────────────────
@@ -22,6 +24,24 @@ vi.mock('../hooks/usePushSubscription.js', () => ({
readNotificationsEnabled: vi.fn(() => false), readNotificationsEnabled: vi.fn(() => false),
})); }));
// Phase 19: SettingsSheet now calls fetchMe and fetchAuthMode inside useQuery.
// Mock client so the test doesn't make real network calls.
vi.mock('../api/client.js', () => ({
fetchMe: vi.fn().mockResolvedValue({
user: {
id: 1,
displayName: 'Test',
color: '#4a90d9',
isAdmin: false,
needsProviderSetup: false,
hasLocalCredential: false,
},
}),
fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }),
fetchChangePassword: vi.fn().mockResolvedValue(undefined),
fetchLinkOidc: vi.fn().mockResolvedValue({ redirectUrl: '/oidc' }),
}));
// ── Minimal Notification stub (jsdom lacks it) ──────────────────────────────── // ── Minimal Notification stub (jsdom lacks it) ────────────────────────────────
beforeEach(() => { beforeEach(() => {
@@ -44,12 +64,21 @@ beforeEach(() => {
import { SettingsSheet } from './SettingsSheet.js'; import { SettingsSheet } from './SettingsSheet.js';
// ── Test helper ───────────────────────────────────────────────────────────────
function renderWithQueryClient(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
// ── Tests ───────────────────────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────────────────────
describe('SettingsSheet — "How to enable" wiring (UAT-05-T4)', () => { describe('SettingsSheet — "How to enable" wiring (UAT-05-T4)', () => {
it('clicking "How to enable" opens the InstructionSheet dialog and does NOT call onClose', () => { it('clicking "How to enable" opens the InstructionSheet dialog and does NOT call onClose', () => {
const onCloseSpy = vi.fn(); const onCloseSpy = vi.fn();
render(<SettingsSheet isOpen={true} onClose={onCloseSpy} />); renderWithQueryClient(<SettingsSheet isOpen={true} onClose={onCloseSpy} />);
// No instruction dialog yet // No instruction dialog yet
expect(screen.queryByRole('dialog', { name: /re-enable notifications/i })).toBeNull(); expect(screen.queryByRole('dialog', { name: /re-enable notifications/i })).toBeNull();
@@ -71,7 +100,7 @@ describe('SettingsSheet — "How to enable" wiring (UAT-05-T4)', () => {
it('InstructionSheet "Done" button closes the instruction dialog without calling sheet onClose', () => { it('InstructionSheet "Done" button closes the instruction dialog without calling sheet onClose', () => {
const onCloseSpy = vi.fn(); const onCloseSpy = vi.fn();
render(<SettingsSheet isOpen={true} onClose={onCloseSpy} />); renderWithQueryClient(<SettingsSheet isOpen={true} onClose={onCloseSpy} />);
// Open the instruction sheet // Open the instruction sheet
fireEvent.click(screen.getByText('How to enable')); fireEvent.click(screen.getByText('How to enable'));
+626
View File
@@ -23,8 +23,10 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { X, Bell, AlertCircle, Loader2 } from 'lucide-react'; import { X, Bell, AlertCircle, Loader2 } from 'lucide-react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { usePushSubscription } from '../hooks/usePushSubscription.js'; import { usePushSubscription } from '../hooks/usePushSubscription.js';
import { InstructionSheet } from './InstructionSheet.js'; import { InstructionSheet } from './InstructionSheet.js';
import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc } from '../api/client.js';
// CR-04: fetch VAPID key (from sessionStorage cache if available) for the // CR-04: fetch VAPID key (from sessionStorage cache if available) for the
// tap-gated subscribe() path. Same logic as PushPermissionPrompt. // tap-gated subscribe() path. Same logic as PushPermissionPrompt.
@@ -53,6 +55,28 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription(); const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription();
const [isTogglingOn, setIsTogglingOn] = useState(false); const [isTogglingOn, setIsTogglingOn] = useState(false);
const [instructionsOpen, setInstructionsOpen] = useState(false); const [instructionsOpen, setInstructionsOpen] = useState(false);
// Phase 19: read meData (same query key as App.tsx — TanStack deduplicates the request)
const meQuery = useQuery({
queryKey: ['me'],
queryFn: fetchMe,
retry: false,
staleTime: 0,
});
const authModeQuery = useQuery({
queryKey: ['authMode'],
queryFn: fetchAuthMode,
retry: false,
staleTime: 60_000,
});
const hasLocalCredential = meQuery.data?.user.hasLocalCredential ?? false;
const oidcEnabled = authModeQuery.data?.oidcEnabled ?? false;
// Change-password sheet state (Surface 12)
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
// Link-OIDC confirmation sheet state (Surface 13)
const [linkOidcOpen, setLinkOidcOpen] = useState(false);
// CR-04: pre-fetch the VAPID key into state so the toggle tap handler can call // CR-04: pre-fetch the VAPID key into state so the toggle tap handler can call
// subscribe(registration, vapidKey) without any network await before pushManager.subscribe(). // subscribe(registration, vapidKey) without any network await before pushManager.subscribe().
const [vapidKey, setVapidKey] = useState<string | null>(null); const [vapidKey, setVapidKey] = useState<string | null>(null);
@@ -341,6 +365,77 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
)} )}
</div> </div>
{/* Surface 12 — Change password row (hasLocalCredential gate) */}
{hasLocalCredential && (
<>
<div
style={{
height: '1px',
background: 'var(--color-border-subtle, var(--color-border))',
margin: 'var(--space-4, 16px) 0',
}}
/>
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-muted, #9CA3AF)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
marginBottom: 'var(--space-2, 8px)',
}}
>
Account
</div>
<button
type="button"
onClick={() => setChangePasswordOpen(true)}
style={{
display: 'flex',
alignItems: 'center',
width: '100%',
minHeight: '44px',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 'var(--space-2, 8px) 0',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-primary, #111318)',
fontFamily: 'var(--font-family-base)',
textAlign: 'left',
}}
>
Change password
</button>
{/* Surface 13 — Link OIDC identity row (hasLocalCredential + oidcEnabled gate) */}
{oidcEnabled && (
<button
type="button"
onClick={() => setLinkOidcOpen(true)}
style={{
display: 'flex',
alignItems: 'center',
width: '100%',
minHeight: '44px',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 'var(--space-2, 8px) 0',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-primary, #111318)',
fontFamily: 'var(--font-family-base)',
textAlign: 'left',
}}
>
Link OIDC identity
</button>
)}
</>
)}
{/* Permission-denied hint — only when OS permission === 'denied' */} {/* Permission-denied hint — only when OS permission === 'denied' */}
{permission === 'denied' && ( {permission === 'denied' && (
<div <div
@@ -394,6 +489,537 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
</div> </div>
{instructionsOpen && <InstructionSheet onClose={() => setInstructionsOpen(false)} />} {instructionsOpen && <InstructionSheet onClose={() => setInstructionsOpen(false)} />}
{/* Surface 12 — Change-password sheet (hasLocalCredential gate) */}
{changePasswordOpen && (
<ChangePasswordSheet
isOpen={changePasswordOpen}
onClose={() => setChangePasswordOpen(false)}
/>
)}
{/* Surface 13 — Link OIDC confirmation sheet (hasLocalCredential + oidcEnabled gate) */}
{linkOidcOpen && (
<LinkOidcSheet isOpen={linkOidcOpen} onClose={() => setLinkOidcOpen(false)} />
)}
</>
);
}
// ── ChangePasswordSheet (Surface 12) ─────────────────────────────────────────
/**
* Surface 12 Self-service password change sheet.
* Opens from the "Change password" row in SettingsSheet.
* Pattern: CredentialSheet (role=dialog, aria-modal, Escape closes, focus heading on open).
* Fields: Current password / New password / Confirm correct autoComplete values.
* Security: T-19-18 password fields are controlled state only; never written to storage.
*/
interface ChangePasswordSheetProps {
isOpen: boolean;
onClose: () => void;
}
function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (isOpen && headingRef.current) {
headingRef.current.focus();
}
}, [isOpen]);
function handleClose() {
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setError(null);
onClose();
}
const changeMutation = useMutation({
mutationFn: async () => {
if (newPassword !== confirmPassword) throw new Error('mismatch');
await fetchChangePassword({ currentPassword, newPassword });
},
onSuccess: () => {
handleClose();
},
onError: (err) => {
const msg = err instanceof Error ? err.message : 'server';
if (msg === 'mismatch') {
setError('Passwords do not match.');
} else if (msg === 'wrong-current') {
setError('Current password is incorrect.');
} else {
setError('Something went wrong. Please try again.');
}
},
});
const isPending = changeMutation.isPending;
const submitDisabled =
isPending ||
currentPassword.length === 0 ||
newPassword.length === 0 ||
confirmPassword.length === 0;
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
onClick={handleClose}
aria-hidden="true"
style={{
position: 'fixed',
inset: 0,
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
zIndex: 302,
}}
/>
{/* Sheet */}
<div
role="dialog"
aria-modal="true"
aria-label="Change password"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'var(--color-surface-raised, #ffffff)',
borderRadius: '12px 12px 0 0',
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
padding: 'var(--space-6, 24px)',
zIndex: 303,
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
maxWidth: '480px',
margin: '0 auto',
}}
>
<h2
ref={headingRef}
tabIndex={-1}
style={{
margin: '0 0 var(--space-6, 24px) 0',
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
lineHeight: 'var(--text-heading-line-height, 1.25)',
color: 'var(--color-text-primary, #111318)',
outline: 'none',
}}
>
Change password
</h2>
{/* Current password */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="change-current-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary, #111318)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Current password
</label>
<input
id="change-current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: `1px solid ${error === 'Current password is incorrect.' ? 'var(--color-destructive)' : 'var(--color-border)'}`,
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary, #111318)',
background: 'var(--color-surface, #ffffff)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* New password */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="change-new-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary, #111318)',
marginBottom: 'var(--space-1, 4px)',
}}
>
New password
</label>
<input
id="change-new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: `1px solid ${error === 'Passwords do not match.' ? 'var(--color-destructive)' : 'var(--color-border)'}`,
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary, #111318)',
background: 'var(--color-surface, #ffffff)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Confirm new password */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label
htmlFor="change-confirm-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary, #111318)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Confirm
</label>
<input
id="change-confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
aria-describedby={error ? 'change-password-error' : undefined}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: `1px solid ${error === 'Passwords do not match.' ? 'var(--color-destructive)' : 'var(--color-border)'}`,
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary, #111318)',
background: 'var(--color-surface, #ffffff)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Error */}
{error && (
<div
id="change-password-error"
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-destructive, #dc2626)',
marginBottom: 'var(--space-4, 16px)',
}}
>
{error}
</div>
)}
{/* Action row */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 'var(--space-3, 12px)',
}}
>
<button
type="button"
onClick={handleClose}
disabled={isPending}
style={{
background: 'none',
border: 'none',
cursor: isPending ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-secondary, #6b7280)',
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-4, 16px)',
fontFamily: 'var(--font-family-base)',
borderRadius: 'var(--space-1, 4px)',
}}
>
Cancel
</button>
<button
type="button"
disabled={submitDisabled}
onClick={() => {
setError(null);
changeMutation.mutate();
}}
style={{
background: submitDisabled
? 'var(--color-border, #e2e4e9)'
: 'var(--color-member-0, #4a90d9)',
color: '#ffffff',
border: 'none',
cursor: submitDisabled ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-4, 16px)',
borderRadius: 'var(--space-1, 4px)',
fontFamily: 'var(--font-family-base)',
transition: 'background 0.15s ease',
}}
>
Change password
</button>
</div>
</div>
</>
);
}
// ── LinkOidcSheet (Surface 13) ────────────────────────────────────────────────
/**
* Surface 13 Link OIDC identity confirmation sheet.
* NOT a form the actual linking happens via OIDC redirect.
* Two-step confirmation: open sheet (step 1) + tap "Continue with OIDC" (step 2).
*
* Copywriting rules (UI-SPEC §Copywriting Contract):
* - Never use "Authelia" (D-06) use "your OIDC provider"
* - Never say "delete" or "remove" when describing consequence use "will be removed" (passive)
* - Body copy: non-alarming, frames linking as an upgrade
*
* Security: T-19-21 no provider-specific branding that leaks infrastructure details.
*/
interface LinkOidcSheetProps {
isOpen: boolean;
onClose: () => void;
}
function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
const [error, setError] = useState<string | null>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
if (isOpen && headingRef.current) {
headingRef.current.focus();
}
}, [isOpen]);
const linkMutation = useMutation({
mutationFn: fetchLinkOidc,
onSuccess: (data) => {
// authorizationUrl is null when OIDC is not configured in env (server could not
// build the URL). Do NOT navigate to null — surface an error and keep the sheet open.
if (!data.authorizationUrl) {
setError('Something went wrong. Please try again.');
return;
}
// Close the sheet and initiate the OIDC link flow via top-level navigation.
onClose();
window.location.href = data.authorizationUrl;
},
onError: () => {
setError('Something went wrong. Please try again.');
},
});
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
onClick={onClose}
aria-hidden="true"
style={{
position: 'fixed',
inset: 0,
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
zIndex: 302,
}}
/>
{/* Sheet */}
<div
role="dialog"
aria-modal="true"
aria-label="Link OIDC identity"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'var(--color-surface-raised, #ffffff)',
borderRadius: '12px 12px 0 0',
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
padding: 'var(--space-6, 24px)',
zIndex: 303,
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
maxWidth: '480px',
margin: '0 auto',
}}
>
<h2
ref={headingRef}
tabIndex={-1}
style={{
margin: '0 0 var(--space-4, 16px) 0',
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
lineHeight: 'var(--text-heading-line-height, 1.25)',
color: 'var(--color-text-primary, #111318)',
outline: 'none',
}}
>
Link OIDC identity
</h2>
{/* Body — informational, not alarming (UI-SPEC §Copywriting Contract) */}
<p
style={{
margin: '0 0 var(--space-3, 12px) 0',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
lineHeight: 'var(--text-body-line-height, 1.5)',
color: 'var(--color-text-secondary, #6b7280)',
}}
>
{
"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."
}
</p>
{/* Secondary note */}
<p
style={{
margin: '0 0 var(--space-6, 24px) 0',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
lineHeight: 'var(--text-label-line-height, 1.4)',
color: 'var(--color-text-muted, #9ca3af)',
}}
>
{"This can't be undone from the app. Contact your admin if you need to revert."}
</p>
{/* Error (post-fetch) */}
{error && (
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
color: 'var(--color-destructive, #dc2626)',
marginBottom: 'var(--space-4, 16px)',
}}
>
{error}
</div>
)}
{/* Action row */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 'var(--space-3, 12px)',
}}
>
<button
type="button"
onClick={onClose}
disabled={linkMutation.isPending}
style={{
background: 'none',
border: 'none',
cursor: linkMutation.isPending ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-secondary, #6b7280)',
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-4, 16px)',
fontFamily: 'var(--font-family-base)',
borderRadius: 'var(--space-1, 4px)',
}}
>
Cancel
</button>
<button
type="button"
disabled={linkMutation.isPending}
onClick={() => {
setError(null);
linkMutation.mutate();
}}
style={{
background: linkMutation.isPending
? 'var(--color-border, #e2e4e9)'
: 'var(--color-member-0, #4a90d9)',
color: '#ffffff',
border: 'none',
cursor: linkMutation.isPending ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-4, 16px)',
borderRadius: 'var(--space-1, 4px)',
fontFamily: 'var(--font-family-base)',
transition: 'background 0.15s ease',
}}
>
Continue with OIDC
</button>
</div>
</div>
</> </>
); );
} }
+638 -25
View File
@@ -23,15 +23,17 @@
* Security: client isAdmin gate is UX only. Server 403 is the real boundary (D-03). * Security: client isAdmin gate is UX only. Server 403 is the real boundary (D-03).
*/ */
import { useState, useRef } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckCircle, AlertCircle } from 'lucide-react'; import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { import {
fetchAdminMembers, fetchAdminMembers,
fetchAdminCalendars, fetchAdminCalendars,
setSharedCalendar, setSharedCalendar,
fetchAdminTimezone, fetchAdminTimezone,
setAdminTimezone, setAdminTimezone,
fetchCreateMember,
fetchAdminResetPassword,
type AdminMember, type AdminMember,
type AdminCalendar, type AdminCalendar,
} from '../api/client.js'; } from '../api/client.js';
@@ -59,6 +61,19 @@ export function AdminPage() {
const [sheetMember, setSheetMember] = useState<AdminMember | null>(null); const [sheetMember, setSheetMember] = useState<AdminMember | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
// Reset-password sheet state (Surface 11B)
const [resetSheetOpen, setResetSheetOpen] = useState(false);
const [resetTargetMember, setResetTargetMember] = useState<AdminMember | null>(null);
// resetTriggerRef: stores the exact button that opened the reset sheet so focus can return on close
const resetTriggerRef = useRef<HTMLButtonElement | null>(null);
// Create-member form state (Surface 11A)
const [createDisplayName, setCreateDisplayName] = useState('');
const [createUsername, setCreateUsername] = useState('');
const [createPassword, setCreatePassword] = useState('');
const [createConfirmPassword, setCreateConfirmPassword] = useState('');
const [createError, setCreateError] = useState<string | null>(null);
// Shared calendar picker state // Shared calendar picker state
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null); const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
@@ -179,6 +194,53 @@ export function AdminPage() {
setSheetOpen(true); setSheetOpen(true);
} }
// Create-member mutation (Surface 11A)
const createMemberMutation = useMutation({
mutationFn: async () => {
// Client-side validation (server also validates; this is for UX)
if (createPassword !== createConfirmPassword) {
throw new Error('mismatch');
}
if (createPassword.length < 8) {
throw new Error('short');
}
await fetchCreateMember({
displayName: createDisplayName.trim(),
username: createUsername.trim(),
password: createPassword,
});
},
onSuccess: () => {
// Clear form + refresh member list
setCreateDisplayName('');
setCreateUsername('');
setCreatePassword('');
setCreateConfirmPassword('');
setCreateError(null);
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
void queryClient.invalidateQueries({ queryKey: ['me'] });
},
onError: (err) => {
const msg = err instanceof Error ? err.message : 'server';
if (msg === 'mismatch') {
setCreateError('Passwords do not match.');
} else if (msg === 'short') {
setCreateError('Password is too short. Use at least 8 characters.');
} else if (msg === 'conflict' || msg.includes('409')) {
setCreateError('That username is already in use. Choose a different one.');
} else {
setCreateError('Something went wrong. Please try again.');
}
},
});
const createSubmitDisabled =
createMemberMutation.isPending ||
createDisplayName.trim().length === 0 ||
createUsername.trim().length === 0 ||
createPassword.length === 0 ||
createConfirmPassword.length === 0;
const saveDisabled = const saveDisabled =
sharedCalMutation.isPending || sharedCalMutation.isPending ||
effectiveSelected === null || effectiveSelected === null ||
@@ -250,6 +312,12 @@ export function AdminPage() {
member={member} member={member}
colorIndex={idx} colorIndex={idx}
onAction={(buttonRef) => openSheet(member, buttonRef)} onAction={(buttonRef) => openSheet(member, buttonRef)}
onResetPassword={(buttonRef) => {
// Capture trigger button so focus can return on close
resetTriggerRef.current = buttonRef.current;
setResetTargetMember(member);
setResetSheetOpen(true);
}}
/> />
))} ))}
</div> </div>
@@ -620,6 +688,230 @@ export function AdminPage() {
</> </>
)} )}
</section> </section>
{/* ── LOCAL ACCOUNTS section ──────────────────────────────────────── */}
<section aria-label="Local Accounts" style={{ marginBottom: 'var(--space-8, 32px)' }}>
<div style={sectionLabelStyle}>Local Accounts</div>
{/* Surface 11A — Add member inline form */}
<div
style={{
border: '1px solid var(--color-border-subtle, var(--color-border))',
borderRadius: '8px',
padding: 'var(--space-4, 16px)',
marginBottom: 'var(--space-6, 24px)',
}}
>
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-4, 16px)',
}}
>
Add member
</div>
{/* Display name */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="admin-create-display-name"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Display name
</label>
<input
id="admin-create-display-name"
type="text"
value={createDisplayName}
onChange={(e) => setCreateDisplayName(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Username */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="admin-create-username"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Username
</label>
<input
id="admin-create-username"
type="text"
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
value={createUsername}
onChange={(e) => setCreateUsername(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Initial password */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="admin-create-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Initial password
</label>
<input
id="admin-create-password"
type="password"
autoComplete="new-password"
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Confirm password */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label
htmlFor="admin-create-confirm-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Confirm password
</label>
<input
id="admin-create-confirm-password"
type="password"
autoComplete="new-password"
value={createConfirmPassword}
onChange={(e) => setCreateConfirmPassword(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Inline error */}
{createError && (
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-destructive)',
marginBottom: 'var(--space-3, 12px)',
}}
>
{createError}
</div>
)}
{/* Action row */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
disabled={createSubmitDisabled}
onClick={() => {
setCreateError(null);
createMemberMutation.mutate();
}}
style={{
background: createSubmitDisabled
? 'var(--color-border, #e2e4e9)'
: 'var(--color-member-0, #4a90d9)',
color: '#ffffff',
border: 'none',
cursor: createSubmitDisabled ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-6, 24px)',
borderRadius: 'var(--space-1, 4px)',
fontFamily: 'var(--font-family-base)',
transition: 'background 0.15s ease',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
}}
>
{createMemberMutation.isPending && (
<Loader2
size={14}
aria-hidden="true"
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
/>
)}
Add member
</button>
</div>
</div>
</section>
</div> </div>
{/* Credential sheet — admin-rotate or admin-add */} {/* Credential sheet — admin-rotate or admin-add */}
@@ -633,6 +925,21 @@ export function AdminPage() {
triggerRef={triggerRef} triggerRef={triggerRef}
/> />
)} )}
{/* Surface 11B — Reset password sheet */}
{resetTargetMember && (
<ResetPasswordSheet
isOpen={resetSheetOpen}
onClose={() => {
setResetSheetOpen(false);
// Return focus to trigger
if (resetTriggerRef.current) {
resetTriggerRef.current.focus();
}
}}
member={resetTargetMember}
/>
)}
</div> </div>
); );
} }
@@ -643,10 +950,12 @@ interface MemberRowProps {
member: AdminMember; member: AdminMember;
colorIndex: number; colorIndex: number;
onAction: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void; onAction: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void;
onResetPassword?: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void;
} }
function MemberRow({ member, colorIndex, onAction }: MemberRowProps) { function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowProps) {
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const resetBtnRef = useRef<HTMLButtonElement>(null);
return ( return (
<div <div
@@ -731,28 +1040,54 @@ function MemberRow({ member, colorIndex, onAction }: MemberRowProps) {
</div> </div>
</div> </div>
{/* Action button */} {/* Action button row */}
<button <div style={{ display: 'flex', gap: 'var(--space-2, 8px)', flexShrink: 0 }}>
ref={buttonRef} {/* Credential rotate/add button */}
type="button" <button
onClick={() => onAction(buttonRef)} ref={buttonRef}
style={{ type="button"
background: 'none', onClick={() => onAction(buttonRef)}
border: '1px solid var(--color-border)', style={{
borderRadius: 'var(--space-1, 4px)', background: 'none',
cursor: 'pointer', border: '1px solid var(--color-border)',
fontSize: 'var(--text-label-size, 13px)', borderRadius: 'var(--space-1, 4px)',
fontWeight: 600, cursor: 'pointer',
color: 'var(--color-text-primary)', fontSize: 'var(--text-label-size, 13px)',
minHeight: '44px', fontWeight: 600,
minWidth: '44px', color: 'var(--color-text-primary)',
padding: '0 var(--space-3, 12px)', minHeight: '44px',
fontFamily: 'var(--font-family-base)', minWidth: '44px',
flexShrink: 0, padding: '0 var(--space-3, 12px)',
}} fontFamily: 'var(--font-family-base)',
> }}
{member.hasCredential ? 'Rotate' : 'Add credential'} >
</button> {member.hasCredential ? 'Rotate' : 'Add credential'}
</button>
{/* Surface 11B — Reset password button (only for members with a local credential) */}
{member.hasLocalCredential && onResetPassword && (
<button
ref={resetBtnRef}
type="button"
onClick={() => onResetPassword(resetBtnRef)}
style={{
background: 'none',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
cursor: 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-3, 12px)',
fontFamily: 'var(--font-family-base)',
}}
>
Reset password
</button>
)}
</div>
</div> </div>
); );
} }
@@ -842,6 +1177,284 @@ function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowPr
); );
} }
// ── ResetPasswordSheet ──────────────────────────────────────────────────────
/**
* Surface 11B Admin password reset sheet.
* Opens as a bottom sheet (mobile) / centered modal (desktop).
* Pattern: CredentialSheet (role=dialog, aria-modal, Escape closes, focus returns to trigger).
* No current-password field admin reset does not require knowing the old password.
*/
interface ResetPasswordSheetProps {
isOpen: boolean;
onClose: () => void;
member: AdminMember;
}
function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps) {
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
// Escape closes the sheet
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen, onClose]);
// Focus heading on open
useEffect(() => {
if (isOpen && headingRef.current) {
headingRef.current.focus();
}
}, [isOpen]);
function handleClose() {
setNewPassword('');
setConfirmPassword('');
setError(null);
onClose();
}
const resetMutation = useMutation({
mutationFn: async () => {
if (newPassword !== confirmPassword) throw new Error('mismatch');
await fetchAdminResetPassword(member.id, newPassword);
},
onSuccess: () => {
handleClose();
},
onError: (err) => {
const msg = err instanceof Error ? err.message : 'server';
if (msg === 'mismatch') {
setError('Passwords do not match.');
} else {
setError('Something went wrong. Please try again.');
}
},
});
const isPending = resetMutation.isPending;
const submitDisabled = isPending || newPassword.length === 0 || confirmPassword.length === 0;
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
onClick={handleClose}
aria-hidden="true"
style={{
position: 'fixed',
inset: 0,
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
zIndex: 300,
}}
/>
{/* Sheet */}
<div
role="dialog"
aria-modal="true"
aria-label="Reset password"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'var(--color-surface, #ffffff)',
borderRadius: '12px 12px 0 0',
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
padding: 'var(--space-6, 24px)',
zIndex: 301,
fontFamily: 'var(--font-family-base)',
maxWidth: '480px',
margin: '0 auto',
}}
>
<h2
ref={headingRef}
tabIndex={-1}
style={{
margin: '0 0 var(--space-1, 4px) 0',
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
lineHeight: 'var(--text-heading-line-height, 1.25)',
color: 'var(--color-text-primary)',
outline: 'none',
}}
>
Reset password
</h2>
{/* Member subtitle */}
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-secondary)',
marginBottom: 'var(--space-6, 24px)',
}}
>
{member.displayName ?? 'Member'}
</div>
{/* New password */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="reset-new-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
New password
</label>
<input
id="reset-new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: `1px solid ${error ? 'var(--color-destructive)' : 'var(--color-border)'}`,
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Confirm new password */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label
htmlFor="reset-confirm-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Confirm new password
</label>
<input
id="reset-confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
aria-describedby={error ? 'reset-error' : undefined}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: `1px solid ${error ? 'var(--color-destructive)' : 'var(--color-border)'}`,
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Inline error */}
{error && (
<div
id="reset-error"
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-destructive)',
marginBottom: 'var(--space-4, 16px)',
}}
>
{error}
</div>
)}
{/* Action row */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 'var(--space-3, 12px)',
}}
>
<button
type="button"
onClick={handleClose}
disabled={isPending}
style={{
background: 'none',
border: 'none',
cursor: isPending ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-secondary)',
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-4, 16px)',
fontFamily: 'var(--font-family-base)',
borderRadius: 'var(--space-1, 4px)',
}}
>
Cancel
</button>
<button
type="button"
disabled={submitDisabled}
onClick={() => {
setError(null);
resetMutation.mutate();
}}
style={{
background: submitDisabled
? 'var(--color-border, #e2e4e9)'
: 'var(--color-member-0, #4a90d9)',
color: '#ffffff',
border: 'none',
cursor: submitDisabled ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-4, 16px)',
borderRadius: 'var(--space-1, 4px)',
fontFamily: 'var(--font-family-base)',
transition: 'background 0.15s ease',
}}
>
Reset password
</button>
</div>
</div>
</>
);
}
// ── EmptyCalendarsState ───────────────────────────────────────────────────── // ── EmptyCalendarsState ─────────────────────────────────────────────────────
function EmptyCalendarsState() { function EmptyCalendarsState() {
+463
View File
@@ -0,0 +1,463 @@
/**
* LoginPage standalone /login route (Phase 19, D-04).
*
* UI-SPEC §Surface Architecture Surfaces 110:
* Surface 1: Full-page standalone route (no AppNav/BottomTabBar/SetupBanner)
* Surface 2: BrandSlot app logo placeholder + name + tagline (Phase 17 seam)
* Surface 3: Login card "Sign in" heading
* Surface 4: Username field (id="login-username", spellCheck/autoCapitalize/autoCorrect off)
* Surface 5: Password field with show/hide toggle (Eye/EyeOff, 44px tap target)
* Surface 6: Error/lockout banner (role="status", aria-live="polite", 4 error variants)
* Surface 7: Primary "Sign in" / "Signing in…" submit button (full-width)
* Surface 8: Method divider ("or") rendered only when oidcEnabled
* Surface 9: "Login with OIDC" outlined button rendered only when oidcEnabled
* Surface 10: "Forgot your password? Ask your admin." helper (informational only)
*
* Auth gate: App.tsx renders this route when meQuery returns 401 AND localEnabled.
* On success: window.location.replace('/') the cookie is set by the API server.
*
* Security:
* T-19-18: password field is controlled state only; never written to localStorage/sessionStorage
* T-19-19: single shared "Incorrect username or password." no field-level blame
* T-19-20: plain-text JSX children; no dangerouslySetInnerHTML (T-05-24)
* T-19-21: D-06 UI never renders the provider name; uses generic "Login with OIDC"
*/
import { useState, useRef, useEffect } from 'react';
import { useMutation } from '@tanstack/react-query';
import { AlertCircle, Eye, EyeOff, Loader2, ShieldCheck } from 'lucide-react';
import { fetchLocalLogin, LoginError } from '../api/client.js';
import { BrandSlot } from '../components/BrandSlot.js';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface LoginPageProps {
authMode?: { localEnabled: boolean; oidcEnabled: boolean };
}
// ── Styles (copied from SetupPage.tsx — UI-SPEC §Design System) ───────────────
const pageStyle: React.CSSProperties = {
minHeight: '100dvh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
background: 'var(--color-surface, #ffffff)',
fontFamily: 'var(--font-family-base)',
color: 'var(--color-text-primary, #111318)',
};
const contentColStyle: React.CSSProperties = {
maxWidth: '400px', // login card is narrower than setup wizard (UI-SPEC Surface 1)
width: '100%',
margin: '0 auto',
padding: 'var(--space-12, 48px) var(--space-6, 24px)',
};
const cardStyle: React.CSSProperties = {
background: 'var(--color-surface, #ffffff)',
border: '1px solid var(--color-border, #e2e4e9)',
borderRadius: '8px',
padding: 'var(--space-6, 24px)',
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
};
const primaryBtnStyle = (disabled: boolean): React.CSSProperties => ({
background: disabled ? 'var(--color-border, #e2e4e9)' : 'var(--color-member-0, #4a90d9)',
color: '#ffffff',
border: 'none',
cursor: disabled ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
minHeight: '44px',
minWidth: '44px',
width: '100%',
padding: '0 var(--space-6, 24px)',
borderRadius: 'var(--space-1, 4px)',
fontFamily: 'var(--font-family-base)',
transition: 'background 0.15s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--space-2, 8px)',
});
const inputStyle = (hasError: boolean): React.CSSProperties => ({
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: `1px solid ${hasError ? 'var(--color-destructive, #dc2626)' : 'var(--color-border, #e2e4e9)'}`,
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary, #111318)',
background: 'var(--color-surface, #ffffff)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
});
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary, #111318)',
marginBottom: 'var(--space-1, 4px)',
};
// ── LoginPage ─────────────────────────────────────────────────────────────────
export function LoginPage({ authMode }: LoginPageProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loginError, setLoginError] = useState<
'invalid' | 'rate-limit' | 'locked' | 'server' | null
>(null);
// Ref for moving focus to the error heading on error (UI-SPEC §Focus Management)
const errorHeadingRef = useRef<HTMLDivElement>(null);
// Ref for password field so Enter in username moves focus there
const passwordRef = useRef<HTMLInputElement>(null);
const oidcEnabled = authMode?.oidcEnabled ?? false;
// Move focus to error banner heading when error state activates
useEffect(() => {
if (loginError && errorHeadingRef.current) {
errorHeadingRef.current.focus();
}
}, [loginError]);
const loginMutation = useMutation({
mutationFn: () => fetchLocalLogin({ username, password }),
onSuccess: () => {
// Cookie is set by the API; replace to clear the /login URL from history
window.location.replace('/');
},
onError: (err) => {
if (err instanceof LoginError) {
setLoginError(err.code);
} else {
setLoginError('server');
}
},
});
const isLoading = loginMutation.isPending;
const bothNonEmpty = username.trim().length > 0 && password.length > 0;
const submitDisabled =
isLoading || !bothNonEmpty || loginError === 'rate-limit' || loginError === 'locked';
// Derive whether inputs should show error state
const inputHasError = loginError === 'invalid';
function handleSubmit() {
if (submitDisabled) return;
setLoginError(null);
loginMutation.mutate();
}
function handleUsernameKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
e.preventDefault();
passwordRef.current?.focus();
}
}
function handlePasswordKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
}
return (
<div style={pageStyle}>
<div style={contentColStyle} role="main">
{/* Surface 2 — Brand Slot (above the login card, in the flow) */}
<BrandSlot />
{/* Surface 3 — Login Card */}
<div style={cardStyle}>
<h2
style={{
margin: '0 0 var(--space-6, 24px) 0',
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
lineHeight: 'var(--text-heading-line-height, 1.25)',
color: 'var(--color-text-primary, #111318)',
}}
>
Sign in
</h2>
{/* Surface 4 — Username field */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label htmlFor="login-username" style={labelStyle}>
Username
</label>
<input
id="login-username"
type="text"
autoFocus
autoComplete="username"
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={handleUsernameKeyDown}
aria-describedby={loginError ? 'login-error' : undefined}
style={inputStyle(inputHasError)}
/>
</div>
{/* Surface 5 — Password field with show/hide toggle */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label htmlFor="login-password" style={labelStyle}>
Password
</label>
<div style={{ position: 'relative' }}>
<input
id="login-password"
ref={passwordRef}
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handlePasswordKeyDown}
onBlur={() => setShowPassword(false)}
aria-describedby={loginError ? 'login-error' : undefined}
style={{ ...inputStyle(inputHasError), paddingRight: '44px' }}
/>
{/* Show/hide toggle button — 44px tap target (UI-SPEC Surface 5) */}
<button
type="button"
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-pressed={showPassword}
onClick={() => setShowPassword((v) => !v)}
style={{
position: 'absolute',
right: 0,
top: 0,
height: '100%',
minWidth: '44px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-text-muted, #9ca3af)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
}}
>
{showPassword ? (
<EyeOff size={16} aria-hidden="true" />
) : (
<Eye size={16} aria-hidden="true" />
)}
</button>
</div>
</div>
{/* Surface 6 — Error / lockout banner */}
{loginError && (
<div
id="login-error"
role="status"
aria-live="polite"
aria-atomic="true"
style={{ marginBottom: 'var(--space-4, 16px)' }}
>
{loginError === 'invalid' && (
<div
ref={errorHeadingRef}
tabIndex={-1}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-destructive, #dc2626)',
outline: 'none',
}}
>
<AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
Incorrect username or password.
</div>
)}
{loginError === 'rate-limit' && (
<div
ref={errorHeadingRef}
tabIndex={-1}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
background: 'var(--color-surface-dim, #f7f7f8)',
borderRadius: 'var(--space-1, 4px)',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-destructive, #dc2626)',
outline: 'none',
}}
>
<AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
Too many attempts. Please wait a moment and try again.
</div>
)}
{loginError === 'locked' && (
<div
ref={errorHeadingRef}
tabIndex={-1}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
background: 'var(--color-surface-dim, #f7f7f8)',
borderRadius: 'var(--space-1, 4px)',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-destructive, #dc2626)',
outline: 'none',
}}
>
<AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
Too many failed attempts for this account. Try again in about 15 minutes, or
contact your admin to reset access.
</div>
)}
{loginError === 'server' && (
<div
ref={errorHeadingRef}
tabIndex={-1}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-destructive, #dc2626)',
outline: 'none',
}}
>
<AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
Something went wrong. Please try again.
</div>
)}
</div>
)}
{/* Surface 7 — Primary submit button */}
<button
type="button"
onClick={handleSubmit}
disabled={submitDisabled}
style={primaryBtnStyle(submitDisabled)}
>
{isLoading && (
<Loader2
size={16}
aria-hidden="true"
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
/>
)}
{isLoading ? 'Signing in…' : 'Sign in'}
</button>
{/* Surface 10 — Forgot password helper (informational only, not interactive) */}
<p
style={{
margin: 0,
marginTop: 'var(--space-4, 16px)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-text-secondary, #6b7280)',
textAlign: 'center',
lineHeight: 'var(--text-label-line-height, 1.4)',
}}
>
Forgot your password? Ask your admin.
</p>
</div>
{/* Surfaces 8 & 9 — Method divider + OIDC button (only when oidcEnabled) */}
{oidcEnabled && (
<>
{/* Surface 8 — Method divider */}
<div
aria-hidden="true"
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3, 12px)',
marginTop: 'var(--space-4, 16px)',
marginBottom: 'var(--space-4, 16px)',
}}
>
<div
style={{
flex: 1,
height: '1px',
background: 'var(--color-border, #e2e4e9)',
}}
/>
<span
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-text-secondary, #6b7280)',
flexShrink: 0,
}}
>
or
</span>
<div
style={{
flex: 1,
height: '1px',
background: 'var(--color-border, #e2e4e9)',
}}
/>
</div>
{/* Surface 9 — OIDC login button — uses generic copy per D-06 */}
<button
type="button"
onClick={() => {
// Initiate OIDC authorization-code flow (same redirect as today's OIDC-only mode)
window.location.href = '/api/login';
}}
style={{
width: '100%',
minHeight: '44px',
background: 'transparent',
border: '1px solid var(--color-member-0, #4a90d9)',
color: 'var(--color-member-0, #4a90d9)',
borderRadius: 'var(--space-1, 4px)',
cursor: 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
fontFamily: 'var(--font-family-base)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--space-2, 8px)',
}}
>
<ShieldCheck size={16} aria-hidden="true" />
Login with OIDC
</button>
</>
)}
</div>
</div>
);
}
+12
View File
@@ -88,6 +88,18 @@
--text-display-weight: 600; --text-display-weight: 600;
--text-display-line-height: 1.2; --text-display-line-height: 1.2;
/*
* BRAND SLOT Phase 17 seam tokens
* Phase 19 sets placeholder defaults; Phase 17 overrides these values only
* never the BrandSlot component structure (see 19-UI-SPEC.md §Brand Slot).
* */
--brand-logo-bg: var(--color-member-0); /* placeholder circle background */
--brand-logo-text: #ffffff; /* placeholder initials color */
--brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */
--brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */
--brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */
/* /*
* BREAKPOINTS (reference; use in @media queries) * BREAKPOINTS (reference; use in @media queries)
* */ * */
+6
View File
@@ -13,6 +13,12 @@ services:
# NODE_ENV !== 'production' (and the production image bakes NODE_ENV=production), # NODE_ENV !== 'production' (and the production image bakes NODE_ENV=production),
# so this can never activate in a shipped image. Required by the e2e harness. # so this can never activate in a shipped image. Required by the e2e harness.
DEV_AUTH_BYPASS: 'true' DEV_AUTH_BYPASS: 'true'
# Phase 19 (AUTH-LOCAL-16, D-14/D-15): required for devSessionCookieMiddleware to
# issue real local-session cookies under bypass AND for the real-login round-trip
# (POST /api/auth/local/login) to sign a session — without it that path 503s.
# Fixed dev-only value, mirrors the CI harness job (.gitea/workflows/ci.yml) —
# NEVER a production secret; this override file is dev-only (target: dev).
LOCAL_SESSION_SECRET: 'dev-secret-change-me-0000000000000000'
mariadb: mariadb:
ports: ports:
+5
View File
@@ -27,6 +27,9 @@ import { randomBytes, createECDH } from 'node:crypto';
const sessionSecret = randomBytes(32).toString('hex'); const sessionSecret = randomBytes(32).toString('hex');
const encKey = randomBytes(32).toString('hex'); const encKey = randomBytes(32).toString('hex');
// Phase 19 (D-05): LOCAL_SESSION_SECRET signs the local-auth JWT session cookie.
// Must be >= 32 chars. 32 random bytes encoded as base64 = 44 chars (safe, distinct from hex keys).
const localSessionSecret = randomBytes(32).toString('base64');
// VAPID key generation (P-256 / prime256v1 — same curve as web-push) // VAPID key generation (P-256 / prime256v1 — same curve as web-push)
const ecdhCurve = createECDH('prime256v1'); const ecdhCurve = createECDH('prime256v1');
@@ -55,4 +58,6 @@ SESSION_SECRET=${sessionSecret}
APP_PASSWORD_ENCRYPTION_KEY=${encKey} APP_PASSWORD_ENCRYPTION_KEY=${encKey}
VAPID_PUBLIC_KEY=${vapid.publicKey} VAPID_PUBLIC_KEY=${vapid.publicKey}
VAPID_PRIVATE_KEY=${vapid.privateKey} VAPID_PRIVATE_KEY=${vapid.privateKey}
# Phase 19 (D-05): Signs local-auth JWT session cookies. Required when not using DEV_AUTH_BYPASS.
LOCAL_SESSION_SECRET=${localSessionSecret}
`); `);