---
phase: 19-local-auth-no-oidc-mode
verified: 2026-06-17T17:51:00Z
status: passed
score: 21/21 must-haves verified
behavior_unverified: 0
overrides_applied: 0
reverified: 2026-06-17T18:20:00Z
reverification_note: "Orchestrator closed the single BLOCKER gap post-verification (commit 53da4be). Both gaps below shared one root cause — a client/server URL mismatch — now fixed and confirmed live (old path /members/:id/reset-password -> 404, correct path /members/:id/password -> 400 route-reached) plus a URL-contract regression test (client.test.ts). PWA 266/266, API 446/446, typecheck clean. Remaining items are genuine human/live-stack UAT (see human_verification) and do not block automated goal achievement."
gaps:
  - truth: "An admin can reset any local member's password without knowing the current one"
    status: resolved
    reason: "FIXED (commit 53da4be): client.ts fetchAdminResetPassword now POSTs /api/admin/members/:id/password, matching the API route. Confirmed live (correct path returns 400 route-reached, not 404) + URL-contract regression test added."
    artifacts:
      - path: "apps/pwa/src/api/client.ts"
        issue: "RESOLVED — URL corrected to `/api/admin/members/${memberId}/password`"
  - truth: "An admin sees a LOCAL ACCOUNTS section to add a member and a per-member Reset-password action"
    status: resolved
    reason: "FIXED (commit 53da4be): same root-cause URL fix; the Reset-password sheet now targets the correct route. UI rendering was already verified."
    artifacts:
      - path: "apps/pwa/src/routes/AdminPage.tsx"
        issue: "RESOLVED — fetchAdminResetPassword now calls the correct URL"
deferred: []
behavior_unverified_items: []
human_verification:
  - test: "Drive /login with playwright-cli against the real non-bypass stack: verify brand slot + form render, wrong-password single error message, correct creds navigate into app, OIDC button gate when oidcEnabled"
    expected: "All 4 error states render correctly per UI-SPEC; no Authelia branding visible; OIDC button only shown when oidcEnabled"
    why_human: "LoginPage renders in a real browser; DEV_AUTH_BYPASS prevents real login-gate testing from playwright-cli under dev harness"
  - test: "After fixing the fetchAdminResetPassword URL: drive the admin Reset-password sheet and confirm admin can reset a member password without knowing current"
    expected: "POST to /api/admin/members/:id/password returns 200; member can then log in with the new password"
    why_human: "Depends on fixing the gap first; then requires end-to-end stack with admin session"
  - test: "Drive the SettingsSheet Change-password sheet with a local user: verify current-password verification and successful update"
    expected: "Wrong current password shows 'Current password is incorrect.'; correct current allows update; subsequent login with new password succeeds"
    why_human: "Requires end-to-end stack with a local user session"
  - test: "Verify rate-limit/lockout flakiness: run localAuth.test.ts Test 5 (10 failures -> 423) 10 times and confirm stable pass rate"
    expected: "Test 5 passes all 10 runs (orchestrator noted one intermittent failure across 3 runs)"
    why_human: "Timing-dependent in-memory test; could be environment-dependent flakiness"
---

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

**Phase Goal:** Let an operator run FamilySync entirely on local DB users with no OIDC — username/password accounts and a local login flow that coexists with the Authelia OIDC path — and optionally wire OIDC in later by claiming/linking an existing local user to an OIDC identity. Removes the hard dependency on a deployed Authelia for small/solo self-hosters.

**Verified:** 2026-06-17T17:51:00Z
**Status:** gaps_found
**Re-verification:** No — initial verification

## Goal Achievement

