Files
familysync/.planning/milestones/v1.1-phases/19-local-auth-no-oidc-mode/19-REVIEW-FIX.md
T
2026-06-18 22:21:38 -04:00

10 KiB

phase, fixed_at, review_path, iteration, findings_in_scope, fixed, skipped, status
phase fixed_at review_path iteration findings_in_scope fixed skipped status
19-local-auth-no-oidc-mode 2026-06-17T20:39:00Z .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md 1 15 15 0 all_fixed

Phase 19: Code Review Fix Report

Fixed at: 2026-06-17T20:39:00Z Source review: .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md Iteration: 1

Summary:

  • Findings in scope: 15 (4 critical, 4 blocker, 7 warning, 4 info — fix_scope: all)
  • Fixed: 15
  • Skipped: 0

Verification: Full API suite 452/452 (34 files, live MariaDB) and full PWA suite 266/266 (22 files) pass; both tsc --noEmit clean. Findings classified as security/availability logic (CR-04, BL-03, WR-06, IN-04) are flagged "requires human verification" below — syntax/tests pass but a human should confirm the threat-model intent.

Fixed Issues

Files modified: apps/pwa/src/api/client.ts, apps/pwa/src/components/SettingsSheet.tsx Commit: 1688f22 Applied fix: Changed fetchLinkOidc to return { authorizationUrl: string | null } matching the server's { signedState, authorizationUrl } contract, and updated LinkOidcSheet to navigate to authorizationUrl (handling the null/unconfigured case by surfacing an error instead of navigating to undefined).

CR-02: Admin "create member" always fails — request field-name mismatch

Files modified: apps/pwa/src/api/client.ts Commit: 93c47b3 Applied fix: fetchCreateMember now sends initialPassword (the field createMemberSchema requires) instead of password, and maps HTTP 409 to Error('conflict') so the AdminPage's existing conflict branch renders the right banner.

CR-03: Self-service password change logs user out on wrong current password

Files modified: apps/api/src/routes/me.ts, apps/pwa/src/api/client.ts, apps/api/tests/routes/me.test.ts Commit: 6ef8e03 Applied fix: Server returns 403 (not 401) for an incorrect current password; client fetchChangePassword branches on 403 → Error('wrong-current') before the 401→SessionExpiredError path, so a mistyped password no longer triggers the global session-expiry logout. Updated me.test Test 2 to expect 403.

CR-04: Login lockout per-IP, global, permanent, unrecoverable — requires human verification

Files modified: apps/api/src/routes/localAuth.ts, apps/api/src/routes/admin.ts, apps/pwa/src/routes/LoginPage.tsx, apps/api/tests/routes/localAuth.test.ts Commit: b083cb7 Applied fix: Re-scoped the rate limiter from client IP to the validated username; added a 15-minute TTL so a 423 lockout auto-expires (self-healing, no restart); wired admin password-reset to call resetLoginAttempts(username) for an immediate unlock; corrected the LoginPage banner copy. Added Test 5b asserting TTL auto-expiry. Human verification: confirm the username-scoping + TTL behaviour matches the intended threat model for the tunnel deployment.

BL-01: devSessionCookieMiddleware issues a session without verifying secret strength

Files modified: apps/api/src/auth/devBypass.ts Commit: 3674b25 Applied fix: Apply the same secret.length >= 32 floor used by the boot guard inside devSessionCookieMiddleware before minting the dev cookie (degrade to no-op if too short), and emit a loud warning when the secret is the well-known dev placeholder.

Files modified: apps/api/src/auth/localSession.ts Commit: cd095e5 Applied fix: clearLocalSessionCookie now mirrors the issue-time secure: process.env.NODE_ENV === 'production' logic instead of hard-coding secure: true, so the deletion cookie is accepted over plain HTTP and logout actually clears the session in HTTP-only self-hosts.

