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

Merged
luckberg merged 78 commits from gsd/phase-19-local-auth-no-oidc-mode into main 2026-06-18 06:25:00 -04:00
Owner

Summary

Phase 19: Local Auth (No-OIDC Mode)
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.
Status: Verified ✓ · Threat-secured ✓

This phase generalizes the single bootstrap local user from Phase 12 into a full local-account model with login. It adds scrypt password hashing (PHC-encoded), stateless HS256 local-session cookies with a boot-time LOCAL_SESSION_SECRET guard, a rate-limited/lockout-protected login route with timing-safe username handling, admin member management and self-service password change, an OIDC-account linking flow guarded by a single-use signed nonce and conflict preflight, a standalone /login PWA surface with an auth-mode gate, and a dev-only break-glass admin-reset CLI. Local and OIDC auth can be live simultaneously; a valid local session is honored ahead of the OIDC guard.

Changes

Plan 19-01: Foundation (TDD)

scrypt hash/verify, local-session JWT helpers, LOCAL_SESSION_SECRET boot guard, local_credentials schema + 0003 migration, .dockerignore scripts exclusion (D-15).
Key files: apps/api/src/auth/localCredentials.ts, localSession.ts, lib/bootGuards.ts, db/schema.ts, db/migrations/0003_warm_deathstrike.sql, .dockerignore

Plan 19-02: Backend account management (TDD)

Admin create/reset member, self-change password, hasLocalCredential, linkOidcToUser helper + POST /api/me/link-oidc.
Key files: apps/api/src/auth/linkOidc.ts, routes/admin.ts, routes/me.ts

Plan 19-03: Login route + middleware (TDD)

localAuthMiddleware, GET /api/auth/mode, POST /api/auth/local/login (rate-limit + lockout, timing-safe), logout, /callback link branch, OIDC-guard skip-when-user-set, de-Authelia copy.
Key files: apps/api/src/auth/localAuthMiddleware.ts, routes/authMode.ts, routes/localAuth.ts, index.ts

Plan 19-04: PWA auth UI

Standalone /login route + brand slot, App.tsx auth-mode gate, AdminPage LOCAL ACCOUNTS section, SettingsSheet change-password + link-OIDC.
Key files: apps/pwa/src/routes/LoginPage.tsx, components/BrandSlot.tsx, routes/AdminPage.tsx, components/SettingsSheet.tsx, api/client.ts, App.tsx

Plan 19-05: Dev-bypass rework + break-glass + CI

Option C devSessionCookieMiddleware (real cookie under bypass), dev-only break-glass reset-admin.ts, e2e login spec, CI harness seed.
Key files: apps/api/scripts/reset-admin.ts, apps/api/src/auth/devBypass.ts, apps/pwa/e2e/login.spec.ts, apps/pwa/e2e/global-setup.ts, .gitea/workflows/ci.yml

Requirements Addressed

AUTH-LOCAL-01 … AUTH-LOCAL-20 (local_credentials schema, scrypt hash/verify, login route, localAuthMiddleware, auth-mode endpoint, logout, admin create-member, admin reset, self-change, OIDC-link, break-glass CLI, LoginPage, admin UI, settings UI, routing gate, dev-bypass/harness rework, hasLocalCredential, de-Authelia copy, rate-limit/lockout, auth unit tests), plus LOCAL_SESSION_SECRET env + boot assertion (D-05) and image-hygiene IMG-01/IMG-02.

Verification

  • Automated verification: passed — 21/21 must-haves (19-VERIFICATION.md). API 446/446, PWA 266/266, e2e desktop 42 passed / 3 skipped, typecheck clean.
  • UAT (19-UAT.md): Test 1 (login page visual + flow) pass live; Test 2 (admin reset-password) pass live; Test 3 pass live; Test 4 resolved-by-fix (admin reset-password URL mismatch, commit 53da4be); Test 5 → ship. 4 UI-polish findings routed to Phase 17.
  • Security: 19-SECURITY.md28/28 threats closed, threats_open: 0 (26 mitigate + 2 accept, ASVS L1, block-on-high). Every mitigation verified against code with file:line evidence.