### Observable Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | A password can be hashed and the same password verifies true; a wrong password verifies false | VERIFIED | `localCredentials.ts` exports `hashPassword`/`verifyPassword`; 5/5 tests pass in `tests/auth/localCredentials.test.ts` |
| 2 | `verifyPassword` returns false (never throws) on a malformed stored hash | VERIFIED | Test 4 in `localCredentials.test.ts` confirms try/catch wraps all crypto errors |
| 3 | A signed local-session JWT round-trips: issue then verify returns the same userId | VERIFIED | Test 1 in `localSession.test.ts` passes; `Jwt.sign`/`Jwt.verify` HS256 with `LOCAL_SESSION_SECRET` |
| 4 | An expired or tampered local-session token verifies to null, never throws | VERIFIED | Test 3 in `localSession.test.ts` passes; `verifyLocalSessionCookie` wraps `Jwt.verify` in try/catch |
| 5 | The API process refuses to boot (exit 1) when `LOCAL_SESSION_SECRET` is missing/short and dev-bypass is off | VERIFIED | `assertLocalSessionSecretSet()` in `bootGuards.ts` (line 53); called from `index.ts` line 230; test confirms exemption for DEV_AUTH_BYPASS=true |
| 6 | The `local_credentials` table exists after migration with unique user_id and unique username | VERIFIED | `0003_warm_deathstrike.sql` has `UNIQUE(user_id)` + `UNIQUE(username)` + FK; migration applied; schema exports `localCredentials` |
| 7 | An admin can create a local member (users row + local_credentials row with a hashed initial password) in one transaction | VERIFIED | `POST /api/admin/members` in `admin.ts` uses `db.transaction`; Test 1 + Test 2 in `admin.test.ts` pass (transaction rollback on dup username) |
| 8 | Creating a member with an already-used username returns 409, not a 500 or a partial insert | VERIFIED | ER_DUP_ENTRY detection in `admin.ts` returns 409; Test 2 confirms no users row created |
| 9 | An admin can reset any local member's password without knowing the current one | FAILED | API route `POST /api/admin/members/:id/password` is correctly implemented and tested, but **`client.ts` `fetchAdminResetPassword` calls `/api/admin/members/${memberId}/reset-password`** — path mismatch causes 404 in production |
| 10 | A user can change their own password only after verifying their current password | VERIFIED | `POST /api/me/password` calls `verifyPassword(current)` before `hashPassword(new)`; Test 2 confirms wrong current → 401, hash unchanged |
| 11 | `GET /api/me` returns `hasLocalCredential` so the PWA knows whether to show Change-password / Link-OIDC | VERIFIED | `resolveAdminAndSetupStatus` selects from `local_credentials` and returns `hasLocalCredential: Boolean(localCred)`; Tests 4 + 5 in `me.test.ts` pass |
| 12 | Linking an OIDC identity binds iss+sub to the current user and deletes their local_credentials row; a conflicting iss+sub is rejected (409) and no local row is deleted | VERIFIED | `linkOidcToUser` in `linkOidc.ts`: preflight SELECT + db.transaction(UPDATE users + DELETE local_credentials); Tests 1 + 2 in `me.test.ts` pass |
| 13 | A valid username+password POST to `/api/auth/local/login` returns 200 and sets a local-session cookie | VERIFIED | `localAuth.ts` Test 1 in `localAuth.test.ts` passes (200 + Set-Cookie) |
| 14 | A wrong password and an unknown username both return the same 401 with the same body (no enumeration, no field discrimination) | VERIFIED | `DUMMY_HASH` timing defense; Test 3 in `localAuth.test.ts` confirms identical 401 body; test passes |
| 15 | After 5 failed attempts the endpoint returns 429; after 10 it returns 423 until an admin reset | VERIFIED | `loginAttempts` Map; counter increments on 429 path too; Tests 4 + 5 in `localAuth.test.ts` pass |
| 16 | A request carrying a valid local-session cookie resolves `c.get('user')` and is NOT 302-redirected to OIDC | VERIFIED | `localAuthMiddleware` sets `c.get('user')`; OIDC guard wrapped with `if (c.get('user')) { next(); return; }` in `index.ts` line 157; middleware Tests 1 + 4 pass |
| 17 | `GET /api/auth/mode` is reachable pre-auth and returns `{ localEnabled:true, oidcEnabled }` reflecting app_config/env | VERIFIED | `authModeRouter` mounted before devAuthBypass (line 114 of index.ts); Tests 5/6/6b in `authMode.test.ts` pass |
| 18 | Logout clears the local-session cookie | VERIFIED | `clearLocalSessionCookie` called on `POST /api/auth/local/logout` and `GET` alias; Test 6 + 6b pass |
| 19 | An unauthenticated user with no valid session lands on `/login` (when localEnabled) and sees the brand slot + username/password form | VERIFIED | App.tsx `authModeQuery` gate; `App.test.tsx` Phase 19 describe block passes; `LoginPage.tsx` has id="login-username", "Sign in" heading; `BrandSlot` component renders |
| 20 | No user-facing string or config comment says "Authelia" | VERIFIED | All modified API and PWA source files return 0 case-insensitive matches for "authelia" in user-facing code (one occurrence in SettingsSheet.tsx is a comment prohibiting the word, not user-facing) |
| 21 | An admin sees a LOCAL ACCOUNTS section to add a member and a per-member Reset-password action | PARTIAL | LOCAL ACCOUNTS section exists in `AdminPage.tsx` (line 691); add-member form is wired to `fetchCreateMember` (correct URL `/api/admin/members`); Reset-password button exists gated on `hasLocalCredential`, but `fetchAdminResetPassword` calls wrong URL — see gap for truth #9 |

