From dc40ba9fb89b8ae635466a7daf4ad5c7d12a1b12 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 15:35:43 -0400 Subject: [PATCH] docs(19): create local-auth phase plan (5 plans, 4 waves) --- .planning/ROADMAP.md | 21 +- .../19-local-auth-no-oidc-mode/19-01-PLAN.md | 265 ++++++++++++++++++ .../19-local-auth-no-oidc-mode/19-02-PLAN.md | 231 +++++++++++++++ .../19-local-auth-no-oidc-mode/19-03-PLAN.md | 253 +++++++++++++++++ .../19-local-auth-no-oidc-mode/19-04-PLAN.md | 231 +++++++++++++++ .../19-local-auth-no-oidc-mode/19-05-PLAN.md | 214 ++++++++++++++ 6 files changed, 1212 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/19-local-auth-no-oidc-mode/19-01-PLAN.md create mode 100644 .planning/phases/19-local-auth-no-oidc-mode/19-02-PLAN.md create mode 100644 .planning/phases/19-local-auth-no-oidc-mode/19-03-PLAN.md create mode 100644 .planning/phases/19-local-auth-no-oidc-mode/19-04-PLAN.md create mode 100644 .planning/phases/19-local-auth-no-oidc-mode/19-05-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 01f14cb..5426de4 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 | | 17. UI Optimization & Polish | v1.1 | 0/? | Not started | - | | 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 | +| 19. Local Auth (No-OIDC Mode) | v1.1 | 0/5 | Planned | - | ## 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. **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. -**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). -**Plans:** 0 plans +**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:** 5 plans (4 waves) **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. Plans: +**Wave 1** -- [ ] TBD (run /gsd-plan-phase 19 to break down) +- [ ] 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)* + +- [ ] 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)* + +- [ ] 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)* + +- [ ] 19-04-PLAN.md — PWA: LoginPage + BrandSlot + App.tsx gate + client.ts + AdminPage + SettingsSheet (AUTH-LOCAL-12/13/14/15) +- [ ] 19-05-PLAN.md — Dev-bypass Option C + break-glass CLI + harness/CI rework + login.spec.ts (AUTH-LOCAL-11/16) diff --git a/.planning/phases/19-local-auth-no-oidc-mode/19-01-PLAN.md b/.planning/phases/19-local-auth-no-oidc-mode/19-01-PLAN.md new file mode 100644 index 0000000..a916bbc --- /dev/null +++ b/.planning/phases/19-local-auth-no-oidc-mode/19-01-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: hashPassword / verifyPassword (node:crypto scrypt, PHC-encoded) + + - 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) + + apps/api/src/auth/localCredentials.ts, apps/api/tests/auth/localCredentials.test.ts + + - 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). + + + 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). + + + pnpm --filter @familysync/api test tests/auth/localCredentials.test.ts + + + - `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 + + hashPassword/verifyPassword implemented with scrypt + timingSafeEqual; all unit tests pass; zero new dependencies. + + + + Task 2: localSession.ts JWT cookie helpers + LOCAL_SESSION_SECRET boot guard + + - 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) + + apps/api/src/auth/localSession.ts, apps/api/src/lib/bootGuards.ts, apps/api/src/index.ts, apps/api/tests/auth/localSession.test.ts + + - 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. + + + 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`: 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). + + + pnpm --filter @familysync/api test tests/auth/localSession.test.ts && pnpm --filter @familysync/api typecheck + + + - `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 + + localSession helpers issue/verify/clear the local-session JWT cookie; verify never throws; LOCAL_SESSION_SECRET boot guard added and wired in index.ts. + + + + Task 3: local_credentials schema + 0003 migration + generate-secrets + .dockerignore (D-15) + + - 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) + + 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 + + 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. + + + pnpm --filter @familysync/api db:migrate && pnpm --filter @familysync/api test tests/db 2>/dev/null || pnpm --filter @familysync/api test + + + - 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 + + local_credentials table defined + migrated; generate-secrets emits LOCAL_SESSION_SECRET; .dockerignore excludes the break-glass scripts dir; test teardown truncates the new table. + + + + Task 4: Verify the 0003 migration is purely additive + LOCAL_SESSION_SECRET set + 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. + 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. + + 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. + + Type "approved" if the migration is purely additive and LOCAL_SESSION_SECRET is set, or describe what the migration unexpectedly touches. + + + + + +## 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 | + + + +- `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 + + + +- 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) + + + +## 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) + + + +Create `.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md` when done + diff --git a/.planning/phases/19-local-auth-no-oidc-mode/19-02-PLAN.md b/.planning/phases/19-local-auth-no-oidc-mode/19-02-PLAN.md new file mode 100644 index 0000000..f51ca12 --- /dev/null +++ b/.planning/phases/19-local-auth-no-oidc-mode/19-02-PLAN.md @@ -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" +--- + + +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). + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: Admin create-member + reset-password + hasLocalCredential on GET /members + + - 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) + + apps/api/src/routes/admin.ts, apps/api/tests/routes/admin.test.ts + + - 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. + + + 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. + + + pnpm --filter @familysync/api test tests/routes/admin.test.ts + + + - `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) + + Admin can create local members (atomic users+local_credentials), reset member passwords, and GET /members reports hasLocalCredential; non-admin is 403; no password echo. + + + + Task 2: Self-change password + hasLocalCredential on GET /api/me + + - 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) + + apps/api/src/routes/me.ts, apps/api/tests/routes/me.test.ts + + - 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. + + + 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. + + + pnpm --filter @familysync/api test tests/routes/me.test.ts + + + - `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) + + Self password-change verifies current then updates; GET /api/me exposes hasLocalCredential; no echo. + + + + Task 3: linkOidcToUser helper + POST /api/me/link-oidc initiation + + + + - 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) + + apps/api/src/auth/linkOidc.ts, apps/api/src/routes/me.ts, apps/api/tests/routes/me.test.ts + + - 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. + + + Create apps/api/src/auth/linkOidc.ts exporting `class OidcLinkConflictError extends Error` and async `linkOidcToUser(userId: number, iss: string, sub: string): Promise`: 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. + + + pnpm --filter @familysync/api test tests/routes/me.test.ts + + + - `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) + + 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. + + + + + +## 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) | + + + +- `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) + + + +- 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 + + + +## 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 + + + +Create `.planning/phases/19-local-auth-no-oidc-mode/19-02-SUMMARY.md` when done + diff --git a/.planning/phases/19-local-auth-no-oidc-mode/19-03-PLAN.md b/.planning/phases/19-local-auth-no-oidc-mode/19-03-PLAN.md new file mode 100644 index 0000000..568fab8 --- /dev/null +++ b/.planning/phases/19-local-auth-no-oidc-mode/19-03-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: localAuthMiddleware + GET /api/auth/mode + + - 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 + + 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 + + - 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. + + + 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. + + + pnpm --filter @familysync/api test tests/auth/localAuthMiddleware.test.ts tests/routes/authMode.test.ts + + + - 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`) + + localAuthMiddleware populates c.get('user') from a valid cookie and passes through cleanly otherwise; /api/auth/mode reports local+oidc availability pre-auth. + + + + Task 2: POST /api/auth/local/login (rate-limit + lockout) + logout + + - 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) + + apps/api/src/routes/localAuth.ts, apps/api/tests/routes/localAuth.test.ts + + - 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. + + + Create apps/api/src/routes/localAuth.ts exporting `localAuthRouter = new Hono()`. Copy `noEchoHook` verbatim from setup.ts. Define an in-memory `loginAttempts = new Map()` (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. + + + pnpm --filter @familysync/api test tests/routes/localAuth.test.ts + + + - 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) + + 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. + + + + Task 3: index.ts wiring (mounts + OIDC guard skip + /callback link branch) + de-Authelia comments + + - 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 + + apps/api/src/index.ts, apps/api/src/auth/middleware.ts + + 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). + + + pnpm --filter @familysync/api test && pnpm --filter @familysync/api typecheck + + + - `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 + + 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. + + + + + +## 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) | + + + +- `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 + + + +- 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) + + + +## 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) + + + +Create `.planning/phases/19-local-auth-no-oidc-mode/19-03-SUMMARY.md` when done + diff --git a/.planning/phases/19-local-auth-no-oidc-mode/19-04-PLAN.md b/.planning/phases/19-local-auth-no-oidc-mode/19-04-PLAN.md new file mode 100644 index 0000000..00337eb --- /dev/null +++ b/.planning/phases/19-local-auth-no-oidc-mode/19-04-PLAN.md @@ -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" +--- + + +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). + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: client.ts fetch fns + LoginError + MeUser.hasLocalCredential, and BrandSlot + + - 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") + + apps/pwa/src/api/client.ts, apps/pwa/src/components/BrandSlot.tsx, apps/pwa/src/styles/tokens.css + + 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` — 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` — 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 `