Files modified: apps/api/src/index.ts Commit: 7153760 Applied fix: In the /callback link path, reject the bind unless the current local session (verifyLocalSessionCookie) matches linkUserId (account-takeover guard), and reject when iss/sub are empty (never call linkOidcToUser with blank identity, which would corrupt identity and delete the user's local credential). Human verification: confirm the session cross-check closes the replay-takeover path described in the review.

BL-04: localAuthMiddleware fabricates oidcSub collisions for local users

Files modified: apps/api/src/auth/devBypass.ts, apps/api/src/auth/localAuthMiddleware.ts, apps/api/tests/auth/localAuthMiddleware.test.ts Commit: 40666e1 Applied fix: Introduced a ContextUser interface with nullable oidcIss/oidcSub; the middleware now stores null for local users instead of the 'local'/String(id) sentinels that shared the uniq_oidc_identity uniqueness domain. Added Test 1c asserting null context for a null-OIDC local user.

WR-01: reset-admin.ts arg parsing trusts --password '' and echoes username

Files modified: apps/api/scripts/reset-admin.ts Commit: c4d8d76 Applied fix: Rewrote parseArgs to support --key=value and to treat --username/--password as value-taking (consuming the next token verbatim, so a ---prefixed or empty password is preserved) and --dry-run as boolean; removed username interpolation from log lines.

WR-02 + WR-04: hardcoded Authelia auth path / OIDC-config detection divergence

Files modified: apps/api/src/auth/oidcConfig.ts (new), apps/api/src/routes/me.ts Commit: 322929a Applied fix: New oidcConfig.ts centralizes the env-OR-app_config resolution (resolveOidcConfig) and discovers the authorization_endpoint from the provider's /.well-known/openid-configuration (discoverAuthorizationEndpoint). me.ts link-oidc now uses both, so a wizard-configured instance no longer reports oidcEnabled:true while returning authorizationUrl:null, and the URL is no longer Authelia-path-specific. (Two findings fixed in one commit — they share the same handler/lines and are inseparable.)

WR-03: scryptSync blocks the event loop on the login hot path

Files modified: apps/api/src/auth/localCredentials.ts, apps/api/src/routes/localAuth.ts, apps/api/src/routes/me.ts, apps/api/src/routes/admin.ts, plus their tests Commit: 30ad25c Applied fix: Converted hashPassword/verifyPassword to async (threadpool scrypt via a typed Promise wrapper), awaited at all call sites, made the login DUMMY_HASH a module-level promise, and moved create-member hashing outside the DB transaction. Updated all test call sites to await. Preserves the timing-defense property while keeping the event loop responsive.

WR-05: noEchoHook pass-through is correct-but-untested

Files modified: apps/api/tests/routes/admin.test.ts, apps/api/tests/routes/me.test.ts Commit: 4bd6b2c Applied fix: Added focused no-echo tests for the admin create-member and me password hook sites asserting a malformed body never includes the submitted password or Zod's received/issues. @hono/zod-validator is already pinned to exact 0.8.0 in package.json.

WR-06: rate-limit lockedUntil refreshed on every blocked attempt — requires human verification

Files modified: apps/api/src/routes/localAuth.ts Commit: 4cf2ad4 Applied fix: The 429 (already-rejected) branch no longer re-arms lockedUntil; the cooldown window stays anchored to when it was first armed, so sustained attacker traffic can no longer slide the window forward indefinitely. Human verification: confirm the window now expires on schedule for a legitimate user behind the same identity.

WR-07: parseInt member/calendar id accepts trailing garbage

Files modified: apps/api/src/routes/admin.ts, apps/api/tests/routes/admin.test.ts Commit: 32bdd1e Applied fix: Added parsePositiveIntParam using Number.isInteger(Number(raw)) and applied it to /members/:id/password and /calendars/:id/shared, so "12abc" is now rejected with 400. Added a test for the calendar route.

IN-01: localSession maxAge/expiry parsing has no validation

Files modified: apps/api/src/auth/localSession.ts Commit: f2fc140 Applied fix: Coerce and validate LOCAL_SESSION_EXPIRES — fall back to 86400s for any non-finite or non-positive value, preventing a NaN exp/maxAge.

IN-02: duplicated inline scrypt implementation across three locations

Files modified: apps/api/tests/auth/localCredentials.test.ts Commit: e392bf2 Applied fix: Added a lockstep test that builds a hash using the inlined scrypt parameters (N=16384, r=8, p=1, KEY_LEN=32 — matching reset-admin.ts and ci.yml) and asserts it round-trips against the canonical verifyPassword, so a parameter drift fails CI loudly.

IN-03: loginAttempts map is unbounded

Files modified: apps/api/src/routes/localAuth.ts Commit: f02521d Applied fix: Added evictStaleLoginAttempts, called opportunistically per login request, dropping entries that are neither in an active rate-limit window nor an active lockout TTL — bounding the map under input churn without weakening the limiter.

Files modified: apps/api/src/auth/linkNonceStore.ts (new), apps/api/src/routes/me.ts, apps/api/src/index.ts Commit: 2691dd0 Applied fix: New in-memory single-use nonce store: me.ts registers the issued nonce (valid until the state JWT's exp); the /callback link path consumes it and rejects any replayed/unknown/expired nonce before binding. Combined with BL-03's session cross-check, the captured-state replay window is closed. Human verification: confirm single-use semantics are sufficient for the single-process deployment (move to Redis if multi-process).

Skipped Issues

None — all 15 in-scope findings were fixed.


Fixed: 2026-06-17T20:39:00Z Fixer: Claude (gsd-code-fixer) Iteration: 1