**Score:** 19/21 truths verified (1 FAILED, 1 PARTIAL)

### Deferred Items

None.

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|---------|--------|---------|
| `apps/api/src/auth/localCredentials.ts` | hashPassword + verifyPassword (scrypt, PHC-encoded) | VERIFIED | 91 lines, node:crypto only, timingSafeEqual |
| `apps/api/src/auth/localSession.ts` | issueLocalSessionCookie + verifyLocalSessionCookie + clearLocalSessionCookie | VERIFIED | 107 lines, Jwt namespace import, local-session cookie |
| `apps/api/src/db/migrations/0003_warm_deathstrike.sql` | CREATE TABLE local_credentials | VERIFIED | Purely additive; UNIQUE(user_id), UNIQUE(username), FK→users cascade |
| `apps/api/src/db/schema.ts` | localCredentials Drizzle table export | VERIFIED | Lines 320-349 |
| `apps/api/src/lib/bootGuards.ts` | assertLocalSessionSecretSet | VERIFIED | Line 53; exits(1) when secret missing/<32 chars outside bypass |
| `apps/api/src/auth/localAuthMiddleware.ts` | localAuthMiddleware | VERIFIED | 99 lines; Pitfall-1 guard; c.set('user') only when cookie valid |
| `apps/api/src/routes/authMode.ts` | GET /api/auth/mode pre-auth | VERIFIED | 50 lines; localEnabled always true; oidcEnabled from env/app_config |
| `apps/api/src/routes/localAuth.ts` | POST /login (rate-limited) + logout | VERIFIED | 165 lines; DUMMY_HASH; noEchoHook; loginAttempts Map |
| `apps/api/src/auth/linkOidc.ts` | linkOidcToUser + OidcLinkConflictError | VERIFIED | 89 lines; preflight SELECT; db.transaction; no email field |
| `apps/pwa/src/routes/LoginPage.tsx` | Standalone /login page (Surfaces 1-10) | VERIFIED | 465 lines; BrandSlot; 4 error states; show/hide; OIDC gate |
| `apps/pwa/src/components/BrandSlot.tsx` | Phase-17 brand seam component | VERIFIED | 84 lines; CSS custom properties; no img; no Authelia |
| `apps/api/src/routes/admin.ts` | POST /members + POST /members/:id/password + hasLocalCredential in GET /members | VERIFIED (API); PARTIAL (client wiring) | Routes exist and are tested; client URL mismatch for reset |
| `apps/api/src/routes/me.ts` | POST /password + POST /link-oidc + hasLocalCredential in GET / | VERIFIED | All three additions present and tested |
| `apps/api/scripts/reset-admin.ts` | Break-glass CLI, dev-only | VERIFIED | 149 lines; NODE_ENV=production guard FIRST; inline scrypt; --dry-run exits 0 |
| `apps/pwa/e2e/login.spec.ts` | Real-login-form e2e | VERIFIED | 148 lines; clearCookies; 3 tests covering brand/form/error/login |

