docs(19): create local-auth phase plan (5 plans, 4 waves)

This commit is contained in:
Lucas Berger
2026-06-17 15:35:43 -04:00
parent 4b461cbaab
commit dc40ba9fb8
6 changed files with 1212 additions and 3 deletions
+18 -3
View File
@@ -437,6 +437,7 @@ At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx`
| 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 6/6 | Complete | 2026-06-13 |
| 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)
@@ -0,0 +1,265 @@
---
phase: 19-local-auth-no-oidc-mode
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
- apps/api/src/db/schema.ts
- apps/api/src/db/migrations/0003_local_credentials.sql
- apps/api/src/auth/localCredentials.ts
- apps/api/src/auth/localSession.ts
- apps/api/src/lib/bootGuards.ts
- apps/api/src/index.ts
- scripts/generate-secrets.mjs
- .dockerignore
- apps/api/tests/auth/localCredentials.test.ts
- apps/api/tests/auth/localSession.test.ts
- apps/api/test/setup.ts
autonomous: false
requirements: [AUTH-LOCAL-01, AUTH-LOCAL-02]
user_setup:
- service: env
why: "New env-floor secret for signing local-session JWTs (D-05). Operator must add LOCAL_SESSION_SECRET to docker-compose env (>=32 chars). generate-secrets.mjs emits a value to copy."
env_vars:
- name: LOCAL_SESSION_SECRET
source: "Generate with `node scripts/generate-secrets.mjs` (this plan extends it to emit LOCAL_SESSION_SECRET) or `openssl rand -base64 32`"
must_haves:
truths:
- "A password can be hashed and the same password verifies true; a wrong password verifies false"
- "verifyPassword returns false (never throws) on a malformed stored hash"
- "A signed local-session JWT round-trips: issue then verify returns the same userId"
- "An expired or tampered local-session token verifies to null, never throws"
- "The API process refuses to boot (exit 1) when LOCAL_SESSION_SECRET is missing/short and dev-bypass is off"
- "The local_credentials table exists after migration with unique user_id and unique username"
artifacts:
- path: "apps/api/src/auth/localCredentials.ts"
provides: "hashPassword + verifyPassword (node:crypto scrypt, PHC-encoded)"
exports: ["hashPassword", "verifyPassword"]
min_lines: 25
- path: "apps/api/src/auth/localSession.ts"
provides: "issueLocalSessionCookie + verifyLocalSessionCookie + clearLocalSessionCookie"
exports: ["issueLocalSessionCookie", "verifyLocalSessionCookie", "clearLocalSessionCookie"]
min_lines: 30
- path: "apps/api/src/db/migrations/0003_local_credentials.sql"
provides: "additive CREATE TABLE local_credentials"
contains: "CREATE TABLE"
- path: "apps/api/src/db/schema.ts"
provides: "localCredentials Drizzle table export"
contains: "localCredentials"
- path: "apps/api/src/lib/bootGuards.ts"
provides: "assertLocalSessionSecretSet boot guard"
contains: "assertLocalSessionSecretSet"
key_links:
- from: "apps/api/src/auth/localSession.ts"
to: "process.env.LOCAL_SESSION_SECRET"
via: "Jwt.sign / Jwt.verify HS256 using the env secret"
pattern: "LOCAL_SESSION_SECRET"
- from: "apps/api/src/index.ts"
to: "apps/api/src/lib/bootGuards.ts"
via: "assertLocalSessionSecretSet() called in isMainModule() boot block"
pattern: "assertLocalSessionSecretSet"
- from: "apps/api/src/db/schema.ts"
to: "apps/api/src/db/migrations/0003_local_credentials.sql"
via: "drizzle-kit generate emits SQL from the localCredentials table"
pattern: "local_credentials"
---
<objective>
Build the Phase 19 local-auth foundation: the `local_credentials` table + migration, the password hashing primitives, the stateless JWT session-cookie helpers, the new `LOCAL_SESSION_SECRET` env var + boot-time assertion, and the D-15 image-hygiene fix for the break-glass script directory.
Purpose: Every other Phase 19 plan depends on these primitives. Hashing and session signing are security-critical with defined I/O — prime TDD candidates (RED before GREEN). This plan also closes the two D-15 gaps the researcher flagged (`scripts/` not in `.dockerignore`; `LOCAL_SESSION_SECRET` not in generate-secrets) so no later plan ships a dev artifact.
Output: `localCredentials.ts`, `localSession.ts`, the schema table + `0003` migration, the `assertLocalSessionSecretSet` boot guard wired in `index.ts`, generate-secrets emitting `LOCAL_SESSION_SECRET`, and `.dockerignore` excluding the break-glass scripts.
Derived REQ-IDs covered: AUTH-LOCAL-01 (local_credentials schema + migration, per D-09), AUTH-LOCAL-02 (scrypt hash/verify, per D-08). Also lands the `LOCAL_SESSION_SECRET` env + boot assertion (D-05) and D-15 hygiene preconditions.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
@.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md
@.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: hashPassword / verifyPassword (node:crypto scrypt, PHC-encoded)</name>
<read_first>
- apps/api/src/auth/user.ts (analog imports + module style; localCredentials.ts substitutes node:crypto for the drizzle imports)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Password Hashing Pattern (the verified scrypt + PHC implementation)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/auth/localCredentials.ts (PHC params N=16384 r=8 p=1 KEY_LEN=32)
- apps/api/tests/auth/user.test.ts (vitest unit test style for auth helpers)
</read_first>
<files>apps/api/src/auth/localCredentials.ts, apps/api/tests/auth/localCredentials.test.ts</files>
<behavior>
- RED first: write apps/api/tests/auth/localCredentials.test.ts asserting:
- Test 1: verifyPassword(hashPassword('hunter2'), 'hunter2') === true
- Test 2: verifyPassword(hashPassword('hunter2'), 'wrong') === false
- Test 3: two hashPassword('x') calls produce different encoded strings (unique salt)
- Test 4: verifyPassword('not-a-valid-hash', 'x') === false (no throw)
- Test 5: a hash encodes 'scrypt' + N + r + p + salt + hash joined by '$' (6 segments)
- Run the suite; confirm it FAILS (module not yet implemented).
</behavior>
<action>
Create apps/api/src/auth/localCredentials.ts exporting `hashPassword(password: string): string` and `verifyPassword(storedEncoded: string, candidate: string): boolean`. Import `scryptSync`, `randomBytes`, `timingSafeEqual` from `node:crypto` — no npm deps (D-08). Constants: SCRYPT_N=16384, SCRYPT_R=8, SCRYPT_P=1, KEY_LEN=32. hashPassword: 16-byte random salt, scryptSync to KEY_LEN, return `['scrypt', N, r, p, salt.toString('base64url'), hash.toString('base64url')].join('$')`. verifyPassword: split on '$', parse params, re-derive with scryptSync using `storedHash.length` as keylen (so buffers are equal length for timingSafeEqual), return `timingSafeEqual(storedHash, candidateHash)` inside try/catch that returns false on any error. Do not log the password. After implementing, run the suite — it must pass (GREEN).
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/auth/localCredentials.test.ts</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test tests/auth/localCredentials.test.ts` exits 0 with all 5 tests green
- Source assertion: `grep -c "node:crypto" apps/api/src/auth/localCredentials.ts` >= 1 and the file contains no `import` from any npm auth/hash package
- Source assertion: `grep -c "timingSafeEqual" apps/api/src/auth/localCredentials.ts` == 1
</acceptance_criteria>
<done>hashPassword/verifyPassword implemented with scrypt + timingSafeEqual; all unit tests pass; zero new dependencies.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: localSession.ts JWT cookie helpers + LOCAL_SESSION_SECRET boot guard</name>
<read_first>
- apps/api/src/auth/persistSessionCookie.ts (exact analog: setCookie attributes httpOnly/secure/sameSite/maxAge; cookie-name resolution)
- apps/api/tests/auth/persistSessionCookie.test.ts (test harness for cookie middleware/helpers)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §JWT Session Cookie Pattern + §Common Pitfalls 8/9/10 (Jwt namespace import; verify throws on expiry; missing-secret boot guard)
- apps/api/src/lib/bootGuards.ts (assertNotDevBypassInProduction structure to mirror)
- apps/api/src/index.ts lines 121-140 (isMainModule + assertNotDevBypassInProduction call site)
</read_first>
<files>apps/api/src/auth/localSession.ts, apps/api/src/lib/bootGuards.ts, apps/api/src/index.ts, apps/api/tests/auth/localSession.test.ts</files>
<behavior>
- RED first: write apps/api/tests/auth/localSession.test.ts asserting (set process.env.LOCAL_SESSION_SECRET to a >=32-char test value in the test):
- Test 1: issue then verify round-trips userId (use a minimal Hono Context mock or a real Hono app route that sets then reads the cookie)
- Test 2: verifyLocalSessionCookie returns null when no local-session cookie present (no throw)
- Test 3: verifyLocalSessionCookie returns null for a tampered/garbage token (no throw — covers Pitfall 9 expiry/throw path)
- Test 4 (bootGuards): assertLocalSessionSecretSet does NOT exit when DEV_AUTH_BYPASS='true' even if secret unset; and the function is exported
- Run the suite; confirm FAIL.
</behavior>
<action>
Create apps/api/src/auth/localSession.ts. Import `{ Jwt }` from `hono/utils/jwt` (namespace import — NOT named sign/verify, per Pitfall 8). Import `setCookie, getCookie, deleteCookie` from `hono/cookie`, `Context` type from `hono`. Cookie name constant `local-session` (distinct from `oidc-auth` — Pitfall 4). `SESSION_MAX_AGE_SECONDS = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400)`. Export async `issueLocalSessionCookie(c, userId)`: read `process.env.LOCAL_SESSION_SECRET`, throw if unset; sign `{ userId, iat, exp }` HS256; setCookie with httpOnly:true, secure:(NODE_ENV==='production'), sameSite:'Lax', path:'/', maxAge. Export async `verifyLocalSessionCookie(c): Promise<number|null>`: return null if no secret or no cookie; try Jwt.verify and return `payload.userId` when numeric, catch → return null. Export `clearLocalSessionCookie(c)`: deleteCookie with matching path/httpOnly/secure/sameSite attributes.
In apps/api/src/lib/bootGuards.ts add `export function assertLocalSessionSecretSet(): void`: return early when `process.env.DEV_AUTH_BYPASS === 'true'` (bypass issues no real secret-signed cookie in dev); otherwise if `LOCAL_SESSION_SECRET` is unset or shorter than 32 chars, console.error a FATAL message and `process.exit(1)`. Mirror the assertNotDevBypassInProduction structure exactly.
In apps/api/src/index.ts, import `assertLocalSessionSecretSet` and call it inside the existing `isMainModule()` boot block immediately AFTER the existing `assertNotDevBypassInProduction()` call (around line 136). Do not change any other boot behavior. Run the suite — it must pass (GREEN).
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/auth/localSession.test.ts && pnpm --filter @familysync/api typecheck</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test tests/auth/localSession.test.ts` exits 0, all 4 tests green
- Source assertion: `grep -c "import { Jwt }" apps/api/src/auth/localSession.ts` == 1 (namespace import, Pitfall 8)
- Source assertion: localSession.ts cookie name is `local-session` (grep `'local-session'`) and is NOT `oidc-auth`
- Source assertion: `grep -c "assertLocalSessionSecretSet" apps/api/src/index.ts` >= 1 (wired at boot)
- `pnpm --filter @familysync/api typecheck` exits 0
</acceptance_criteria>
<done>localSession helpers issue/verify/clear the local-session JWT cookie; verify never throws; LOCAL_SESSION_SECRET boot guard added and wired in index.ts.</done>
</task>
<task type="auto">
<name>Task 3: local_credentials schema + 0003 migration + generate-secrets + .dockerignore (D-15)</name>
<read_first>
- apps/api/src/db/schema.ts (memberCredentials block — the exact template; confirm int/varchar/timestamp/unique/index already imported)
- apps/api/src/db/migrations/0002_lethal_millenium_guard.sql (additive-migration example shape)
- apps/api/test/setup.ts (afterEach TRUNCATE list — localCredentials must be added so tests reset it)
- scripts/generate-secrets.mjs (existing secret-emitter to extend with LOCAL_SESSION_SECRET)
- .dockerignore (currently excludes only apps/api/scripts/seed-credential.mjs — the break-glass dir is NOT covered; D-15 / RESEARCH open question 4)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/db/schema.ts (localCredentials table definition)
</read_first>
<files>apps/api/src/db/schema.ts, apps/api/src/db/migrations/0003_local_credentials.sql, apps/api/test/setup.ts, scripts/generate-secrets.mjs, .dockerignore</files>
<action>
In apps/api/src/db/schema.ts add and export `localCredentials = mysqlTable('local_credentials', {...})` mirroring memberCredentials: `id` autoincrement PK; `userId` int('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }); `username` varchar('username', { length: 128 }).notNull(); `passwordHash` varchar('password_hash', { length: 256 }).notNull(); `createdAt` timestamp defaultNow().notNull(); `updatedAt` timestamp defaultNow().onUpdateNow(). Indexes/constraints: `unique('uniq_local_cred_user').on(t.userId)`, `unique('uniq_local_cred_username').on(t.username)`, `index('idx_local_credentials_user_id').on(t.userId)`. No new imports needed.
Generate the migration: run `pnpm --filter @familysync/api db:generate` to emit apps/api/src/db/migrations/0003_local_credentials.sql. Review it — it MUST be purely additive (CREATE TABLE local_credentials only; no ALTER/DROP/TRUNCATE on existing tables). Drizzle generate+migrate, never push (established rule). Commit the generated SQL as an artifact.
In apps/api/test/setup.ts add `local_credentials` to the afterEach TRUNCATE set so unit/integration tests reset it between runs.
In scripts/generate-secrets.mjs add a `LOCAL_SESSION_SECRET` line emitting a base64 32-byte value (same generation approach as the existing SESSION_SECRET / encryption key it already emits), so an operator copies it into env (D-05 / Pitfall 10).
In .dockerignore add a line `apps/api/scripts/` (exclude the entire break-glass scripts dir) so the future reset-admin.ts can never ship in the prod image (D-15, IMG-02). Keep the existing `apps/api/scripts/seed-credential.mjs` line or let the dir exclusion supersede it.
</action>
<verify>
<automated>pnpm --filter @familysync/api db:migrate && pnpm --filter @familysync/api test tests/db 2>/dev/null || pnpm --filter @familysync/api test</automated>
</verify>
<acceptance_criteria>
- File exists: `apps/api/src/db/migrations/0003_local_credentials.sql` and `grep -c "CREATE TABLE" apps/api/src/db/migrations/0003_local_credentials.sql` >= 1
- Negative assertion: the 0003 SQL contains no `DROP TABLE` and no `TRUNCATE` (grep -c each == 0)
- `pnpm --filter @familysync/api db:migrate` exits 0 (table applied to dev DB)
- Source assertion: `grep -c "localCredentials" apps/api/src/db/schema.ts` >= 1 and the export is present
- Source assertion: `grep -c "apps/api/scripts/$" .dockerignore` >= 1 OR `.dockerignore` contains a line `apps/api/scripts/` excluding the dir
- Source assertion: `grep -c "LOCAL_SESSION_SECRET" scripts/generate-secrets.mjs` >= 1
- Source assertion: `grep -c "local_credentials" apps/api/test/setup.ts` >= 1
</acceptance_criteria>
<done>local_credentials table defined + migrated; generate-secrets emits LOCAL_SESSION_SECRET; .dockerignore excludes the break-glass scripts dir; test teardown truncates the new table.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: Verify the 0003 migration is purely additive + LOCAL_SESSION_SECRET set</name>
<action>Pause for human review of the generated migration SQL and the local env before proceeding. This is a blocking checkpoint — the executor performs no code change here; it presents the migration and waits for approval.</action>
<what-built>The 0003 migration was generated by drizzle-kit and applied to the dev DB. Because Drizzle's generate step can occasionally emit unexpected ALTER/DROP statements against populated MariaDB (the exact reason this repo forbids `push`), the generated SQL needs a human eyeball before it is trusted as a committed artifact.</what-built>
<how-to-verify>
1. Open apps/api/src/db/migrations/0003_local_credentials.sql.
2. Confirm it contains ONLY a `CREATE TABLE local_credentials (...)` statement with the two UNIQUE constraints (uniq_local_cred_user, uniq_local_cred_username) and the user_id index.
3. Confirm there is NO statement touching users, member_credentials, calendars, calendar_events, app_config, or any existing table (no ALTER, DROP, RENAME, TRUNCATE).
4. Confirm LOCAL_SESSION_SECRET is set in your local .env (>=32 chars) — without it the API will refuse to boot in non-bypass mode.
</how-to-verify>
<resume-signal>Type "approved" if the migration is purely additive and LOCAL_SESSION_SECRET is set, or describe what the migration unexpectedly touches.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| operator env → API process | LOCAL_SESSION_SECRET and scrypt run server-side only; never returned to a client |
| build context → published image | `.dockerignore` is the boundary that keeps dev/break-glass artifacts out of the prod image |
## STRIDE Threat Register (ASVS L1, block on high)
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-01 | Information Disclosure | password hashing | mitigate | scrypt + 16-byte per-hash random salt; timingSafeEqual; no password in logs (V2/V6 ASVS L1) |
| T-19-02 | Spoofing | local-session JWT | mitigate | HS256 signed with LOCAL_SESSION_SECRET; verify rejects tampered tokens (returns null) (V3) |
| T-19-03 | Elevation of Privilege | missing LOCAL_SESSION_SECRET | mitigate | assertLocalSessionSecretSet boot guard: refuse to start (exit 1) when unset/<32 chars in non-bypass mode (Pitfall 10) |
| T-19-04 | Tampering | dev/break-glass artifact in prod image | mitigate | `.dockerignore` excludes apps/api/scripts/ (IMG-02); tests/ already excluded; defense-in-depth for D-15 |
| T-19-SC | Tampering | npm installs | mitigate | Zero new packages this phase (RESEARCH §Standard Stack); nothing to vet |
</threat_model>
<verification>
- `pnpm --filter @familysync/api test` green (includes the two new unit suites)
- `pnpm --filter @familysync/api typecheck` exits 0
- `pnpm --filter @familysync/api db:migrate` applies 0003 cleanly
- Human checkpoint confirms the migration is purely additive
</verification>
<success_criteria>
- AUTH-LOCAL-01: local_credentials table exists with unique user_id + unique username (migration applied)
- AUTH-LOCAL-02: hashPassword/verifyPassword pass round-trip, wrong-password, malformed-hash, and unique-salt tests
- LOCAL_SESSION_SECRET present in generate-secrets.mjs; boot guard wired in index.ts
- .dockerignore excludes apps/api/scripts/ (D-15)
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 01)
- Table: `local_credentials` (columns: id, user_id [UNIQUE, FK→users.id cascade], username [UNIQUE], password_hash, created_at, updated_at)
- Migration: `apps/api/src/db/migrations/0003_local_credentials.sql`
- Functions: `hashPassword`, `verifyPassword` (apps/api/src/auth/localCredentials.ts)
- Functions: `issueLocalSessionCookie`, `verifyLocalSessionCookie`, `clearLocalSessionCookie` (apps/api/src/auth/localSession.ts)
- Function: `assertLocalSessionSecretSet` (apps/api/src/lib/bootGuards.ts)
- Env var: `LOCAL_SESSION_SECRET` (env-only; never in app_config/DB)
- Cookie: `local-session` (httpOnly, Secure in prod, SameSite=Lax)
- Schema export: `localCredentials`
- .dockerignore: `apps/api/scripts/` exclusion (D-15)
</artifacts_produced>
<output>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md` when done
</output>
@@ -0,0 +1,231 @@
---
phase: 19-local-auth-no-oidc-mode
plan: 02
type: tdd
wave: 2
depends_on: ["19-01"]
files_modified:
- apps/api/src/routes/admin.ts
- apps/api/src/routes/me.ts
- apps/api/src/auth/linkOidc.ts
- apps/api/tests/routes/admin.test.ts
- apps/api/tests/routes/me.test.ts
autonomous: true
requirements: [AUTH-LOCAL-07, AUTH-LOCAL-08, AUTH-LOCAL-09, AUTH-LOCAL-10, AUTH-LOCAL-17]
must_haves:
truths:
- "An admin can create a local member (users row + local_credentials row with a hashed initial password) in one transaction"
- "Creating a member with an already-used username returns 409, not a 500 or a partial insert"
- "An admin can reset any local member's password without knowing the current one"
- "A user can change their own password only after verifying their current password"
- "GET /api/me returns hasLocalCredential so the PWA knows whether to show Change-password / Link-OIDC"
- "Linking an OIDC identity binds iss+sub to the current user and deletes their local_credentials row; a conflicting iss+sub is rejected (409) and no local row is deleted"
- "No password or Zod received-value is ever echoed in any response or log on these routes"
artifacts:
- path: "apps/api/src/auth/linkOidc.ts"
provides: "linkOidcToUser(userId, iss, sub) — binds identity + deletes local cred, 409 on conflict"
exports: ["linkOidcToUser", "OidcLinkConflictError"]
min_lines: 25
- path: "apps/api/src/routes/admin.ts"
provides: "POST /members + POST /members/:id/password + hasLocalCredential in GET /members"
contains: "members"
- path: "apps/api/src/routes/me.ts"
provides: "POST /password + POST /link-oidc + hasLocalCredential in GET /"
contains: "hasLocalCredential"
key_links:
- from: "apps/api/src/routes/admin.ts"
to: "apps/api/src/auth/localCredentials.ts"
via: "hashPassword on create-member and reset-password"
pattern: "hashPassword"
- from: "apps/api/src/routes/me.ts"
to: "apps/api/src/auth/localCredentials.ts"
via: "verifyPassword(current) then hashPassword(new) on self-change"
pattern: "verifyPassword"
- from: "apps/api/src/auth/linkOidc.ts"
to: "apps/api/src/db/schema.ts"
via: "uniq_oidc_identity preflight SELECT + UPDATE users + DELETE local_credentials"
pattern: "localCredentials"
---
<objective>
Build the backend account-management surface for local auth: admin-creates-member, admin-reset-password, self-change-password, the `hasLocalCredential` signal on `/api/me`, and the OIDC-link binding helper (`linkOidcToUser`) that the middleware plan's `/callback` will invoke.
Purpose: These are API endpoints with defined request/response contracts and high security stakes (credential creation, password reset, identity binding) — TDD candidates. The OIDC-link binding is extracted into a standalone `linkOidc.ts` helper so the middleware plan (19-03) can call it from `/callback` without this plan and that plan touching the same file.
Output: extended `admin.ts` + `me.ts`, new `linkOidc.ts` helper, extended `admin.test.ts` + `me.test.ts`.
Derived REQ-IDs covered: AUTH-LOCAL-07 (admin create member, D-10), AUTH-LOCAL-08 (admin reset, D-11), AUTH-LOCAL-09 (self-change, D-11), AUTH-LOCAL-10 (OIDC-link, D-12), AUTH-LOCAL-17 (hasLocalCredential).
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
@.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md
@.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md
@.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: Admin create-member + reset-password + hasLocalCredential on GET /members</name>
<read_first>
- apps/api/src/routes/admin.ts (requireAdmin guard at line ~42; noEchoHook lines ~70-74; POST /credentials lines ~112-132; GET /members lines ~83-102; db.transaction in PUT /calendars/:id/shared lines ~170-183)
- apps/api/tests/routes/admin.test.ts (existing admin route test patterns + mock setup)
- apps/api/src/auth/localCredentials.ts (hashPassword — from 19-01)
- apps/api/src/db/schema.ts (users, localCredentials, COLOR_PALETTE for member color)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/routes/admin.ts (LEFT JOIN extension + 409 pattern + transaction)
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surface 11A/11B (field names, copy, validation: passwords-match, min-8-char)
</read_first>
<files>apps/api/src/routes/admin.ts, apps/api/tests/routes/admin.test.ts</files>
<behavior>
- RED first: extend apps/api/tests/routes/admin.test.ts asserting:
- Test 1: POST /api/admin/members { displayName, username, initialPassword } → 201, inserts a users row + a local_credentials row whose hash verifies against the initial password
- Test 2: POST /api/admin/members with a username already in local_credentials → 409, no new users row created (transaction rolled back)
- Test 3: POST /api/admin/members/:id/password { newPassword } → 200, the stored hash now verifies the new password; current password NOT required
- Test 4: a non-admin caller gets 403 on both routes (requireAdmin already covers it — assert it)
- Test 5: GET /api/admin/members returns hasLocalCredential:true for a member with a local_credentials row, false otherwise
- Run; confirm FAIL.
</behavior>
<action>
Extend apps/api/src/routes/admin.ts (do NOT move the `adminRouter.use('*', requireAdmin)` first statement). Reuse the existing `noEchoHook`. Add `POST /members` with a zValidator json schema `{ displayName: string min1, username: string min1 max128, initialPassword: string min8 }` + noEchoHook: in a `db.transaction`, INSERT users (displayName, color from COLOR_PALETTE round-robin or existing color-assignment helper), then INSERT local_credentials (user_id, username, passwordHash via hashPassword(initialPassword)); on a username uniqueness violation return `c.json({ error: 'Username already in use' }, 409)`. Add `POST /members/:id/password` with schema `{ newPassword: string min8 }` + noEchoHook: verify the target user exists and has a local_credentials row (404 if not), UPDATE local_credentials SET password_hash = hashPassword(newPassword) WHERE user_id = :id. Extend the existing `GET /members` query with a `.leftJoin(localCredentials, eq(localCredentials.userId, users.id))` and map `hasLocalCredential: row.localCredId !== null` into each member object alongside the existing `hasCredential`. Use the established error-response pattern (known error → 4xx; unexpected → console.error without body + 503). Never log request bodies. Run the suite — GREEN.
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/routes/admin.test.ts</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test tests/routes/admin.test.ts` exits 0, all new tests green
- Source assertion: `grep -c "requireAdmin" apps/api/src/routes/admin.ts` >= 1 and the `.use('*', requireAdmin)` line remains the first router statement
- Source assertion: `grep -c "hashPassword" apps/api/src/routes/admin.ts` >= 1
- Source assertion: `grep -c "noEchoHook" apps/api/src/routes/admin.ts` >= 1 used on both new POST routes
- Behavior: duplicate-username create returns 409 and leaves the users table unchanged (transaction rollback verified in Test 2)
</acceptance_criteria>
<done>Admin can create local members (atomic users+local_credentials), reset member passwords, and GET /members reports hasLocalCredential; non-admin is 403; no password echo.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: Self-change password + hasLocalCredential on GET /api/me</name>
<read_first>
- apps/api/src/routes/me.ts (resolveUserId lines ~74-86; meNoEchoHook lines ~164-168; resolveAdminAndSetupStatus lines ~50-66; POST /credential lines ~154-202; response shape lines ~93-139)
- apps/api/tests/routes/me.test.ts (existing me-route test patterns)
- apps/api/src/auth/localCredentials.ts (verifyPassword + hashPassword)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/routes/me.ts (resolveAdminAndSetupStatus extension + change-password route)
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surface 12 (current/new/confirm fields, error copy)
</read_first>
<files>apps/api/src/routes/me.ts, apps/api/tests/routes/me.test.ts</files>
<behavior>
- RED first: extend apps/api/tests/routes/me.test.ts asserting:
- Test 1: POST /api/me/password { currentPassword, newPassword } with correct current → 200, stored hash now verifies newPassword
- Test 2: wrong currentPassword → 401, hash unchanged
- Test 3: user with no local_credentials row → 404
- Test 4: GET /api/me includes hasLocalCredential (true when a row exists, false otherwise) alongside isAdmin/needsProviderSetup
- Run; confirm FAIL.
</behavior>
<action>
Extend apps/api/src/routes/me.ts. Reuse `resolveUserId` and the existing `meNoEchoHook`. Add `POST /password` with zValidator json `{ currentPassword: string min1, newPassword: string min8 }` + meNoEchoHook: resolveUserId (401 if null); SELECT the user's local_credentials row (404 if none); `verifyPassword(cred.passwordHash, currentPassword)` → 401 `{ error: 'Current password incorrect' }` on false; else UPDATE local_credentials SET password_hash = hashPassword(newPassword) WHERE user_id. Extend `resolveAdminAndSetupStatus` (or the GET / handler) to also SELECT whether a local_credentials row exists for the user and include `hasLocalCredential: boolean` in the GET /api/me response object next to isAdmin and needsProviderSetup. Use the established error pattern; never log the body. Run the suite — GREEN.
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/routes/me.test.ts</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test tests/routes/me.test.ts` exits 0, all new tests green
- Source assertion: `grep -c "verifyPassword" apps/api/src/routes/me.ts` >= 1
- Source assertion: `grep -c "hasLocalCredential" apps/api/src/routes/me.ts` >= 1
- Behavior: wrong current password returns 401 and leaves the hash unchanged (Test 2)
</acceptance_criteria>
<done>Self password-change verifies current then updates; GET /api/me exposes hasLocalCredential; no echo.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 3: linkOidcToUser helper + POST /api/me/link-oidc initiation</name>
<!-- planner-discipline-allow: email -->
<!-- planner-discipline-allow: never uses email -->
<read_first>
- apps/api/src/auth/user.ts (upsertUser — identity = oidc_iss+oidc_sub never email, D-10; the strictness the link helper must replicate)
- apps/api/src/db/schema.ts (users.oidcIss/oidcSub, uniq_oidc_identity index line ~63; localCredentials)
- apps/api/src/routes/me.ts (resolveUserId; route registration style)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §OIDC-Link Flow + §Common Pitfalls 6 (iss+sub uniqueness; 409; state param)
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surface 13 (link copy + 409 post-redirect error)
</read_first>
<files>apps/api/src/auth/linkOidc.ts, apps/api/src/routes/me.ts, apps/api/tests/routes/me.test.ts</files>
<behavior>
- RED first: extend apps/api/tests/routes/me.test.ts asserting:
- Test 1: linkOidcToUser(userId, iss, sub) where no other user holds iss+sub → UPDATEs users.oidc_iss/oidc_sub for userId AND DELETEs that user's local_credentials row
- Test 2: linkOidcToUser when iss+sub already belongs to a DIFFERENT user → throws OidcLinkConflictError AND the target user's local_credentials row is NOT deleted (binding aborted before any write)
- Test 3: POST /api/me/link-oidc (authenticated) → returns a redirect target / authorization-code initiation payload that encodes the current userId in signed state (assert the response shape only; the actual OIDC redirect is exercised by 19-03's /callback)
- Run; confirm FAIL.
</behavior>
<action>
Create apps/api/src/auth/linkOidc.ts exporting `class OidcLinkConflictError extends Error` and async `linkOidcToUser(userId: number, iss: string, sub: string): Promise<void>`: preflight SELECT users WHERE oidc_iss=iss AND oidc_sub=sub LIMIT 1 — if a row exists with id !== userId, throw OidcLinkConflictError (do NOT write anything). Otherwise run a db.transaction: UPDATE users SET oidc_iss=iss, oidc_sub=sub, claimed=true WHERE id=userId; DELETE FROM local_credentials WHERE user_id=userId. Bind by iss+sub only — never email (D-10/D-12). The uniq_oidc_identity DB constraint is the safety net behind the preflight (Pitfall 6).
In apps/api/src/routes/me.ts add `POST /link-oidc`: resolveUserId (401 if null); produce the OIDC authorization-code initiation with a signed `state` encoding `{ linkUserId: userId, nonce }` so the 19-03 /callback can read it. Return the initiation payload/redirect target the PWA needs (Surface 13 "Continue with OIDC"). Do not perform the binding here — the binding happens in /callback (19-03) via linkOidcToUser. Run the suite — GREEN.
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/routes/me.test.ts</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test tests/routes/me.test.ts` exits 0, link tests green
- Source assertion: linkOidc.ts performs a preflight SELECT before the UPDATE (grep for the conflict check) and throws OidcLinkConflictError on a foreign iss+sub
- Behavior: Test 2 confirms NO local_credentials deletion occurs on conflict
- Source assertion: `grep -ci "email" apps/api/src/auth/linkOidc.ts` == 0 (binding never uses email — D-10)
</acceptance_criteria>
<done>linkOidcToUser binds iss+sub and drops the local credential atomically, 409-equivalent on conflict with no partial write; /api/me/link-oidc initiates the signed-state OIDC redirect.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → /api/admin/* | untrusted member-management input crosses here; gated by requireAdmin |
| client → /api/me/* | self-service password/link input; gated by session (resolveUserId) |
| OIDC token → users row | iss+sub binding crosses an external-identity boundary |
## STRIDE Threat Register (ASVS L1, block on high)
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-05 | Elevation of Privilege | POST /api/admin/members | mitigate | requireAdmin router guard; integration test asserts 403 for non-admin (V4) |
| T-19-06 | Information Disclosure | password in Zod error | mitigate | noEchoHook on every credential route; no console.log of bodies (V5; RESEARCH Pitfall 3) |
| T-19-07 | Elevation of Privilege | self-change password | mitigate | verifyPassword(current) required before update; resolveUserId from session not body (V4) |
| T-19-08 | Elevation of Privilege | OIDC-link account takeover | mitigate | preflight iss+sub uniqueness → conflict aborts before any write; uniq_oidc_identity DB constraint backstop (RESEARCH Pitfall 6) |
| T-19-09 | Tampering | OIDC-link CSRF | mitigate | userId carried in signed OIDC `state` (nonce); binding only for the state-encoded user |
| T-19-10 | Tampering | partial insert on create-member failure | mitigate | db.transaction wraps users + local_credentials; 409 rolls back (RESEARCH Pitfall 5) |
</threat_model>
<verification>
- `pnpm --filter @familysync/api test` green (admin + me suites)
- `pnpm --filter @familysync/api typecheck` exits 0
- No password or Zod received-value appears in any response body or log (noEchoHook everywhere)
</verification>
<success_criteria>
- AUTH-LOCAL-07/08: admin create + reset member passwords (atomic, 409 on dup, 403 for non-admin)
- AUTH-LOCAL-09: self-change requires correct current password
- AUTH-LOCAL-10: OIDC-link binds iss+sub + drops local cred, conflict aborts cleanly
- AUTH-LOCAL-17: GET /api/me exposes hasLocalCredential
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 02)
- Route: `POST /api/admin/members` (admin create local member)
- Route: `POST /api/admin/members/:id/password` (admin reset)
- Route: `POST /api/me/password` (self-change)
- Route: `POST /api/me/link-oidc` (OIDC-link initiation, signed state)
- Function: `linkOidcToUser(userId, iss, sub)` + `OidcLinkConflictError` (apps/api/src/auth/linkOidc.ts)
- Response field: `hasLocalCredential` on GET /api/me and GET /api/admin/members
</artifacts_produced>
<output>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-02-SUMMARY.md` when done
</output>
@@ -0,0 +1,253 @@
---
phase: 19-local-auth-no-oidc-mode
plan: 03
type: tdd
wave: 3
depends_on: ["19-01", "19-02"]
files_modified:
- apps/api/src/auth/localAuthMiddleware.ts
- apps/api/src/routes/authMode.ts
- apps/api/src/routes/localAuth.ts
- apps/api/src/auth/middleware.ts
- apps/api/src/index.ts
- apps/api/tests/auth/localAuthMiddleware.test.ts
- apps/api/tests/routes/authMode.test.ts
- apps/api/tests/routes/localAuth.test.ts
autonomous: true
requirements: [AUTH-LOCAL-03, AUTH-LOCAL-04, AUTH-LOCAL-05, AUTH-LOCAL-06, AUTH-LOCAL-18, AUTH-LOCAL-19, AUTH-LOCAL-20]
must_haves:
truths:
- "A valid username+password POST to /api/auth/local/login returns 200 and sets a local-session cookie"
- "A wrong password and an unknown username both return the same 401 with the same body (no enumeration, no field discrimination)"
- "After 5 failed attempts the endpoint returns 429; after 10 it returns 423 until an admin reset"
- "A request carrying a valid local-session cookie resolves c.get('user') and is NOT 302-redirected to OIDC"
- "A request with no local-session cookie falls through unchanged to the OIDC guard"
- "GET /api/auth/mode is reachable pre-auth and returns { localEnabled:true, oidcEnabled } reflecting app_config/env"
- "Logout clears the local-session cookie"
- "An OIDC callback carrying a valid link-state binds the identity via linkOidcToUser and drops the local credential"
- "No user-facing string or config comment says 'Authelia'"
artifacts:
- path: "apps/api/src/auth/localAuthMiddleware.ts"
provides: "localAuthMiddleware — local-session cookie → c.set('user')"
exports: ["localAuthMiddleware"]
min_lines: 20
- path: "apps/api/src/routes/authMode.ts"
provides: "GET /api/auth/mode pre-auth endpoint"
exports: ["authModeRouter"]
min_lines: 12
- path: "apps/api/src/routes/localAuth.ts"
provides: "POST /login (rate-limited) + POST/GET /logout"
exports: ["localAuthRouter"]
min_lines: 40
- path: "apps/api/src/index.ts"
provides: "pre-auth auth routes mount + localAuthMiddleware slot + OIDC guard skip-when-user-set + /callback link branch"
contains: "localAuthMiddleware"
key_links:
- from: "apps/api/src/auth/localAuthMiddleware.ts"
to: "apps/api/src/auth/localSession.ts"
via: "verifyLocalSessionCookie → load users row → c.set('user', shape)"
pattern: "verifyLocalSessionCookie"
- from: "apps/api/src/index.ts"
to: "apps/api/src/auth/localAuthMiddleware.ts"
via: "app.use('/api/*', localAuthMiddleware()) between devAuthBypass and the OIDC guard"
pattern: "localAuthMiddleware\\(\\)"
- from: "apps/api/src/index.ts"
to: "apps/api/src/auth/linkOidc.ts"
via: "/callback reads link-state and calls linkOidcToUser"
pattern: "linkOidcToUser"
- from: "apps/api/src/routes/localAuth.ts"
to: "apps/api/src/auth/localSession.ts"
via: "issueLocalSessionCookie on success / clearLocalSessionCookie on logout"
pattern: "issueLocalSessionCookie"
---
<objective>
Wire the local-auth request path: the `localAuthMiddleware` that turns a `local-session` cookie into `c.get('user')`, the pre-auth `GET /api/auth/mode` endpoint, the rate-limited `POST /api/auth/local/login` + logout routes, the `index.ts` middleware mount (including the OIDC-guard skip-when-already-authed wrapper and the `/callback` link branch), and the D-06 de-Authelia-ization of config comments.
Purpose: This is the security seam of the phase — login verification, session issuance, middleware ordering so the OIDC guard never 302-redirects a valid local session, and the rate-limit/lockout state machine. All have defined I/O — TDD. It runs after 19-02 because the `/callback` link branch calls `linkOidcToUser` (19-02) and after 19-01 for the session/hash primitives.
Output: `localAuthMiddleware.ts`, `authMode.ts`, `localAuth.ts`, edited `index.ts` + `middleware.ts`, three new test suites.
Derived REQ-IDs covered: AUTH-LOCAL-03 (login), AUTH-LOCAL-04 (middleware), AUTH-LOCAL-05 (mode), AUTH-LOCAL-06 (logout), AUTH-LOCAL-18 (de-Authelia, D-06), AUTH-LOCAL-19 (rate-limit/lockout), AUTH-LOCAL-20 (auth unit tests). Coexistence per D-01/D-02/D-03. The localAuthMiddleware-beside-OIDC-guard seam is the "clean internal seam, no plugin/registry framework" required by D-07 — this phase ships exactly local + one generic OIDC and nothing more.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
@.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md
@.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md
@.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md
@.planning/phases/19-local-auth-no-oidc-mode/19-02-SUMMARY.md
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: localAuthMiddleware + GET /api/auth/mode</name>
<read_first>
- apps/api/src/auth/devBypass.ts (the c.set('user', ...) contract + DEV_USER shape + ContextVariableMap augmentation the middleware must match; lines 30-76)
- apps/api/tests/auth/devBypass.test.ts (middleware unit-test style)
- apps/api/src/auth/localSession.ts (verifyLocalSessionCookie — from 19-01)
- apps/api/src/routes/setup.ts (GET /status pre-auth pattern lines ~86-95 — authMode mirrors it)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §localAuthMiddleware.ts + §authMode.ts (exact patterns)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Middleware Slot + §Auth Mode Endpoint + §Common Pitfalls 1
</read_first>
<files>apps/api/src/auth/localAuthMiddleware.ts, apps/api/src/routes/authMode.ts, apps/api/tests/auth/localAuthMiddleware.test.ts, apps/api/tests/routes/authMode.test.ts</files>
<behavior>
- RED: write apps/api/tests/auth/localAuthMiddleware.test.ts:
- Test 1: with a valid local-session cookie for an existing user, the middleware sets c.get('user') to {id, oidcIss, oidcSub, displayName, color} and calls next
- Test 2: with no cookie, the middleware is a pure passthrough — c.get('user') stays unset (NOT undefined-set) so the OIDC guard can still fire (Pitfall 1)
- Test 3: with a cookie whose userId has no users row, passthrough (no crash)
- Test 4: when c.get('user') is already set (devAuthBypass ran first), middleware does not overwrite and calls next
- RED: write apps/api/tests/routes/authMode.test.ts:
- Test 5: GET /api/auth/mode returns { localEnabled:true, oidcEnabled:false } when no oidc_issuer in env or app_config
- Test 6: returns oidcEnabled:true when app_config has oidc_issuer (or OIDC_ISSUER env set)
- Run; confirm FAIL.
</behavior>
<action>
Create apps/api/src/auth/localAuthMiddleware.ts exporting `localAuthMiddleware(): MiddlewareHandler`. Side-effect import the ContextVariableMap augmentation (`import '../auth/devBypass.js'`) so c.set('user') is typed. In the handler: if `c.get('user')` already set → next() (devAuthBypass-first). Else `verifyLocalSessionCookie(c)`; if null → next() passthrough. Else SELECT the users row by id; if found, `c.set('user', { id, oidcIss: row.oidcIss ?? 'local', oidcSub: row.oidcSub ?? String(row.id), displayName: row.displayName ?? null, color: row.color })`; always next(). MUST never set user to undefined on the no-cookie path (Pitfall 1).
Create apps/api/src/routes/authMode.ts exporting `authModeRouter = new Hono()` with `GET /`: `localEnabled` always true (D-01); `oidcEnabled` = Boolean(process.env.OIDC_ISSUER) OR, if absent, Boolean of an app_config row keyed `oidc_issuer`; `return c.json({ localEnabled: true, oidcEnabled })`. No auth gate (pre-auth, mirrors setup GET /status). Run the suites — GREEN.
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/auth/localAuthMiddleware.test.ts tests/routes/authMode.test.ts</automated>
</verify>
<acceptance_criteria>
- Both suites exit 0, all 6 tests green
- Source assertion: `grep -c "verifyLocalSessionCookie" apps/api/src/auth/localAuthMiddleware.ts` >= 1
- Negative assertion: middleware no-cookie path calls next() without c.set('user') — verified by Test 2 (OIDC guard fall-through intact)
- Source assertion: authMode returns localEnabled true unconditionally (grep `localEnabled: true`)
</acceptance_criteria>
<done>localAuthMiddleware populates c.get('user') from a valid cookie and passes through cleanly otherwise; /api/auth/mode reports local+oidc availability pre-auth.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: POST /api/auth/local/login (rate-limit + lockout) + logout</name>
<read_first>
- apps/api/src/routes/setup.ts (noEchoHook lines ~49-53; zValidator usage; pre-auth router style)
- apps/api/tests/routes/login.test.ts (existing auth-route test mock patterns)
- apps/api/src/auth/localCredentials.ts (verifyPassword — from 19-01), apps/api/src/auth/localSession.ts (issue/clear cookie — from 19-01)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Local Login Endpoint + §Rate Limiting (loginAttempts Map; 5→429, 10→423; dummy-hash timing defense; same-401 copy)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §localAuth.ts
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surface 6 (401/429/423 → error copy the PWA renders)
</read_first>
<files>apps/api/src/routes/localAuth.ts, apps/api/tests/routes/localAuth.test.ts</files>
<behavior>
- RED: write apps/api/tests/routes/localAuth.test.ts:
- Test 1: valid username+password → 200 { ok:true } and a Set-Cookie for local-session
- Test 2: wrong password → 401 { error: 'Invalid credentials' }
- Test 3: unknown username → 401 with the SAME body as Test 2 (no enumeration / no field discrimination)
- Test 4: 5 consecutive failures from one IP → the 6th returns 429
- Test 5: 10 failures → 423; a successful login after a reset/cleared map clears the counter
- Test 6: POST /api/auth/local/logout (and GET alias) clears the local-session cookie (Set-Cookie maxAge 0 / expired)
- Test 7 (no-echo): a malformed body (missing password) returns 400 { error: 'Invalid request' } and the response body contains neither the submitted value nor a Zod `received` field
- Run; confirm FAIL.
</behavior>
<action>
Create apps/api/src/routes/localAuth.ts exporting `localAuthRouter = new Hono()`. Copy `noEchoHook` verbatim from setup.ts. Define an in-memory `loginAttempts = new Map<string, { count, lockedUntil, lockedOut }>()` (household scale; no Redis). Constants RATE_WINDOW_FAILURES=5, RATE_WINDOW_SECS=60, LOCKOUT_FAILURES=10. `POST /login` with zValidator json `{ username: string min1 max128 trim, password: string min1 max1000 }` + noEchoHook: derive IP from `x-forwarded-for` (Pangolin sets it) else host; if locked → 423; if count>=5 and within window → 429; SELECT local_credentials by username; ALWAYS run verifyPassword (use a precomputed dummy hash when username unknown, to defeat the timing oracle — RESEARCH Pitfall 2); on invalid → increment counter, set lockedUntil, set lockedOut at >=10, return 401 `{ error: 'Invalid credentials' }`; on success → `loginAttempts.delete(ip)`, `issueLocalSessionCookie(c, cred.userId)`, return 200 `{ ok: true }`. `POST /logout` and `GET /logout` (alias) → `clearLocalSessionCookie(c)` then 200 `{ ok: true }`. Standard error pattern for unexpected errors (console.error without body + 503). Run the suite — GREEN.
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/routes/localAuth.test.ts</automated>
</verify>
<acceptance_criteria>
- Suite exits 0; all 7 tests green
- Behavior: Test 3 confirms unknown-username and wrong-password 401 bodies are byte-identical (no enumeration)
- Behavior: Test 4/5 confirm 429 at 6th attempt and 423 at lockout
- Source assertion: `grep -c "noEchoHook" apps/api/src/routes/localAuth.ts` >= 1 on the login route
- Source assertion: login ALWAYS calls verifyPassword even on unknown username (dummy-hash path present — grep for the dummy/filler hash)
</acceptance_criteria>
<done>Login verifies timing-safely, issues the session cookie, enforces per-IP rate-limit (429) and lockout (423), and never echoes the password; logout clears the cookie.</done>
</task>
<task type="auto">
<name>Task 3: index.ts wiring (mounts + OIDC guard skip + /callback link branch) + de-Authelia comments</name>
<read_first>
- apps/api/src/index.ts (lines 25-110: devBypassActive, /callback handler line ~40, /api/setup mount line ~49, devAuthBypass + oidcConfigFallback + oidcAuthMiddleware + persistSessionCookie chain lines ~55-73; isMainModule boot block lines ~121-140)
- apps/api/src/auth/middleware.ts (header comment + inline 'Authelia base URL' comments to genericize — D-06)
- apps/api/src/auth/linkOidc.ts (linkOidcToUser + OidcLinkConflictError — from 19-02; called from the /callback link branch)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/index.ts (new chain) + §apps/api/src/auth/middleware.ts (comment-only de-Authelia)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Middleware Slot (skip-when-user-set wrapper) + §OIDC-Link Flow (signed state in /callback) + §BYO-Auth De-Authelia-ization
</read_first>
<files>apps/api/src/index.ts, apps/api/src/auth/middleware.ts</files>
<action>
In apps/api/src/index.ts: mount the new pre-auth routes immediately after the existing `app.route('/api/setup', setupRouter)` line — `app.route('/api/auth', authModeRouter)` and `app.route('/api/auth', localAuthRouter)` (both before any /api/* middleware). After `app.use('/api/*', devAuthBypass())`, add `app.use('/api/*', localAuthMiddleware())`. Inside the existing `if (!devBypassActive)` block, replace the bare `app.use('/api/*', oidcAuthMiddleware())` with a wrapper: `app.use('/api/*', async (c, next) => { if (c.get('user')) { await next(); return; } await oidcAuthMiddleware()(c, next); })` so a valid local (or dev) session is not 302-redirected to OIDC (RESEARCH Pitfall 1). Keep oidcConfigFallbackMiddleware and persistSessionCookie unchanged and in order.
Extend the existing `/callback` handler (registered before the OIDC guard) to support link mode: when the callback's signed `state` carries a `linkUserId`, after `processOAuthCallback` resolves the OIDC `iss+sub`, call `linkOidcToUser(linkUserId, iss, sub)`; on `OidcLinkConflictError` redirect to a generic error page/route (UI-SPEC Surface 13 409 copy) without deleting any local credential; on success continue the normal post-login redirect (the user is now OIDC-only). Do NOT alter the existing non-link callback behavior. The `assertLocalSessionSecretSet()` boot call added in 19-01 stays as-is.
In apps/api/src/auth/middleware.ts: comment-only de-Authelia-ization (D-06) — change the header comment and any inline references from Authelia-specific wording ("Authelia as the identity provider", "Authelia base URL") to generic "OIDC identity provider" / "OIDC issuer URL". No runtime behavior change. Do not rename any env var or app_config key (they are already generic).
</action>
<verify>
<automated>pnpm --filter @familysync/api test && pnpm --filter @familysync/api typecheck</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test` exits 0 (full API suite green, including the new auth suites)
- `pnpm --filter @familysync/api typecheck` exits 0
- Source assertion: `grep -c "localAuthMiddleware()" apps/api/src/index.ts` >= 1 mounted on /api/* AFTER devAuthBypass and BEFORE the OIDC guard
- Source assertion: index.ts OIDC guard is wrapped with a `if (c.get('user'))` skip (grep the wrapper)
- Source assertion: `grep -c "linkOidcToUser" apps/api/src/index.ts` >= 1 (callback link branch)
- Negative assertion (D-06): `grep -ci "authelia" apps/api/src/auth/middleware.ts` == 0 and `grep -ci "authelia" apps/api/src/index.ts` == 0
</acceptance_criteria>
<done>Auth routes mounted pre-auth; localAuthMiddleware in slot; OIDC guard skipped when a local/dev user is set; /callback handles link mode via linkOidcToUser; Authelia removed from API comments.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → POST /api/auth/local/login | unauthenticated credential submission; the brute-force surface |
| local-session cookie → c.get('user') | the request-auth boundary localAuthMiddleware enforces |
| OIDC callback state → identity binding | external-identity boundary with CSRF/conflict risk |
## STRIDE Threat Register (ASVS L1, block on high)
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-11 | Elevation of Privilege | login brute-force | mitigate | per-IP rate-limit (5→429), lockout (10→423) resolved only by admin reset (V2) |
| T-19-12 | Information Disclosure | username enumeration timing | mitigate | dummy-hash verifyPassword on unknown username; identical 401 body (RESEARCH Pitfall 2) |
| T-19-13 | Spoofing | OIDC guard 302 on valid local session | mitigate | guard wrapped to skip when c.get('user') set (RESEARCH Pitfall 1) — local sessions are honored |
| T-19-14 | Information Disclosure | password echoed in Zod error | mitigate | noEchoHook on login route (V5) |
| T-19-15 | Elevation of Privilege | account takeover via /callback link | mitigate | linkOidcToUser preflight conflict (409) + signed state (T-19-08/09 from 19-02) |
| T-19-16 | Information Disclosure | infra leak via "Authelia" copy | accept→mitigate | D-06 removes provider-specific wording from comments/UI; low severity, done for hygiene |
| T-19-17 | Spoofing | session fixation | mitigate | a fresh signed JWT is issued on every successful login; exp claim bounds lifetime (V3) |
</threat_model>
<verification>
- `pnpm --filter @familysync/api test` green (all API suites)
- `pnpm --filter @familysync/api typecheck` exits 0
- A valid local-session request reaches downstream routes without a 302 (Pitfall 1 covered by index wiring + middleware Test 2)
- No "Authelia" string remains in API source comments
</verification>
<success_criteria>
- AUTH-LOCAL-03/06: login (200/401/429/423) + logout work
- AUTH-LOCAL-04: localAuthMiddleware sets c.get('user') from cookie, passes through without
- AUTH-LOCAL-05: /api/auth/mode pre-auth, reflects oidc config
- AUTH-LOCAL-18: no Authelia copy in API comments
- AUTH-LOCAL-19/20: rate-limit + lockout + unit coverage
- D-03 coexistence: existing OIDC users unaffected (guard wrapper only skips when a user is already set)
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 03)
- Middleware: `localAuthMiddleware` (apps/api/src/auth/localAuthMiddleware.ts)
- Router: `authModeRouter``GET /api/auth/mode` (pre-auth)
- Router: `localAuthRouter``POST /api/auth/local/login`, `POST /api/auth/local/logout`, `GET /api/auth/local/logout`
- index.ts: pre-auth auth-route mounts, localAuthMiddleware slot, OIDC-guard skip-when-user-set wrapper, /callback link-mode branch
- middleware.ts: de-Authelia-ized comments (D-06)
- In-memory rate-limit/lockout state machine (login)
</artifacts_produced>
<output>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-03-SUMMARY.md` when done
</output>
@@ -0,0 +1,231 @@
---
phase: 19-local-auth-no-oidc-mode
plan: 04
type: execute
wave: 4
depends_on: ["19-02", "19-03"]
files_modified:
- apps/pwa/src/components/BrandSlot.tsx
- apps/pwa/src/routes/LoginPage.tsx
- apps/pwa/src/api/client.ts
- apps/pwa/src/App.tsx
- apps/pwa/src/routes/AdminPage.tsx
- apps/pwa/src/components/SettingsSheet.tsx
- apps/pwa/src/styles/tokens.css
autonomous: false
requirements: [AUTH-LOCAL-12, AUTH-LOCAL-13, AUTH-LOCAL-14, AUTH-LOCAL-15]
must_haves:
truths:
- "An unauthenticated user with no valid session lands on /login (when localEnabled) and sees the brand slot + username/password form"
- "Submitting valid credentials logs the user in and navigates into the app; the local-session cookie is set by the API"
- "401/429/423/5xx each render their distinct copy from the UI-SPEC; invalid-credentials does not say which field is wrong"
- "When oidcEnabled, an 'or' divider + 'Login with OIDC' button appear; the word 'Authelia' never appears"
- "An admin sees a LOCAL ACCOUNTS section to add a member and a per-member Reset-password action"
- "A local user sees Change-password (and, when oidcEnabled, Link OIDC identity) in Settings; OIDC-only users do not"
artifacts:
- path: "apps/pwa/src/routes/LoginPage.tsx"
provides: "standalone /login page (Surfaces 1-10)"
min_lines: 80
- path: "apps/pwa/src/components/BrandSlot.tsx"
provides: "Phase-17 brand seam component"
exports: ["BrandSlot"]
min_lines: 15
- path: "apps/pwa/src/App.tsx"
provides: "auth-mode fetch gate + /login route"
contains: "authMode"
key_links:
- from: "apps/pwa/src/App.tsx"
to: "apps/pwa/src/api/client.ts"
via: "fetchAuthMode() gates the /login redirect"
pattern: "authMode"
- from: "apps/pwa/src/routes/LoginPage.tsx"
to: "apps/pwa/src/api/client.ts"
via: "fetchLocalLogin posts credentials; LoginError code drives the error state"
pattern: "fetchLocalLogin"
- from: "apps/pwa/src/components/SettingsSheet.tsx"
to: "apps/pwa/src/api/client.ts"
via: "hasLocalCredential from /api/me gates Change-password / Link-OIDC rows"
pattern: "hasLocalCredential"
---
<objective>
Build the PWA local-login UI and the account-management surfaces per the approved UI-SPEC: the standalone `/login` page (brand slot + form + error states + optional OIDC button), the App.tsx auth-mode routing gate, the client.ts fetch functions + typed `LoginError`, the AdminPage LOCAL ACCOUNTS additions, and the SettingsSheet change-password / link-OIDC rows.
Purpose: This is the first real login UI in the app — end-user-facing, phone-first, must be slick for the non-technical Apple member (CLAUDE.md). It is layout/glue/state code (type: execute, not TDD). Verification leans on the project's `playwright-cli` convention for desktop/Chromium-driveable flows rather than human checkpoints.
Output: LoginPage, BrandSlot, edited App.tsx + client.ts + AdminPage + SettingsSheet + tokens.css brand-seam vars.
Derived REQ-IDs covered: AUTH-LOCAL-12 (LoginPage), AUTH-LOCAL-13 (admin UI), AUTH-LOCAL-14 (settings UI), AUTH-LOCAL-15 (routing gate). D-04 (login UI exists), D-02 (chooser), D-06 (no Authelia), D-12 (link confirm copy).
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md
@.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md
@.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
@.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md
@.planning/phases/19-local-auth-no-oidc-mode/19-02-SUMMARY.md
@.planning/phases/19-local-auth-no-oidc-mode/19-03-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: client.ts fetch fns + LoginError + MeUser.hasLocalCredential, and BrandSlot</name>
<read_first>
- apps/pwa/src/api/client.ts (fetchMe lines ~74-84; handleAuthResponse lines ~51-58; SessionExpiredError class lines ~33-39; MeUser interface lines ~62-68)
- apps/pwa/src/routes/SetupPage.tsx (ShieldCheck header block — BrandSlot analog)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/pwa/src/api/client.ts + §apps/pwa/src/components/BrandSlot.tsx (exact code patterns)
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md §Brand Slot + Surface 2 (placeholder structure, copy "FamilySync" / "Family calendar & lists")
</read_first>
<files>apps/pwa/src/api/client.ts, apps/pwa/src/components/BrandSlot.tsx, apps/pwa/src/styles/tokens.css</files>
<action>
In apps/pwa/src/api/client.ts add: `fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnabled: boolean }>` (plain GET /api/auth/mode, no credentials needed). `fetchLocalLogin({ username, password }): Promise<void>` — POST /api/auth/local/login with credentials:'include', redirect:'manual'; map status 401→`new LoginError('invalid')`, 429→`LoginError('rate-limit')`, 423→`LoginError('locked')`, other non-ok→`LoginError('server')`. `fetchLocalLogout(): Promise<void>` — POST /api/auth/local/logout. Add the typed `class LoginError extends Error` with `readonly code: 'invalid'|'rate-limit'|'locked'|'server'` (mirror the SessionExpiredError class shape incl. Object.setPrototypeOf). Add `hasLocalCredential: boolean` to the `MeUser` interface. Optionally add `fetchChangePassword`, `fetchCreateMember`, `fetchAdminResetPassword`, `fetchLinkOidc` following the same fetch+throw pattern (used by Tasks 2/3).
Create apps/pwa/src/components/BrandSlot.tsx exporting `BrandSlot()` — the placeholder structure from the UI-SPEC: a 48px circle (`var(--brand-logo-size)` / `var(--brand-logo-bg)` / `var(--brand-logo-border-radius)`) with white "FS" initials, an `<h1>FamilySync</h1>` (Display 24/600), and a tagline "Family calendar & lists" (Body 15/400, secondary). No `<img>` yet (Phase 17 seam). No props.
In apps/pwa/src/styles/tokens.css add the brand-seam custom properties under `:root` with placeholder defaults: `--brand-logo-bg: var(--color-member-0)`, `--brand-logo-text: #ffffff`, `--brand-logo-size: 48px`, `--brand-logo-border-radius: 50%`. Phase 17 overrides these values only.
</action>
<verify>
<automated>pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa test</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/pwa typecheck` exits 0
- Source assertion: `grep -c "class LoginError" apps/pwa/src/api/client.ts` == 1 with the 4 codes
- Source assertion: `grep -c "hasLocalCredential" apps/pwa/src/api/client.ts` >= 1 (MeUser extended)
- Source assertion: `grep -c "--brand-logo-size" apps/pwa/src/styles/tokens.css` == 1
- Negative assertion: `grep -ci "authelia" apps/pwa/src/components/BrandSlot.tsx` == 0
</acceptance_criteria>
<done>client.ts exposes auth-mode/login/logout fetchers + LoginError + MeUser.hasLocalCredential; BrandSlot renders the Phase-17-ready placeholder; brand-seam tokens defined.</done>
</task>
<task type="auto">
<name>Task 2: LoginPage + App.tsx routing gate</name>
<read_first>
- apps/pwa/src/routes/SetupPage.tsx (pageStyle/contentColStyle/cardStyle/primaryBtnStyle/ghostBtnStyle/inputStyle/labelStyle — copy verbatim; useMutation + error-state pattern)
- apps/pwa/src/App.tsx (setupQuery gate lines ~72-79 + /setup route lines ~156-167; AuthSplash usage; meQuery)
- apps/pwa/src/components/BrandSlot.tsx (from Task 1)
- apps/pwa/src/api/client.ts (fetchAuthMode, fetchLocalLogin, LoginError — from Task 1)
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surfaces 1-10 + Interaction Contract + Accessibility Contract + Copywriting Contract (exact copy, ids, aria, focus, tab order, show/hide toggle)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/pwa/src/routes/LoginPage.tsx + §apps/pwa/src/App.tsx (password show/hide, gate logic)
</read_first>
<files>apps/pwa/src/routes/LoginPage.tsx, apps/pwa/src/App.tsx</files>
<action>
Create apps/pwa/src/routes/LoginPage.tsx — a standalone full-page route (no AppNav/BottomTabBar/SetupBanner), copying SetupPage's page/card/input/button styles. Layout: `<BrandSlot />` above the login card (Surface 2/3). Card heading `<h2>Sign in</h2>`. Username field (Surface 4: id="login-username", label "Username", autoComplete="username", spellCheck=false, autoCapitalize="none", autoCorrect="off"). Password field with show/hide toggle (Surface 5: id="login-password", autoComplete="current-password", paddingRight 44, Eye/EyeOff button with aria-label + aria-pressed, 44px tap target; toggle resets to hidden on blur). Error/lockout banner (Surface 6: id="login-error", role="status", aria-live="polite", aria-atomic="true") with the four copy variants keyed off LoginError.code (invalid → "Incorrect username or password." and both inputs get destructive border, no field blamed; rate-limit → "Too many attempts. Please wait a moment and try again." + submit disabled; locked → "This account is temporarily locked. Contact your admin to reset access." + submit disabled; server → "Something went wrong. Please try again." + submit re-enabled). Submit button (Surface 7: full-width filled accent, "Sign in"/"Signing in…" with Loader2, minHeight 44, disabled until both fields non-empty). Forgot-password helper (Surface 10: "Forgot your password? Ask your admin." non-interactive). Method divider + OIDC button (Surfaces 8/9) rendered only when `authMode.oidcEnabled` — "or" divider then outlined "Login with OIDC" (ShieldCheck icon; NEVER "Authelia"); on click initiate the OIDC flow (top-level nav to /api/login). useMutation(fetchLocalLogin) → onSuccess `window.location.replace('/')`, onError set the LoginError code into local error state. Focus username on mount; move focus to the error heading on error; Enter in username → password, Enter in password → submit. role="main" on content column; `<h1>` is the brand-slot app name.
In apps/pwa/src/App.tsx: add an `authModeQuery` (queryKey ['authMode'], fetchAuthMode, retry false, staleTime 60_000). Add a `/login` route rendering `<LoginPage authMode={authModeQuery.data} />` as a sibling of the `*` route (standalone, outside the app shell — same structure as /setup). Gate logic, applied AFTER the existing setup gate (setup wins): if the user is unauthenticated (meQuery 401/error) AND `authMode.localEnabled` → render `<Navigate to="/login" replace />`; if unauthenticated AND `!localEnabled && oidcEnabled` → top-level redirect to /api/login (today's OIDC-only behavior). Keep AuthSplash during auth-state loading. Do not change the setup gate precedence.
</action>
<verify>
<automated>pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa build && pnpm --filter @familysync/pwa test</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/pwa typecheck` and `build` exit 0
- Source assertion: `grep -c "fetchLocalLogin" apps/pwa/src/routes/LoginPage.tsx` >= 1
- Source assertion: LoginPage renders all four error copies (grep each UI-SPEC string)
- Source assertion: `grep -c "authModeQuery" apps/pwa/src/App.tsx` >= 1 and a `/login` route is registered
- Negative assertion: `grep -ci "authelia" apps/pwa/src/routes/LoginPage.tsx` == 0
- Negative assertion: login invalid-credentials copy does not name a specific field (single shared message — UI-SPEC Surface 6 variant 1)
</acceptance_criteria>
<done>/login renders the brand slot + accessible username/password form with show/hide, four error states, optional OIDC button; App.tsx routes unauthenticated local-mode users to /login after the setup gate.</done>
</task>
<task type="auto">
<name>Task 3: AdminPage LOCAL ACCOUNTS + SettingsSheet change-password / link-OIDC</name>
<read_first>
- apps/pwa/src/routes/AdminPage.tsx (sectionLabelStyle lines ~42-49; CredentialSheet open/trigger pattern lines ~53-100; membersQuery lines ~76-81; member-row action button pattern)
- apps/pwa/src/components/SettingsSheet.tsx (bottom-sheet dialog lines ~146-178; Escape listener lines ~69-76; settings rows)
- apps/pwa/src/components/CredentialSheet.tsx (useMutation + invalidateQueries lines ~115-144; focus-on-open lines ~97-102; error-state pattern)
- apps/pwa/src/api/client.ts (fetchCreateMember / fetchAdminResetPassword / fetchChangePassword / fetchLinkOidc — from Task 1)
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surfaces 11A/11B/12/13 + Copywriting Contract + Destructive Actions (exact copy, field labels, autoComplete values, two-step link confirm)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §AdminPage.tsx + §SettingsSheet.tsx
</read_first>
<files>apps/pwa/src/routes/AdminPage.tsx, apps/pwa/src/components/SettingsSheet.tsx</files>
<action>
In apps/pwa/src/routes/AdminPage.tsx add a "LOCAL ACCOUNTS" section (sectionLabelStyle) below the existing MEMBERS / SHARED CALENDAR sections. Surface 11A — inline "Add member" form: Display name, Username (autoComplete off, spellCheck false, autoCapitalize none), Initial password + Confirm password (autoComplete new-password), filled "Add member" submit disabled until required fields filled and passwords match; on success clear the form + invalidate ['admin','members'] and ['me']; error copy: username taken → "That username is already in use. Choose a different one.", mismatch → "Passwords do not match.", short → "Password is too short. Use at least 8 characters." Surface 11B — a per-member "Reset password" action button shown only for members with `hasLocalCredential`, opening a bottom-sheet/modal (CredentialSheet dialog pattern: role=dialog, aria-modal, Escape closes, focus returns to trigger) with New password + Confirm (autoComplete new-password, no current-password field), "Reset password" submit; success closes silently.
In apps/pwa/src/components/SettingsSheet.tsx add a "Change password" row shown only when `meData.user.hasLocalCredential` (Surface 12) opening a nested sheet with Current/New/Confirm fields (correct autoComplete values), submit disabled until filled + new/confirm match; error variants: wrong current → "Current password is incorrect.", mismatch → "Passwords do not match.", generic → "Something went wrong. Please try again." Add a "Link OIDC identity" row shown only when `hasLocalCredential` AND `oidcEnabled` (Surface 13) opening a confirmation sheet (NOT a form) with the exact body copy "After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed." + secondary note "This can't be undone from the app. Contact your admin if you need to revert." + Cancel / "Continue with OIDC" (never "Authelia"); on Continue, close the sheet and initiate the OIDC link flow (fetchLinkOidc → follow the returned redirect). Reuse the existing bottom-sheet dialog + Escape + focus patterns; never echo a password; no dangerouslySetInnerHTML.
</action>
<verify>
<automated>pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa test && pnpm --filter @familysync/pwa lint</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/pwa typecheck`, `test`, `lint` exit 0
- Source assertion: AdminPage gates the Reset-password action on `hasLocalCredential` (grep)
- Source assertion: SettingsSheet gates Change-password on `hasLocalCredential` and Link-OIDC on `hasLocalCredential` + `oidcEnabled` (grep)
- Source assertion: the link-confirm body uses "your local password will be removed" and does NOT use the word "delete" (negative grep `delete` in the link copy region) — UI-SPEC copy rule
- Negative assertion: `grep -ci "authelia" apps/pwa/src/components/SettingsSheet.tsx` == 0 and in AdminPage.tsx == 0
</acceptance_criteria>
<done>Admin can add a member + reset member passwords; a local user can change their password and (when OIDC enabled) link an OIDC identity via a two-step confirmation; all gated by hasLocalCredential/oidcEnabled; no Authelia copy.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: playwright-cli walkthrough of the login + admin/settings surfaces</name>
<action>Drive the login + admin/settings flows with the playwright-cli skill against the host dev stack, then pause for human confirmation. This is a blocking checkpoint — no code change; the executor runs the browser walkthrough and waits for approval.</action>
<what-built>The full local-login UI and account-management surfaces. Drive them in a desktop Chromium browser with the project's playwright-cli skill (CLAUDE.md convention: prefer automated browser checks over manual). The dev stack is reached via the Phase-7 dev-bypass; to test the real login form, clear the local-session cookie first (Plan 05 makes this possible). iOS-Safari-standalone behavior remains a separate device-only gate, not part of this check.</what-built>
<how-to-verify>
1. Start the host-side dev stack (API + PWA dev servers, DEV_AUTH_BYPASS=true). Use the playwright-cli skill to open the PWA.
2. Clear cookies / open an incognito context so no session exists → confirm the app redirects to /login and the brand slot + "Sign in" card render with username/password fields and the show/hide toggle.
3. Submit a wrong password (dev creds from Plan 05's seed: devuser / a wrong value) → confirm the single "Incorrect username or password." message and that neither field is individually blamed.
4. Submit the correct dev creds (devuser / devpass) → confirm navigation into the calendar.
5. With OIDC configured in app_config, reload /login → confirm the "or" divider + "Login with OIDC" button appear and the word "Authelia" appears nowhere.
6. As an admin, open /admin → confirm LOCAL ACCOUNTS section with Add-member form + a per-member Reset-password action. Open Settings → confirm Change-password and (when OIDC on) Link OIDC identity rows, with the non-alarming link copy.
</how-to-verify>
<resume-signal>Type "approved" if the flows render and behave per the UI-SPEC, or describe what differs.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser DOM → password fields | password values must never persist to localStorage/sessionStorage or be echoed |
| client state → API | the PWA mirrors 401/429/423 but never derives auth; the server is authoritative |
## STRIDE Threat Register (ASVS L1, block on high)
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-18 | Information Disclosure | password in client storage | mitigate | password fields are React-controlled state only; never written to localStorage/sessionStorage (UI-SPEC Security Display Rules) |
| T-19-19 | Information Disclosure | field-level credential hint | mitigate | single "Incorrect username or password." copy; no field-specific error (timing-safe parity with the API) |
| T-19-20 | Tampering | XSS via rendered values | mitigate | plain-text JSX children; no dangerouslySetInnerHTML (project convention T-05-24) |
| T-19-21 | Information Disclosure | infra leak via provider branding | mitigate | D-06: UI never renders "Authelia"; generic "Login with OIDC" |
| T-19-22 | Elevation of Privilege | client-only admin gating | accept | client `isAdmin`/`hasLocalCredential` are UX-only; the server requireAdmin/session is the real boundary (documented prior decision) |
</threat_model>
<verification>
- `pnpm --filter @familysync/pwa typecheck && build && test && lint` all green
- playwright-cli human checkpoint confirms the login flow, error parity, OIDC chooser, and admin/settings surfaces
- No "Authelia" string in any PWA source touched by this plan
</verification>
<success_criteria>
- AUTH-LOCAL-12: /login renders + logs in + shows correct error states
- AUTH-LOCAL-13: admin add-member + reset-password surfaces work
- AUTH-LOCAL-14: settings change-password + link-OIDC surfaces work
- AUTH-LOCAL-15: App.tsx gate routes unauthenticated local-mode users to /login (after setup gate)
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 04)
- Component: `LoginPage` (apps/pwa/src/routes/LoginPage.tsx) + `/login` route
- Component: `BrandSlot` (apps/pwa/src/components/BrandSlot.tsx) — Phase-17 seam
- client.ts: `fetchAuthMode`, `fetchLocalLogin`, `fetchLocalLogout`, `LoginError`, `MeUser.hasLocalCredential` (+ create/reset/change/link fetchers)
- App.tsx: `authModeQuery` gate + `/login` route
- AdminPage LOCAL ACCOUNTS section (add member + reset password)
- SettingsSheet Change-password + Link-OIDC rows
- tokens.css brand-seam custom properties (--brand-logo-*)
</artifacts_produced>
<output>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-04-SUMMARY.md` when done
</output>
@@ -0,0 +1,214 @@
---
phase: 19-local-auth-no-oidc-mode
plan: 05
type: execute
wave: 4
depends_on: ["19-01", "19-03"]
files_modified:
- apps/api/src/auth/devBypass.ts
- apps/api/scripts/reset-admin.ts
- apps/pwa/e2e/global-setup.ts
- apps/pwa/e2e/login.spec.ts
- .gitea/workflows/ci.yml
autonomous: false
requirements: [AUTH-LOCAL-11, AUTH-LOCAL-16]
must_haves:
truths:
- "With DEV_AUTH_BYPASS=true, every request also carries a real local-session cookie for the dev user, so the PWA login gate skips to the app"
- "The Phase-7/8 harness still reaches the authed PWA without manual login (existing specs unchanged)"
- "A login-specific spec can clear the local-session cookie and exercise the real /login form against the seeded dev credential"
- "global-setup seeds a local_credentials row for the dev user (id=1) and truncates it between runs"
- "CI provides LOCAL_SESSION_SECRET to the harness job and seeds the local_credentials table"
- "The break-glass CLI creates/resets a local admin by username, runs only outside production, and is excluded from the prod image"
artifacts:
- path: "apps/api/scripts/reset-admin.ts"
provides: "break-glass create/reset local admin CLI (dev-only)"
min_lines: 30
- path: "apps/pwa/e2e/login.spec.ts"
provides: "real-login-form e2e covering the gate + form (AUTH-LOCAL-12/15)"
min_lines: 25
- path: "apps/pwa/e2e/global-setup.ts"
provides: "local_credentials dev seed + truncate"
contains: "local_credentials"
key_links:
- from: "apps/api/src/auth/devBypass.ts"
to: "apps/api/src/auth/localSession.ts"
via: "devSessionCookieMiddleware issues a real local-session cookie for DEV_USER (Option C)"
pattern: "local-session"
- from: "apps/pwa/e2e/global-setup.ts"
to: "local_credentials table"
via: "INSERT ... ON DUPLICATE KEY UPDATE seed for dev user id=1"
pattern: "local_credentials"
- from: ".gitea/workflows/ci.yml"
to: "LOCAL_SESSION_SECRET"
via: "harness job env + table seed"
pattern: "LOCAL_SESSION_SECRET"
---
<objective>
Rework the dev-bypass + Phase-7/8 Playwright harness to coexist with the new login UI (Option C: bypass issues a real `local-session` cookie), add the break-glass CLI, seed `local_credentials` for the dev user, add a real-login e2e spec, and update the CI harness job — all while preserving the D-15 dev-only/no-prod-image guarantees.
Purpose: The new login gate would otherwise break the harness, which reaches the authed PWA purely via DEV_AUTH_BYPASS (D-14). Option C is the minimal-change path: the bypass keeps setting `c.get('user')` AND now also issues the same `local-session` cookie the PWA gate expects, so existing specs pass unchanged; a dedicated login spec clears the cookie to test the real form. This is glue + CI + a CLI script (type: execute). D-15 is enforced by the existing IMG-01/02/03 gates plus the `.dockerignore apps/api/scripts/` exclusion added in 19-01.
Output: edited `devBypass.ts`, new `reset-admin.ts`, edited `global-setup.ts`, new `login.spec.ts`, edited `ci.yml`.
Derived REQ-IDs covered: AUTH-LOCAL-11 (break-glass CLI, D-13), AUTH-LOCAL-16 (dev-bypass + harness rework, D-14/D-15).
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
@.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md
@.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md
@.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md
@.planning/phases/19-local-auth-no-oidc-mode/19-03-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Option C — devSessionCookieMiddleware issues a real local-session cookie under bypass</name>
<read_first>
- apps/api/src/auth/devBypass.ts (DEV_USER shape lines ~30-36; devAuthBypass() env-guard structure lines ~58-76; the production hard-guard is the FIRST check and must stay first)
- apps/api/tests/auth/devBypass.test.ts (the test that must keep passing)
- apps/api/src/auth/localSession.ts (issueLocalSessionCookie + getCookie('local-session') — from 19-01)
- apps/api/src/index.ts (where devAuthBypass() is mounted — the companion middleware mounts just after it; from 19-03 wiring)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Dev-Bypass Rework (Option C; D-15 compliance) + Pitfall 7
</read_first>
<files>apps/api/src/auth/devBypass.ts, apps/api/src/index.ts</files>
<action>
In apps/api/src/auth/devBypass.ts add `export function devSessionCookieMiddleware(): MiddlewareHandler`. Keep the production hard-guard as the FIRST check (return no-op when NODE_ENV==='production') and a no-op when DEV_AUTH_BYPASS!=='true' — identical guard order to devAuthBypass so the IMG-01 boot guard / `assertNotDevBypassInProduction` continues to protect it. When active: on each request that does NOT already have a `local-session` cookie (getCookie), call `issueLocalSessionCookie(c, DEV_USER.id)` so the PWA login gate sees a valid session and skips /login. Then next(). devAuthBypass() itself is unchanged (still sets c.get('user')).
In apps/api/src/index.ts mount `app.use('/api/*', devSessionCookieMiddleware())` immediately AFTER `app.use('/api/*', devAuthBypass())` (it is a no-op outside bypass mode, so it is safe to mount unconditionally like devAuthBypass). Do not change the OIDC-side chain.
</action>
<verify>
<automated>pnpm --filter @familysync/api test tests/auth/devBypass.test.ts && pnpm --filter @familysync/api typecheck</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test tests/auth/devBypass.test.ts` exits 0 (existing bypass behavior intact)
- Source assertion: devSessionCookieMiddleware's FIRST conditional is `NODE_ENV === 'production'` returning a no-op (grep the guard order) — D-15
- Source assertion: `grep -c "issueLocalSessionCookie" apps/api/src/auth/devBypass.ts` >= 1
- Source assertion: `grep -c "devSessionCookieMiddleware" apps/api/src/index.ts` >= 1 mounted after devAuthBypass
</acceptance_criteria>
<done>Under DEV_AUTH_BYPASS, a real local-session cookie is issued for the dev user (production-guarded); existing bypass tests still pass.</done>
</task>
<task type="auto">
<name>Task 2: Break-glass reset-admin CLI (dev-only)</name>
<read_first>
- apps/pwa/e2e/global-setup.ts (mysql2/promise connection lines ~95-101; ON DUPLICATE KEY upsert lines ~125-129; NODE_ENV production guard lines ~34-44 — the "plain Node.js only" inline-hash constraint, Pitfall 11)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Break-Glass (script contract; tsx run via docker exec) + §Common Pitfalls 11
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/scripts/reset-admin.ts (DB connection, idempotent upsert, dev-only guard, --arg parsing, inline hashPassword)
- .dockerignore (confirm apps/api/scripts/ is excluded — added in 19-01; this CLI relies on that exclusion for D-15)
</read_first>
<files>apps/api/scripts/reset-admin.ts</files>
<action>
Create apps/api/scripts/reset-admin.ts — a standalone script runnable as `docker exec -it familysync-api node --import=tsx/esm scripts/reset-admin.ts --username admin --password '<new>'`. First statement: a dev-only guard that throws when `NODE_ENV === 'production'` (defense-in-depth; the script is also `.dockerignore`d per 19-01, IMG-02). Parse `--username` and `--password` from process.argv (no new deps). Connect via mysql2/promise using DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env (same defaults as global-setup.ts). Inline a `hashPassword` (copy the 5-line scrypt PHC implementation — cannot import compiled TS from a plain script, Pitfall 11). Upsert: find-or-insert a users row for the username with `is_admin=true, claimed=true`; then INSERT ... ON DUPLICATE KEY UPDATE the local_credentials row (user_id, username, password_hash). Print the resulting user id. Support a `--dry-run` flag that validates args + connection without writing (used by the validation command). Never log the password value.
</action>
<verify>
<automated>cd apps/api && node --import=tsx/esm scripts/reset-admin.ts --dry-run --username smoketest --password ignored; echo "exit=$?"</automated>
</verify>
<acceptance_criteria>
- The `--dry-run` invocation exits 0 and prints no password value (grep the output for the literal 'ignored' → absent)
- Source assertion: the FIRST executable statement guards `NODE_ENV === 'production'` (throws) — D-13/D-15
- Source assertion: `grep -c "scryptSync" apps/api/scripts/reset-admin.ts` >= 1 (inline hash, no TS import)
- Source assertion: `.dockerignore` excludes `apps/api/scripts/` (carried from 19-01) so this file never ships
</acceptance_criteria>
<done>reset-admin.ts creates/resets a local admin by username, refuses to run in production, is excluded from the prod image, and supports --dry-run.</done>
</task>
<task type="auto">
<name>Task 3: global-setup local_credentials seed + login.spec.ts + CI harness job env</name>
<read_first>
- apps/pwa/e2e/global-setup.ts (TRUNCATE block lines ~106-109; users seed lines ~125-129; member_credentials seed lines ~143-147; the inline-hash constraint Pitfall 11)
- apps/pwa/e2e/layout.spec.ts + apps/pwa/e2e/calendar.spec.ts (spec structure, device-profile usage, DEV_AUTH_BYPASS auth-reached precondition, serviceWorkers block)
- apps/pwa/playwright.config.ts (iphone/pixel/desktop projects; baseURL; webServer)
- .gitea/workflows/ci.yml (the harness job: DEV_AUTH_BYPASS env, dev-stack bring-up, MariaDB seed step)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Dev-Bypass Rework (global-setup change + CI env) + §PWA Routing Gate
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surfaces 1-10 (selectors/copy the spec asserts: id="login-username", "Sign in", error copy)
</read_first>
<files>apps/pwa/e2e/global-setup.ts, apps/pwa/e2e/login.spec.ts, .gitea/workflows/ci.yml</files>
<action>
In apps/pwa/e2e/global-setup.ts: add `local_credentials` to the TRUNCATE set; inline a `hashPasswordInline(password)` (scrypt PHC, Pitfall 11 — global-setup is plain Node.js); after the existing users seed, `INSERT INTO local_credentials (user_id, username, password_hash) VALUES (1, 'devuser', ?) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)` with `hashPasswordInline('devpass')`. The existing NODE_ENV/ DEV_AUTH_BYPASS guards already cover the new seed.
Create apps/pwa/e2e/login.spec.ts: in a context that clears the `local-session` cookie (so the bypass-issued cookie does not auto-skip the gate), assert: (1) navigating to the app redirects to /login and the brand slot + username/password form render (id="login-username", "Sign in"); (2) a wrong password shows the single "Incorrect username or password." message; (3) logging in as devuser/devpass navigates into the app. Follow the existing spec structure (device profiles, serviceWorkers block, no-SW-controller precondition). Keep the other specs (layout/calendar/lists) reaching the app via the bypass-issued cookie unchanged.
In .gitea/workflows/ci.yml harness job: add `LOCAL_SESSION_SECRET` to the job env (a fixed dev value >=32 chars, e.g. a documented `dev-secret-change-me-0000000000000000` length-padded) so devSessionCookieMiddleware and global-setup's hash work; if the CI step seeds tables directly, add the `local_credentials` seed there too (mirroring the member_credentials seed). The harness still runs with DEV_AUTH_BYPASS=true; LOCAL_SESSION_SECRET stays dev-only (never in the published image — IMG gates).
</action>
<verify>
<automated>pnpm --filter @familysync/pwa test:e2e --grep "login"</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/pwa test:e2e --grep "login"` passes (real-login-form spec green on at least the desktop/chromium profile)
- Source assertion: `grep -c "local_credentials" apps/pwa/e2e/global-setup.ts` >= 2 (TRUNCATE + INSERT)
- Source assertion: `grep -c "LOCAL_SESSION_SECRET" .gitea/workflows/ci.yml` >= 1 in the harness job
- Behavior: the existing layout/calendar/lists specs still reach the authed app (run `pnpm --filter @familysync/pwa test:e2e` — full harness green)
</acceptance_criteria>
<done>global-setup seeds + truncates local_credentials; a real-login e2e spec passes; existing harness specs still reach the app via the bypass cookie; CI harness job has LOCAL_SESSION_SECRET + the seed.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: Verify full harness + CI green and D-15 image boundary intact</name>
<action>Run the full harness locally + push for the CI run, then pause for human confirmation that all specs and the CI harness job are green and no dev artifact ships. Blocking checkpoint — no code change; the executor presents results and waits for approval.</action>
<what-built>The reworked dev-bypass (Option C) and CI harness. The full Playwright harness (both the new login spec and the unchanged layout/calendar/lists specs) is the automated proof. This checkpoint confirms the CI run is green end-to-end and that no dev artifact leaks into the published image — the D-15 boundary that the IMG-01/02/03 gates and the new .dockerignore exclusion enforce.</what-built>
<how-to-verify>
1. Run `pnpm --filter @familysync/pwa test:e2e` locally (host dev stack, DEV_AUTH_BYPASS=true, LOCAL_SESSION_SECRET set) → confirm all specs pass, including login.spec.ts and the unchanged layout/calendar/lists specs.
2. Push the branch and confirm the Gitea CI harness job is green (it brings up the dev stack with LOCAL_SESSION_SECRET + seeds local_credentials).
3. Confirm D-15: `.dockerignore` excludes `apps/api/scripts/` (reset-admin.ts) and `apps/pwa/e2e/` (the dev seed); the published image contains no local_credentials dev seed and no reset-admin script. Spot-check the publish.yml image-hygiene assertion still passes.
</how-to-verify>
<resume-signal>Type "approved" if the full harness + CI are green and no dev artifact ships, or describe the failure.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| dev env → published image | the D-15 boundary: dev seed, dev session secret, break-glass script must never ship |
| CI runner → dev stack | DEV_AUTH_BYPASS + LOCAL_SESSION_SECRET are dev-only CI values, never production secrets |
## STRIDE Threat Register (ASVS L1, block on high)
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-23 | Elevation of Privilege | dev local_credentials seed in prod image | mitigate | seed lives only in global-setup.ts (apps/pwa/e2e/ — .dockerignore'd) and the CI step; never in a migration or startup code (RESEARCH Pitfall 7) |
| T-19-24 | Elevation of Privilege | devSessionCookieMiddleware active in prod | mitigate | production hard-guard is the FIRST check; assertNotDevBypassInProduction (IMG-01) blocks DEV_AUTH_BYPASS in prod |
| T-19-25 | Tampering | break-glass script shipped in image | mitigate | apps/api/scripts/ excluded in .dockerignore (19-01, IMG-02); NODE_ENV=production guard in the script |
| T-19-26 | Information Disclosure | break-glass password in logs | mitigate | reset-admin never logs the password value; --dry-run validates without writing |
| T-19-SC | Tampering | npm installs | mitigate | zero new packages this plan |
</threat_model>
<verification>
- `pnpm --filter @familysync/pwa test:e2e` full harness green (login + existing specs)
- `pnpm --filter @familysync/api test` green (devBypass test intact)
- Human checkpoint confirms CI green + D-15 boundary intact (no dev artifact in the image)
</verification>
<success_criteria>
- AUTH-LOCAL-16: harness reaches the authed PWA via the bypass-issued local-session cookie; a login spec tests the real form; CI updated
- AUTH-LOCAL-11: break-glass CLI creates/resets a local admin, dev-only, image-excluded
- D-15: no dev seed, dev secret, or break-glass script ships in the published image
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 05)
- Middleware: `devSessionCookieMiddleware` (apps/api/src/auth/devBypass.ts) — Option C
- Script: `apps/api/scripts/reset-admin.ts` (break-glass CLI, dev-only, .dockerignore'd)
- e2e: `apps/pwa/e2e/login.spec.ts` (real-login-form spec)
- global-setup.ts: local_credentials dev seed (devuser/devpass) + TRUNCATE
- ci.yml: LOCAL_SESSION_SECRET in the harness job + local_credentials seed
</artifacts_produced>
<output>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-05-SUMMARY.md` when done
</output>