---
phase: 19-local-auth-no-oidc-mode
plan: 03
type: tdd
wave: 3
depends_on: ["19-01", "19-02"]
files_modified:
- apps/api/src/auth/localAuthMiddleware.ts
- apps/api/src/routes/authMode.ts
- apps/api/src/routes/localAuth.ts
- apps/api/src/auth/middleware.ts
- apps/api/src/index.ts
- apps/api/tests/auth/localAuthMiddleware.test.ts
- apps/api/tests/routes/authMode.test.ts
- apps/api/tests/routes/localAuth.test.ts
autonomous: true
requirements: [AUTH-LOCAL-03, AUTH-LOCAL-04, AUTH-LOCAL-05, AUTH-LOCAL-06, AUTH-LOCAL-18, AUTH-LOCAL-19, AUTH-LOCAL-20]
must_haves:
truths:
- "A valid username+password POST to /api/auth/local/login returns 200 and sets a local-session cookie"
- "A wrong password and an unknown username both return the same 401 with the same body (no enumeration, no field discrimination)"
- "After 5 failed attempts the endpoint returns 429; after 10 it returns 423 until an admin reset"
- "A request carrying a valid local-session cookie resolves c.get('user') and is NOT 302-redirected to OIDC"
- "A request with no local-session cookie falls through unchanged to the OIDC guard"
- "GET /api/auth/mode is reachable pre-auth and returns { localEnabled:true, oidcEnabled } reflecting app_config/env"
- "Logout clears the local-session cookie"
- "An OIDC callback carrying a valid link-state binds the identity via linkOidcToUser and drops the local credential"
- "No user-facing string or config comment says 'Authelia'"
artifacts:
- path: "apps/api/src/auth/localAuthMiddleware.ts"
provides: "localAuthMiddleware — local-session cookie → c.set('user')"
exports: ["localAuthMiddleware"]
min_lines: 20
- path: "apps/api/src/routes/authMode.ts"
provides: "GET /api/auth/mode pre-auth endpoint"
exports: ["authModeRouter"]
min_lines: 12
- path: "apps/api/src/routes/localAuth.ts"
provides: "POST /login (rate-limited) + POST/GET /logout"
exports: ["localAuthRouter"]
min_lines: 40
- path: "apps/api/src/index.ts"
provides: "pre-auth auth routes mount + localAuthMiddleware slot + OIDC guard skip-when-user-set + /callback link branch"
contains: "localAuthMiddleware"
key_links:
- from: "apps/api/src/auth/localAuthMiddleware.ts"
to: "apps/api/src/auth/localSession.ts"
via: "verifyLocalSessionCookie → load users row → c.set('user', shape)"
pattern: "verifyLocalSessionCookie"
- from: "apps/api/src/index.ts"
to: "apps/api/src/auth/localAuthMiddleware.ts"
via: "app.use('/api/*', localAuthMiddleware()) between devAuthBypass and the OIDC guard"
pattern: "localAuthMiddleware\\(\\)"
- from: "apps/api/src/index.ts"
to: "apps/api/src/auth/linkOidc.ts"
via: "/callback reads link-state and calls linkOidcToUser"
pattern: "linkOidcToUser"
- from: "apps/api/src/routes/localAuth.ts"
to: "apps/api/src/auth/localSession.ts"
via: "issueLocalSessionCookie on success / clearLocalSessionCookie on logout"
pattern: "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.
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
@.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.
## 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) |
- `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
- AUTH-LOCAL-03/06: login (200/401/429/423) + logout work
- AUTH-LOCAL-04: localAuthMiddleware sets c.get('user') from cookie, passes through without
- AUTH-LOCAL-05: /api/auth/mode pre-auth, reflects oidc config
- AUTH-LOCAL-18: no Authelia copy in API comments
- AUTH-LOCAL-19/20: rate-limit + lockout + unit coverage
- D-03 coexistence: existing OIDC users unaffected (guard wrapper only skips when a user is already set)
## Artifacts this phase produces (Plan 03)
- Middleware: `localAuthMiddleware` (apps/api/src/auth/localAuthMiddleware.ts)
- Router: `authModeRouter` → `GET /api/auth/mode` (pre-auth)
- Router: `localAuthRouter` → `POST /api/auth/local/login`, `POST /api/auth/local/logout`, `GET /api/auth/local/logout`
- index.ts: pre-auth auth-route mounts, localAuthMiddleware slot, OIDC-guard skip-when-user-set wrapper, /callback link-mode branch
- middleware.ts: de-Authelia-ized comments (D-06)
- In-memory rate-limit/lockout state machine (login)