### Key Link Verification

| From | To | Via | Status | Details |
|------|-----|-----|--------|---------|
| `localSession.ts` | `process.env.LOCAL_SESSION_SECRET` | Jwt.sign/Jwt.verify HS256 | WIRED | Line 41 + 83; throws if unset |
| `index.ts` | `bootGuards.ts` | `assertLocalSessionSecretSet()` at boot | WIRED | Line 230 of index.ts |
| `schema.ts` | `0003_warm_deathstrike.sql` | drizzle-kit generate emits SQL | WIRED | Migration applied and confirmed additive |
| `localAuthMiddleware.ts` | `localSession.ts` | `verifyLocalSessionCookie → c.set('user')` | WIRED | Line 55 of middleware |
| `index.ts` | `localAuthMiddleware.ts` | `app.use('/api/*', localAuthMiddleware())` after devAuthBypass, before OIDC | WIRED | Line 132 of index.ts |
| `index.ts` | `linkOidc.ts` | `/callback` reads link-state and calls `linkOidcToUser` | WIRED | Lines 66-86 of index.ts |
| `localAuth.ts` | `localSession.ts` | `issueLocalSessionCookie` on success / `clearLocalSessionCookie` on logout | WIRED | Lines 145, 159 |
| `admin.ts` | `localCredentials.ts` | `hashPassword` on create-member and reset-password | WIRED | Lines 165, 237 |
| `me.ts` | `localCredentials.ts` | `verifyPassword(current)` then `hashPassword(new)` | WIRED | Lines 258, 264 |
| `client.ts` (PWA) | `/api/admin/members/:id/password` (API) | `fetchAdminResetPassword` for admin reset | NOT_WIRED | `client.ts` line 204 calls `/api/admin/members/${memberId}/reset-password` but API path is `/api/admin/members/:id/password` |
| `devBypass.ts` | `localSession.ts` | `devSessionCookieMiddleware` issues real local-session cookie | WIRED | Line 127 of devBypass.ts |
| `global-setup.ts` | `local_credentials table` | INSERT devuser/devpass ON DUPLICATE KEY UPDATE | WIRED | Lines 166-169 of global-setup.ts |
| `.gitea/workflows/ci.yml` | `LOCAL_SESSION_SECRET` | harness job env | WIRED | Line 349; value `dev-secret-change-me-0000000000000000` |

### Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|--------------|--------|-------------------|--------|
| `LoginPage.tsx` | `loginError` state | `fetchLocalLogin` -> API 401/429/423 | Yes — API returns real status codes | FLOWING |
| `SettingsSheet.tsx` | `hasLocalCredential` | `useQuery(['me'])` -> `GET /api/me` -> DB SELECT local_credentials | Yes — real DB query | FLOWING |
| `AdminPage.tsx` | `member.hasLocalCredential` | `membersQuery` -> `GET /api/admin/members` -> LEFT JOIN local_credentials | Yes — real DB query | FLOWING |
| `App.tsx` | `authModeQuery.data` | `fetchAuthMode` -> `GET /api/auth/mode` -> env/app_config | Yes — real env/DB check | FLOWING |

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| hashPassword/verifyPassword round-trip | `pnpm --filter @familysync/api test tests/auth/localCredentials.test.ts` | 5/5 pass | PASS |
| JWT session cookie round-trip | `pnpm --filter @familysync/api test tests/auth/localSession.test.ts` | 5/5 pass | PASS |
| localAuthMiddleware Pitfall-1 guard | `pnpm --filter @familysync/api test tests/auth/localAuthMiddleware.test.ts` | 4/4 pass | PASS |
| Login rate-limit/lockout | `pnpm --filter @familysync/api test tests/routes/localAuth.test.ts` | 8/8 pass | PASS |
| App.tsx auth gate (Phase 19) | `pnpm --filter @familysync/pwa test src/App.test.tsx` | 2/2 Phase-19 tests pass | PASS |
| Full API suite | `pnpm --filter @familysync/api test` (446 tests) | 446/446 pass (34 files) | PASS |
| Full PWA unit suite | `pnpm --filter @familysync/pwa test` (265 tests) | 265/265 pass (22 files) | PASS |

