chore: merge executor worktree (worktree-agent-a11c9f6d35e8d8721)
This commit is contained in:
@@ -0,0 +1,182 @@
|
|||||||
|
---
|
||||||
|
phase: 19-local-auth-no-oidc-mode
|
||||||
|
plan: "03"
|
||||||
|
subsystem: auth
|
||||||
|
tags: [local-auth, middleware, rate-limit, lockout, session-cookie, oidc-link, de-authelia, tdd]
|
||||||
|
status: complete
|
||||||
|
dependency_graph:
|
||||||
|
requires:
|
||||||
|
- verifyLocalSessionCookie / issueLocalSessionCookie / clearLocalSessionCookie (from 19-01)
|
||||||
|
- localCredentials Drizzle table (from 19-01)
|
||||||
|
- hashPassword / verifyPassword (from 19-01)
|
||||||
|
- linkOidcToUser / OidcLinkConflictError (from 19-02)
|
||||||
|
provides:
|
||||||
|
- localAuthMiddleware: cookie → c.set('user') middleware (AUTH-LOCAL-04)
|
||||||
|
- GET /api/auth/mode: pre-auth OIDC config endpoint (AUTH-LOCAL-05)
|
||||||
|
- POST /api/auth/local/login: rate-limited + timing-safe login (AUTH-LOCAL-03, AUTH-LOCAL-19)
|
||||||
|
- POST/GET /api/auth/local/logout: session cookie clear (AUTH-LOCAL-06)
|
||||||
|
- index.ts: pre-auth mounts + localAuthMiddleware slot + OIDC guard skip-when-user-set + /callback link branch (AUTH-LOCAL-04, AUTH-LOCAL-10)
|
||||||
|
- middleware.ts: de-Authelia-ized comments (AUTH-LOCAL-18)
|
||||||
|
affects:
|
||||||
|
- apps/api/src/index.ts (route mounts, middleware chain, /callback extension)
|
||||||
|
- apps/api/src/auth/middleware.ts (comment-only D-06 changes)
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- TDD RED/GREEN per task (failing test committed before implementation)
|
||||||
|
- In-memory loginAttempts Map: per-IP rate-limit (5→429) + lockout (10→423)
|
||||||
|
- DUMMY_HASH timing defense: verifyPassword always runs, even for unknown usernames (T-19-12)
|
||||||
|
- noEchoHook on login: Zod errors never echo submitted values (T-19-14)
|
||||||
|
- oidcAuthMiddleware() factory called once at construction, returned handler in skip-when-user-set wrapper (D-03)
|
||||||
|
- Jwt.verify on URL state param to extract linkUserId in /callback link branch (T-19-15)
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- apps/api/src/auth/localAuthMiddleware.ts
|
||||||
|
- apps/api/src/routes/authMode.ts
|
||||||
|
- apps/api/src/routes/localAuth.ts
|
||||||
|
- apps/api/tests/auth/localAuthMiddleware.test.ts
|
||||||
|
- apps/api/tests/routes/authMode.test.ts
|
||||||
|
- apps/api/tests/routes/localAuth.test.ts
|
||||||
|
modified:
|
||||||
|
- apps/api/src/index.ts
|
||||||
|
- apps/api/src/auth/middleware.ts
|
||||||
|
decisions:
|
||||||
|
- "loginAttempts counter increments even on 429 responses — brute-force accumulates toward lockout (10→423) even during rate-window; original implementation only incremented on final auth check"
|
||||||
|
- "oidcAuthMiddleware() factory called once at app construction (not per-request) to preserve test assertion that it is called exactly once during app init"
|
||||||
|
- "localAuthMiddleware casts user value to typeof DEV_USER for ContextVariableMap compatibility — the narrow const type from devBypass.ts as const requires an explicit cast"
|
||||||
|
- "Authelia removed from 2 comments in index.ts and 2 comments in middleware.ts (D-06 / AUTH-LOCAL-18)"
|
||||||
|
metrics:
|
||||||
|
duration: "~16 minutes"
|
||||||
|
completed: "2026-06-17"
|
||||||
|
tasks_completed: 3
|
||||||
|
tasks_total: 3
|
||||||
|
files_created: 6
|
||||||
|
files_modified: 2
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 19 Plan 03: Auth Routes + Middleware Wiring Summary
|
||||||
|
|
||||||
|
**One-liner:** localAuthMiddleware (cookie→c.set('user')), GET /api/auth/mode pre-auth endpoint, rate-limited POST /api/auth/local/login with timing-safe dummy-hash, logout, index.ts middleware chain wired with OIDC-guard skip-when-user-set and /callback link branch for linkOidcToUser, de-Authelia-ized middleware comments.
|
||||||
|
|
||||||
|
## Tasks Completed
|
||||||
|
|
||||||
|
| Task | RED Commit | GREEN Commit | Key Files |
|
||||||
|
|------|-----------|-------------|-----------|
|
||||||
|
| 1: localAuthMiddleware + GET /api/auth/mode | ac32bd4 | be7a0ae | localAuthMiddleware.ts, authMode.ts, index.ts |
|
||||||
|
| 2: POST /api/auth/local/login (rate-limit + lockout) + logout | db66295 | c437f40 | localAuth.ts |
|
||||||
|
| 3: index.ts wiring + /callback link branch + de-Authelia comments | — | 9b569ef | index.ts, middleware.ts |
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
### Task 1: localAuthMiddleware + GET /api/auth/mode (TDD)
|
||||||
|
|
||||||
|
**`apps/api/src/auth/localAuthMiddleware.ts`** — exports `localAuthMiddleware(): MiddlewareHandler`:
|
||||||
|
- If `c.get('user')` already set (devAuthBypass ran first): no-op, calls next()
|
||||||
|
- Calls `verifyLocalSessionCookie(c)` — returns null if no cookie / invalid / expired
|
||||||
|
- On null: calls next() WITHOUT c.set('user') — Pitfall-1 guard; OIDC guard fires on absent key
|
||||||
|
- On valid userId: SELECTs users row; if found, `c.set('user', {...} as typeof DEV_USER)` with matching shape
|
||||||
|
|
||||||
|
**`apps/api/src/routes/authMode.ts`** — exports `authModeRouter` with GET /mode:
|
||||||
|
- `localEnabled: true` unconditionally (D-01)
|
||||||
|
- `oidcEnabled: Boolean(OIDC_ISSUER env)` || falls back to `app_config` oidc_issuer row
|
||||||
|
- No auth gate — pre-auth endpoint
|
||||||
|
|
||||||
|
Tests: 8 tests pass (4 middleware + 4 mode tests)
|
||||||
|
|
||||||
|
### Task 2: POST /api/auth/local/login + logout (TDD)
|
||||||
|
|
||||||
|
**`apps/api/src/routes/localAuth.ts`** — exports `localAuthRouter` and `loginAttempts`:
|
||||||
|
- `POST /local/login` — zValidator + noEchoHook + per-IP loginAttempts Map
|
||||||
|
- lockedOut check (>= LOCKOUT_FAILURES=10) → 423 `{ error: 'Account locked' }`
|
||||||
|
- Rate window check (>= RATE_WINDOW_FAILURES=5, within RATE_WINDOW_SECS=60) → increments counter + 429
|
||||||
|
- Selects local_credentials by username; ALWAYS runs verifyPassword (DUMMY_HASH on unknown username — T-19-12)
|
||||||
|
- Same 401 body for wrong-password AND unknown-username (no enumeration)
|
||||||
|
- On success: loginAttempts.delete(ip), issueLocalSessionCookie, 200 `{ ok: true }`
|
||||||
|
- `POST /local/logout` + `GET /local/logout` → clearLocalSessionCookie → 200 `{ ok: true }`
|
||||||
|
|
||||||
|
Tests: 8 tests pass (login success/failure/enumeration/rate-limit/lockout/logout/no-echo)
|
||||||
|
|
||||||
|
### Task 3: index.ts wiring + /callback link branch + de-Authelia comments
|
||||||
|
|
||||||
|
**`apps/api/src/index.ts`** changes:
|
||||||
|
- Pre-auth mounts: `app.route('/api/auth', authModeRouter)` + `app.route('/api/auth', localAuthRouter)` before devAuthBypass
|
||||||
|
- `app.use('/api/*', localAuthMiddleware())` after devAuthBypass, before OIDC guard
|
||||||
|
- OIDC guard wrapper: `oidcHandler = oidcAuthMiddleware()` stored once at construction, invoked per-request only when `c.get('user')` is falsy (D-03 coexistence seam)
|
||||||
|
- `/callback` extended: reads URL `state` param, tries Jwt.verify with LOCAL_SESSION_SECRET; if `linkUserId` in payload → call linkOidcToUser after processOAuthCallback; OidcLinkConflictError → redirect `/?error=oidc-link-conflict`
|
||||||
|
- Authelia references removed from 2 comments (D-06)
|
||||||
|
|
||||||
|
**`apps/api/src/auth/middleware.ts`** changes:
|
||||||
|
- Header: "Authelia as the identity provider" → "generic OIDC identity provider" (D-06)
|
||||||
|
- "Authelia base URL" → "OIDC issuer URL" (D-06)
|
||||||
|
- "Authelia's refresh_token_lifespan" → "the OIDC provider's refresh_token_lifespan" (D-06)
|
||||||
|
|
||||||
|
Full suite: 446/446 tests pass; `pnpm --filter @familysync/api typecheck` exits 0.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Rate-limit counter not incrementing during 429 window**
|
||||||
|
- **Found during:** Task 2 GREEN phase (Test 5 returning 429 instead of 423 for the 11th attempt)
|
||||||
|
- **Issue:** After 5 failures, subsequent attempts returned 429 (early return) without incrementing the counter. The counter never reached LOCKOUT_FAILURES=10 because the early-return prevented accumulation.
|
||||||
|
- **Fix:** Inside the rate-window 429 branch: increment counter, update lockedUntil, check if lockedOut (returns 423 if so), else return 429. This way brute-force attacks accumulate toward lockout even during the rate window.
|
||||||
|
- **Files modified:** apps/api/src/routes/localAuth.ts
|
||||||
|
- **Commit:** c437f40
|
||||||
|
|
||||||
|
**2. [Rule 1 - Bug] oidcAuthMiddleware() factory called per-request in OIDC guard wrapper**
|
||||||
|
- **Found during:** Task 3 execution — me.test.ts assertion that oidcAuthMiddleware is called exactly once during app init
|
||||||
|
- **Issue:** Original OIDC guard wrapper called `oidcAuthMiddleware()(c, next)` per-request; existing test `wires oidcAuthMiddleware on /api/* when bypass is not active` asserts `oidcMiddlewareSpy.toHaveBeenCalledTimes(1)` (factory called once at construction).
|
||||||
|
- **Fix:** Store `const oidcHandler = oidcAuthMiddleware()` at construction time; invoke `oidcHandler(c, next)` per-request inside the wrapper.
|
||||||
|
- **Files modified:** apps/api/src/index.ts
|
||||||
|
- **Commit:** 9b569ef
|
||||||
|
|
||||||
|
**3. [Rule 1 - Bug] Type incompatibility: localAuthMiddleware c.set('user') type error**
|
||||||
|
- **Found during:** Task 3 typecheck
|
||||||
|
- **Issue:** `ContextVariableMap` maps 'user' to `typeof DEV_USER` (narrow `as const` literal). The middleware constructs `{ id: number; oidcIss: string; ... }` which TypeScript rejects as incompatible.
|
||||||
|
- **Fix:** Add `import type { DEV_USER }` and cast with `as typeof DEV_USER` on the c.set call.
|
||||||
|
- **Files modified:** apps/api/src/auth/localAuthMiddleware.ts
|
||||||
|
- **Commit:** 9b569ef
|
||||||
|
|
||||||
|
## Threat Surface Scan
|
||||||
|
|
||||||
|
All new/modified routes in this plan:
|
||||||
|
- `GET /api/auth/mode` — pre-auth, no credentials, no sensitive data; reads only env/app_config
|
||||||
|
- `POST /api/auth/local/login` — new attack surface; mitigated by T-19-11 (rate-limit), T-19-12 (timing-safe dummy hash, no-enumeration 401), T-19-14 (noEchoHook)
|
||||||
|
- `POST/GET /api/auth/local/logout` — clears cookie only; no sensitive data exposed
|
||||||
|
|
||||||
|
No new trust boundaries beyond those in the plan's threat model.
|
||||||
|
|
||||||
|
## TDD Gate Compliance
|
||||||
|
|
||||||
|
**Task 1 (TDD):**
|
||||||
|
- RED commit: ac32bd4 — test(19-03): add failing tests for localAuthMiddleware and GET /api/auth/mode
|
||||||
|
- GREEN commit: be7a0ae — feat(19-03): implement localAuthMiddleware, GET /api/auth/mode...
|
||||||
|
|
||||||
|
**Task 2 (TDD):**
|
||||||
|
- RED commit: db66295 — test(19-03): add failing tests for POST /api/auth/local/login + logout
|
||||||
|
- GREEN commit: c437f40 — feat(19-03): implement POST /api/auth/local/login (rate-limit + lockout) + logout
|
||||||
|
|
||||||
|
**Task 3 (auto):** No TDD cycle required.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None. All new endpoints return real data and perform real operations.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
All created files confirmed present on disk:
|
||||||
|
- FOUND: apps/api/src/auth/localAuthMiddleware.ts
|
||||||
|
- FOUND: apps/api/src/routes/authMode.ts
|
||||||
|
- FOUND: apps/api/src/routes/localAuth.ts
|
||||||
|
- FOUND: apps/api/tests/auth/localAuthMiddleware.test.ts
|
||||||
|
- FOUND: apps/api/tests/routes/authMode.test.ts
|
||||||
|
- FOUND: apps/api/tests/routes/localAuth.test.ts
|
||||||
|
|
||||||
|
All commits confirmed in git log:
|
||||||
|
- ac32bd4: test(19-03): add failing tests for localAuthMiddleware and GET /api/auth/mode
|
||||||
|
- be7a0ae: feat(19-03): implement localAuthMiddleware, GET /api/auth/mode, and pre-auth route mounts
|
||||||
|
- db66295: test(19-03): add failing tests for POST /api/auth/local/login + logout
|
||||||
|
- c437f40: feat(19-03): implement POST /api/auth/local/login (rate-limit + lockout) + logout
|
||||||
|
- 9b569ef: feat(19-03): wire /callback link branch, OIDC-guard skip, de-Authelia comments
|
||||||
|
|
||||||
|
Test results: 446/446 pass (34 test files); `pnpm --filter @familysync/api typecheck` exits 0.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* localAuthMiddleware.ts — local-session cookie → c.set('user') middleware (AUTH-LOCAL-04).
|
||||||
|
*
|
||||||
|
* Reads the `local-session` cookie, validates the JWT, fetches the users row,
|
||||||
|
* and populates `c.get('user')` with the same shape as devAuthBypass so that all
|
||||||
|
* downstream routes work unchanged.
|
||||||
|
*
|
||||||
|
* Mount in index.ts AFTER devAuthBypass() and BEFORE the OIDC guard:
|
||||||
|
* app.use('/api/*', devAuthBypass());
|
||||||
|
* app.use('/api/*', localAuthMiddleware()); ← HERE
|
||||||
|
* if (!devBypassActive) {
|
||||||
|
* app.use('/api/*', oidcAuthMiddleware()); ← skip if c.get('user') set
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Security contract:
|
||||||
|
* - If c.get('user') is already set (devAuthBypass ran first): no-op passthrough.
|
||||||
|
* This is Test 4 — dev user is not overwritten.
|
||||||
|
* - If no 'local-session' cookie is present: pure passthrough WITHOUT calling
|
||||||
|
* c.set('user', undefined). The OIDC guard fires on falsy c.get('user') only when
|
||||||
|
* the value was never set — calling c.set('user', undefined) would suppress it.
|
||||||
|
* This is Pitfall-1 / Test 2.
|
||||||
|
* - If cookie is valid but the users row is gone: passthrough (no crash).
|
||||||
|
* This is Test 3.
|
||||||
|
*
|
||||||
|
* ContextVariableMap: the `user` shape is declared in devBypass.ts. Import it as a
|
||||||
|
* side-effect so c.set('user', ...) is typed correctly throughout this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Side-effect import: extends ContextVariableMap with the `user` key (Pitfall — shared shape).
|
||||||
|
import './devBypass.js';
|
||||||
|
import type { MiddlewareHandler } from 'hono';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { users } from '../db/schema.js';
|
||||||
|
import { verifyLocalSessionCookie } from './localSession.js';
|
||||||
|
import type { DEV_USER } from './devBypass.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Hono MiddlewareHandler that:
|
||||||
|
* 1. Checks if c.get('user') is already set — no-op if so (devAuthBypass-first).
|
||||||
|
* 2. Calls verifyLocalSessionCookie(c) — returns null if no cookie / invalid / expired.
|
||||||
|
* 3. On null: calls next() WITHOUT setting user (pure passthrough — OIDC guard can fire).
|
||||||
|
* 4. On valid userId: SELECTs the users row; if found, c.set('user', {...}); always next().
|
||||||
|
*/
|
||||||
|
export function localAuthMiddleware(): MiddlewareHandler {
|
||||||
|
return async (c, next) => {
|
||||||
|
// If a prior middleware (devAuthBypass) already set the user, do not overwrite.
|
||||||
|
if (c.get('user')) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the local-session JWT cookie — returns userId or null.
|
||||||
|
// verifyLocalSessionCookie returns null (never throws) on any error (Pitfall 9 guard).
|
||||||
|
const userId = await verifyLocalSessionCookie(c);
|
||||||
|
|
||||||
|
if (userId === null) {
|
||||||
|
// No valid local session — pass through WITHOUT setting c.get('user').
|
||||||
|
// CRITICAL: Do NOT call c.set('user', undefined) — that sets the key to undefined
|
||||||
|
// which is falsy but "set", breaking the OIDC guard's c.get('user') check (Pitfall 1).
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the users row to populate the same shape as DEV_USER.
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
oidcIss: users.oidcIss,
|
||||||
|
oidcSub: users.oidcSub,
|
||||||
|
displayName: users.displayName,
|
||||||
|
color: users.color,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
// userId from JWT but no users row (deleted user) — passthrough without setting user.
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate c.get('user') with the same shape as DEV_USER (devBypass.ts ContextVariableMap).
|
||||||
|
// oidcIss/oidcSub: local users have nullable oidcIss/oidcSub — use fallback strings so the
|
||||||
|
// shape is compatible with typeof DEV_USER at runtime. Cast required because ContextVariableMap
|
||||||
|
// is narrowed to the const DEV_USER literal type.
|
||||||
|
c.set('user', {
|
||||||
|
id: row.id,
|
||||||
|
oidcIss: row.oidcIss ?? 'local',
|
||||||
|
oidcSub: row.oidcSub ?? String(row.id),
|
||||||
|
displayName: row.displayName ?? null,
|
||||||
|
color: row.color ?? '#4A90D9',
|
||||||
|
} as typeof DEV_USER);
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* OIDC authentication middleware wiring.
|
* OIDC authentication middleware wiring.
|
||||||
*
|
*
|
||||||
* Configures @hono/oidc-auth for Authelia as the identity provider.
|
* Configures @hono/oidc-auth for the generic OIDC identity provider (D-06).
|
||||||
*
|
*
|
||||||
* Required env vars:
|
* Required env vars:
|
||||||
* OIDC_AUTH_SECRET — 32+ char random string for JWT cookie signing (T-02-03)
|
* OIDC_AUTH_SECRET — 32+ char random string for JWT cookie signing (T-02-03)
|
||||||
* OIDC_ISSUER — Authelia base URL (middleware fetches /.well-known/openid-configuration)
|
* OIDC_ISSUER — OIDC issuer URL (middleware fetches /.well-known/openid-configuration)
|
||||||
* OIDC_CLIENT_ID — registered client ID in Authelia
|
* OIDC_CLIENT_ID — registered client ID at the OIDC provider
|
||||||
* OIDC_CLIENT_SECRET — PLAIN text secret (NOT the pbkdf2 hash — see Pitfall 7)
|
* OIDC_CLIENT_SECRET — PLAIN text secret (NOT the pbkdf2 hash — see Pitfall 7)
|
||||||
* OIDC_REDIRECT_URI — https://familysync.<domain>/callback
|
* OIDC_REDIRECT_URI — https://familysync.<domain>/callback
|
||||||
* OIDC_AUTH_EXTERNAL_URL — https://familysync.<domain> — MANDATORY behind Pangolin (Pitfall 1)
|
* OIDC_AUTH_EXTERNAL_URL — https://familysync.<domain> — MANDATORY behind Pangolin (Pitfall 1)
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
* Every OIDC_AUTH_REFRESH_INTERVAL (default 15 min) the middleware calls
|
* Every OIDC_AUTH_REFRESH_INTERVAL (default 15 min) the middleware calls
|
||||||
* the token endpoint with the stored refresh token — no iframe required (D-12).
|
* the token endpoint with the stored refresh token — no iframe required (D-12).
|
||||||
* Session lifespan is governed by OIDC_AUTH_EXPIRES (default 1 day) and
|
* Session lifespan is governed by OIDC_AUTH_EXPIRES (default 1 day) and
|
||||||
* Authelia's refresh_token_lifespan.
|
* the OIDC provider's refresh_token_lifespan.
|
||||||
*
|
*
|
||||||
* Scopes: openid, profile, email only — no 'groups' scope (D-11).
|
* Scopes: openid, profile, email only — no 'groups' scope (D-11).
|
||||||
*
|
*
|
||||||
|
|||||||
+92
-6
@@ -11,17 +11,23 @@ import { listsRouter, listItemsRouter } from './routes/lists.js';
|
|||||||
import { pushRouter } from './routes/push.js';
|
import { pushRouter } from './routes/push.js';
|
||||||
import { adminRouter } from './routes/admin.js';
|
import { adminRouter } from './routes/admin.js';
|
||||||
import { setupRouter } from './routes/setup.js';
|
import { setupRouter } from './routes/setup.js';
|
||||||
|
import { authModeRouter } from './routes/authMode.js';
|
||||||
|
import { localAuthRouter } from './routes/localAuth.js';
|
||||||
import {
|
import {
|
||||||
oidcAuthMiddleware,
|
oidcAuthMiddleware,
|
||||||
processOAuthCallback,
|
processOAuthCallback,
|
||||||
oidcConfigFallbackMiddleware,
|
oidcConfigFallbackMiddleware,
|
||||||
} from './auth/middleware.js';
|
} from './auth/middleware.js';
|
||||||
import { devAuthBypass } from './auth/devBypass.js';
|
import { devAuthBypass } from './auth/devBypass.js';
|
||||||
|
import { localAuthMiddleware } from './auth/localAuthMiddleware.js';
|
||||||
import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
||||||
import { startBrokerPoller } from './broker/poller.js';
|
import { startBrokerPoller } from './broker/poller.js';
|
||||||
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
|
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
|
||||||
import { startReminderScheduler } from './broker/reminderScheduler.js';
|
import { startReminderScheduler } from './broker/reminderScheduler.js';
|
||||||
import { assertNotDevBypassInProduction, assertLocalSessionSecretSet } from './lib/bootGuards.js';
|
import { assertNotDevBypassInProduction, assertLocalSessionSecretSet } from './lib/bootGuards.js';
|
||||||
|
import { getAuth } from './auth/middleware.js';
|
||||||
|
import { Jwt } from 'hono/utils/jwt';
|
||||||
|
import { linkOidcToUser, OidcLinkConflictError } from './auth/linkOidc.js';
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
|
|
||||||
export const app = new Hono();
|
export const app = new Hono();
|
||||||
@@ -36,8 +42,63 @@ if (devBypassActive) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OIDC callback — must be registered BEFORE oidcAuthMiddleware so the
|
// OIDC callback — must be registered BEFORE oidcAuthMiddleware so the
|
||||||
// authorization-code exchange is not itself intercepted by the auth check (T-02-02)
|
// authorization-code exchange is not itself intercepted by the auth check (T-02-02).
|
||||||
app.get('/callback', (c) => processOAuthCallback(c));
|
//
|
||||||
|
// Phase 19 — link mode (AUTH-LOCAL-10, T-19-15):
|
||||||
|
// When POST /api/me/link-oidc initiates a link flow, a signed JWT state is included
|
||||||
|
// in the authorization URL as the `state` parameter. On callback, we read that raw URL
|
||||||
|
// state param, try to decode it as our signed JWT, and if it carries `linkUserId`, we
|
||||||
|
// call linkOidcToUser after processOAuthCallback establishes the OIDC session.
|
||||||
|
//
|
||||||
|
// Security: the signed state JWT prevents CSRF (T-19-09); linkOidcToUser preflight
|
||||||
|
// prevents account takeover via conflict (T-19-15 / T-19-08).
|
||||||
|
//
|
||||||
|
// Normal (non-link) callbacks are unaffected — processOAuthCallback is called in all paths.
|
||||||
|
app.get('/callback', async (c) => {
|
||||||
|
// Attempt to extract linkUserId from the signed state param BEFORE processOAuthCallback
|
||||||
|
// consumes it. The state param may be our signed JWT (link mode) or a random string (normal).
|
||||||
|
let linkUserId: number | null = null;
|
||||||
|
const rawState = c.req.query('state');
|
||||||
|
if (rawState) {
|
||||||
|
const secret = process.env.LOCAL_SESSION_SECRET;
|
||||||
|
if (secret) {
|
||||||
|
try {
|
||||||
|
const payload = await Jwt.verify(rawState, secret, 'HS256');
|
||||||
|
if (typeof payload.linkUserId === 'number') {
|
||||||
|
linkUserId = payload.linkUserId;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not our signed link state — normal OIDC callback, proceed normally.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the OIDC authorization-code exchange (sets the OIDC session cookie + redirects).
|
||||||
|
const callbackResponse = await processOAuthCallback(c);
|
||||||
|
|
||||||
|
// Link mode: after session is established, bind the OIDC identity to the local user.
|
||||||
|
if (linkUserId !== null) {
|
||||||
|
try {
|
||||||
|
const auth = await getAuth(c);
|
||||||
|
if (auth) {
|
||||||
|
const iss = (auth.iss as string | undefined) ?? '';
|
||||||
|
const sub = auth.sub ?? '';
|
||||||
|
await linkOidcToUser(linkUserId, iss, sub);
|
||||||
|
// On success: user is now OIDC-only; normal redirect via callbackResponse proceeds.
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof OidcLinkConflictError) {
|
||||||
|
// 409: iss+sub already linked to a different user — redirect to conflict error page.
|
||||||
|
// UI-SPEC Surface 13 error copy: "This OIDC identity is already linked to another account."
|
||||||
|
return c.redirect('/?error=oidc-link-conflict');
|
||||||
|
}
|
||||||
|
// Unexpected error during link binding — log and continue with normal redirect.
|
||||||
|
console.error('[callback] linkOidcToUser unexpected error:', err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return callbackResponse;
|
||||||
|
});
|
||||||
|
|
||||||
// GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05)
|
// GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05)
|
||||||
app.route('/health', healthRouter);
|
app.route('/health', healthRouter);
|
||||||
@@ -48,15 +109,26 @@ app.route('/health', healthRouter);
|
|||||||
// 423 lock after setup is complete (SETUP-04 / D-10).
|
// 423 lock after setup is complete (SETUP-04 / D-10).
|
||||||
app.route('/api/setup', setupRouter);
|
app.route('/api/setup', setupRouter);
|
||||||
|
|
||||||
|
// Phase 19 — pre-auth auth routes: GET /api/auth/mode and POST /api/auth/local/login, /logout.
|
||||||
|
// Mounted BEFORE devAuthBypass so they are reachable without a session (D-01 / AUTH-LOCAL-05).
|
||||||
|
app.route('/api/auth', authModeRouter);
|
||||||
|
app.route('/api/auth', localAuthRouter);
|
||||||
|
|
||||||
// Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'.
|
// Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'.
|
||||||
// When active, injects DEV_USER into the Hono context and the OIDC guard is NOT mounted.
|
// When active, injects DEV_USER into the Hono context and the OIDC guard is NOT mounted.
|
||||||
// Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts).
|
// Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts).
|
||||||
app.use('/api/*', devAuthBypass());
|
app.use('/api/*', devAuthBypass());
|
||||||
|
|
||||||
|
// Phase 19 — local-session middleware: sets c.get('user') from 'local-session' JWT cookie.
|
||||||
|
// No-op passthrough when no cookie is present — the OIDC guard fires for unauthenticated.
|
||||||
|
// Runs AFTER devAuthBypass (which may set c.get('user') first) and BEFORE the OIDC guard.
|
||||||
|
// The OIDC guard below is wrapped to skip when c.get('user') is already set (Pitfall 1 guard).
|
||||||
|
app.use('/api/*', localAuthMiddleware());
|
||||||
|
|
||||||
// Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05).
|
// Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05).
|
||||||
// Skipped entirely when devBypassActive so that local dev works without Authelia.
|
// Skipped entirely when devBypassActive so that local dev works without the OIDC provider.
|
||||||
// In production devBypassActive is always false — OIDC is unconditionally mounted.
|
// In production devBypassActive is always false — OIDC is unconditionally mounted.
|
||||||
// Unauthenticated requests receive a 302 redirect to Authelia's authorize endpoint.
|
// Unauthenticated requests receive a 302 redirect to the OIDC authorize endpoint.
|
||||||
// OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct
|
// OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct
|
||||||
// redirect_uri (Pitfall 1). Set it to https://familysync.<domain>.
|
// redirect_uri (Pitfall 1). Set it to https://familysync.<domain>.
|
||||||
if (!devBypassActive) {
|
if (!devBypassActive) {
|
||||||
@@ -67,7 +139,21 @@ if (!devBypassActive) {
|
|||||||
// A2 CONFIRMED: oidcAuthMiddleware() reads process.env per-request (call time), so
|
// A2 CONFIRMED: oidcAuthMiddleware() reads process.env per-request (call time), so
|
||||||
// injecting into process.env here is safe and effective (see auth/middleware.ts A2 note).
|
// injecting into process.env here is safe and effective (see auth/middleware.ts A2 note).
|
||||||
app.use('/api/*', oidcConfigFallbackMiddleware);
|
app.use('/api/*', oidcConfigFallbackMiddleware);
|
||||||
app.use('/api/*', oidcAuthMiddleware());
|
// Phase 19 / D-03: OIDC guard wrapped to skip when c.get('user') is already set.
|
||||||
|
// A valid local-session (or dev-bypass) user must NOT be 302-redirected to the OIDC
|
||||||
|
// provider — the skip-when-set wrapper is the coexistence seam (D-03 / RESEARCH Pitfall 1).
|
||||||
|
//
|
||||||
|
// IMPORTANT: oidcAuthMiddleware() factory is called ONCE at app construction time (not per
|
||||||
|
// request) to match the prior behavior and keep test assertions about "called once" valid.
|
||||||
|
// The returned handler is stored and invoked per-request inside the wrapper.
|
||||||
|
const oidcHandler = oidcAuthMiddleware();
|
||||||
|
app.use('/api/*', async (c, next) => {
|
||||||
|
if (c.get('user')) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await oidcHandler(c, next);
|
||||||
|
});
|
||||||
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
|
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
|
||||||
app.use('/api/*', persistSessionCookie());
|
app.use('/api/*', persistSessionCookie());
|
||||||
}
|
}
|
||||||
@@ -76,7 +162,7 @@ if (!devBypassActive) {
|
|||||||
|
|
||||||
// GET /api/login — login entry point for the PWA.
|
// GET /api/login — login entry point for the PWA.
|
||||||
// Flow (production): unauthenticated top-level nav hits the OIDC guard above,
|
// Flow (production): unauthenticated top-level nav hits the OIDC guard above,
|
||||||
// which 302-redirects to Authelia. After login, Authelia POSTs to /callback,
|
// which 302-redirects to the OIDC provider. After login, the provider POSTs to /callback,
|
||||||
// the middleware sets a `continue` cookie pointing back to /api/login, and the
|
// the middleware sets a `continue` cookie pointing back to /api/login, and the
|
||||||
// browser follows it here — now authenticated. The handler then redirects to /
|
// browser follows it here — now authenticated. The handler then redirects to /
|
||||||
// so the SPA boots with a valid session. Under DEV_AUTH_BYPASS the guard is not
|
// so the SPA boots with a valid session. Under DEV_AUTH_BYPASS the guard is not
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* authMode.ts — GET /api/auth/mode pre-auth endpoint (AUTH-LOCAL-05).
|
||||||
|
*
|
||||||
|
* Returns { localEnabled: boolean, oidcEnabled: boolean } reflecting the current
|
||||||
|
* authentication configuration. This endpoint is intentionally reachable before
|
||||||
|
* authentication (same pre-auth pattern as GET /api/setup/status).
|
||||||
|
*
|
||||||
|
* Mount in index.ts BEFORE devAuthBypass and OIDC guard:
|
||||||
|
* app.route('/api/auth', authModeRouter); ← pre-auth
|
||||||
|
*
|
||||||
|
* Response contract:
|
||||||
|
* - localEnabled: always true — local auth is the default, always available (D-01).
|
||||||
|
* - oidcEnabled: true when OIDC_ISSUER env var is set, OR when app_config has
|
||||||
|
* an oidc_issuer row (supports wizard-configured OIDC before container restart).
|
||||||
|
*
|
||||||
|
* No auth gate, no isSetupLocked() check — the PWA fetches this on app load before
|
||||||
|
* knowing if the user is authenticated.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { appConfig } from '../db/schema.js';
|
||||||
|
|
||||||
|
export const authModeRouter = new Hono();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET / (mounted as /api/auth, so effective path is GET /api/auth/mode)
|
||||||
|
*
|
||||||
|
* Checks OIDC_ISSUER env var first (process.env is cheapest); falls back to
|
||||||
|
* app_config DB row if env var is absent (wizard-configured OIDC).
|
||||||
|
*/
|
||||||
|
authModeRouter.get('/mode', async (c) => {
|
||||||
|
// localEnabled: always true (D-01)
|
||||||
|
// oidcEnabled: env var first, then app_config fallback
|
||||||
|
const issuerFromEnv = process.env.OIDC_ISSUER;
|
||||||
|
let oidcEnabled = Boolean(issuerFromEnv);
|
||||||
|
|
||||||
|
if (!oidcEnabled) {
|
||||||
|
// Check app_config for oidc_issuer (wizard-written, pre-restart-fallback pattern from middleware.ts)
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'oidc_issuer'))
|
||||||
|
.limit(1);
|
||||||
|
oidcEnabled = Boolean(row?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ localEnabled: true, oidcEnabled });
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* localAuth.ts — local authentication routes (AUTH-LOCAL-03, AUTH-LOCAL-06).
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* POST /api/auth/local/login — rate-limited credential verify + session cookie issue
|
||||||
|
* POST /api/auth/local/logout — clear local-session cookie
|
||||||
|
* GET /api/auth/local/logout — alias for POST /logout (some browsers prefer GET)
|
||||||
|
*
|
||||||
|
* Mounted in index.ts as app.route('/api/auth', localAuthRouter) BEFORE any auth middleware.
|
||||||
|
* This means POST /api/auth/local/login is reachable without a session (pre-auth, per D-01).
|
||||||
|
*
|
||||||
|
* Security (T-19-11, T-19-12, T-19-14):
|
||||||
|
* - noEchoHook on login: Zod errors NEVER return received values (T-19-14 / Pitfall 7).
|
||||||
|
* - Dummy-hash timing defense: verifyPassword is always called, even for unknown usernames,
|
||||||
|
* to prevent timing-oracle username enumeration attacks (T-19-12 / RESEARCH Pitfall 2).
|
||||||
|
* - Same 401 body for wrong password AND unknown username — no field discrimination (T-19-12).
|
||||||
|
* - Per-IP in-memory rate-limiting: 5 failures → 429; 10 failures → 423 (T-19-11).
|
||||||
|
* - Lockout (423) cleared only by admin password reset — no self-service unlock.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { Context } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { localCredentials } from '../db/schema.js';
|
||||||
|
import { verifyPassword, hashPassword } from '../auth/localCredentials.js';
|
||||||
|
import { issueLocalSessionCookie, clearLocalSessionCookie } from '../auth/localSession.js';
|
||||||
|
|
||||||
|
export const localAuthRouter = new Hono();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// noEchoHook — NEVER return Zod validation error details for the login route.
|
||||||
|
// Zod's error object contains issues[].received which may echo the submitted password.
|
||||||
|
// Always return { error: 'Invalid request' } 400, no other fields (T-19-14).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const noEchoHook = (result: { success: boolean }, c: Context) => {
|
||||||
|
if (!result.success) {
|
||||||
|
return c.json({ error: 'Invalid request' }, 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zod schema for login body
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
username: z.string().min(1).max(128).trim(),
|
||||||
|
password: z.string().min(1).max(1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-IP rate-limiting state (in-memory Map — household scale, no Redis needed).
|
||||||
|
//
|
||||||
|
// State shape per IP: { count, lockedUntil (epoch ms), lockedOut (bool) }
|
||||||
|
// count >= RATE_WINDOW_FAILURES AND Date.now() < lockedUntil → 429
|
||||||
|
// count >= LOCKOUT_FAILURES → 423 (permanent until admin reset)
|
||||||
|
// Success → delete entry (clears counter)
|
||||||
|
//
|
||||||
|
// RATE_WINDOW_FAILURES: 5 failures → 60s cooldown (429)
|
||||||
|
// LOCKOUT_FAILURES: 10 failures → account locked (423)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const loginAttempts = new Map<string, { count: number; lockedUntil: number; lockedOut: boolean }>();
|
||||||
|
|
||||||
|
const RATE_WINDOW_FAILURES = 5;
|
||||||
|
const RATE_WINDOW_SECS = 60;
|
||||||
|
const LOCKOUT_FAILURES = 10;
|
||||||
|
|
||||||
|
// Pre-computed dummy hash used to run verifyPassword on unknown-username paths
|
||||||
|
// (timing defense — prevents timing-oracle username enumeration, RESEARCH Pitfall 2).
|
||||||
|
// Computed once at module load time; the actual value is never used for auth.
|
||||||
|
const DUMMY_HASH = hashPassword('dummy-constant-time-filler-xyzzy');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /local/login → POST /api/auth/local/login
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook), async (c) => {
|
||||||
|
// Derive client IP from Pangolin-set X-Forwarded-For header; fall back to host header.
|
||||||
|
const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim()
|
||||||
|
?? c.req.raw.headers.get('host')
|
||||||
|
?? 'unknown';
|
||||||
|
|
||||||
|
const attempt = loginAttempts.get(ip);
|
||||||
|
|
||||||
|
// 423: account locked (>= LOCKOUT_FAILURES total failures, admin must reset)
|
||||||
|
// Check lockedOut FIRST — lockout takes precedence over rate window.
|
||||||
|
if (attempt?.lockedOut) {
|
||||||
|
return c.json({ error: 'Account locked' }, 423);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 429: rate window — >= RATE_WINDOW_FAILURES failures within the cooldown window.
|
||||||
|
// Increment the counter even on 429 so continued brute-force accumulates toward lockout.
|
||||||
|
if (attempt && attempt.count >= RATE_WINDOW_FAILURES && Date.now() < attempt.lockedUntil) {
|
||||||
|
attempt.count += 1;
|
||||||
|
attempt.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000;
|
||||||
|
attempt.lockedOut = attempt.count >= LOCKOUT_FAILURES;
|
||||||
|
loginAttempts.set(ip, attempt);
|
||||||
|
if (attempt.lockedOut) {
|
||||||
|
return c.json({ error: 'Account locked' }, 423);
|
||||||
|
}
|
||||||
|
return c.json({ error: 'Too many attempts' }, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password } = c.req.valid('json');
|
||||||
|
|
||||||
|
// Look up local_credentials by username
|
||||||
|
let cred: { userId: number; passwordHash: string } | undefined;
|
||||||
|
try {
|
||||||
|
const [found] = await db
|
||||||
|
.select({ userId: localCredentials.userId, passwordHash: localCredentials.passwordHash })
|
||||||
|
.from(localCredentials)
|
||||||
|
.where(eq(localCredentials.username, username))
|
||||||
|
.limit(1);
|
||||||
|
cred = found;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[localAuth/POST /local/login] DB error:', err instanceof Error ? err.message : String(err));
|
||||||
|
return c.json({ error: 'Service unavailable' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ALWAYS run verifyPassword — even for unknown usernames — to prevent timing-oracle
|
||||||
|
// username enumeration (T-19-12 / RESEARCH Pitfall 2). Use a pre-computed dummy hash
|
||||||
|
// so the scrypt work is always performed regardless of whether username was found.
|
||||||
|
const valid = cred
|
||||||
|
? verifyPassword(cred.passwordHash, password)
|
||||||
|
: verifyPassword(DUMMY_HASH, password);
|
||||||
|
|
||||||
|
if (!valid || !cred) {
|
||||||
|
// Increment failure counter
|
||||||
|
const cur = loginAttempts.get(ip) ?? { count: 0, lockedUntil: 0, lockedOut: false };
|
||||||
|
cur.count += 1;
|
||||||
|
cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000;
|
||||||
|
cur.lockedOut = cur.count >= LOCKOUT_FAILURES;
|
||||||
|
loginAttempts.set(ip, cur);
|
||||||
|
// Same 401 body for wrong password AND unknown username — no field discrimination (T-19-12)
|
||||||
|
return c.json({ error: 'Invalid credentials' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: clear failure counter, issue the local-session JWT cookie, return ok
|
||||||
|
loginAttempts.delete(ip);
|
||||||
|
try {
|
||||||
|
await issueLocalSessionCookie(c, cred.userId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[localAuth/POST /local/login] Cookie issue error:', err instanceof Error ? err.message : String(err));
|
||||||
|
return c.json({ error: 'Service unavailable' }, 503);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /local/logout → POST /api/auth/local/logout
|
||||||
|
// GET /local/logout → GET /api/auth/local/logout (browser-redirect alias)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function handleLogout(c: Context) {
|
||||||
|
clearLocalSessionCookie(c);
|
||||||
|
return c.json({ ok: true }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
localAuthRouter.post('/local/logout', handleLogout);
|
||||||
|
localAuthRouter.get('/local/logout', handleLogout);
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* localAuthMiddleware() — unit tests (Plan 19-03, TDD RED → GREEN).
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* Test 1: valid local-session cookie for an existing user → c.get('user') set; next() called
|
||||||
|
* Test 2: no cookie → pure passthrough; c.get('user') NOT set (OIDC guard fall-through intact)
|
||||||
|
* Test 3: cookie with valid JWT but userId has no users row → passthrough (no crash)
|
||||||
|
* Test 4: c.get('user') already set (devAuthBypass ran first) → not overwritten; next() called
|
||||||
|
*
|
||||||
|
* Security:
|
||||||
|
* - Test 2 is the Pitfall-1 guard: middleware must NEVER call c.set('user', undefined).
|
||||||
|
* The OIDC guard only fires when c.get('user') is falsy; setting it to undefined
|
||||||
|
* would suppress the OIDC guard for unauthenticated requests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock DB — avoids real DB connections in this middleware unit-test
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockDbSelectResult: Array<{
|
||||||
|
id: number;
|
||||||
|
oidcIss: string | null;
|
||||||
|
oidcSub: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
color: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
vi.mock('../../src/db/client.js', () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockImplementation(() => Promise.resolve(mockDbSelectResult)),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock verifyLocalSessionCookie — controls what userId the cookie yields
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let mockVerifyResult: number | null = null;
|
||||||
|
|
||||||
|
vi.mock('../../src/auth/localSession.js', () => ({
|
||||||
|
verifyLocalSessionCookie: vi.fn().mockImplementation(() => Promise.resolve(mockVerifyResult)),
|
||||||
|
issueLocalSessionCookie: vi.fn(),
|
||||||
|
clearLocalSessionCookie: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getMiddleware() {
|
||||||
|
const { localAuthMiddleware } = await import('../../src/auth/localAuthMiddleware.js');
|
||||||
|
return localAuthMiddleware;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeApp(middleware: ReturnType<typeof vi.fn>, presetUser?: unknown) {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
if (presetUser !== undefined) {
|
||||||
|
// Simulate devAuthBypass having already set c.get('user')
|
||||||
|
app.use('/api/*', async (c, next) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
c.set('user', presetUser as any);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||||
|
app.use('/api/*', middleware());
|
||||||
|
|
||||||
|
let capturedUser: unknown = 'NOT_SET_SENTINEL';
|
||||||
|
let nextCalled = false;
|
||||||
|
|
||||||
|
app.get('/api/test', (c) => {
|
||||||
|
capturedUser = c.get('user');
|
||||||
|
nextCalled = true;
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { app, getCapturedUser: () => capturedUser, wasNextCalled: () => nextCalled };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('localAuthMiddleware', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockVerifyResult = null;
|
||||||
|
mockDbSelectResult.splice(0);
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 1: valid local-session cookie for existing user → sets c.get("user") and calls next', async () => {
|
||||||
|
mockVerifyResult = 42;
|
||||||
|
mockDbSelectResult.push({
|
||||||
|
id: 42,
|
||||||
|
oidcIss: 'local',
|
||||||
|
oidcSub: '42',
|
||||||
|
displayName: 'Test User',
|
||||||
|
color: '#4A90D9',
|
||||||
|
});
|
||||||
|
|
||||||
|
const localAuthMiddleware = await getMiddleware();
|
||||||
|
const { app } = makeApp(localAuthMiddleware);
|
||||||
|
|
||||||
|
const res = await app.request('/api/test');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// Re-import to inspect captured value via the route handler's closure
|
||||||
|
// We verify by checking response — route returns 200 only if next() was called
|
||||||
|
// The actual user value is verified via the app route handler
|
||||||
|
const body = (await res.json()) as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 1b: user shape from DB row is correct (id, oidcIss, oidcSub, displayName, color)', async () => {
|
||||||
|
mockVerifyResult = 7;
|
||||||
|
mockDbSelectResult.push({
|
||||||
|
id: 7,
|
||||||
|
oidcIss: 'https://auth.example.com',
|
||||||
|
oidcSub: 'sub-abc',
|
||||||
|
displayName: 'Alice',
|
||||||
|
color: '#FF5733',
|
||||||
|
});
|
||||||
|
|
||||||
|
const localAuthMiddleware = await getMiddleware();
|
||||||
|
const app = new Hono();
|
||||||
|
let capturedUser: unknown;
|
||||||
|
|
||||||
|
app.use('/api/*', localAuthMiddleware());
|
||||||
|
app.get('/api/test', (c) => {
|
||||||
|
capturedUser = c.get('user');
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/test');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(capturedUser).toBeDefined();
|
||||||
|
const u = capturedUser as { id: number; oidcIss: string; oidcSub: string; displayName: string | null; color: string };
|
||||||
|
expect(u.id).toBe(7);
|
||||||
|
expect(u.oidcIss).toBe('https://auth.example.com');
|
||||||
|
expect(u.oidcSub).toBe('sub-abc');
|
||||||
|
expect(u.displayName).toBe('Alice');
|
||||||
|
expect(u.color).toBe('#FF5733');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2: no cookie → pure passthrough; c.get("user") remains unset (Pitfall-1 guard)', async () => {
|
||||||
|
mockVerifyResult = null; // No cookie / invalid
|
||||||
|
|
||||||
|
const localAuthMiddleware = await getMiddleware();
|
||||||
|
const app = new Hono();
|
||||||
|
let capturedUser: unknown = 'NOT_SET_SENTINEL';
|
||||||
|
let nextCalled = false;
|
||||||
|
|
||||||
|
app.use('/api/*', localAuthMiddleware());
|
||||||
|
app.get('/api/test', (c) => {
|
||||||
|
capturedUser = c.get('user');
|
||||||
|
nextCalled = true;
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/test');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(nextCalled).toBe(true);
|
||||||
|
// CRITICAL: user must remain UNSET (undefined), NOT set to undefined explicitly.
|
||||||
|
// The OIDC guard checks c.get('user') — if it's undefined (not set), the guard fires.
|
||||||
|
// The middleware must call next() without c.set('user') on the no-cookie path.
|
||||||
|
expect(capturedUser).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3: valid cookie but userId has no users row → passthrough (no crash)', async () => {
|
||||||
|
mockVerifyResult = 999; // Valid JWT payload, userId=999
|
||||||
|
mockDbSelectResult.splice(0); // No DB row for userId 999
|
||||||
|
|
||||||
|
const localAuthMiddleware = await getMiddleware();
|
||||||
|
const app = new Hono();
|
||||||
|
let capturedUser: unknown = 'NOT_SET_SENTINEL';
|
||||||
|
let nextCalled = false;
|
||||||
|
|
||||||
|
app.use('/api/*', localAuthMiddleware());
|
||||||
|
app.get('/api/test', (c) => {
|
||||||
|
capturedUser = c.get('user');
|
||||||
|
nextCalled = true;
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/test');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(nextCalled).toBe(true);
|
||||||
|
// No row found — should passthrough without setting user
|
||||||
|
expect(capturedUser).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4: c.get("user") already set (devAuthBypass ran first) → not overwritten; next() called', async () => {
|
||||||
|
const preExistingUser = {
|
||||||
|
id: 1,
|
||||||
|
oidcIss: 'dev',
|
||||||
|
oidcSub: 'dev-user',
|
||||||
|
displayName: 'Dev User',
|
||||||
|
color: '#4A90D9',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Even if verifyLocalSessionCookie would succeed, the existing user must not be overwritten
|
||||||
|
mockVerifyResult = 99; // A DIFFERENT userId
|
||||||
|
mockDbSelectResult.push({
|
||||||
|
id: 99,
|
||||||
|
oidcIss: 'local',
|
||||||
|
oidcSub: '99',
|
||||||
|
displayName: 'Another User',
|
||||||
|
color: '#FF0000',
|
||||||
|
});
|
||||||
|
|
||||||
|
const localAuthMiddleware = await getMiddleware();
|
||||||
|
const app = new Hono();
|
||||||
|
let capturedUser: unknown;
|
||||||
|
|
||||||
|
// Simulate devAuthBypass having set the user first
|
||||||
|
app.use('/api/*', async (c, next) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
c.set('user', preExistingUser as any);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
app.use('/api/*', localAuthMiddleware());
|
||||||
|
app.get('/api/test', (c) => {
|
||||||
|
capturedUser = c.get('user');
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/test');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// User must remain the dev-bypass user, not overwritten
|
||||||
|
expect(capturedUser).toEqual(preExistingUser);
|
||||||
|
expect((capturedUser as typeof preExistingUser).id).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/auth/mode — unit tests (Plan 19-03, TDD RED → GREEN).
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* Test 5: returns { localEnabled:true, oidcEnabled:false } when no oidc_issuer in env or app_config
|
||||||
|
* Test 6: returns { oidcEnabled:true } when OIDC_ISSUER env var is set
|
||||||
|
* Test 6b: returns { oidcEnabled:true } when oidc_issuer is in app_config (no env var)
|
||||||
|
*
|
||||||
|
* Pre-auth surface: reachable without OIDC session (same as /api/setup/status).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB mock — controls app_config rows returned for oidc_issuer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let mockAppConfigOidcIssuer: string | null = null;
|
||||||
|
|
||||||
|
vi.mock('../../src/db/client.js', () => ({
|
||||||
|
db: {
|
||||||
|
execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]),
|
||||||
|
select: vi.fn().mockImplementation(() => ({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockImplementation(() => {
|
||||||
|
if (mockAppConfigOidcIssuer) {
|
||||||
|
return Promise.resolve([{ value: mockAppConfigOidcIssuer }]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
onDuplicateKeyUpdate: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@hono/oidc-auth', () => ({
|
||||||
|
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
|
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
|
||||||
|
getAuth: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||||
|
devAuthBypass:
|
||||||
|
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
||||||
|
localAuthMiddleware:
|
||||||
|
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Env snapshot
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const originalOidcIssuer = process.env.OIDC_ISSUER;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockAppConfigOidcIssuer = null;
|
||||||
|
delete process.env.OIDC_ISSUER;
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalOidcIssuer === undefined) {
|
||||||
|
delete process.env.OIDC_ISSUER;
|
||||||
|
} else {
|
||||||
|
process.env.OIDC_ISSUER = originalOidcIssuer;
|
||||||
|
}
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/auth/mode', () => {
|
||||||
|
it('Test 5: returns { localEnabled:true, oidcEnabled:false } when no oidc_issuer in env or app_config', async () => {
|
||||||
|
delete process.env.OIDC_ISSUER;
|
||||||
|
mockAppConfigOidcIssuer = null;
|
||||||
|
|
||||||
|
const { app } = await import('../../src/index.js');
|
||||||
|
const res = await app.request('/api/auth/mode');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { localEnabled: boolean; oidcEnabled: boolean };
|
||||||
|
expect(body.localEnabled).toBe(true);
|
||||||
|
expect(body.oidcEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 6: returns { oidcEnabled:true } when OIDC_ISSUER env var is set', async () => {
|
||||||
|
process.env.OIDC_ISSUER = 'https://auth.example.com';
|
||||||
|
mockAppConfigOidcIssuer = null;
|
||||||
|
|
||||||
|
const { app } = await import('../../src/index.js');
|
||||||
|
const res = await app.request('/api/auth/mode');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { localEnabled: boolean; oidcEnabled: boolean };
|
||||||
|
expect(body.localEnabled).toBe(true);
|
||||||
|
expect(body.oidcEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 6b: returns { oidcEnabled:true } when oidc_issuer is in app_config (no env var)', async () => {
|
||||||
|
delete process.env.OIDC_ISSUER;
|
||||||
|
mockAppConfigOidcIssuer = 'https://auth-from-config.example.com';
|
||||||
|
|
||||||
|
const { app } = await import('../../src/index.js');
|
||||||
|
const res = await app.request('/api/auth/mode');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { localEnabled: boolean; oidcEnabled: boolean };
|
||||||
|
expect(body.localEnabled).toBe(true);
|
||||||
|
expect(body.oidcEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/auth/local/login + POST/GET /api/auth/local/logout — tests (Plan 19-03, TDD RED → GREEN).
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* Test 1: valid username+password → 200 { ok:true } + Set-Cookie for local-session
|
||||||
|
* Test 2: wrong password → 401 { error: 'Invalid credentials' }
|
||||||
|
* Test 3: unknown username → 401 with SAME body as Test 2 (no enumeration / no field discrimination)
|
||||||
|
* Test 4: 5 consecutive failures from one IP → 6th returns 429
|
||||||
|
* Test 5: 10 failures → 423 (lockedOut); a cleared map resets the counter
|
||||||
|
* Test 6: POST /api/auth/local/logout clears the local-session cookie (expired Set-Cookie)
|
||||||
|
* Test 6b: GET /api/auth/local/logout (alias) also clears the local-session cookie
|
||||||
|
* Test 7 (no-echo): malformed body (missing password) → 400 { error: 'Invalid request' };
|
||||||
|
* body must NOT contain submitted value or Zod 'received' field
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* Tests mock DB client and issueLocalSessionCookie/clearLocalSessionCookie.
|
||||||
|
* loginAttempts Map is imported directly and cleared between tests.
|
||||||
|
* IP is derived from x-forwarded-for header.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock DB client
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type LocalCredRow = { userId: number; passwordHash: string } | undefined;
|
||||||
|
let mockCredRow: LocalCredRow;
|
||||||
|
|
||||||
|
vi.mock('../../src/db/client.js', () => ({
|
||||||
|
db: {
|
||||||
|
execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]),
|
||||||
|
select: vi.fn().mockImplementation(() => ({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockImplementation(() =>
|
||||||
|
Promise.resolve(mockCredRow ? [mockCredRow] : [])
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
onDuplicateKeyUpdate: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock localSession helpers — track calls and control cookie behavior
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let issueSessionCalled = false;
|
||||||
|
let issuedUserId: number | null = null;
|
||||||
|
let clearSessionCalled = false;
|
||||||
|
|
||||||
|
vi.mock('../../src/auth/localSession.js', () => ({
|
||||||
|
issueLocalSessionCookie: vi.fn().mockImplementation(
|
||||||
|
(_c: unknown, userId: number) => {
|
||||||
|
issueSessionCalled = true;
|
||||||
|
issuedUserId = userId;
|
||||||
|
// Simulate setting a cookie on the context
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
),
|
||||||
|
clearLocalSessionCookie: vi.fn().mockImplementation((_c: unknown) => {
|
||||||
|
clearSessionCalled = true;
|
||||||
|
}),
|
||||||
|
verifyLocalSessionCookie: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock devAuthBypass and OIDC — standard passthrough for route tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||||
|
devAuthBypass:
|
||||||
|
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
||||||
|
localAuthMiddleware:
|
||||||
|
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@hono/oidc-auth', () => ({
|
||||||
|
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
|
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
|
||||||
|
getAuth: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeLoginRequest(body: Record<string, string>, ip = '1.2.3.4'): Request {
|
||||||
|
return new Request('http://localhost/api/auth/local/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-forwarded-for': ip,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getApp() {
|
||||||
|
const { app } = await import('../../src/index.js');
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const originalNodeEnv = process.env.NODE_ENV;
|
||||||
|
const originalBypass = process.env.DEV_AUTH_BYPASS;
|
||||||
|
const originalSecret = process.env.LOCAL_SESSION_SECRET;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Use dev-bypass mode so no OIDC redirect occurs
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.DEV_AUTH_BYPASS = 'true';
|
||||||
|
// Provide a valid LOCAL_SESSION_SECRET for the session helpers
|
||||||
|
process.env.LOCAL_SESSION_SECRET = 'test-secret-that-is-at-least-32-chars!!';
|
||||||
|
|
||||||
|
issueSessionCalled = false;
|
||||||
|
issuedUserId = null;
|
||||||
|
clearSessionCalled = false;
|
||||||
|
mockCredRow = undefined;
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
// Clear the rate-limit map between tests
|
||||||
|
const { loginAttempts } = await import('../../src/routes/localAuth.js');
|
||||||
|
loginAttempts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.NODE_ENV = originalNodeEnv;
|
||||||
|
if (originalBypass === undefined) delete process.env.DEV_AUTH_BYPASS;
|
||||||
|
else process.env.DEV_AUTH_BYPASS = originalBypass;
|
||||||
|
if (originalSecret === undefined) delete process.env.LOCAL_SESSION_SECRET;
|
||||||
|
else process.env.LOCAL_SESSION_SECRET = originalSecret;
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import verifyPassword/hashPassword for test credential setup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getLocalCredentials() {
|
||||||
|
return import('../../src/auth/localCredentials.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// POST /api/auth/local/login
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('POST /api/auth/local/login', () => {
|
||||||
|
it('Test 1: valid username+password → 200 { ok:true } and issueLocalSessionCookie called', async () => {
|
||||||
|
const { hashPassword } = await getLocalCredentials();
|
||||||
|
const hash = hashPassword('correcthorse');
|
||||||
|
mockCredRow = { userId: 5, passwordHash: hash };
|
||||||
|
|
||||||
|
const app = await getApp();
|
||||||
|
const res = await app.fetch(makeLoginRequest({ username: 'alice', password: 'correcthorse' }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(issueSessionCalled).toBe(true);
|
||||||
|
expect(issuedUserId).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2: wrong password → 401 { error: "Invalid credentials" }', async () => {
|
||||||
|
const { hashPassword } = await getLocalCredentials();
|
||||||
|
const hash = hashPassword('correcthorse');
|
||||||
|
mockCredRow = { userId: 5, passwordHash: hash };
|
||||||
|
|
||||||
|
const app = await getApp();
|
||||||
|
const res = await app.fetch(makeLoginRequest({ username: 'alice', password: 'wrongpassword' }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = (await res.json()) as { error: string };
|
||||||
|
expect(body.error).toBe('Invalid credentials');
|
||||||
|
expect(issueSessionCalled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3: unknown username → 401 with SAME body as wrong password (no enumeration)', async () => {
|
||||||
|
mockCredRow = undefined; // No credential row found
|
||||||
|
|
||||||
|
const app = await getApp();
|
||||||
|
const res = await app.fetch(makeLoginRequest({ username: 'unknown-user', password: 'anypassword' }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = (await res.json()) as { error: string };
|
||||||
|
// CRITICAL: same body as wrong-password case (Test 2) — no field discrimination
|
||||||
|
expect(body.error).toBe('Invalid credentials');
|
||||||
|
expect(issueSessionCalled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4: 5 consecutive failures → 6th attempt returns 429', async () => {
|
||||||
|
mockCredRow = undefined; // Always unknown — every attempt fails
|
||||||
|
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
// 5 failures to trigger the rate window
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const res = await app.fetch(
|
||||||
|
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1')
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6th attempt from same IP → 429
|
||||||
|
const res6 = await app.fetch(
|
||||||
|
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1')
|
||||||
|
);
|
||||||
|
expect(res6.status).toBe(429);
|
||||||
|
const body = (await res6.json()) as { error: string };
|
||||||
|
expect(body.error).toBe('Too many attempts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 5: 10 failures → 423 (account locked); cleared map resets counter', async () => {
|
||||||
|
mockCredRow = undefined;
|
||||||
|
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
// 10 failures from same IP → lockout
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await app.fetch(
|
||||||
|
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11th attempt → 423 (locked)
|
||||||
|
const res11 = await app.fetch(
|
||||||
|
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
|
||||||
|
);
|
||||||
|
expect(res11.status).toBe(423);
|
||||||
|
const body = (await res11.json()) as { error: string };
|
||||||
|
expect(body.error).toBe('Account locked');
|
||||||
|
|
||||||
|
// Clear the map (simulates admin reset) → counter gone → next attempt is 401 again (not locked)
|
||||||
|
const { loginAttempts } = await import('../../src/routes/localAuth.js');
|
||||||
|
loginAttempts.delete('10.0.0.2');
|
||||||
|
|
||||||
|
const resAfterReset = await app.fetch(
|
||||||
|
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
|
||||||
|
);
|
||||||
|
expect(resAfterReset.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 7 (no-echo): malformed body (missing password) → 400 { error: "Invalid request" }; body has no echoed value or Zod received field', async () => {
|
||||||
|
const app = await getApp();
|
||||||
|
// Body with username but missing password (Zod will reject)
|
||||||
|
const res = await app.fetch(
|
||||||
|
new Request('http://localhost/api/auth/local/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' },
|
||||||
|
body: JSON.stringify({ username: 'mysecretusername' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const bodyText = await res.text();
|
||||||
|
const parsed = JSON.parse(bodyText) as { error: string };
|
||||||
|
expect(parsed.error).toBe('Invalid request');
|
||||||
|
// CRITICAL (no-echo): the response must NOT contain the submitted value or Zod 'received' field
|
||||||
|
expect(bodyText).not.toContain('mysecretusername');
|
||||||
|
expect(bodyText).not.toContain('received');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// POST /api/auth/local/logout + GET /api/auth/local/logout
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('POST /api/auth/local/logout', () => {
|
||||||
|
it('Test 6: POST /logout → 200 { ok:true } and clearLocalSessionCookie called', async () => {
|
||||||
|
const app = await getApp();
|
||||||
|
const res = await app.fetch(
|
||||||
|
new Request('http://localhost/api/auth/local/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'x-forwarded-for': '1.2.3.4' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(clearSessionCalled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/auth/local/logout', () => {
|
||||||
|
it('Test 6b: GET /logout (alias) → 200 { ok:true } and clearLocalSessionCookie called', async () => {
|
||||||
|
const app = await getApp();
|
||||||
|
const res = await app.fetch(
|
||||||
|
new Request('http://localhost/api/auth/local/logout', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'x-forwarded-for': '1.2.3.4' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(clearSessionCalled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user