Files
2026-06-18 22:21:38 -04:00

22 KiB

phase, verified, status, score, behavior_unverified, overrides_applied, reverified, reverification_note, gaps, deferred, behavior_unverified_items, human_verification
phase verified status score behavior_unverified overrides_applied reverified reverification_note gaps deferred behavior_unverified_items human_verification
19-local-auth-no-oidc-mode 2026-06-17T17:51:00Z passed 21/21 must-haves verified 0 0 2026-06-17T18:20:00Z 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.
truth status reason artifacts
An admin can reset any local member's password without knowing the current one resolved 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.
path issue
apps/pwa/src/api/client.ts RESOLVED — URL corrected to `/api/admin/members/${memberId}/password`
truth status reason artifacts
An admin sees a LOCAL ACCOUNTS section to add a member and a per-member Reset-password action resolved FIXED (commit 53da4be): same root-cause URL fix; the Reset-password sheet now targets the correct route. UI rendering was already verified.
path issue
apps/pwa/src/routes/AdminPage.tsx RESOLVED — fetchAdminResetPassword now calls the correct URL
test expected why_human
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 All 4 error states render correctly per UI-SPEC; no Authelia branding visible; OIDC button only shown when oidcEnabled LoginPage renders in a real browser; DEV_AUTH_BYPASS prevents real login-gate testing from playwright-cli under dev harness
test expected why_human
After fixing the fetchAdminResetPassword URL: drive the admin Reset-password sheet and confirm admin can reset a member password without knowing current POST to /api/admin/members/:id/password returns 200; member can then log in with the new password Depends on fixing the gap first; then requires end-to-end stack with admin session
test expected why_human
Drive the SettingsSheet Change-password sheet with a local user: verify current-password verification and successful update Wrong current password shows 'Current password is incorrect.'; correct current allows update; subsequent login with new password succeeds Requires end-to-end stack with a local user session
test expected why_human
Verify rate-limit/lockout flakiness: run localAuth.test.ts Test 5 (10 failures -> 423) 10 times and confirm stable pass rate Test 5 passes all 10 runs (orchestrator noted one intermittent failure across 3 runs) 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
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.tsxResetPasswordSheetfetchAdminResetPassword) 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:

// 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)