254 lines
19 KiB
Markdown
254 lines
19 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
@$HOME/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="tdd" tdd="true">
|
|
<name>Task 1: localAuthMiddleware + GET /api/auth/mode</name>
|
|
<read_first>
|
|
- 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
|
|
</read_first>
|
|
<files>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</files>
|
|
<behavior>
|
|
- 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.
|
|
</behavior>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @familysync/api test tests/auth/localAuthMiddleware.test.ts tests/routes/authMode.test.ts</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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`)
|
|
</acceptance_criteria>
|
|
<done>localAuthMiddleware populates c.get('user') from a valid cookie and passes through cleanly otherwise; /api/auth/mode reports local+oidc availability pre-auth.</done>
|
|
</task>
|
|
|
|
<task type="tdd" tdd="true">
|
|
<name>Task 2: POST /api/auth/local/login (rate-limit + lockout) + logout</name>
|
|
<read_first>
|
|
- 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)
|
|
</read_first>
|
|
<files>apps/api/src/routes/localAuth.ts, apps/api/tests/routes/localAuth.test.ts</files>
|
|
<behavior>
|
|
- 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.
|
|
</behavior>
|
|
<action>
|
|
Create apps/api/src/routes/localAuth.ts exporting `localAuthRouter = new Hono()`. Copy `noEchoHook` verbatim from setup.ts. Define an in-memory `loginAttempts = new Map<string, { count, lockedUntil, lockedOut }>()` (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.
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @familysync/api test tests/routes/localAuth.test.ts</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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)
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: index.ts wiring (mounts + OIDC guard skip + /callback link branch) + de-Authelia comments</name>
|
|
<read_first>
|
|
- 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
|
|
</read_first>
|
|
<files>apps/api/src/index.ts, apps/api/src/auth/middleware.ts</files>
|
|
<action>
|
|
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).
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @familysync/api test && pnpm --filter @familysync/api typecheck</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `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
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
- `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
|
|
</verification>
|
|
|
|
<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: `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)
|
|
</artifacts_produced>
|
|
|
|
<output>
|
|
Create `.planning/phases/19-local-auth-no-oidc-mode/19-03-SUMMARY.md` when done
|
|
</output>
|