### Probe Execution

No conventional `scripts/*/tests/probe-*.sh` probes defined for this phase. N/A.

### Requirements Coverage

| Requirement | Plan | Description | Status | Evidence |
|-------------|------|-------------|--------|---------|
| AUTH-LOCAL-01 | 19-01 | local_credentials schema + migration | SATISFIED | Table in schema.ts + 0003 migration applied |
| AUTH-LOCAL-02 | 19-01 | scrypt hash/verify (node:crypto) | SATISFIED | localCredentials.ts; 5 tests pass |
| AUTH-LOCAL-03 | 19-03 | Login route (rate-limited) | SATISFIED | localAuth.ts POST /local/login; 8 tests pass |
| AUTH-LOCAL-04 | 19-03 | localAuthMiddleware | SATISFIED | localAuthMiddleware.ts; 4 tests pass |
| AUTH-LOCAL-05 | 19-03 | Auth-mode endpoint | SATISFIED | authMode.ts GET /mode; 3 tests pass |
| AUTH-LOCAL-06 | 19-03 | Logout | SATISFIED | POST+GET /local/logout; Tests 6+6b pass |
| AUTH-LOCAL-07 | 19-02 | Admin create-member | SATISFIED | admin.ts POST /members; db.transaction; 409 on dup |
| AUTH-LOCAL-08 | 19-02 | Admin reset password | PARTIAL | API route correct (`/members/:id/password`); client.ts calls wrong URL (`/reset-password`) — gap |
| AUTH-LOCAL-09 | 19-02 | Self-change password | SATISFIED | me.ts POST /password; verifyPassword(current) required |
| AUTH-LOCAL-10 | 19-02 | OIDC-link | SATISFIED | linkOidc.ts + /callback link branch; preflight SELECT |
| AUTH-LOCAL-11 | 19-05 | Break-glass CLI | SATISFIED | reset-admin.ts; production guard FIRST; --dry-run exits 0 |
| AUTH-LOCAL-12 | 19-04 | LoginPage | SATISFIED | LoginPage.tsx 465 lines; 4 error states; id="login-username" |
| AUTH-LOCAL-13 | 19-04 | Admin UI (LOCAL ACCOUNTS section) | PARTIAL | Section exists; add-member wired correctly; reset-password UI exists but client URL wrong |
| AUTH-LOCAL-14 | 19-04 | Settings UI (Change-password + Link-OIDC) | SATISFIED | SettingsSheet.tsx; both gated on hasLocalCredential; link-OIDC also gates on oidcEnabled |
| AUTH-LOCAL-15 | 19-04 | Routing gate | SATISFIED | App.tsx authModeQuery gate; App.test.tsx Phase 19 tests pass |
| AUTH-LOCAL-16 | 19-05 | Dev-bypass/harness rework | SATISFIED | devSessionCookieMiddleware; global-setup seed; login.spec.ts; CI updated |
| AUTH-LOCAL-17 | 19-02 | hasLocalCredential | SATISFIED | GET /api/me + GET /api/admin/members both return hasLocalCredential |
| AUTH-LOCAL-18 | 19-03 | De-Authelia copy | SATISFIED | 0 occurrences of "authelia" (case-insensitive) in all modified source/template files |
| AUTH-LOCAL-19 | 19-03 | Rate-limit/lockout | SATISFIED | loginAttempts Map; 5→429; 10→423; tests pass |
| AUTH-LOCAL-20 | 19-03 | Auth unit tests | SATISFIED | 8 new test files; 446/446 API + 265/265 PWA pass |
| D-05 / LOCAL_SESSION_SECRET | 19-01 | Env var + boot assertion | SATISFIED | generate-secrets.mjs emits it; assertLocalSessionSecretSet in bootGuards.ts; docker-compose.dev.yml has value |

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `apps/pwa/src/api/client.ts` | 204 | Wrong URL in `fetchAdminResetPassword` — calls `/reset-password` instead of `/password` | Blocker | Admin password reset 404s in production |
| `apps/pwa/src/components/SettingsSheet.tsx` | 828 | Comment says "Never use 'Authelia'" — this is a code comment prohibiting the word, not a violation | Info | Not a problem; serves as documentation |

