Files
2026-06-18 22:21:38 -04:00

19 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
19-local-auth-no-oidc-mode 03 tdd 3
19-01
19-02
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
true
AUTH-LOCAL-03
AUTH-LOCAL-04
AUTH-LOCAL-05
AUTH-LOCAL-06
AUTH-LOCAL-18
AUTH-LOCAL-19
AUTH-LOCAL-20
truths artifacts key_links
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'
path provides exports min_lines
apps/api/src/auth/localAuthMiddleware.ts localAuthMiddleware — local-session cookie → c.set('user')
localAuthMiddleware
20
path provides exports min_lines
apps/api/src/routes/authMode.ts GET /api/auth/mode pre-auth endpoint
authModeRouter
12
path provides exports min_lines
apps/api/src/routes/localAuth.ts POST /login (rate-limited) + POST/GET /logout
localAuthRouter
40
path provides contains
apps/api/src/index.ts pre-auth auth routes mount + localAuthMiddleware slot + OIDC guard skip-when-user-set + /callback link branch localAuthMiddleware
from to via pattern
apps/api/src/auth/localAuthMiddleware.ts apps/api/src/auth/localSession.ts verifyLocalSessionCookie → load users row → c.set('user', shape) verifyLocalSessionCookie
from to via pattern
apps/api/src/index.ts apps/api/src/auth/localAuthMiddleware.ts 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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.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
  • 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) </success_criteria>

<artifacts_produced>

Artifacts this phase produces (Plan 03)

  • Middleware: localAuthMiddleware (apps/api/src/auth/localAuthMiddleware.ts)
  • Router: authModeRouterGET /api/auth/mode (pre-auth)
  • Router: localAuthRouterPOST /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) </artifacts_produced>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-03-SUMMARY.md` when done