FamilySync

` (Display 24/600), and a tagline "Family calendar & lists" (Body 15/400, secondary). No `` 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. +
+ + pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa test + + + - `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 + + client.ts exposes auth-mode/login/logout fetchers + LoginError + MeUser.hasLocalCredential; BrandSlot renders the Phase-17-ready placeholder; brand-seam tokens defined. +
+ + + Task 2: LoginPage + App.tsx routing gate + + - 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) + + apps/pwa/src/routes/LoginPage.tsx, apps/pwa/src/App.tsx + + 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: `` above the login card (Surface 2/3). Card heading `

Sign in

`. 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; `

` 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 `` 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 ``; 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. + + + pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa build && pnpm --filter @familysync/pwa test + + + - `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) + + /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. + + + + Task 3: AdminPage LOCAL ACCOUNTS + SettingsSheet change-password / link-OIDC + + - 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 + + apps/pwa/src/routes/AdminPage.tsx, apps/pwa/src/components/SettingsSheet.tsx + + 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. + + + pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa test && pnpm --filter @familysync/pwa lint + + + - `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 + + 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. + + + + Task 4: playwright-cli walkthrough of the login + admin/settings surfaces + 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. + 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. + + 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. + + Type "approved" if the flows render and behave per the UI-SPEC, or describe what differs. + + + + + +## 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) | + + + +- `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 + + + +- 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) + + + +## 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-*) + + + +Create `.planning/phases/19-local-auth-no-oidc-mode/19-04-SUMMARY.md` when done + diff --git a/.planning/phases/19-local-auth-no-oidc-mode/19-05-PLAN.md b/.planning/phases/19-local-auth-no-oidc-mode/19-05-PLAN.md new file mode 100644 index 0000000..b8356e9 --- /dev/null +++ b/.planning/phases/19-local-auth-no-oidc-mode/19-05-PLAN.md @@ -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" +--- + + +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). + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: Option C — devSessionCookieMiddleware issues a real local-session cookie under bypass + + - 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 + + apps/api/src/auth/devBypass.ts, apps/api/src/index.ts + + 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. + + + pnpm --filter @familysync/api test tests/auth/devBypass.test.ts && pnpm --filter @familysync/api typecheck + + + - `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 + + Under DEV_AUTH_BYPASS, a real local-session cookie is issued for the dev user (production-guarded); existing bypass tests still pass. + + + + Task 2: Break-glass reset-admin CLI (dev-only) + + - 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) + + apps/api/scripts/reset-admin.ts + + 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 ''`. 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. + + + cd apps/api && node --import=tsx/esm scripts/reset-admin.ts --dry-run --username smoketest --password ignored; echo "exit=$?" + + + - 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 + + 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. + + + + Task 3: global-setup local_credentials seed + login.spec.ts + CI harness job env + + - 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) + + apps/pwa/e2e/global-setup.ts, apps/pwa/e2e/login.spec.ts, .gitea/workflows/ci.yml + + 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). + + + pnpm --filter @familysync/pwa test:e2e --grep "login" + + + - `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) + + 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. + + + + Task 4: Verify full harness + CI green and D-15 image boundary intact + 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. + 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. + + 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. + + Type "approved" if the full harness + CI are green and no dev artifact ships, or describe the failure. + + + + + +## 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 | + + + +- `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) + + + +- 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 + + + +## 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 + + + +Create `.planning/phases/19-local-auth-no-oidc-mode/19-05-SUMMARY.md` when done +