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