Key Decisions

  • scrypt over argon2/bcrypt, PHC-style encoded (scrypt$N$r$p$salt$hash) — parameter upgrades without DB migration; hashing later made async off the event loop (WR-03).
  • Stateless HS256 session cookie (local-session, distinct from oidc-auth); fresh JWT issued every login (session-fixation defense); boot refuses to start when LOCAL_SESSION_SECRET is unset/<32 chars (exempt only under DEV_AUTH_BYPASS).
  • Login lockout keyed on username, not IP (CR-04) — deliberate deviation from the plan's "per-IP" wording: in this single Pangolin-tunnel topology all traffic shares one X-Forwarded-For hop, so IP-keying would let one actor lock out every member; username-keyed with TTL auto-expiry + admin-reset unlock is stronger here.
  • OIDC-link takeover guards: preflight iss+sub uniqueness conflict (409) before any write, uniq_oidc_identity DB backstop, single-use signed-nonce state (IN-04), and session-match + empty-iss/sub rejection (BL-03).
  • Local sessions coexist with OIDC: the OIDC guard is wrapped to skip when c.get('user') is already set, rather than a deploy-time mode switch.
  • Dev/break-glass isolation (D-15): apps/api/scripts/ excluded from the image; reset-admin.ts throws first if NODE_ENV=production; dev seed lives only in e2e/global-setup.ts and CI.

TDD Audit

This project's commits do not emit a gate_status Git trailer (0 of 70 non-merge commits carry one), so every row below normalizes to missing per the audit's strict trailer rule. The RED→GREEN discipline is nonetheless visible in the commit graph: each test: commit lands a failing test immediately before its feat: implementation.

Test commit Impl commit gate_status
7ece966 test(19-01): failing scrypt hash/verify tests 85b01b5 feat(19-01): implement hashPassword/verifyPassword missing
0d8f3fa test(19-01): failing localSession/boot-guard tests 7d61148 feat(19-01): localSession helpers + boot guard missing
b2c7902 test(19-02): failing admin create/reset tests 6232aa0 feat(19-02): admin create-member/reset missing
80b5906 test(19-02): failing self-change password tests c88f7d4 feat(19-02): self-change password missing
8ced2d0 test(19-02): failing linkOidcToUser tests efb80c8 feat(19-02): linkOidcToUser + /me/link-oidc missing
ac32bd4 test(19-03): failing middleware + auth-mode tests be7a0ae feat(19-03): localAuthMiddleware + /auth/mode missing
db66295 test(19-03): failing login + logout tests c437f40 feat(19-03): login (rate-limit/lockout) + logout missing
af0a70c test(19): UAT completion missing

Aggregate (informational): 0 skill, 0 fallback, 0 exempt — 8 test-commit rows missing the trailer; 20 fix: commits (CR/BL/WR/IN code-review remediations) and the remaining impl commits are not trailer-tagged either.

gate_status: skill=0, fallback=0, exempt=0, missing=70

