--- phase: 19-local-auth-no-oidc-mode reviewed: 2026-06-17T00:00:00Z depth: deep files_reviewed: 39 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: 4 blocker: 4 warning: 7 info: 4 total: 15 status: issues_found --- # Phase 19: Code Review Report **Reviewed:** 2026-06-17 **Depth:** deep **Files Reviewed:** 39 (auth source + routes + PWA + CI) **Status:** issues_found ## Summary Phase 19 adds a local username/password authentication mode alongside the existing OIDC flow. The cryptographic core (scrypt PHC hashing, constant-time compare, dummy-hash timing defense, HS256 session JWT with boot guards) is implemented carefully and is sound. The middleware chain (`devAuthBypass → devSessionCookie → localAuthMiddleware → OIDC guard`) and the admin-privilege boundary (`requireAdmin` DB-enforced on every `/api/admin/*` request) are correct. The serious problems are at the **client↔server API contract boundary** — the exact place a deep cross-file review is meant to catch. Three Phase-19 client functions in `apps/pwa/src/api/client.ts` disagree with their server routes on field names, response shape, or status-code handling, so the corresponding features (OIDC-link, admin create-member, and self-service password change error handling) are broken end-to-end despite each side individually passing its own unit tests. There is also an authentication availability defect: the per-IP login lockout is mislabeled as "account locked," is global, and never expires — a single attacker IP can permanently deny login for the whole household with no self-recovery path. ## Critical Issues ### CR-01: OIDC-link flow is broken — client/server response-shape mismatch **File:** `apps/pwa/src/api/client.ts:226-238` and `apps/api/src/routes/me.ts:301-341` **Issue:** `fetchLinkOidc()` reads `result.redirectUrl` and its return type is `{ redirectUrl: string }`. The server's `POST /api/me/link-oidc` returns `{ signedState, authorizationUrl }` — there is no `redirectUrl` key. The caller (Surface 13 "Link OIDC") will navigate to `undefined`, so the entire OIDC-link feature (AUTH-LOCAL-10) cannot work in the browser. Each side's own unit tests pass because neither test crosses the boundary. Additionally `authorizationUrl` can legitimately be `null` (OIDC unconfigured), which the client type does not model. **Fix:** Make the contract agree. Either return `redirectUrl` from the server, or read `authorizationUrl` on the client and handle `null`: ```ts export async function fetchLinkOidc(): Promise<{ authorizationUrl: string | null }> { // ... return res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>; } // caller: const { authorizationUrl } = await fetchLinkOidc(); if (authorizationUrl) window.location.href = authorizationUrl; ``` ### CR-02: Admin "create member" always fails — request field-name mismatch **File:** `apps/pwa/src/api/client.ts:176-194` and `apps/api/src/routes/admin.ts:123-133` **Issue:** `fetchCreateMember` POSTs `{ displayName, username, password }`. The server's `createMemberSchema` requires `{ displayName, username, initialPassword }`. The `password` field is ignored and `initialPassword` is missing, so Zod validation fails and the route returns `400 { error: 'Invalid request' }` (via `noEchoHook`) for *every* valid admin attempt to create a local member (AUTH-LOCAL-07). The Admin UI maps a non-409 error to the generic "Something went wrong" banner, so the admin can never create an account. **Fix:** Send the field the server expects: ```ts body: JSON.stringify({ displayName: body.displayName, username: body.username, initialPassword: body.password, }), ``` ### CR-03: Self-service password change logs the user out on a wrong current password **File:** `apps/pwa/src/api/client.ts:148-165` and `apps/api/src/routes/me.ts:233-261` **Issue:** `fetchChangePassword` treats `res.status === 401` as `SessionExpiredError`, which the global MutationCache handler interprets as "session expired → arm the re-auth interstitial / redirect to login." But `POST /api/me/password` returns **401** `{ error: 'Current password incorrect' }` when the supplied current password is wrong (me.ts:259-260). So a user who simply mistypes their current password is forcibly logged out instead of seeing "current password incorrect." The client doc comment even claims "401 → wrong current password" while the code routes 401 to `SessionExpiredError`. The documented 422 validation branch also never fires — the server returns 400 (noEchoHook) or 404, not 422. **Fix:** Distinguish auth-expiry from an in-app 401. Have the route return a distinct status for "wrong current password" (e.g. 403 or a body code), and branch on it client-side before treating 401 as session expiry: ```ts if (res.status === 401) { const body = await res.json().catch(() => ({})); if (body?.error === 'Current password incorrect') throw new Error('wrong-current'); throw new SessionExpiredError(); } ``` ### CR-04: Login lockout is per-IP, global, permanent, and unrecoverable **File:** `apps/api/src/routes/localAuth.ts:55-140` **Issue:** Multiple correctness/availability defects in one mechanism: 1. The `loginAttempts` map is keyed by **IP**, yet the 423 response and the PWA banner say "This account is temporarily locked." It is neither account-scoped nor temporary. 2. Once `count >= LOCKOUT_FAILURES (10)`, `lockedOut` is set permanently. The only documented clear path is "admin password reset" — but no admin route ever clears `loginAttempts` (admin.ts reset-password updates the hash, not the in-memory map). So **the lockout is genuinely unrecoverable without a process restart**. 3. Because it is per-IP and all household traffic arrives via the Pangolin tunnel with the same `X-Forwarded-For` first hop, one bad actor (or one user fat-fingering 10 times) can lock out **every** member at that egress IP. This is a self-inflicted DoS on a 2-person household whose entire reason for existing is low-friction access. 4. `X-Forwarded-For` is attacker-controllable on any request that does not pass through the trusted proxy; an attacker can rotate the header to get unlimited fresh rate-limit buckets, defeating the brute-force defense entirely while still being able to lock *other* identities by spoofing their IP if it were ever known. **Fix:** Re-scope the limiter to the submitted username (not IP), make the 423 lockout expire on a timer (or actually wire admin reset to clear it), correct the banner copy, and only trust `X-Forwarded-For` when the request demonstrably came from the known proxy (or use the leftmost-trusted hop). At minimum, give the lockout a TTL so a restart is not required: ```ts // derive key from the validated username, and expire lockout after N minutes const LOCKOUT_TTL_MS = 15 * 60 * 1000; if (attempt?.lockedOut && Date.now() < attempt.lockedUntil) { /* 423 */ } ``` ## Blockers ### BL-01: `devSessionCookieMiddleware` issues a session for user id=1 without verifying the user exists or the secret is strong **File:** `apps/api/src/auth/devBypass.ts:102-131` and `apps/api/src/lib/bootGuards.ts:53-66` **Issue:** In dev-bypass mode the boot guard `assertLocalSessionSecretSet()` is *skipped* entirely (returns early when `DEV_AUTH_BYPASS==='true'`). `devSessionCookieMiddleware` then only checks that `LOCAL_SESSION_SECRET` is *present* (truthy), not that it is ≥32 chars, and mints a real, fully-valid `local-session` JWT for `DEV_USER.id (=1)`. The CI sets a weak fixed secret `'dev-secret-change-me-0000000000000000'`. Any cookie minted under bypass is a genuine, signature-valid session token for user 1 — if that same weak/known secret is ever present in a non-bypass environment (e.g. an operator copies the dev compose), forged sessions are trivial. The hard `NODE_ENV==='production'` guard mitigates the worst case, but this is a latent footgun: the boot guard's length check is the documented defense and it is bypassed here. **Fix:** Apply the same `secret.length >= 32` floor inside `devSessionCookieMiddleware` before issuing, and emit a loud warning if the dev secret is the placeholder value. Do not treat "present" as "safe." ### BL-02: Logout cannot clear the cookie in non-production (attribute mismatch) **File:** `apps/api/src/auth/localSession.ts:53-59` vs `97-106` **Issue:** `issueLocalSessionCookie` sets `secure: process.env.NODE_ENV === 'production'` (i.e. `secure:false` in dev/test over HTTP). `clearLocalSessionCookie` hard-codes `secure: true`. Browsers require the `Secure` attribute on a deletion cookie to match the context: over plain HTTP a `Secure` delete-cookie is rejected, so `POST /local/logout` returns 200 but the `local-session` cookie is **not actually cleared** in any non-HTTPS deployment (local dev, and any HTTP-only self-host). The user appears logged in after "logout." The inline comment acknowledges the mismatch but waves it away with "logout should happen over HTTPS" — that is an unsafe assumption for a self-hosted app that explicitly supports private-IP/HTTP internal access. **Fix:** Mirror the issue-time logic on delete: ```ts deleteCookie(c, COOKIE_NAME, { path: '/', httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Lax', }); ``` ### BL-03: OIDC-link binding swallows `processOAuthCallback` failure and can bind on a stale/blank identity **File:** `apps/api/src/index.ts:57-101` **Issue:** In the `/callback` link path the code calls `processOAuthCallback(c)` then `getAuth(c)` and binds whatever `iss`/`sub` it finds to `linkUserId`. Two problems: (1) `iss`/`sub` fall back to `''` (`auth.iss ?? ''`, `auth.sub ?? ''`); if `getAuth` returns a partially-populated session, `linkOidcToUser(linkUserId, '', '')` will write `oidc_iss=''`/`oidc_sub=''` onto the user and **delete their local_credentials** — locking them out of both auth methods. (2) `linkUserId` comes from a JWT signed with `LOCAL_SESSION_SECRET`, but nothing verifies the *currently authenticated session* matches `linkUserId`; the signed-state CSRF defense assumes the state can only have been produced by `POST /api/me/link-oidc`, but the callback binds to `linkUserId` regardless of who completes the OIDC login. An attacker who can get a victim to complete an OIDC login while replaying a captured (still-valid, 10-min) link state binds the *attacker's* OIDC identity to the *victim's* account — account takeover. **Fix:** Reject the bind unless `iss` and `sub` are both non-empty, and cross-check that the OIDC identity being bound is the one the initiating user intended (e.g. require the post-callback session's subject to be confirmed by the user, or bind only when the initiating local session is still present and matches `linkUserId`). Never call `linkOidcToUser` with empty iss/sub. ### BL-04: `localAuthMiddleware` fabricates `oidcSub` collisions for local users **File:** `apps/api/src/auth/localAuthMiddleware.ts:88-94` and `apps/api/src/db/schema.ts:60-64` **Issue:** When populating context for a local user with null OIDC fields, the middleware substitutes `oidcIss: 'local'` and `oidcSub: String(row.id)`. This is only a context shape and is not persisted by the middleware — but `me.ts:resolveUserId` (the OIDC path) and `upsertUser` key identity on `iss+sub`, and the `users` table has `unique('uniq_oidc_identity').on(oidcIss, oidcSub)`. If any code path ever upserts using the context's `('local', String(id))` pair (e.g. a future call to `upsertUser` with these values), two local users would deterministically collide or a local user could shadow a real OIDC identity whose `(iss,sub)` happened to equal `('local','')`. The fabricated values leak a synthetic identity namespace that overlaps the real one. **Fix:** Keep the context `oidcIss/oidcSub` as `null` for local users (widen the `ContextVariableMap` `user` type to allow null) rather than inventing `'local'`/`String(id)` sentinels that share a uniqueness domain with real OIDC identities. ## Warnings ### WR-01: `reset-admin.ts` interpolates the username into a log line and trusts `--password ''` **File:** `apps/api/scripts/reset-admin.ts:60-66, 123, 133` **Issue:** Two issues. (1) The arg parser treats any token starting with `--` as a new flag, so `--password --foo` yields `password=''`; combined with the dry-run branch the validation is loose. More importantly a password that legitimately begins with `--` (or is the empty string) is silently coerced to `''`. (2) The username is interpolated directly into `console.log(... username="${username}")`; while not an injection into SQL (queries are parameterized — good), logging the username is a minor info disclosure for a break-glass tool and inconsistent with the password-never-logged contract. **Fix:** Parse `--password=value` and `--password value` explicitly; do not infer empty strings from a following flag. Avoid echoing the username, or document it as acceptable. ### WR-02: `me.ts` builds the OIDC authorization URL with a hardcoded Authelia path **File:** `apps/api/src/routes/me.ts:331` **Issue:** `new URL(`${issuer}/api/oidc/authorization`)` hardcodes Authelia's authorization endpoint path. The project's stated design is to discover endpoints via `/.well-known/openid-configuration` (the whole reason `@hono/oidc-auth` is used). Any non-Authelia or differently-mounted provider will get a wrong URL. This is a latent correctness bug that compounds CR-01. **Fix:** Resolve the authorization endpoint from the discovery document rather than assuming `/api/oidc/authorization`. ### WR-03: `scryptSync` blocks the event loop on the login hot path **File:** `apps/api/src/auth/localCredentials.ts:42-53, 71-90` and `localAuth.ts:127-129` **Issue:** `verifyPassword` always runs `scryptSync` (N=16384) synchronously, including the dummy-hash branch on every failed/unknown login. The file comment justifies this for a 2-person household, but combined with the per-IP rate limiter and the always-run dummy hash, a burst of unauthenticated `POST /local/login` requests can pin the single Node event loop (each scrypt is ~tens of ms of blocking CPU) and stall *all* other API traffic — a cheap unauthenticated DoS. The rate limiter does not protect this because the scrypt runs *before* the failure counter is consulted on the dummy path for new IPs. **Fix:** Use `promisify(scrypt)` (async) so hashing does not block the loop, as the comment itself suggests. This keeps the timing-defense property while preventing loop starvation. ### WR-04: `authMode` / OIDC-enabled detection diverges across three files **File:** `apps/api/src/routes/authMode.ts:33-49`, `apps/api/src/auth/middleware.ts:62-114`, `apps/api/src/routes/me.ts:323-328` **Issue:** Three independent notions of "is OIDC configured": `authMode` checks `OIDC_ISSUER` env OR `app_config.oidc_issuer`; the fallback middleware injects issuer/client-id/external-url from app_config; but `me.ts` link-oidc only builds a URL when `OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_REDIRECT_URI` are all in **env** (it never consults app_config). So a wizard-configured-but-not-restarted instance reports `oidcEnabled:true` from `/api/auth/mode`, shows the "Link OIDC" button, but `link-oidc` returns `authorizationUrl:null` — inconsistent state surfaced to the user. **Fix:** Centralize the "OIDC configured" resolution (env-or-app_config) in one helper and use it in all three sites. ### WR-05: `noEchoHook` return value is ignored by `@hono/zod-validator` in one of two styles **File:** `apps/api/src/routes/localAuth.ts:39-43`, `apps/api/src/routes/admin.ts:74-78`, `apps/api/src/routes/me.ts:178-182` **Issue:** The hook signature is `(result, c)` and returns `c.json(...)` only on failure. This relies on zValidator short-circuiting when the hook returns a Response. That contract holds for current `@hono/zod-validator`, but the hook does not `return` anything on success and does not assert `result.success` narrows the type, so a future validator version that requires an explicit early-return-on-success, or that passes through when the hook returns `undefined`, would silently start echoing Zod errors (the exact T-19-14 leak this guards against). It is correct today but fragile and untested for the pass-through case. **Fix:** Add a focused test asserting that a malformed body never includes `received`/the submitted value for each hook site (localAuth has one; admin/me password routes should too), and pin the `@hono/zod-validator` version. ### WR-06: Rate-limit `lockedUntil` is refreshed on every blocked attempt, extending the window indefinitely **File:** `apps/api/src/routes/localAuth.ts:97-106, 133-137` **Issue:** On a 429 the code sets `attempt.lockedUntil = Date.now() + RATE_WINDOW_SECS*1000` again, so an attacker who keeps hitting the endpoint perpetually slides the cooldown forward — a legitimate user behind the same IP can never get back in even after pausing, because every attacker request re-arms the window. Coupled with CR-04 this makes the household-wide lock effectively permanent under sustained traffic. **Fix:** Do not extend `lockedUntil` on requests that are themselves rejected by the window; only set it when transitioning from below-threshold to at-threshold. ### WR-07: `parseInt` member/calendar id accepts trailing garbage **File:** `apps/api/src/routes/admin.ts:215-218, 307-311` **Issue:** `parseInt(c.req.param('id'), 10)` returns `12` for `"12abc"` and the `isNaN` guard passes. Not exploitable here (the value is used only in a parameterized `eq`), but it silently accepts malformed ids and could mask client bugs. The `/members/:id/password` and `/calendars/:id/shared` routes both use this pattern. **Fix:** Validate with `Number.isInteger(Number(raw))` or a Zod param schema so `"12abc"` is rejected with 400. ## Info ### IN-01: `localSession` `maxAge`/expiry parsing has no validation **File:** `apps/api/src/auth/localSession.ts:31` **Issue:** `Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400)` yields `NaN` for a malformed value, producing a JWT with `exp = now + NaN` (→ `NaN`) and a cookie `maxAge: NaN`. Verify behavior is then "always expired" or "never expires" depending on the JWT lib's NaN handling. **Fix:** Coerce and validate: `const n = Number(env); SESSION_MAX_AGE = Number.isFinite(n) && n > 0 ? n : 86400;` ### IN-02: Duplicated inline scrypt implementation across three locations **File:** `apps/api/scripts/reset-admin.ts:45-51`, `.gitea/workflows/ci.yml:307-311`, `apps/api/src/auth/localCredentials.ts:42-53` **Issue:** The PHC scrypt hash is copy-pasted in the reset-admin script, the CI seed step, and the canonical module. If the parameters ever change (the file comment advertises parameter evolution as a feature), these three drift and produce incompatible hashes. The duplication is documented as necessary (cannot import compiled TS from a plain script), but there is no test asserting the three stay in lockstep. **Fix:** Add a test that imports `hashPassword` and asserts a known input round-trips against a hash produced by the inlined parameters, so a parameter change fails CI loudly. ### IN-03: `loginAttempts` map is unbounded (memory growth) **File:** `apps/api/src/routes/localAuth.ts:66` **Issue:** Entries are only removed on a *successful* login for that IP. Spoofed/rotated `X-Forwarded-For` values (see CR-04) accumulate map entries with no eviction, a slow memory leak. Out of strict v1 perf scope, noted because it is reachable by unauthenticated input. **Fix:** Add periodic eviction of entries whose `lockedUntil` is far in the past. ### IN-04: `me.ts` link-oidc nonce is generated but never persisted/verified **File:** `apps/api/src/routes/me.ts:312-320` and `apps/api/src/index.ts:60-74` **Issue:** The signed state carries a `nonce` "to prevent replay," but the `/callback` handler never records or checks the nonce — it only verifies the JWT signature and reads `linkUserId`. A captured state JWT is fully replayable within its 10-minute window (the signature stays valid), so the nonce provides no actual replay protection. This underlies the takeover concern in BL-03. **Fix:** Persist issued nonces (or a single-use jti) and reject a state whose nonce was already consumed, or shorten the window and bind the state to the initiating session cookie. --- _Reviewed: 2026-06-17_ _Reviewer: Claude (gsd-code-reviewer)_ _Depth: deep_