--- 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) Create `.planning/phases/19-local-auth-no-oidc-mode/19-03-SUMMARY.md` when done