## Summary **Phase 19: Local Auth (No-OIDC Mode)** **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. **Status:** Verified ✓ · Threat-secured ✓ This phase generalizes the single bootstrap local user from Phase 12 into a full local-account model with login. It adds scrypt password hashing (PHC-encoded), stateless HS256 local-session cookies with a boot-time `LOCAL_SESSION_SECRET` guard, a rate-limited/lockout-protected login route with timing-safe username handling, admin member management and self-service password change, an OIDC-account linking flow guarded by a single-use signed nonce and conflict preflight, a standalone `/login` PWA surface with an auth-mode gate, and a dev-only break-glass admin-reset CLI. Local and OIDC auth can be live simultaneously; a valid local session is honored ahead of the OIDC guard. ## Changes ### Plan 19-01: Foundation (TDD) scrypt hash/verify, local-session JWT helpers, `LOCAL_SESSION_SECRET` boot guard, `local_credentials` schema + 0003 migration, `.dockerignore` scripts exclusion (D-15). **Key files:** `apps/api/src/auth/localCredentials.ts`, `localSession.ts`, `lib/bootGuards.ts`, `db/schema.ts`, `db/migrations/0003_warm_deathstrike.sql`, `.dockerignore` ### Plan 19-02: Backend account management (TDD) Admin create/reset member, self-change password, `hasLocalCredential`, `linkOidcToUser` helper + `POST /api/me/link-oidc`. **Key files:** `apps/api/src/auth/linkOidc.ts`, `routes/admin.ts`, `routes/me.ts` ### Plan 19-03: Login route + middleware (TDD) `localAuthMiddleware`, `GET /api/auth/mode`, `POST /api/auth/local/login` (rate-limit + lockout, timing-safe), logout, `/callback` link branch, OIDC-guard skip-when-user-set, de-Authelia copy. **Key files:** `apps/api/src/auth/localAuthMiddleware.ts`, `routes/authMode.ts`, `routes/localAuth.ts`, `index.ts` ### Plan 19-04: PWA auth UI Standalone `/login` route + brand slot, `App.tsx` auth-mode gate, AdminPage LOCAL ACCOUNTS section, SettingsSheet change-password + link-OIDC. **Key files:** `apps/pwa/src/routes/LoginPage.tsx`, `components/BrandSlot.tsx`, `routes/AdminPage.tsx`, `components/SettingsSheet.tsx`, `api/client.ts`, `App.tsx` ### Plan 19-05: Dev-bypass rework + break-glass + CI Option C `devSessionCookieMiddleware` (real cookie under bypass), dev-only break-glass `reset-admin.ts`, e2e login spec, CI harness seed. **Key files:** `apps/api/scripts/reset-admin.ts`, `apps/api/src/auth/devBypass.ts`, `apps/pwa/e2e/login.spec.ts`, `apps/pwa/e2e/global-setup.ts`, `.gitea/workflows/ci.yml` ## Requirements Addressed AUTH-LOCAL-01 … AUTH-LOCAL-20 (local_credentials schema, scrypt hash/verify, login route, localAuthMiddleware, auth-mode endpoint, logout, admin create-member, admin reset, self-change, OIDC-link, break-glass CLI, LoginPage, admin UI, settings UI, routing gate, dev-bypass/harness rework, hasLocalCredential, de-Authelia copy, rate-limit/lockout, auth unit tests), plus `LOCAL_SESSION_SECRET` env + boot assertion (D-05) and image-hygiene IMG-01/IMG-02. ## Verification - [x] Automated verification: **passed** — 21/21 must-haves (`19-VERIFICATION.md`). API 446/446, PWA 266/266, e2e desktop 42 passed / 3 skipped, typecheck clean. - [x] UAT (`19-UAT.md`): Test 1 (login page visual + flow) pass live; Test 2 (admin reset-password) pass live; Test 3 pass live; Test 4 resolved-by-fix (admin reset-password URL mismatch, commit `53da4be`); Test 5 → ship. 4 UI-polish findings routed to Phase 17. - [x] Security: `19-SECURITY.md` — **28/28 threats closed, threats_open: 0** (26 mitigate + 2 accept, ASVS L1, block-on-high). Every mitigation verified against code with file:line evidence. ## Key Decisions - **scrypt over argon2/bcrypt**, PHC-style encoded (`scrypt$N$r$p$salt$hash`) — parameter upgrades without DB migration; hashing later made async off the event loop (WR-03). - **Stateless HS256 session cookie** (`local-session`, distinct from `oidc-auth`); fresh JWT issued every login (session-fixation defense); boot refuses to start when `LOCAL_SESSION_SECRET` is unset/<32 chars (exempt only under `DEV_AUTH_BYPASS`). - **Login lockout keyed on username, not IP** (CR-04) — deliberate deviation from the plan's "per-IP" wording: in this single Pangolin-tunnel topology all traffic shares one X-Forwarded-For hop, so IP-keying would let one actor lock out every member; username-keyed with TTL auto-expiry + admin-reset unlock is stronger here. - **OIDC-link takeover guards**: preflight iss+sub uniqueness conflict (409) before any write, `uniq_oidc_identity` DB backstop, single-use signed-nonce state (IN-04), and session-match + empty-iss/sub rejection (BL-03). - **Local sessions coexist with OIDC**: the OIDC guard is wrapped to skip when `c.get('user')` is already set, rather than a deploy-time mode switch. - **Dev/break-glass isolation (D-15)**: `apps/api/scripts/` excluded from the image; `reset-admin.ts` throws first if `NODE_ENV=production`; dev seed lives only in `e2e/global-setup.ts` and CI. ## TDD Audit This project's commits do not emit a `gate_status` Git trailer (0 of 70 non-merge commits carry one), so every row below normalizes to `missing` per the audit's strict trailer rule. The RED→GREEN discipline is nonetheless visible in the commit graph: each `test:` commit lands a failing test immediately before its `feat:` implementation. | Test commit | Impl commit | gate_status | |---|---|---| | `7ece966` test(19-01): failing scrypt hash/verify tests | `85b01b5` feat(19-01): implement hashPassword/verifyPassword | missing | | `0d8f3fa` test(19-01): failing localSession/boot-guard tests | `7d61148` feat(19-01): localSession helpers + boot guard | missing | | `b2c7902` test(19-02): failing admin create/reset tests | `6232aa0` feat(19-02): admin create-member/reset | missing | | `80b5906` test(19-02): failing self-change password tests | `c88f7d4` feat(19-02): self-change password | missing | | `8ced2d0` test(19-02): failing linkOidcToUser tests | `efb80c8` feat(19-02): linkOidcToUser + /me/link-oidc | missing | | `ac32bd4` test(19-03): failing middleware + auth-mode tests | `be7a0ae` feat(19-03): localAuthMiddleware + /auth/mode | missing | | `db66295` test(19-03): failing login + logout tests | `c437f40` feat(19-03): login (rate-limit/lockout) + logout | missing | | `af0a70c` test(19): UAT completion | — | missing | Aggregate (informational): 0 skill, 0 fallback, 0 exempt — 8 test-commit rows missing the trailer; 20 `fix:` commits (CR/BL/WR/IN code-review remediations) and the remaining impl commits are not trailer-tagged either. gate_status: skill=0, fallback=0, exempt=0, missing=70
luckberg added 76 commits 2026-06-17 22:49:28 -04:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All 6 design dimensions PASS plus Phase 17 brand-slot readiness contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers password hashing (node:crypto scrypt), JWT session cookies
(hono/utils/jwt), middleware ordering, local_credentials schema,
OIDC-link flow, dev-bypass rework (option C), and break-glass CLI.
Resolves all five open questions from CONTEXT.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- node:crypto scrypt (N=16384, r=8, p=1, 32-byte output) — zero new dependencies (D-08)
- 16-byte random salt per hash; PHC-encoded format: scrypt$N$r$p$salt_b64url$hash_b64url
- timingSafeEqual for constant-time comparison (prevents timing oracle attacks, T-19-01)
- verifyPassword returns false on any error (never throws); passwords never logged
- All 5 unit tests pass (round-trip, wrong-pw, unique-salt, malformed-hash, PHC-shape)
- localSession.ts: issueLocalSessionCookie/verifyLocalSessionCookie/clearLocalSessionCookie
  - Jwt namespace import from hono/utils/jwt (Pitfall 8 — not named sign/verify)
  - Cookie name: 'local-session' (distinct from 'oidc-auth', Pitfall 4)
  - httpOnly, sameSite=Lax, secure in production; try/catch on Jwt.verify (Pitfall 9)
  - verifyLocalSessionCookie returns null (never throws) on any error