No unreferenced TBD/FIXME/XXX debt markers found in Phase 19 files.

### Human Verification Required

#### 1. Login Page Visual + Flow Verification

**Test:** Using playwright-cli against the non-bypass stack (or deploy stack), navigate to the PWA without a session. Confirm /login renders with BrandSlot ("FamilySync", "Family calendar & lists"), Sign in heading, username + password fields with show/hide toggle. Test all 4 error states.

**Expected:** BrandSlot visible at top; form renders with id="login-username" and id="login-password"; submitting wrong credentials shows exactly "Incorrect username or password." (no field blame); submitting correct credentials navigates into the app. When oidcEnabled, "or" divider + "Login with OIDC" button appear (no "Authelia").

**Why human:** LoginPage renders in a real browser; DEV_AUTH_BYPASS prevents real login-gate testing under dev harness. playwright-cli can drive /login directly (not the gate redirect) in Chromium.

#### 2. Admin Reset Password (After Fixing Gap)

**Test:** After fixing the `fetchAdminResetPassword` URL in `client.ts`, log in as admin, open /admin, expand LOCAL ACCOUNTS for a member with `hasLocalCredential:true`, click Reset-password, enter a new password, submit.

**Expected:** 200 from `POST /api/admin/members/:id/password`; the member can then log in with the new password.

**Why human:** The gap (URL mismatch) must be fixed first; then requires a live admin session with real local credential data.

#### 3. Settings Change-Password Flow

**Test:** Log in as a local user. Open Settings. Confirm "Account" section with "Change password" row visible (only when hasLocalCredential). Click, enter wrong current password — confirm "Current password is incorrect." error. Enter correct current and new password — confirm success.

**Expected:** Current password validation works server-side (401 on wrong current); hash updated on success; new password works on next login.

**Why human:** Requires live stack with a local user session; involves stateful password update.

#### 4. Rate-Limit Flakiness Characterization

**Test:** Run `pnpm --filter @familysync/api test tests/routes/localAuth.test.ts` 10 times in sequence and note pass rate for Test 5 (10 failures → 423).

**Expected:** 10/10 pass. Orchestrator noted one intermittent failure across 3 run history.

**Why human:** Timing-dependent in-memory state machine; could be flaky under CI load; needs repeated observation to characterize.

---

## Gaps Summary

### 1. `fetchAdminResetPassword` URL Mismatch (AUTH-LOCAL-08 / AUTH-LOCAL-13)

**Root cause:** `apps/pwa/src/api/client.ts` line 204 calls `/api/admin/members/${memberId}/reset-password` but the API registers the route as `POST /api/admin/members/:id/password` (in `apps/api/src/routes/admin.ts` line 212). These paths are different — `/reset-password` vs `/password`.

**Impact:** The Admin page "Reset password" sheet (`AdminPage.tsx` → `ResetPasswordSheet` → `fetchAdminResetPassword`) will receive a 404 response on every submit in a production (non-mocked) environment. The underlying API endpoint is correctly implemented and tested; only the client URL is wrong.

**API tests pass** because `admin.test.ts` calls the correct `/api/admin/members/${newMemberId}/password` path directly via `jsonRequest`, not via `client.ts`.

**Fix:** Change line 204 of `client.ts`:
```typescript
// Wrong:
const res = await fetch(`/api/admin/members/${memberId}/reset-password`, {
// Correct:
const res = await fetch(`/api/admin/members/${memberId}/password`, {
```

This is a one-line fix. No API change needed.

---

_Verified: 2026-06-17T17:51:00Z_
_Verifier: Claude (gsd-verifier)_
