chore: archive v1.1 phase directories to milestones/v1.1-phases/
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a2890d1542
commit
c7955a46b9
@@ -0,0 +1,189 @@
|
||||
---
|
||||
phase: 19-local-auth-no-oidc-mode
|
||||
reviewed: 2026-06-17T00:00:00Z
|
||||
depth: deep
|
||||
files_reviewed: 41
|
||||
files_reviewed_list:
|
||||
- apps/api/scripts/reset-admin.ts
|
||||
- apps/api/src/auth/devBypass.ts
|
||||
- apps/api/src/auth/linkOidc.ts
|
||||
- apps/api/src/auth/localAuthMiddleware.ts
|
||||
- apps/api/src/auth/localCredentials.ts
|
||||
- apps/api/src/auth/localSession.ts
|
||||
- apps/api/src/auth/middleware.ts
|
||||
- apps/api/src/db/migrations/0003_warm_deathstrike.sql
|
||||
- apps/api/src/db/schema.ts
|
||||
- apps/api/src/index.ts
|
||||
- apps/api/src/lib/bootGuards.ts
|
||||
- apps/api/src/routes/admin.ts
|
||||
- apps/api/src/routes/authMode.ts
|
||||
- apps/api/src/routes/localAuth.ts
|
||||
- apps/api/src/routes/me.ts
|
||||
- apps/api/tests/auth/localAuthMiddleware.test.ts
|
||||
- apps/api/tests/auth/localCredentials.test.ts
|
||||
- apps/api/tests/auth/localSession.test.ts
|
||||
- apps/api/test/setup.ts
|
||||
- apps/api/tests/lib/requireAdmin.test.ts
|
||||
- apps/api/tests/routes/admin.test.ts
|
||||
- apps/api/tests/routes/authMode.test.ts
|
||||
- apps/api/tests/routes/lists.test.ts
|
||||
- apps/api/tests/routes/localAuth.test.ts
|
||||
- apps/api/tests/routes/me.test.ts
|
||||
- apps/api/tests/routes/push.test.ts
|
||||
- apps/api/tests/routes/setup.test.ts
|
||||
- apps/pwa/e2e/global-setup.ts
|
||||
- apps/pwa/e2e/login.spec.ts
|
||||
- apps/pwa/src/api/client.ts
|
||||
- apps/pwa/src/App.test.tsx
|
||||
- apps/pwa/src/App.tsx
|
||||
- apps/pwa/src/components/BrandSlot.tsx
|
||||
- apps/pwa/src/components/InstructionSheet.test.tsx
|
||||
- apps/pwa/src/components/SettingsSheet.tsx
|
||||
- apps/pwa/src/routes/AdminPage.tsx
|
||||
- apps/pwa/src/routes/LoginPage.tsx
|
||||
- apps/pwa/src/styles/tokens.css
|
||||
- .gitea/workflows/ci.yml
|
||||
- scripts/generate-secrets.mjs
|
||||
findings:
|
||||
critical: 0
|
||||
blocker: 0
|
||||
warning: 0
|
||||
info: 2
|
||||
total: 2
|
||||
status: clean
|
||||
---
|
||||
|
||||
# Phase 19: Code Review Report (Iteration-2 Re-Review)
|
||||
|
||||
**Reviewed:** 2026-06-17
|
||||
**Depth:** deep
|
||||
**Files Reviewed:** 41
|
||||
**Status:** clean
|
||||
|
||||
## Summary
|
||||
|
||||
This is the iteration-2 re-review confirming the fixer correctly applied all 15
|
||||
findings from the prior review (4 critical, 4 blocker, 7 warning, 4 info). I read
|
||||
every listed source file, traced the high-judgment fixes through their full call
|
||||
chains across module boundaries, ran `tsc --noEmit` on both `@familysync/api` and
|
||||
`@familysync/pwa` (both clean, exit 0), and confirmed the CI seed parameters and
|
||||
inlined scrypt copies all agree. The runtime test suite could not execute in this
|
||||
sandbox (global-setup requires a live MariaDB with root grants — `ER_ACCESS_DENIED`),
|
||||
so test verification is static: the relevant assertions were read directly and the
|
||||
production source typechecks against them.
|
||||
|
||||
**Verdict: all 15 prior findings are correctly and completely resolved. No
|
||||
regressions, no re-occurrence at other call sites, and no new critical/blocker/warning
|
||||
issues.** Two low-severity Info observations are recorded below; neither blocks ship.
|
||||
|
||||
### Confirmation of high-judgment fixes (verified by tracing, not just diff)
|
||||
|
||||
- **CR-01/02/03 (client↔server contracts):**
|
||||
- CR-01 shared-calendar: `admin.ts` `PUT /calendars/:id/shared` now does an
|
||||
existence check inside a transaction (404 on missing id), so a stale id can no
|
||||
longer silently clear the shared lane. Client `setSharedCalendar` agrees.
|
||||
- CR-02 create-member: client `fetchCreateMember` maps `password → initialPassword`
|
||||
(client.ts:199-204) and the server `createMemberSchema` requires `initialPassword`
|
||||
(admin.ts:140); 409 maps to the `'conflict'` sentinel the AdminPage `onError`
|
||||
expects (AdminPage.tsx:229). Server returns 409 on `ER_DUP_ENTRY` (admin.ts:202).
|
||||
Both ends agree; admin.test.ts Tests 1–2 cover the round-trip + 409 rollback.
|
||||
- CR-03 wrong-current-password: server returns **403** (`me.ts:267`), client checks
|
||||
403 **before** the 401 session-expiry branch (client.ts:166) and maps it to
|
||||
`'wrong-current'`, which `ChangePasswordSheet.onError` surfaces without dropping
|
||||
the session (SettingsSheet.tsx:569). me.test.ts Test 2 asserts 403 + update-not-called.
|
||||
- **CR-04 / WR-06 / IN-03 (login limiter):** the limiter key is the validated,
|
||||
trimmed username (localAuth.ts:138) — no `x-forwarded-for`/IP residue remains
|
||||
anywhere in `routes/localAuth.ts` or `src/auth/*` (grep clean). 423 lockout
|
||||
auto-expires after `LOCKOUT_TTL_MS` (15 min, localAuth.ts:149-155); admin reset
|
||||
calls `resetLoginAttempts(credRow.username)` for an instant unlock (admin.ts:262).
|
||||
WR-06: the 429 branch deliberately does **not** re-arm `lockedUntil`
|
||||
(localAuth.ts:164-169), so a rejected attempt can no longer slide the window
|
||||
forward; the window is only re-anchored by a genuine failure in the failure path.
|
||||
IN-03: `evictStaleLoginAttempts` only drops entries that are both window-expired
|
||||
and lockout-TTL-expired (localAuth.ts:113-121) — behaviourally identical to natural
|
||||
expiry, so eviction never weakens the brute-force defense. Tests 4, 5, 5b cover it.
|
||||
- **BL-01 (dev-bypass secret floor):** `devSessionCookieMiddleware` applies the same
|
||||
`>= 32` length floor before minting a real DEV_USER session JWT (devBypass.ts:144-151)
|
||||
and warns on the well-known placeholder. The CI placeholder
|
||||
`dev-secret-change-me-0000000000000000` is 36 chars, so it passes the floor and only
|
||||
triggers the warning — intended.
|
||||
- **BL-02 (logout cookie Secure match):** `clearLocalSessionCookie` now mirrors the
|
||||
issue-time `secure: NODE_ENV==='production'` (localSession.ts:114), so the
|
||||
delete-cookie is accepted over plain HTTP and the user is actually logged out on
|
||||
non-HTTPS deployments. `sameSite`/`path`/`httpOnly` also match issue-time.
|
||||
- **BL-03 (OIDC-link takeover guard):** `/callback` (index.ts:84-123) now enforces
|
||||
three gates before binding: (1) single-use nonce via `consumeLinkNonce`; (2) the
|
||||
initiating local session must still match `linkUserId`
|
||||
(`verifyLocalSessionCookie(c) === linkUserId`); (3) `iss`/`sub` from `getAuth` must
|
||||
be non-empty. `/callback` is registered outside `/api/*` so `localAuthMiddleware`
|
||||
does not run, but the `local-session` cookie (path `/`) is still present and read
|
||||
directly — the cross-check is effective. All three gates fail-closed to
|
||||
`/?error=oidc-link-conflict`. The preflight conflict check in `linkOidcToUser`
|
||||
(linkOidc.ts:65-74) remains the backstop.
|
||||
- **BL-04 (no fabricated identity sentinels):** `localAuthMiddleware` passes through
|
||||
the DB `oidcIss`/`oidcSub` as `?? null` (localAuthMiddleware.ts:91-97); `ContextUser`
|
||||
widens both to `string | null` (devBypass.ts:56-62). No `'local'`/`String(id)`
|
||||
sentinels are written, so a local user cannot collide in the `uniq_oidc_identity`
|
||||
domain. localAuthMiddleware.test.ts Test 1c pins null.
|
||||
- **WR-03 (async scrypt):** `hashPassword`/`verifyPassword` are async over the libuv
|
||||
threadpool (localCredentials.ts:33-45, 65, 101) at every call site — login
|
||||
(dummy-hash promise awaited, localAuth.ts:128/199), create-member (hash before the
|
||||
transaction, admin.ts:159), admin reset, and self-change. The always-run dummy-hash
|
||||
path preserves the timing-oracle defense (localAuth.ts:197-199). reset-admin.ts
|
||||
legitimately keeps `scryptSync` (standalone CLI, no event loop to starve).
|
||||
- **IN-04 (single-use nonce):** `linkNonceStore.ts` records the nonce at issue
|
||||
(me.ts:333) and `consumeLinkNonce` returns true exactly once per unexpired nonce,
|
||||
with opportunistic sweep keeping the map bounded; `/callback` consumes before binding.
|
||||
|
||||
### Cross-cutting checks
|
||||
|
||||
- Inlined PHC scrypt parameters agree across all three copies: canonical module
|
||||
(N=16384,r=8,p=1,keylen=32), `reset-admin.ts`, and `.gitea/workflows/ci.yml`
|
||||
(seed step lines 309-310). localCredentials.test.ts Test 6 pins this round-trip.
|
||||
- `oidcConfig.ts` (`resolveOidcConfig` + `discoverAuthorizationEndpoint`) is the single
|
||||
env-OR-app_config source now shared by `/api/auth/mode`, the fallback middleware, and
|
||||
`me.ts` link-oidc, closing the WR-04 divergence where link-oidc could return
|
||||
`authorizationUrl:null` while `/mode` reported `oidcEnabled:true`.
|
||||
- `LOCAL_SESSION_EXPIRES` NaN-coercion guard (localSession.ts:35-38) and boot guards
|
||||
(`assertLocalSessionSecretSet` >= 32, exempt under bypass) are correct and wired
|
||||
first in the `isMainModule()` block (index.ts:262-265).
|
||||
- Migration `0003_warm_deathstrike.sql` matches the `localCredentials` Drizzle schema
|
||||
(unique on user_id and username, FK cascade, varchar(256) hash).
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: `fetchLinkOidc` declared return type is narrower than the cast it returns
|
||||
|
||||
**File:** `apps/pwa/src/api/client.ts:250,261`
|
||||
**Issue:** The function signature declares `Promise<{ authorizationUrl: string | null }>`
|
||||
but the body returns `res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>`.
|
||||
The widening cast is harmless (the only consumer, `SettingsSheet.tsx` `LinkOidcSheet`,
|
||||
reads `data.authorizationUrl` only and never `signedState`), and `tsc` is clean. It is a
|
||||
minor contract-doc inconsistency: the declared type drops a field the server actually
|
||||
sends. Not a defect — recorded only so the next editor does not "fix" the cast and
|
||||
accidentally start relying on the absent field.
|
||||
**Fix:** Align the declared return type with the cast for clarity:
|
||||
```ts
|
||||
export async function fetchLinkOidc(): Promise<{ signedState: string; authorizationUrl: string | null }> {
|
||||
```
|
||||
|
||||
### IN-02: 429 rate-limit branch short-circuits before the dummy-hash work
|
||||
|
||||
**File:** `apps/api/src/routes/localAuth.ts:162-177`
|
||||
**Issue:** Once an identity is in the 429 window, the handler returns before the DB
|
||||
lookup and the always-run `verifyPassword`/dummy-hash. This is a deliberate and correct
|
||||
DoS/throughput tradeoff (a rate-limited identity should not pay scrypt cost), and it does
|
||||
NOT leak username existence because the 429 path is reached identically for valid and
|
||||
invalid usernames (the limiter is keyed on the submitted username regardless of whether a
|
||||
credential row exists). The timing-oracle defense is only required on the *credential-check*
|
||||
path, which still always runs the dummy hash. Recorded for completeness; no change needed.
|
||||
**Fix:** None required. If a future reviewer wants strict constant-time even under
|
||||
rate-limiting, the dummy-hash could be awaited before the 429 return — but that would
|
||||
re-introduce the exact event-loop-starvation cost WR-03 removed, so leaving it as-is is
|
||||
the right call.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-06-17_
|
||||
_Reviewer: Claude (gsd-code-reviewer)_
|
||||
_Depth: deep_
|
||||
Reference in New Issue
Block a user