app.use('/api/*', localAuthMiddleware()) between devAuthBypass and the OIDC guard
localAuthMiddleware()
from
to
via
pattern
apps/api/src/index.ts
apps/api/src/auth/linkOidc.ts
/callback reads link-state and calls linkOidcToUser
linkOidcToUser
from
to
via
pattern
apps/api/src/routes/localAuth.ts
apps/api/src/auth/localSession.ts
issueLocalSessionCookie on success / clearLocalSessionCookie on logout
issueLocalSessionCookie
Wire the local-auth request path: the `localAuthMiddleware` that turns a `local-session` cookie into `c.get('user')`, the pre-auth `GET /api/auth/mode` endpoint, the rate-limited `POST /api/auth/local/login` + logout routes, the `index.ts` middleware mount (including the OIDC-guard skip-when-already-authed wrapper and the `/callback` link branch), and the D-06 de-Authelia-ization of config comments.
Purpose: This is the security seam of the phase — login verification, session issuance, middleware ordering so the OIDC guard never 302-redirects a valid local session, and the rate-limit/lockout state machine. All have defined I/O — TDD. It runs after 19-02 because the /callback link branch calls linkOidcToUser (19-02) and after 19-01 for the session/hash primitives.
Output: localAuthMiddleware.ts, authMode.ts, localAuth.ts, edited index.ts + middleware.ts, three new test suites.
Derived REQ-IDs covered: AUTH-LOCAL-03 (login), AUTH-LOCAL-04 (middleware), AUTH-LOCAL-05 (mode), AUTH-LOCAL-06 (logout), AUTH-LOCAL-18 (de-Authelia, D-06), AUTH-LOCAL-19 (rate-limit/lockout), AUTH-LOCAL-20 (auth unit tests). Coexistence per D-01/D-02/D-03. The localAuthMiddleware-beside-OIDC-guard seam is the "clean internal seam, no plugin/registry framework" required by D-07 — this phase ships exactly local + one generic OIDC and nothing more.
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
@.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md
@.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md
@.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md
@.planning/phases/19-local-auth-no-oidc-mode/19-02-SUMMARY.md
Task 1: localAuthMiddleware + GET /api/auth/mode
- apps/api/src/auth/devBypass.ts (the c.set('user', ...) contract + DEV_USER shape + ContextVariableMap augmentation the middleware must match; lines 30-76)
- apps/api/tests/auth/devBypass.test.ts (middleware unit-test style)
- apps/api/src/auth/localSession.ts (verifyLocalSessionCookie — from 19-01)
- apps/api/src/routes/setup.ts (GET /status pre-auth pattern lines ~86-95 — authMode mirrors it)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §localAuthMiddleware.ts + §authMode.ts (exact patterns)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Middleware Slot + §Auth Mode Endpoint + §Common Pitfalls 1
apps/api/src/auth/localAuthMiddleware.ts, apps/api/src/routes/authMode.ts, apps/api/tests/auth/localAuthMiddleware.test.ts, apps/api/tests/routes/authMode.test.ts
- RED: write apps/api/tests/auth/localAuthMiddleware.test.ts:
- Test 1: with a valid local-session cookie for an existing user, the middleware sets c.get('user') to {id, oidcIss, oidcSub, displayName, color} and calls next
- Test 2: with no cookie, the middleware is a pure passthrough — c.get('user') stays unset (NOT undefined-set) so the OIDC guard can still fire (Pitfall 1)
- Test 3: with a cookie whose userId has no users row, passthrough (no crash)
- Test 4: when c.get('user') is already set (devAuthBypass ran first), middleware does not overwrite and calls next
- RED: write apps/api/tests/routes/authMode.test.ts:
- Test 5: GET /api/auth/mode returns { localEnabled:true, oidcEnabled:false } when no oidc_issuer in env or app_config
- Test 6: returns oidcEnabled:true when app_config has oidc_issuer (or OIDC_ISSUER env set)
- Run; confirm FAIL.
Create apps/api/src/auth/localAuthMiddleware.ts exporting `localAuthMiddleware(): MiddlewareHandler`. Side-effect import the ContextVariableMap augmentation (`import '../auth/devBypass.js'`) so c.set('user') is typed. In the handler: if `c.get('user')` already set → next() (devAuthBypass-first). Else `verifyLocalSessionCookie(c)`; if null → next() passthrough. Else SELECT the users row by id; if found, `c.set('user', { id, oidcIss: row.oidcIss ?? 'local', oidcSub: row.oidcSub ?? String(row.id), displayName: row.displayName ?? null, color: row.color })`; always next(). MUST never set user to undefined on the no-cookie path (Pitfall 1).
Create apps/api/src/routes/authMode.ts exporting `authModeRouter = new Hono()` with `GET /`: `localEnabled` always true (D-01); `oidcEnabled` = Boolean(process.env.OIDC_ISSUER) OR, if absent, Boolean of an app_config row keyed `oidc_issuer`; `return c.json({ localEnabled: true, oidcEnabled })`. No auth gate (pre-auth, mirrors setup GET /status). Run the suites — GREEN.
pnpm --filter @familysync/api test tests/auth/localAuthMiddleware.test.ts tests/routes/authMode.test.ts
- Both suites exit 0, all 6 tests green
- Source assertion: `grep -c "verifyLocalSessionCookie" apps/api/src/auth/localAuthMiddleware.ts` >= 1
- Negative assertion: middleware no-cookie path calls next() without c.set('user') — verified by Test 2 (OIDC guard fall-through intact)
- Source assertion: authMode returns localEnabled true unconditionally (grep `localEnabled: true`)
localAuthMiddleware populates c.get('user') from a valid cookie and passes through cleanly otherwise; /api/auth/mode reports local+oidc availability pre-auth.
Task 2: POST /api/auth/local/login (rate-limit + lockout) + logout
- apps/api/src/routes/setup.ts (noEchoHook lines ~49-53; zValidator usage; pre-auth router style)
- apps/api/tests/routes/login.test.ts (existing auth-route test mock patterns)
- apps/api/src/auth/localCredentials.ts (verifyPassword — from 19-01), apps/api/src/auth/localSession.ts (issue/clear cookie — from 19-01)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Local Login Endpoint + §Rate Limiting (loginAttempts Map; 5→429, 10→423; dummy-hash timing defense; same-401 copy)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §localAuth.ts
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surface 6 (401/429/423 → error copy the PWA renders)
apps/api/src/routes/localAuth.ts, apps/api/tests/routes/localAuth.test.ts
- RED: write apps/api/tests/routes/localAuth.test.ts:
- Test 1: valid username+password → 200 { ok:true } and a Set-Cookie for local-session
- Test 2: wrong password → 401 { error: 'Invalid credentials' }
- Test 3: unknown username → 401 with the SAME body as Test 2 (no enumeration / no field discrimination)
- Test 4: 5 consecutive failures from one IP → the 6th returns 429
- Test 5: 10 failures → 423; a successful login after a reset/cleared map clears the counter
- Test 6: POST /api/auth/local/logout (and GET alias) clears the local-session cookie (Set-Cookie maxAge 0 / expired)
- Test 7 (no-echo): a malformed body (missing password) returns 400 { error: 'Invalid request' } and the response body contains neither the submitted value nor a Zod `received` field
- Run; confirm FAIL.
Create apps/api/src/routes/localAuth.ts exporting `localAuthRouter = new Hono()`. Copy `noEchoHook` verbatim from setup.ts. Define an in-memory `loginAttempts = new Map()` (household scale; no Redis). Constants RATE_WINDOW_FAILURES=5, RATE_WINDOW_SECS=60, LOCKOUT_FAILURES=10. `POST /login` with zValidator json `{ username: string min1 max128 trim, password: string min1 max1000 }` + noEchoHook: derive IP from `x-forwarded-for` (Pangolin sets it) else host; if locked → 423; if count>=5 and within window → 429; SELECT local_credentials by username; ALWAYS run verifyPassword (use a precomputed dummy hash when username unknown, to defeat the timing oracle — RESEARCH Pitfall 2); on invalid → increment counter, set lockedUntil, set lockedOut at >=10, return 401 `{ error: 'Invalid credentials' }`; on success → `loginAttempts.delete(ip)`, `issueLocalSessionCookie(c, cred.userId)`, return 200 `{ ok: true }`. `POST /logout` and `GET /logout` (alias) → `clearLocalSessionCookie(c)` then 200 `{ ok: true }`. Standard error pattern for unexpected errors (console.error without body + 503). Run the suite — GREEN.
pnpm --filter @familysync/api test tests/routes/localAuth.test.ts
- Suite exits 0; all 7 tests green
- Behavior: Test 3 confirms unknown-username and wrong-password 401 bodies are byte-identical (no enumeration)
- Behavior: Test 4/5 confirm 429 at 6th attempt and 423 at lockout
- Source assertion: `grep -c "noEchoHook" apps/api/src/routes/localAuth.ts` >= 1 on the login route
- Source assertion: login ALWAYS calls verifyPassword even on unknown username (dummy-hash path present — grep for the dummy/filler hash)
Login verifies timing-safely, issues the session cookie, enforces per-IP rate-limit (429) and lockout (423), and never echoes the password; logout clears the cookie.
Task 3: index.ts wiring (mounts + OIDC guard skip + /callback link branch) + de-Authelia comments
- apps/api/src/index.ts (lines 25-110: devBypassActive, /callback handler line ~40, /api/setup mount line ~49, devAuthBypass + oidcConfigFallback + oidcAuthMiddleware + persistSessionCookie chain lines ~55-73; isMainModule boot block lines ~121-140)
- apps/api/src/auth/middleware.ts (header comment + inline 'Authelia base URL' comments to genericize — D-06)
- apps/api/src/auth/linkOidc.ts (linkOidcToUser + OidcLinkConflictError — from 19-02; called from the /callback link branch)
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/index.ts (new chain) + §apps/api/src/auth/middleware.ts (comment-only de-Authelia)
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Middleware Slot (skip-when-user-set wrapper) + §OIDC-Link Flow (signed state in /callback) + §BYO-Auth De-Authelia-ization
apps/api/src/index.ts, apps/api/src/auth/middleware.ts
In apps/api/src/index.ts: mount the new pre-auth routes immediately after the existing `app.route('/api/setup', setupRouter)` line — `app.route('/api/auth', authModeRouter)` and `app.route('/api/auth', localAuthRouter)` (both before any /api/* middleware). After `app.use('/api/*', devAuthBypass())`, add `app.use('/api/*', localAuthMiddleware())`. Inside the existing `if (!devBypassActive)` block, replace the bare `app.use('/api/*', oidcAuthMiddleware())` with a wrapper: `app.use('/api/*', async (c, next) => { if (c.get('user')) { await next(); return; } await oidcAuthMiddleware()(c, next); })` so a valid local (or dev) session is not 302-redirected to OIDC (RESEARCH Pitfall 1). Keep oidcConfigFallbackMiddleware and persistSessionCookie unchanged and in order.
Extend the existing `/callback` handler (registered before the OIDC guard) to support link mode: when the callback's signed `state` carries a `linkUserId`, after `processOAuthCallback` resolves the OIDC `iss+sub`, call `linkOidcToUser(linkUserId, iss, sub)`; on `OidcLinkConflictError` redirect to a generic error page/route (UI-SPEC Surface 13 409 copy) without deleting any local credential; on success continue the normal post-login redirect (the user is now OIDC-only). Do NOT alter the existing non-link callback behavior. The `assertLocalSessionSecretSet()` boot call added in 19-01 stays as-is.
In apps/api/src/auth/middleware.ts: comment-only de-Authelia-ization (D-06) — change the header comment and any inline references from Authelia-specific wording ("Authelia as the identity provider", "Authelia base URL") to generic "OIDC identity provider" / "OIDC issuer URL". No runtime behavior change. Do not rename any env var or app_config key (they are already generic).
pnpm --filter @familysync/api test && pnpm --filter @familysync/api typecheck
- `pnpm --filter @familysync/api test` exits 0 (full API suite green, including the new auth suites)
- `pnpm --filter @familysync/api typecheck` exits 0
- Source assertion: `grep -c "localAuthMiddleware()" apps/api/src/index.ts` >= 1 mounted on /api/* AFTER devAuthBypass and BEFORE the OIDC guard
- Source assertion: index.ts OIDC guard is wrapped with a `if (c.get('user'))` skip (grep the wrapper)
- Source assertion: `grep -c "linkOidcToUser" apps/api/src/index.ts` >= 1 (callback link branch)
- Negative assertion (D-06): `grep -ci "authelia" apps/api/src/auth/middleware.ts` == 0 and `grep -ci "authelia" apps/api/src/index.ts` == 0
Auth routes mounted pre-auth; localAuthMiddleware in slot; OIDC guard skipped when a local/dev user is set; /callback handles link mode via linkOidcToUser; Authelia removed from API comments.
<threat_model>
Trust Boundaries
Boundary
Description
client → POST /api/auth/local/login
unauthenticated credential submission; the brute-force surface
local-session cookie → c.get('user')
the request-auth boundary localAuthMiddleware enforces
OIDC callback state → identity binding
external-identity boundary with CSRF/conflict risk
STRIDE Threat Register (ASVS L1, block on high)
Threat ID
Category
Component
Disposition
Mitigation Plan
T-19-11
Elevation of Privilege
login brute-force
mitigate
per-IP rate-limit (5→429), lockout (10→423) resolved only by admin reset (V2)
T-19-12
Information Disclosure
username enumeration timing
mitigate
dummy-hash verifyPassword on unknown username; identical 401 body (RESEARCH Pitfall 2)
T-19-13
Spoofing
OIDC guard 302 on valid local session
mitigate
guard wrapped to skip when c.get('user') set (RESEARCH Pitfall 1) — local sessions are honored
T-19-14
Information Disclosure
password echoed in Zod error
mitigate
noEchoHook on login route (V5)
T-19-15
Elevation of Privilege
account takeover via /callback link
mitigate
linkOidcToUser preflight conflict (409) + signed state (T-19-08/09 from 19-02)
T-19-16
Information Disclosure
infra leak via "Authelia" copy
accept→mitigate
D-06 removes provider-specific wording from comments/UI; low severity, done for hygiene
T-19-17
Spoofing
session fixation
mitigate
a fresh signed JWT is issued on every successful login; exp claim bounds lifetime (V3)
</threat_model>
- `pnpm --filter @familysync/api test` green (all API suites)
- `pnpm --filter @familysync/api typecheck` exits 0
- A valid local-session request reaches downstream routes without a 302 (Pitfall 1 covered by index wiring + middleware Test 2)
- No "Authelia" string remains in API source comments
<success_criteria>
AUTH-LOCAL-03/06: login (200/401/429/423) + logout work
AUTH-LOCAL-04: localAuthMiddleware sets c.get('user') from cookie, passes through without