- bootGuards.ts: assertLocalSessionSecretSet — exit(1) if secret missing/<32 chars
  - Exempt when DEV_AUTH_BYPASS=true (bypass doesn't issue local-session cookies)
- index.ts: wire assertLocalSessionSecretSet() after assertNotDevBypassInProduction()
- All 5 unit tests pass; typecheck exits 0
- schema.ts: export localCredentials = mysqlTable('local_credentials', {...})
  - user_id FK->users(cascade), username, password_hash, createdAt, updatedAt
  - UNIQUE(user_id), UNIQUE(username), INDEX(user_id)
- 0003_warm_deathstrike.sql: purely additive CREATE TABLE (no ALTER/DROP/TRUNCATE on existing tables)
  - Applied to dev DB: pnpm --filter @familysync/api db:migrate exits 0
- test/setup.ts: add localCredentials to afterEach delete cleanup (FK-safe ordering)
- generate-secrets.mjs: emit LOCAL_SESSION_SECRET (base64 32-byte, >=32 chars, D-05)
- .dockerignore: add apps/api/scripts/ exclusion (D-15/IMG-02) — entire break-glass dir excluded
RED phase for Task 1:
- Test 1: POST /api/admin/members creates users row + local_credentials, hash verifies
- Test 2: duplicate username returns 409, transaction rolled back (no orphaned user row)
- Test 3: admin reset password updates hash, old password no longer verifies
- Test 4: non-admin gets 403 on both POST /members and POST /members/:id/password
- Test 5: GET /api/admin/members returns hasLocalCredential:true/false per local cred existence
- POST /api/admin/members: atomic tx (users + local_credentials), 409 on dup username
- POST /api/admin/members/:id/password: admin reset (no current-pwd required), 404 if no local cred
- GET /api/admin/members: LEFT JOIN local_credentials, hasLocalCredential in each member row
- noEchoHook on both POST routes (T-19-06); requireAdmin via router.use('*') remains first statement
- Dup-entry detection via error message string match (Drizzle wraps mysql2 ER_DUP_ENTRY)
RED phase for Task 2:
- Test 1: POST /api/me/password correct current → 200, new hash verifies newPassword
- Test 2: wrong currentPassword → 401, UPDATE not called (hash unchanged)
- Test 3: no local_credentials row → 404
- Test 4 (GET /api/me): hasLocalCredential:true/false based on local_credentials existence
- POST /api/me/password: verifyPassword(current) gate before hashPassword(new) update
- 401 on wrong current password, 404 if no local_credentials row, 200 on success
- meNoEchoHook on /password route (T-19-06, never echo submitted password)
- resolveAdminAndSetupStatus extended with hasLocalCredential (AUTH-LOCAL-17)
- GET /api/me response includes hasLocalCredential alongside isAdmin/needsProviderSetup
RED phase for Task 3:
- Test 1: linkOidcToUser updates users.oidc_iss/sub and deletes local_credentials
- Test 2: linkOidcToUser throws OidcLinkConflictError on conflict, no local_cred deletion
- Test 3: POST /api/me/link-oidc returns initiation payload (state / authorizationUrl)
apps/api/src/auth/linkOidc.ts (new):
- OidcLinkConflictError: thrown when iss+sub already belongs to a different user
- linkOidcToUser(userId, iss, sub): preflight SELECT for conflict, then db.transaction
  (UPDATE users SET oidc_iss/sub/claimed + DELETE local_credentials); atomic, no email (D-10)
- 88 lines; no email in source (D-10/T-19-08 assertion passes)

apps/api/src/routes/me.ts:
- POST /api/me/link-oidc: resolveUserId (401 if null), sign state JWT
  ({ linkUserId, nonce, iat, exp } HS256 with LOCAL_SESSION_SECRET, 10-min window)
- Returns { signedState, authorizationUrl } — 19-03 /callback reads linkUserId from state
- authorizationUrl constructed from OIDC env vars when configured, null otherwise
- T-19-09: per-request nonce in state prevents CSRF/replay
- RED: 8 tests failing (modules not yet created)
- localAuthMiddleware: 4 tests for cookie→user shape, no-cookie passthrough, missing user row, devAuthBypass coexistence
- authMode: 3 tests for mode response with no oidc, env oidc, app_config oidc
- localAuthMiddleware: cookie→c.set('user') with Pitfall-1 guard (no-set on no-cookie path)
- authMode: GET /api/auth/mode pre-auth endpoint (localEnabled:true, oidcEnabled from env+config)
- localAuth: POST /api/auth/local/login (rate-limit + timing-safe), logout routes
- index.ts: mount authModeRouter + localAuthRouter pre-auth; localAuthMiddleware after devAuthBypass; OIDC guard wrapped skip-when-user-set
- RED: 8 tests for login success, wrong-password 401, unknown-username 401 (no enumeration), rate-limit 429, lockout 423, logout cookie clear, no-echo 400
- Rate-limit: per-IP in-memory Map; 5 failures → 429, 10 → 423 (lockedOut)
- Counter increments even on 429 so brute-force accumulates toward lockout
- Timing-safe: DUMMY_HASH ensures verifyPassword runs for unknown usernames (T-19-12)
- noEchoHook: Zod errors never echo submitted values (T-19-14)
- Same 401 body for wrong-password and unknown-username (no enumeration)
- POST+GET /local/logout clear the local-session cookie
- /callback: reads signed state, extracts linkUserId, calls linkOidcToUser after OIDC session set; OidcLinkConflictError redirects to /?error=oidc-link-conflict
- OIDC guard: oidcAuthMiddleware() factory called once at construction, handler invoked per-request inside skip-when-user-set wrapper (D-03)
- middleware.ts: de-Authelia-ize comments — generic OIDC identity provider language (D-06, AUTH-LOCAL-18)
- localAuthMiddleware.ts: cast to typeof DEV_USER for ContextVariableMap type compatibility
- All 446 tests pass; typecheck clean
- Add LoginError class (4 codes: invalid/rate-limit/locked/server) mirroring SessionExpiredError shape
- Add hasLocalCredential to MeUser interface
- Add fetchAuthMode, fetchLocalLogin, fetchLocalLogout (pre-auth endpoints)
- Add fetchChangePassword, fetchCreateMember, fetchAdminResetPassword, fetchLinkOidc
- Create BrandSlot component with Phase-17-ready placeholder (48px circle, FS initials, h1, tagline)
- Add --brand-logo-* CSS custom properties to tokens.css (Phase 17 seam)
- Add devSessionCookieMiddleware() to devBypass.ts (production hard-guard FIRST)
- Issues local-session JWT cookie for DEV_USER when no cookie present under bypass
- Pure no-op when NODE_ENV=production, DEV_AUTH_BYPASS!=true, or secret not set
- Mount devSessionCookieMiddleware() after devAuthBypass() in index.ts
- Existing devBypass tests: 3/3 pass; typecheck: exit 0
- Create apps/api/scripts/reset-admin.ts (AUTH-LOCAL-11, D-13, D-15)
- Dev-only guard as FIRST executable statement (NODE_ENV=production throws)
- Inline scrypt PHC hashPassword (cannot import compiled TS, Pitfall 11)
- Parse --username/--password from argv; never log password value (T-19-26)
- --dry-run validates args + DB connection without writing
- Upserts users (is_admin=true) + local_credentials rows idempotently
- Script excluded from prod image via .dockerignore apps/api/scripts/ (IMG-02)
- dry-run: exit=0, no password in output verified
- Create LoginPage with BrandSlot, username/password form, show/hide toggle
- Four error states: invalid credentials, rate-limit, locked, server (all per UI-SPEC)
- OIDC method divider + 'Login with OIDC' button rendered only when oidcEnabled
- Accessibility: role=main, h1 in BrandSlot, h2 Sign in, aria-live error banner, 44px targets
- Focus management: username autofocus, Enter navigates username→password→submit
- App.tsx: add authModeQuery (queryKey ['authMode'], staleTime 60s)
- App.tsx: add /login standalone route (sibling of /setup, no AppNav/BottomTabBar)
- App.tsx: login gate after setup gate — meQuery error + localEnabled → Navigate /login
- App.tsx: OidcRedirect helper for OIDC-only mode (meQuery error + !localEnabled + oidcEnabled)
- Fix App.test.tsx to include fetchAuthMode mock and hasLocalCredential in user fixture
- global-setup.ts: TRUNCATE local_credentials + seed devuser/devpass (PHC scrypt inline)
- Create login.spec.ts: real-login-form e2e (gate redirect, wrong-password error, correct login)
- ci.yml: add LOCAL_SESSION_SECRET dev value + local_credentials seed step in harness job
- Fix all test mocks: add devSessionCookieMiddleware no-op to vi.mock(devBypass.js) blocks
  in admin/setup/push/lists/localAuth/authMode/requireAdmin tests (Rule 1 - Bug: missing export)
- Full API suite: 446/446 tests pass; pnpm typecheck: exit 0
- Add hasLocalCredential to AdminMember type (mirrors API extension from plan 19-02)
- Add createMember mutation + Surface 11A inline add-member form in AdminPage
- Add Surface 11B Reset-password button in MemberRow (hasLocalCredential gate)
- Add ResetPasswordSheet component (bottom-sheet, role=dialog, focus-managed, Escape closes)
- Add Surface 12 Change-password row in SettingsSheet (hasLocalCredential gate)
- Add Surface 13 Link-OIDC identity row in SettingsSheet (hasLocalCredential + oidcEnabled gate)
- Add ChangePasswordSheet component (current/new/confirm fields, change-password mutation)
- Add LinkOidcSheet component (confirmation dialog; uses generic OIDC copy per D-06, no provider branding)
- Fix: update InstructionSheet.test.tsx to wrap with QueryClientProvider (Rule 1 - now uses useQuery)
- Fix: remove stale eslint-disable in App.test.tsx (lint --max-warnings 0 would fail)
- All 263 tests pass; typecheck clean; lint clean
Two issues surfaced only when plans 19-04 (login UI) and 19-05 (Option C
bypass + login.spec) were merged together and run against the real stack —
neither executor could catch them in isolation:

1. LOCAL_SESSION_SECRET was added to the CI harness (ci.yml) but not to the
   local dev stack (docker-compose.dev.yml). Without it the real-login success
   path (POST /api/auth/local/login) 503s when signing the session cookie, so
   the e2e round-trip failed. Add the same fixed dev-only value to the dev
   compose override (dev-only target; never a production secret).

2. login.spec test 1 assumed clearing the local-session cookie yields a
   logged-out state, but under the always-on DEV_AUTH_BYPASS devAuthBypass()
   injects DEV_USER into /api/me regardless of any cookie — a logged-out state
   is architecturally unreachable in this bypass-only harness. Reframe the test
   to drive /login directly (validating the real-browser render of all brand +
   form surfaces) and move the unauthenticated root->/login redirect-gate
   coverage to a unit test in App.test.tsx where meQuery.isError is controllable.

Result: API 446/446, PWA 265/265 (+2 gate tests), e2e desktop 42 passed / 3
skipped (all login specs green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
VERIFICATION.md found a cross-layer URL mismatch: fetchAdminResetPassword
POSTed to /api/admin/members/:id/reset-password but the API registers the
route as /api/admin/members/:id/password (admin.ts), so the Admin reset sheet
404'd on every submit. Confirmed live: old path -> 404, correct path -> 400
(route reached). Unit tests missed it because API tests hit the real path
directly and PWA tests mock the fetcher — no test crossed both layers.

Fix the client URL and add a URL-contract regression test that pins the exact
path (asserts fetch is called with /api/admin/members/:id/password).

PWA 266/266 (+1), typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(phase-19): add security threat verification (28/28 closed, threats_open: 0)
CI / changes (pull_request) Successful in 10s
CI / fast-checks (pull_request) Failing after 2m17s
CI / api (pull_request) Successful in 2m51s
CI / security (pull_request) Failing after 12s
CI / harness (pull_request) Successful in 5m9s
CI / gate (pull_request) Failing after 2s
abf7be782a
luckberg added 2 commits 2026-06-17 23:05:20 -04:00
fix(19): satisfy CI fast-checks + secret scan
CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
b6490feff4
Lint (eslint --max-warnings 0):
- index.ts: disable no-unsafe-argument on the type-only Context mismatch when
  delegating to the OIDC handler inside the local-session skip wrapper
- localAuth.ts: handleLogout is sync (no await) — drop async (require-await)
- devBypass.ts: disable detect-possible-timing-attacks on the public well-known
  dev-placeholder string compare (not a secret comparison)
- remove dead code / unused bindings flagged by no-unused-vars: makeTestApp
  (localSession.test), makeUnauthContext + BrowserContext import (login.spec),
  unused memberId (admin.test), unused txSelectCount counter (me.test)
- localAuthMiddleware.test / me.test: fix unused + reflow-detached
  eslint-disable directives

Format: prettier --write across the 20 Phase-19 files that were never formatted.

Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char
TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key
regex hit on "credential atomically, 409-equivalent"). Neither is a real secret.

Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks),
PWA 266/266, API 452/452.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
luckberg merged commit 18d3ee6a4f into main 2026-06-18 06:25:00 -04:00
luckberg deleted branch gsd/phase-19-local-auth-no-oidc-mode 2026-06-18 06:25:00 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: luckberg/familysync#23