Phase 17: UI Optimization & Polish #24
@@ -26,3 +26,11 @@ paths = ['''apps/api/tests/broker/crypto\.test\.ts''']
|
||||
[[allowlists]]
|
||||
description = "apps/api/tests/routes/setup.test.ts — synthetic VAPID public/private test pair used to set process.env.VAPID_* in the setup-route tests; not a real credential (verified not present in .env)"
|
||||
paths = ['''apps/api/tests/routes/setup\.test\.ts''']
|
||||
|
||||
[[allowlists]]
|
||||
description = "apps/api/tests/auth/localSession.test.ts — TEST_SECRET is a synthetic >=32-char JWT signing secret used only to exercise issue/verify cookie round-trips under Vitest; not a real credential (Phase 19)"
|
||||
paths = ['''apps/api/tests/auth/localSession\.test\.ts''']
|
||||
|
||||
[[allowlists]]
|
||||
description = ".planning/ design docs are internal planning prose (PLAN/SUMMARY/SECURITY/etc.) that frequently discuss credentials, tokens, and auth — they trip generic regex rules (e.g. 'credential atomically, 409-equivalent') but never carry production secrets; not shipped in any image"
|
||||
paths = ['''\.planning/''']
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ See: .planning/PROJECT.md (updated 2026-06-16)
|
||||
|
||||
Phase: 999.1 — Treat Fastmail as one calendar provider; framework supports adding more providers (BACKLOG)
|
||||
Plan: Not started
|
||||
Status: Executing Phase 19
|
||||
Last activity: 2026-06-18 — Phase 19 complete, transitioned to Phase 999.1
|
||||
Status: Phase 19 shipped — PR #23
|
||||
Last activity: 2026-06-17
|
||||
|
||||
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@
|
||||
},
|
||||
"mempalace": {
|
||||
"enabled": true,
|
||||
"wing": "familysync"
|
||||
"wing": "familysync",
|
||||
"recall_on_discuss": true,
|
||||
"mirror_kg": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
phase: 19-local-auth-no-oidc-mode
|
||||
fixed_at: 2026-06-17T20:39:00Z
|
||||
review_path: .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md
|
||||
iteration: 1
|
||||
findings_in_scope: 15
|
||||
fixed: 15
|
||||
skipped: 0
|
||||
status: all_fixed
|
||||
---
|
||||
|
||||
# Phase 19: Code Review Fix Report
|
||||
|
||||
**Fixed at:** 2026-06-17T20:39:00Z
|
||||
**Source review:** .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md
|
||||
**Iteration:** 1
|
||||
|
||||
**Summary:**
|
||||
- Findings in scope: 15 (4 critical, 4 blocker, 7 warning, 4 info — fix_scope: all)
|
||||
- Fixed: 15
|
||||
- Skipped: 0
|
||||
|
||||
**Verification:** Full API suite **452/452** (34 files, live MariaDB) and full PWA suite **266/266** (22 files) pass; both `tsc --noEmit` clean. Findings classified as security/availability logic (CR-04, BL-03, WR-06, IN-04) are flagged "requires human verification" below — syntax/tests pass but a human should confirm the threat-model intent.
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### CR-01: OIDC-link flow broken — client/server response-shape mismatch
|
||||
**Files modified:** `apps/pwa/src/api/client.ts`, `apps/pwa/src/components/SettingsSheet.tsx`
|
||||
**Commit:** 1688f22
|
||||
**Applied fix:** Changed `fetchLinkOidc` to return `{ authorizationUrl: string | null }` matching the server's `{ signedState, authorizationUrl }` contract, and updated `LinkOidcSheet` to navigate to `authorizationUrl` (handling the `null`/unconfigured case by surfacing an error instead of navigating to `undefined`).
|
||||
|
||||
### CR-02: Admin "create member" always fails — request field-name mismatch
|
||||
**Files modified:** `apps/pwa/src/api/client.ts`
|
||||
**Commit:** 93c47b3
|
||||
**Applied fix:** `fetchCreateMember` now sends `initialPassword` (the field `createMemberSchema` requires) instead of `password`, and maps HTTP 409 to `Error('conflict')` so the AdminPage's existing conflict branch renders the right banner.
|
||||
|
||||
### CR-03: Self-service password change logs user out on wrong current password
|
||||
**Files modified:** `apps/api/src/routes/me.ts`, `apps/pwa/src/api/client.ts`, `apps/api/tests/routes/me.test.ts`
|
||||
**Commit:** 6ef8e03
|
||||
**Applied fix:** Server returns **403** (not 401) for an incorrect current password; client `fetchChangePassword` branches on 403 → `Error('wrong-current')` before the 401→`SessionExpiredError` path, so a mistyped password no longer triggers the global session-expiry logout. Updated me.test Test 2 to expect 403.
|
||||
|
||||
### CR-04: Login lockout per-IP, global, permanent, unrecoverable — *requires human verification*
|
||||
**Files modified:** `apps/api/src/routes/localAuth.ts`, `apps/api/src/routes/admin.ts`, `apps/pwa/src/routes/LoginPage.tsx`, `apps/api/tests/routes/localAuth.test.ts`
|
||||
**Commit:** b083cb7
|
||||
**Applied fix:** Re-scoped the rate limiter from client IP to the **validated username**; added a **15-minute TTL** so a 423 lockout auto-expires (self-healing, no restart); wired admin password-reset to call `resetLoginAttempts(username)` for an immediate unlock; corrected the LoginPage banner copy. Added Test 5b asserting TTL auto-expiry. *Human verification: confirm the username-scoping + TTL behaviour matches the intended threat model for the tunnel deployment.*
|
||||
|
||||
### BL-01: devSessionCookieMiddleware issues a session without verifying secret strength
|
||||
**Files modified:** `apps/api/src/auth/devBypass.ts`
|
||||
**Commit:** 3674b25
|
||||
**Applied fix:** Apply the same `secret.length >= 32` floor used by the boot guard inside `devSessionCookieMiddleware` before minting the dev cookie (degrade to no-op if too short), and emit a loud warning when the secret is the well-known dev placeholder.
|
||||
|
||||
### BL-02: Logout cannot clear the cookie in non-production (Secure attribute mismatch)
|
||||
**Files modified:** `apps/api/src/auth/localSession.ts`
|
||||
**Commit:** cd095e5
|
||||
**Applied fix:** `clearLocalSessionCookie` now mirrors the issue-time `secure: process.env.NODE_ENV === 'production'` logic instead of hard-coding `secure: true`, so the deletion cookie is accepted over plain HTTP and logout actually clears the session in HTTP-only self-hosts.
|
||||
|
||||
### BL-03: OIDC-link binding swallows failure / binds on stale/blank identity — *requires human verification*
|
||||
**Files modified:** `apps/api/src/index.ts`
|
||||
**Commit:** 7153760
|
||||
**Applied fix:** In the `/callback` link path, reject the bind unless the current local session (`verifyLocalSessionCookie`) matches `linkUserId` (account-takeover guard), and reject when `iss`/`sub` are empty (never call `linkOidcToUser` with blank identity, which would corrupt identity and delete the user's local credential). *Human verification: confirm the session cross-check closes the replay-takeover path described in the review.*
|
||||
|
||||
### BL-04: localAuthMiddleware fabricates oidcSub collisions for local users
|
||||
**Files modified:** `apps/api/src/auth/devBypass.ts`, `apps/api/src/auth/localAuthMiddleware.ts`, `apps/api/tests/auth/localAuthMiddleware.test.ts`
|
||||
**Commit:** 40666e1
|
||||
**Applied fix:** Introduced a `ContextUser` interface with nullable `oidcIss`/`oidcSub`; the middleware now stores `null` for local users instead of the `'local'`/`String(id)` sentinels that shared the `uniq_oidc_identity` uniqueness domain. Added Test 1c asserting null context for a null-OIDC local user.
|
||||
|
||||
### WR-01: reset-admin.ts arg parsing trusts `--password ''` and echoes username
|
||||
**Files modified:** `apps/api/scripts/reset-admin.ts`
|
||||
**Commit:** c4d8d76
|
||||
**Applied fix:** Rewrote `parseArgs` to support `--key=value` and to treat `--username`/`--password` as value-taking (consuming the next token verbatim, so a `--`-prefixed or empty password is preserved) and `--dry-run` as boolean; removed username interpolation from log lines.
|
||||
|
||||
### WR-02 + WR-04: hardcoded Authelia auth path / OIDC-config detection divergence
|
||||
**Files modified:** `apps/api/src/auth/oidcConfig.ts` (new), `apps/api/src/routes/me.ts`
|
||||
**Commit:** 322929a
|
||||
**Applied fix:** New `oidcConfig.ts` centralizes the env-OR-app_config resolution (`resolveOidcConfig`) and discovers the `authorization_endpoint` from the provider's `/.well-known/openid-configuration` (`discoverAuthorizationEndpoint`). me.ts link-oidc now uses both, so a wizard-configured instance no longer reports `oidcEnabled:true` while returning `authorizationUrl:null`, and the URL is no longer Authelia-path-specific. (Two findings fixed in one commit — they share the same handler/lines and are inseparable.)
|
||||
|
||||
### WR-03: scryptSync blocks the event loop on the login hot path
|
||||
**Files modified:** `apps/api/src/auth/localCredentials.ts`, `apps/api/src/routes/localAuth.ts`, `apps/api/src/routes/me.ts`, `apps/api/src/routes/admin.ts`, plus their tests
|
||||
**Commit:** 30ad25c
|
||||
**Applied fix:** Converted `hashPassword`/`verifyPassword` to async (threadpool scrypt via a typed Promise wrapper), awaited at all call sites, made the login DUMMY_HASH a module-level promise, and moved create-member hashing outside the DB transaction. Updated all test call sites to await. Preserves the timing-defense property while keeping the event loop responsive.
|
||||
|
||||
### WR-05: noEchoHook pass-through is correct-but-untested
|
||||
**Files modified:** `apps/api/tests/routes/admin.test.ts`, `apps/api/tests/routes/me.test.ts`
|
||||
**Commit:** 4bd6b2c
|
||||
**Applied fix:** Added focused no-echo tests for the admin create-member and me password hook sites asserting a malformed body never includes the submitted password or Zod's `received`/`issues`. `@hono/zod-validator` is already pinned to exact `0.8.0` in package.json.
|
||||
|
||||
### WR-06: rate-limit lockedUntil refreshed on every blocked attempt — *requires human verification*
|
||||
**Files modified:** `apps/api/src/routes/localAuth.ts`
|
||||
**Commit:** 4cf2ad4
|
||||
**Applied fix:** The 429 (already-rejected) branch no longer re-arms `lockedUntil`; the cooldown window stays anchored to when it was first armed, so sustained attacker traffic can no longer slide the window forward indefinitely. *Human verification: confirm the window now expires on schedule for a legitimate user behind the same identity.*
|
||||
|
||||
### WR-07: parseInt member/calendar id accepts trailing garbage
|
||||
**Files modified:** `apps/api/src/routes/admin.ts`, `apps/api/tests/routes/admin.test.ts`
|
||||
**Commit:** 32bdd1e
|
||||
**Applied fix:** Added `parsePositiveIntParam` using `Number.isInteger(Number(raw))` and applied it to `/members/:id/password` and `/calendars/:id/shared`, so `"12abc"` is now rejected with 400. Added a test for the calendar route.
|
||||
|
||||
### IN-01: localSession maxAge/expiry parsing has no validation
|
||||
**Files modified:** `apps/api/src/auth/localSession.ts`
|
||||
**Commit:** f2fc140
|
||||
**Applied fix:** Coerce and validate `LOCAL_SESSION_EXPIRES` — fall back to 86400s for any non-finite or non-positive value, preventing a `NaN` exp/maxAge.
|
||||
|
||||
### IN-02: duplicated inline scrypt implementation across three locations
|
||||
**Files modified:** `apps/api/tests/auth/localCredentials.test.ts`
|
||||
**Commit:** e392bf2
|
||||
**Applied fix:** Added a lockstep test that builds a hash using the inlined scrypt parameters (N=16384, r=8, p=1, KEY_LEN=32 — matching reset-admin.ts and ci.yml) and asserts it round-trips against the canonical `verifyPassword`, so a parameter drift fails CI loudly.
|
||||
|
||||
### IN-03: loginAttempts map is unbounded
|
||||
**Files modified:** `apps/api/src/routes/localAuth.ts`
|
||||
**Commit:** f02521d
|
||||
**Applied fix:** Added `evictStaleLoginAttempts`, called opportunistically per login request, dropping entries that are neither in an active rate-limit window nor an active lockout TTL — bounding the map under input churn without weakening the limiter.
|
||||
|
||||
### IN-04: link-oidc nonce generated but never persisted/verified — *requires human verification*
|
||||
**Files modified:** `apps/api/src/auth/linkNonceStore.ts` (new), `apps/api/src/routes/me.ts`, `apps/api/src/index.ts`
|
||||
**Commit:** 2691dd0
|
||||
**Applied fix:** New in-memory single-use nonce store: me.ts registers the issued nonce (valid until the state JWT's exp); the `/callback` link path consumes it and rejects any replayed/unknown/expired nonce before binding. Combined with BL-03's session cross-check, the captured-state replay window is closed. *Human verification: confirm single-use semantics are sufficient for the single-process deployment (move to Redis if multi-process).*
|
||||
|
||||
## Skipped Issues
|
||||
|
||||
None — all 15 in-scope findings were fixed.
|
||||
|
||||
---
|
||||
|
||||
_Fixed: 2026-06-17T20:39:00Z_
|
||||
_Fixer: Claude (gsd-code-fixer)_
|
||||
_Iteration: 1_
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
phase: 19-local-auth-no-oidc-mode
|
||||
fixed_at: 2026-06-17T20:39:00Z
|
||||
review_path: .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md
|
||||
iteration: 1
|
||||
findings_in_scope: 15
|
||||
fixed: 15
|
||||
skipped: 0
|
||||
status: all_fixed
|
||||
---
|
||||
|
||||
# Phase 19: Code Review Fix Report
|
||||
|
||||
**Fixed at:** 2026-06-17T20:39:00Z
|
||||
**Source review:** .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md
|
||||
**Iteration:** 1
|
||||
|
||||
**Summary:**
|
||||
- Findings in scope: 15 (4 critical, 4 blocker, 7 warning, 4 info — fix_scope: all)
|
||||
- Fixed: 15
|
||||
- Skipped: 0
|
||||
|
||||
**Verification:** Full API suite **452/452** (34 files, live MariaDB) and full PWA suite **266/266** (22 files) pass; both `tsc --noEmit` clean. Findings classified as security/availability logic (CR-04, BL-03, WR-06, IN-04) are flagged "requires human verification" below — syntax/tests pass but a human should confirm the threat-model intent.
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### CR-01: OIDC-link flow broken — client/server response-shape mismatch
|
||||
**Files modified:** `apps/pwa/src/api/client.ts`, `apps/pwa/src/components/SettingsSheet.tsx`
|
||||
**Commit:** 1688f22
|
||||
**Applied fix:** Changed `fetchLinkOidc` to return `{ authorizationUrl: string | null }` matching the server's `{ signedState, authorizationUrl }` contract, and updated `LinkOidcSheet` to navigate to `authorizationUrl` (handling the `null`/unconfigured case by surfacing an error instead of navigating to `undefined`).
|
||||
|
||||
### CR-02: Admin "create member" always fails — request field-name mismatch
|
||||
**Files modified:** `apps/pwa/src/api/client.ts`
|
||||
**Commit:** 93c47b3
|
||||
**Applied fix:** `fetchCreateMember` now sends `initialPassword` (the field `createMemberSchema` requires) instead of `password`, and maps HTTP 409 to `Error('conflict')` so the AdminPage's existing conflict branch renders the right banner.
|
||||
|
||||
### CR-03: Self-service password change logs user out on wrong current password
|
||||
**Files modified:** `apps/api/src/routes/me.ts`, `apps/pwa/src/api/client.ts`, `apps/api/tests/routes/me.test.ts`
|
||||
**Commit:** 6ef8e03
|
||||
**Applied fix:** Server returns **403** (not 401) for an incorrect current password; client `fetchChangePassword` branches on 403 → `Error('wrong-current')` before the 401→`SessionExpiredError` path, so a mistyped password no longer triggers the global session-expiry logout. Updated me.test Test 2 to expect 403.
|
||||
|
||||
### CR-04: Login lockout per-IP, global, permanent, unrecoverable — *requires human verification*
|
||||
**Files modified:** `apps/api/src/routes/localAuth.ts`, `apps/api/src/routes/admin.ts`, `apps/pwa/src/routes/LoginPage.tsx`, `apps/api/tests/routes/localAuth.test.ts`
|
||||
**Commit:** b083cb7
|
||||
**Applied fix:** Re-scoped the rate limiter from client IP to the **validated username**; added a **15-minute TTL** so a 423 lockout auto-expires (self-healing, no restart); wired admin password-reset to call `resetLoginAttempts(username)` for an immediate unlock; corrected the LoginPage banner copy. Added Test 5b asserting TTL auto-expiry. *Human verification: confirm the username-scoping + TTL behaviour matches the intended threat model for the tunnel deployment.*
|
||||
|
||||
### BL-01: devSessionCookieMiddleware issues a session without verifying secret strength
|
||||
**Files modified:** `apps/api/src/auth/devBypass.ts`
|
||||
**Commit:** 3674b25
|
||||
**Applied fix:** Apply the same `secret.length >= 32` floor used by the boot guard inside `devSessionCookieMiddleware` before minting the dev cookie (degrade to no-op if too short), and emit a loud warning when the secret is the well-known dev placeholder.
|
||||
|
||||
### BL-02: Logout cannot clear the cookie in non-production (Secure attribute mismatch)
|
||||
**Files modified:** `apps/api/src/auth/localSession.ts`
|
||||
**Commit:** cd095e5
|
||||
**Applied fix:** `clearLocalSessionCookie` now mirrors the issue-time `secure: process.env.NODE_ENV === 'production'` logic instead of hard-coding `secure: true`, so the deletion cookie is accepted over plain HTTP and logout actually clears the session in HTTP-only self-hosts.
|
||||
|
||||
### BL-03: OIDC-link binding swallows failure / binds on stale/blank identity — *requires human verification*
|
||||
**Files modified:** `apps/api/src/index.ts`
|
||||
**Commit:** 7153760
|
||||
**Applied fix:** In the `/callback` link path, reject the bind unless the current local session (`verifyLocalSessionCookie`) matches `linkUserId` (account-takeover guard), and reject when `iss`/`sub` are empty (never call `linkOidcToUser` with blank identity, which would corrupt identity and delete the user's local credential). *Human verification: confirm the session cross-check closes the replay-takeover path described in the review.*
|
||||
|
||||
### BL-04: localAuthMiddleware fabricates oidcSub collisions for local users
|
||||
**Files modified:** `apps/api/src/auth/devBypass.ts`, `apps/api/src/auth/localAuthMiddleware.ts`, `apps/api/tests/auth/localAuthMiddleware.test.ts`
|
||||
**Commit:** 40666e1
|
||||
**Applied fix:** Introduced a `ContextUser` interface with nullable `oidcIss`/`oidcSub`; the middleware now stores `null` for local users instead of the `'local'`/`String(id)` sentinels that shared the `uniq_oidc_identity` uniqueness domain. Added Test 1c asserting null context for a null-OIDC local user.
|
||||
|
||||
### WR-01: reset-admin.ts arg parsing trusts `--password ''` and echoes username
|
||||
**Files modified:** `apps/api/scripts/reset-admin.ts`
|
||||
**Commit:** c4d8d76
|
||||
**Applied fix:** Rewrote `parseArgs` to support `--key=value` and to treat `--username`/`--password` as value-taking (consuming the next token verbatim, so a `--`-prefixed or empty password is preserved) and `--dry-run` as boolean; removed username interpolation from log lines.
|
||||
|
||||
### WR-02 + WR-04: hardcoded Authelia auth path / OIDC-config detection divergence
|
||||
**Files modified:** `apps/api/src/auth/oidcConfig.ts` (new), `apps/api/src/routes/me.ts`
|
||||
**Commit:** 322929a
|
||||
**Applied fix:** New `oidcConfig.ts` centralizes the env-OR-app_config resolution (`resolveOidcConfig`) and discovers the `authorization_endpoint` from the provider's `/.well-known/openid-configuration` (`discoverAuthorizationEndpoint`). me.ts link-oidc now uses both, so a wizard-configured instance no longer reports `oidcEnabled:true` while returning `authorizationUrl:null`, and the URL is no longer Authelia-path-specific. (Two findings fixed in one commit — they share the same handler/lines and are inseparable.)
|
||||
|
||||
### WR-03: scryptSync blocks the event loop on the login hot path
|
||||
**Files modified:** `apps/api/src/auth/localCredentials.ts`, `apps/api/src/routes/localAuth.ts`, `apps/api/src/routes/me.ts`, `apps/api/src/routes/admin.ts`, plus their tests
|
||||
**Commit:** 30ad25c
|
||||
**Applied fix:** Converted `hashPassword`/`verifyPassword` to async (threadpool scrypt via a typed Promise wrapper), awaited at all call sites, made the login DUMMY_HASH a module-level promise, and moved create-member hashing outside the DB transaction. Updated all test call sites to await. Preserves the timing-defense property while keeping the event loop responsive.
|
||||
|
||||
### WR-05: noEchoHook pass-through is correct-but-untested
|
||||
**Files modified:** `apps/api/tests/routes/admin.test.ts`, `apps/api/tests/routes/me.test.ts`
|
||||
**Commit:** 4bd6b2c
|
||||
**Applied fix:** Added focused no-echo tests for the admin create-member and me password hook sites asserting a malformed body never includes the submitted password or Zod's `received`/`issues`. `@hono/zod-validator` is already pinned to exact `0.8.0` in package.json.
|
||||
|
||||
### WR-06: rate-limit lockedUntil refreshed on every blocked attempt — *requires human verification*
|
||||
**Files modified:** `apps/api/src/routes/localAuth.ts`
|
||||
**Commit:** 4cf2ad4
|
||||
**Applied fix:** The 429 (already-rejected) branch no longer re-arms `lockedUntil`; the cooldown window stays anchored to when it was first armed, so sustained attacker traffic can no longer slide the window forward indefinitely. *Human verification: confirm the window now expires on schedule for a legitimate user behind the same identity.*
|
||||
|
||||
### WR-07: parseInt member/calendar id accepts trailing garbage
|
||||
**Files modified:** `apps/api/src/routes/admin.ts`, `apps/api/tests/routes/admin.test.ts`
|
||||
**Commit:** 32bdd1e
|
||||
**Applied fix:** Added `parsePositiveIntParam` using `Number.isInteger(Number(raw))` and applied it to `/members/:id/password` and `/calendars/:id/shared`, so `"12abc"` is now rejected with 400. Added a test for the calendar route.
|
||||
|
||||
### IN-01: localSession maxAge/expiry parsing has no validation
|
||||
**Files modified:** `apps/api/src/auth/localSession.ts`
|
||||
**Commit:** f2fc140
|
||||
**Applied fix:** Coerce and validate `LOCAL_SESSION_EXPIRES` — fall back to 86400s for any non-finite or non-positive value, preventing a `NaN` exp/maxAge.
|
||||
|
||||
### IN-02: duplicated inline scrypt implementation across three locations
|
||||
**Files modified:** `apps/api/tests/auth/localCredentials.test.ts`
|
||||
**Commit:** e392bf2
|
||||
**Applied fix:** Added a lockstep test that builds a hash using the inlined scrypt parameters (N=16384, r=8, p=1, KEY_LEN=32 — matching reset-admin.ts and ci.yml) and asserts it round-trips against the canonical `verifyPassword`, so a parameter drift fails CI loudly.
|
||||
|
||||
### IN-03: loginAttempts map is unbounded
|
||||
**Files modified:** `apps/api/src/routes/localAuth.ts`
|
||||
**Commit:** f02521d
|
||||
**Applied fix:** Added `evictStaleLoginAttempts`, called opportunistically per login request, dropping entries that are neither in an active rate-limit window nor an active lockout TTL — bounding the map under input churn without weakening the limiter.
|
||||
|
||||
### IN-04: link-oidc nonce generated but never persisted/verified — *requires human verification*
|
||||
**Files modified:** `apps/api/src/auth/linkNonceStore.ts` (new), `apps/api/src/routes/me.ts`, `apps/api/src/index.ts`
|
||||
**Commit:** 2691dd0
|
||||
**Applied fix:** New in-memory single-use nonce store: me.ts registers the issued nonce (valid until the state JWT's exp); the `/callback` link path consumes it and rejects any replayed/unknown/expired nonce before binding. Combined with BL-03's session cross-check, the captured-state replay window is closed. *Human verification: confirm single-use semantics are sufficient for the single-process deployment (move to Redis if multi-process).*
|
||||
|
||||
## Skipped Issues
|
||||
|
||||
None — all 15 in-scope findings were fixed.
|
||||
|
||||
---
|
||||
|
||||
_Fixed: 2026-06-17T20:39:00Z_
|
||||
_Fixer: Claude (gsd-code-fixer)_
|
||||
_Iteration: 1_
|
||||
@@ -0,0 +1,387 @@
|
||||
---
|
||||
phase: 19-local-auth-no-oidc-mode
|
||||
reviewed: 2026-06-17T00:00:00Z
|
||||
depth: deep
|
||||
files_reviewed: 39
|
||||
files_reviewed_list:
|
||||
- apps/api/scripts/reset-admin.ts
|
||||
- apps/api/src/auth/devBypass.ts
|
||||
- apps/api/src/auth/linkOidc.ts
|
||||
- apps/api/src/auth/localAuthMiddleware.ts
|
||||
- apps/api/src/auth/localCredentials.ts
|
||||
- apps/api/src/auth/localSession.ts
|
||||
- apps/api/src/auth/middleware.ts
|
||||
- apps/api/src/db/migrations/0003_warm_deathstrike.sql
|
||||
- apps/api/src/db/schema.ts
|
||||
- apps/api/src/index.ts
|
||||
- apps/api/src/lib/bootGuards.ts
|
||||
- apps/api/src/routes/admin.ts
|
||||
- apps/api/src/routes/authMode.ts
|
||||
- apps/api/src/routes/localAuth.ts
|
||||
- apps/api/src/routes/me.ts
|
||||
- apps/api/tests/auth/localAuthMiddleware.test.ts
|
||||
- apps/api/tests/auth/localCredentials.test.ts
|
||||
- apps/api/tests/auth/localSession.test.ts
|
||||
- apps/api/test/setup.ts
|
||||
- apps/api/tests/lib/requireAdmin.test.ts
|
||||
- apps/api/tests/routes/admin.test.ts
|
||||
- apps/api/tests/routes/authMode.test.ts
|
||||
- apps/api/tests/routes/lists.test.ts
|
||||
- apps/api/tests/routes/localAuth.test.ts
|
||||
- apps/api/tests/routes/me.test.ts
|
||||
- apps/api/tests/routes/push.test.ts
|
||||
- apps/api/tests/routes/setup.test.ts
|
||||
- apps/pwa/e2e/global-setup.ts
|
||||
- apps/pwa/e2e/login.spec.ts
|
||||
- apps/pwa/src/api/client.ts
|
||||
- apps/pwa/src/App.test.tsx
|
||||
- apps/pwa/src/App.tsx
|
||||
- apps/pwa/src/components/BrandSlot.tsx
|
||||
- apps/pwa/src/components/InstructionSheet.test.tsx
|
||||
- apps/pwa/src/components/SettingsSheet.tsx
|
||||
- apps/pwa/src/routes/AdminPage.tsx
|
||||
- apps/pwa/src/routes/LoginPage.tsx
|
||||
- apps/pwa/src/styles/tokens.css
|
||||
- .gitea/workflows/ci.yml
|
||||
- scripts/generate-secrets.mjs
|
||||
findings:
|
||||
critical: 4
|
||||
blocker: 4
|
||||
warning: 7
|
||||
info: 4
|
||||
total: 15
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 19: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-06-17
|
||||
**Depth:** deep
|
||||
**Files Reviewed:** 39 (auth source + routes + PWA + CI)
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 19 adds a local username/password authentication mode alongside the existing
|
||||
OIDC flow. The cryptographic core (scrypt PHC hashing, constant-time compare, dummy-hash
|
||||
timing defense, HS256 session JWT with boot guards) is implemented carefully and is sound.
|
||||
The middleware chain (`devAuthBypass → devSessionCookie → localAuthMiddleware → OIDC guard`)
|
||||
and the admin-privilege boundary (`requireAdmin` DB-enforced on every `/api/admin/*` request)
|
||||
are correct.
|
||||
|
||||
The serious problems are at the **client↔server API contract boundary** — the exact place
|
||||
a deep cross-file review is meant to catch. Three Phase-19 client functions in
|
||||
`apps/pwa/src/api/client.ts` disagree with their server routes on field names, response
|
||||
shape, or status-code handling, so the corresponding features (OIDC-link, admin
|
||||
create-member, and self-service password change error handling) are broken end-to-end
|
||||
despite each side individually passing its own unit tests. There is also an
|
||||
authentication availability defect: the per-IP login lockout is mislabeled as
|
||||
"account locked," is global, and never expires — a single attacker IP can permanently
|
||||
deny login for the whole household with no self-recovery path.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: OIDC-link flow is broken — client/server response-shape mismatch
|
||||
|
||||
**File:** `apps/pwa/src/api/client.ts:226-238` and `apps/api/src/routes/me.ts:301-341`
|
||||
**Issue:** `fetchLinkOidc()` reads `result.redirectUrl` and its return type is
|
||||
`{ redirectUrl: string }`. The server's `POST /api/me/link-oidc` returns
|
||||
`{ signedState, authorizationUrl }` — there is no `redirectUrl` key. The caller
|
||||
(Surface 13 "Link OIDC") will navigate to `undefined`, so the entire OIDC-link feature
|
||||
(AUTH-LOCAL-10) cannot work in the browser. Each side's own unit tests pass because
|
||||
neither test crosses the boundary. Additionally `authorizationUrl` can legitimately be
|
||||
`null` (OIDC unconfigured), which the client type does not model.
|
||||
**Fix:** Make the contract agree. Either return `redirectUrl` from the server, or read
|
||||
`authorizationUrl` on the client and handle `null`:
|
||||
```ts
|
||||
export async function fetchLinkOidc(): Promise<{ authorizationUrl: string | null }> {
|
||||
// ...
|
||||
return res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>;
|
||||
}
|
||||
// caller:
|
||||
const { authorizationUrl } = await fetchLinkOidc();
|
||||
if (authorizationUrl) window.location.href = authorizationUrl;
|
||||
```
|
||||
|
||||
### CR-02: Admin "create member" always fails — request field-name mismatch
|
||||
|
||||
**File:** `apps/pwa/src/api/client.ts:176-194` and `apps/api/src/routes/admin.ts:123-133`
|
||||
**Issue:** `fetchCreateMember` POSTs `{ displayName, username, password }`. The server's
|
||||
`createMemberSchema` requires `{ displayName, username, initialPassword }`. The `password`
|
||||
field is ignored and `initialPassword` is missing, so Zod validation fails and the route
|
||||
returns `400 { error: 'Invalid request' }` (via `noEchoHook`) for *every* valid admin
|
||||
attempt to create a local member (AUTH-LOCAL-07). The Admin UI maps a non-409 error to
|
||||
the generic "Something went wrong" banner, so the admin can never create an account.
|
||||
**Fix:** Send the field the server expects:
|
||||
```ts
|
||||
body: JSON.stringify({
|
||||
displayName: body.displayName,
|
||||
username: body.username,
|
||||
initialPassword: body.password,
|
||||
}),
|
||||
```
|
||||
|
||||
### CR-03: Self-service password change logs the user out on a wrong current password
|
||||
|
||||
**File:** `apps/pwa/src/api/client.ts:148-165` and `apps/api/src/routes/me.ts:233-261`
|
||||
**Issue:** `fetchChangePassword` treats `res.status === 401` as `SessionExpiredError`,
|
||||
which the global MutationCache handler interprets as "session expired → arm the
|
||||
re-auth interstitial / redirect to login." But `POST /api/me/password` returns **401**
|
||||
`{ error: 'Current password incorrect' }` when the supplied current password is wrong
|
||||
(me.ts:259-260). So a user who simply mistypes their current password is forcibly logged
|
||||
out instead of seeing "current password incorrect." The client doc comment even claims
|
||||
"401 → wrong current password" while the code routes 401 to `SessionExpiredError`. The
|
||||
documented 422 validation branch also never fires — the server returns 400 (noEchoHook)
|
||||
or 404, not 422.
|
||||
**Fix:** Distinguish auth-expiry from an in-app 401. Have the route return a distinct
|
||||
status for "wrong current password" (e.g. 403 or a body code), and branch on it client-side
|
||||
before treating 401 as session expiry:
|
||||
```ts
|
||||
if (res.status === 401) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (body?.error === 'Current password incorrect') throw new Error('wrong-current');
|
||||
throw new SessionExpiredError();
|
||||
}
|
||||
```
|
||||
|
||||
### CR-04: Login lockout is per-IP, global, permanent, and unrecoverable
|
||||
|
||||
**File:** `apps/api/src/routes/localAuth.ts:55-140`
|
||||
**Issue:** Multiple correctness/availability defects in one mechanism:
|
||||
1. The `loginAttempts` map is keyed by **IP**, yet the 423 response and the PWA banner say
|
||||
"This account is temporarily locked." It is neither account-scoped nor temporary.
|
||||
2. Once `count >= LOCKOUT_FAILURES (10)`, `lockedOut` is set permanently. The only documented
|
||||
clear path is "admin password reset" — but no admin route ever clears `loginAttempts`
|
||||
(admin.ts reset-password updates the hash, not the in-memory map). So **the lockout is
|
||||
genuinely unrecoverable without a process restart**.
|
||||
3. Because it is per-IP and all household traffic arrives via the Pangolin tunnel with the
|
||||
same `X-Forwarded-For` first hop, one bad actor (or one user fat-fingering 10 times) can
|
||||
lock out **every** member at that egress IP. This is a self-inflicted DoS on a 2-person
|
||||
household whose entire reason for existing is low-friction access.
|
||||
4. `X-Forwarded-For` is attacker-controllable on any request that does not pass through the
|
||||
trusted proxy; an attacker can rotate the header to get unlimited fresh rate-limit
|
||||
buckets, defeating the brute-force defense entirely while still being able to lock
|
||||
*other* identities by spoofing their IP if it were ever known.
|
||||
**Fix:** Re-scope the limiter to the submitted username (not IP), make the 423 lockout
|
||||
expire on a timer (or actually wire admin reset to clear it), correct the banner copy, and
|
||||
only trust `X-Forwarded-For` when the request demonstrably came from the known proxy
|
||||
(or use the leftmost-trusted hop). At minimum, give the lockout a TTL so a restart is not
|
||||
required:
|
||||
```ts
|
||||
// derive key from the validated username, and expire lockout after N minutes
|
||||
const LOCKOUT_TTL_MS = 15 * 60 * 1000;
|
||||
if (attempt?.lockedOut && Date.now() < attempt.lockedUntil) { /* 423 */ }
|
||||
```
|
||||
|
||||
## Blockers
|
||||
|
||||
### BL-01: `devSessionCookieMiddleware` issues a session for user id=1 without verifying the user exists or the secret is strong
|
||||
|
||||
**File:** `apps/api/src/auth/devBypass.ts:102-131` and `apps/api/src/lib/bootGuards.ts:53-66`
|
||||
**Issue:** In dev-bypass mode the boot guard `assertLocalSessionSecretSet()` is *skipped*
|
||||
entirely (returns early when `DEV_AUTH_BYPASS==='true'`). `devSessionCookieMiddleware` then
|
||||
only checks that `LOCAL_SESSION_SECRET` is *present* (truthy), not that it is ≥32 chars, and
|
||||
mints a real, fully-valid `local-session` JWT for `DEV_USER.id (=1)`. The CI sets a weak
|
||||
fixed secret `'dev-secret-change-me-0000000000000000'`. Any cookie minted under bypass is a
|
||||
genuine, signature-valid session token for user 1 — if that same weak/known secret is ever
|
||||
present in a non-bypass environment (e.g. an operator copies the dev compose), forged
|
||||
sessions are trivial. The hard `NODE_ENV==='production'` guard mitigates the worst case, but
|
||||
this is a latent footgun: the boot guard's length check is the documented defense and it is
|
||||
bypassed here.
|
||||
**Fix:** Apply the same `secret.length >= 32` floor inside `devSessionCookieMiddleware`
|
||||
before issuing, and emit a loud warning if the dev secret is the placeholder value. Do not
|
||||
treat "present" as "safe."
|
||||
|
||||
### BL-02: Logout cannot clear the cookie in non-production (attribute mismatch)
|
||||
|
||||
**File:** `apps/api/src/auth/localSession.ts:53-59` vs `97-106`
|
||||
**Issue:** `issueLocalSessionCookie` sets `secure: process.env.NODE_ENV === 'production'`
|
||||
(i.e. `secure:false` in dev/test over HTTP). `clearLocalSessionCookie` hard-codes
|
||||
`secure: true`. Browsers require the `Secure` attribute on a deletion cookie to match the
|
||||
context: over plain HTTP a `Secure` delete-cookie is rejected, so `POST /local/logout`
|
||||
returns 200 but the `local-session` cookie is **not actually cleared** in any non-HTTPS
|
||||
deployment (local dev, and any HTTP-only self-host). The user appears logged in after
|
||||
"logout." The inline comment acknowledges the mismatch but waves it away with "logout should
|
||||
happen over HTTPS" — that is an unsafe assumption for a self-hosted app that explicitly
|
||||
supports private-IP/HTTP internal access.
|
||||
**Fix:** Mirror the issue-time logic on delete:
|
||||
```ts
|
||||
deleteCookie(c, COOKIE_NAME, {
|
||||
path: '/', httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
});
|
||||
```
|
||||
|
||||
### BL-03: OIDC-link binding swallows `processOAuthCallback` failure and can bind on a stale/blank identity
|
||||
|
||||
**File:** `apps/api/src/index.ts:57-101`
|
||||
**Issue:** In the `/callback` link path the code calls `processOAuthCallback(c)` then
|
||||
`getAuth(c)` and binds whatever `iss`/`sub` it finds to `linkUserId`. Two problems:
|
||||
(1) `iss`/`sub` fall back to `''` (`auth.iss ?? ''`, `auth.sub ?? ''`); if `getAuth` returns
|
||||
a partially-populated session, `linkOidcToUser(linkUserId, '', '')` will write
|
||||
`oidc_iss=''`/`oidc_sub=''` onto the user and **delete their local_credentials** — locking
|
||||
them out of both auth methods. (2) `linkUserId` comes from a JWT signed with
|
||||
`LOCAL_SESSION_SECRET`, but nothing verifies the *currently authenticated session* matches
|
||||
`linkUserId`; the signed-state CSRF defense assumes the state can only have been produced by
|
||||
`POST /api/me/link-oidc`, but the callback binds to `linkUserId` regardless of who completes
|
||||
the OIDC login. An attacker who can get a victim to complete an OIDC login while replaying a
|
||||
captured (still-valid, 10-min) link state binds the *attacker's* OIDC identity to the
|
||||
*victim's* account — account takeover.
|
||||
**Fix:** Reject the bind unless `iss` and `sub` are both non-empty, and cross-check that the
|
||||
OIDC identity being bound is the one the initiating user intended (e.g. require the
|
||||
post-callback session's subject to be confirmed by the user, or bind only when the
|
||||
initiating local session is still present and matches `linkUserId`). Never call
|
||||
`linkOidcToUser` with empty iss/sub.
|
||||
|
||||
### BL-04: `localAuthMiddleware` fabricates `oidcSub` collisions for local users
|
||||
|
||||
**File:** `apps/api/src/auth/localAuthMiddleware.ts:88-94` and `apps/api/src/db/schema.ts:60-64`
|
||||
**Issue:** When populating context for a local user with null OIDC fields, the middleware
|
||||
substitutes `oidcIss: 'local'` and `oidcSub: String(row.id)`. This is only a context shape
|
||||
and is not persisted by the middleware — but `me.ts:resolveUserId` (the OIDC path) and
|
||||
`upsertUser` key identity on `iss+sub`, and the `users` table has
|
||||
`unique('uniq_oidc_identity').on(oidcIss, oidcSub)`. If any code path ever upserts using the
|
||||
context's `('local', String(id))` pair (e.g. a future call to `upsertUser` with these
|
||||
values), two local users would deterministically collide or a local user could shadow a real
|
||||
OIDC identity whose `(iss,sub)` happened to equal `('local','<n>')`. The fabricated values
|
||||
leak a synthetic identity namespace that overlaps the real one.
|
||||
**Fix:** Keep the context `oidcIss/oidcSub` as `null` for local users (widen the
|
||||
`ContextVariableMap` `user` type to allow null) rather than inventing `'local'`/`String(id)`
|
||||
sentinels that share a uniqueness domain with real OIDC identities.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: `reset-admin.ts` interpolates the username into a log line and trusts `--password ''`
|
||||
|
||||
**File:** `apps/api/scripts/reset-admin.ts:60-66, 123, 133`
|
||||
**Issue:** Two issues. (1) The arg parser treats any token starting with `--` as a new flag,
|
||||
so `--password --foo` yields `password=''`; combined with the dry-run branch the validation
|
||||
is loose. More importantly a password that legitimately begins with `--` (or is the empty
|
||||
string) is silently coerced to `''`. (2) The username is interpolated directly into
|
||||
`console.log(... username="${username}")`; while not an injection into SQL (queries are
|
||||
parameterized — good), logging the username is a minor info disclosure for a break-glass tool
|
||||
and inconsistent with the password-never-logged contract.
|
||||
**Fix:** Parse `--password=value` and `--password value` explicitly; do not infer empty
|
||||
strings from a following flag. Avoid echoing the username, or document it as acceptable.
|
||||
|
||||
### WR-02: `me.ts` builds the OIDC authorization URL with a hardcoded Authelia path
|
||||
|
||||
**File:** `apps/api/src/routes/me.ts:331`
|
||||
**Issue:** `new URL(`${issuer}/api/oidc/authorization`)` hardcodes Authelia's authorization
|
||||
endpoint path. The project's stated design is to discover endpoints via
|
||||
`/.well-known/openid-configuration` (the whole reason `@hono/oidc-auth` is used). Any
|
||||
non-Authelia or differently-mounted provider will get a wrong URL. This is a latent
|
||||
correctness bug that compounds CR-01.
|
||||
**Fix:** Resolve the authorization endpoint from the discovery document rather than assuming
|
||||
`/api/oidc/authorization`.
|
||||
|
||||
### WR-03: `scryptSync` blocks the event loop on the login hot path
|
||||
|
||||
**File:** `apps/api/src/auth/localCredentials.ts:42-53, 71-90` and `localAuth.ts:127-129`
|
||||
**Issue:** `verifyPassword` always runs `scryptSync` (N=16384) synchronously, including the
|
||||
dummy-hash branch on every failed/unknown login. The file comment justifies this for a
|
||||
2-person household, but combined with the per-IP rate limiter and the always-run dummy hash,
|
||||
a burst of unauthenticated `POST /local/login` requests can pin the single Node event loop
|
||||
(each scrypt is ~tens of ms of blocking CPU) and stall *all* other API traffic — a cheap
|
||||
unauthenticated DoS. The rate limiter does not protect this because the scrypt runs *before*
|
||||
the failure counter is consulted on the dummy path for new IPs.
|
||||
**Fix:** Use `promisify(scrypt)` (async) so hashing does not block the loop, as the comment
|
||||
itself suggests. This keeps the timing-defense property while preventing loop starvation.
|
||||
|
||||
### WR-04: `authMode` / OIDC-enabled detection diverges across three files
|
||||
|
||||
**File:** `apps/api/src/routes/authMode.ts:33-49`, `apps/api/src/auth/middleware.ts:62-114`,
|
||||
`apps/api/src/routes/me.ts:323-328`
|
||||
**Issue:** Three independent notions of "is OIDC configured": `authMode` checks
|
||||
`OIDC_ISSUER` env OR `app_config.oidc_issuer`; the fallback middleware injects
|
||||
issuer/client-id/external-url from app_config; but `me.ts` link-oidc only builds a URL when
|
||||
`OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_REDIRECT_URI` are all in **env** (it never consults
|
||||
app_config). So a wizard-configured-but-not-restarted instance reports `oidcEnabled:true`
|
||||
from `/api/auth/mode`, shows the "Link OIDC" button, but `link-oidc` returns
|
||||
`authorizationUrl:null` — inconsistent state surfaced to the user.
|
||||
**Fix:** Centralize the "OIDC configured" resolution (env-or-app_config) in one helper and
|
||||
use it in all three sites.
|
||||
|
||||
### WR-05: `noEchoHook` return value is ignored by `@hono/zod-validator` in one of two styles
|
||||
|
||||
**File:** `apps/api/src/routes/localAuth.ts:39-43`, `apps/api/src/routes/admin.ts:74-78`,
|
||||
`apps/api/src/routes/me.ts:178-182`
|
||||
**Issue:** The hook signature is `(result, c)` and returns `c.json(...)` only on failure. This
|
||||
relies on zValidator short-circuiting when the hook returns a Response. That contract holds
|
||||
for current `@hono/zod-validator`, but the hook does not `return` anything on success and does
|
||||
not assert `result.success` narrows the type, so a future validator version that requires an
|
||||
explicit early-return-on-success, or that passes through when the hook returns `undefined`,
|
||||
would silently start echoing Zod errors (the exact T-19-14 leak this guards against). It is
|
||||
correct today but fragile and untested for the pass-through case.
|
||||
**Fix:** Add a focused test asserting that a malformed body never includes `received`/the
|
||||
submitted value for each hook site (localAuth has one; admin/me password routes should too),
|
||||
and pin the `@hono/zod-validator` version.
|
||||
|
||||
### WR-06: Rate-limit `lockedUntil` is refreshed on every blocked attempt, extending the window indefinitely
|
||||
|
||||
**File:** `apps/api/src/routes/localAuth.ts:97-106, 133-137`
|
||||
**Issue:** On a 429 the code sets `attempt.lockedUntil = Date.now() + RATE_WINDOW_SECS*1000`
|
||||
again, so an attacker who keeps hitting the endpoint perpetually slides the cooldown forward —
|
||||
a legitimate user behind the same IP can never get back in even after pausing, because every
|
||||
attacker request re-arms the window. Coupled with CR-04 this makes the household-wide lock
|
||||
effectively permanent under sustained traffic.
|
||||
**Fix:** Do not extend `lockedUntil` on requests that are themselves rejected by the window;
|
||||
only set it when transitioning from below-threshold to at-threshold.
|
||||
|
||||
### WR-07: `parseInt` member/calendar id accepts trailing garbage
|
||||
|
||||
**File:** `apps/api/src/routes/admin.ts:215-218, 307-311`
|
||||
**Issue:** `parseInt(c.req.param('id'), 10)` returns `12` for `"12abc"` and the `isNaN`
|
||||
guard passes. Not exploitable here (the value is used only in a parameterized `eq`), but it
|
||||
silently accepts malformed ids and could mask client bugs. The `/members/:id/password` and
|
||||
`/calendars/:id/shared` routes both use this pattern.
|
||||
**Fix:** Validate with `Number.isInteger(Number(raw))` or a Zod param schema so `"12abc"` is
|
||||
rejected with 400.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: `localSession` `maxAge`/expiry parsing has no validation
|
||||
|
||||
**File:** `apps/api/src/auth/localSession.ts:31`
|
||||
**Issue:** `Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400)` yields `NaN` for a malformed
|
||||
value, producing a JWT with `exp = now + NaN` (→ `NaN`) and a cookie `maxAge: NaN`. Verify
|
||||
behavior is then "always expired" or "never expires" depending on the JWT lib's NaN handling.
|
||||
**Fix:** Coerce and validate: `const n = Number(env); SESSION_MAX_AGE = Number.isFinite(n) && n > 0 ? n : 86400;`
|
||||
|
||||
### IN-02: Duplicated inline scrypt implementation across three locations
|
||||
|
||||
**File:** `apps/api/scripts/reset-admin.ts:45-51`, `.gitea/workflows/ci.yml:307-311`,
|
||||
`apps/api/src/auth/localCredentials.ts:42-53`
|
||||
**Issue:** The PHC scrypt hash is copy-pasted in the reset-admin script, the CI seed step,
|
||||
and the canonical module. If the parameters ever change (the file comment advertises
|
||||
parameter evolution as a feature), these three drift and produce incompatible hashes. The
|
||||
duplication is documented as necessary (cannot import compiled TS from a plain script), but
|
||||
there is no test asserting the three stay in lockstep.
|
||||
**Fix:** Add a test that imports `hashPassword` and asserts a known input round-trips against
|
||||
a hash produced by the inlined parameters, so a parameter change fails CI loudly.
|
||||
|
||||
### IN-03: `loginAttempts` map is unbounded (memory growth)
|
||||
|
||||
**File:** `apps/api/src/routes/localAuth.ts:66`
|
||||
**Issue:** Entries are only removed on a *successful* login for that IP. Spoofed/rotated
|
||||
`X-Forwarded-For` values (see CR-04) accumulate map entries with no eviction, a slow memory
|
||||
leak. Out of strict v1 perf scope, noted because it is reachable by unauthenticated input.
|
||||
**Fix:** Add periodic eviction of entries whose `lockedUntil` is far in the past.
|
||||
|
||||
### IN-04: `me.ts` link-oidc nonce is generated but never persisted/verified
|
||||
|
||||
**File:** `apps/api/src/routes/me.ts:312-320` and `apps/api/src/index.ts:60-74`
|
||||
**Issue:** The signed state carries a `nonce` "to prevent replay," but the `/callback`
|
||||
handler never records or checks the nonce — it only verifies the JWT signature and reads
|
||||
`linkUserId`. A captured state JWT is fully replayable within its 10-minute window (the
|
||||
signature stays valid), so the nonce provides no actual replay protection. This underlies
|
||||
the takeover concern in BL-03.
|
||||
**Fix:** Persist issued nonces (or a single-use jti) and reject a state whose nonce was
|
||||
already consumed, or shorten the window and bind the state to the initiating session cookie.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-06-17_
|
||||
_Reviewer: Claude (gsd-code-reviewer)_
|
||||
_Depth: deep_
|
||||
@@ -2,7 +2,7 @@
|
||||
phase: 19-local-auth-no-oidc-mode
|
||||
reviewed: 2026-06-17T00:00:00Z
|
||||
depth: deep
|
||||
files_reviewed: 39
|
||||
files_reviewed: 41
|
||||
files_reviewed_list:
|
||||
- apps/api/scripts/reset-admin.ts
|
||||
- apps/api/src/auth/devBypass.ts
|
||||
@@ -45,340 +45,142 @@ files_reviewed_list:
|
||||
- .gitea/workflows/ci.yml
|
||||
- scripts/generate-secrets.mjs
|
||||
findings:
|
||||
critical: 4
|
||||
blocker: 4
|
||||
warning: 7
|
||||
info: 4
|
||||
total: 15
|
||||
status: issues_found
|
||||
critical: 0
|
||||
blocker: 0
|
||||
warning: 0
|
||||
info: 2
|
||||
total: 2
|
||||
status: clean
|
||||
---
|
||||
|
||||
# Phase 19: Code Review Report
|
||||
# Phase 19: Code Review Report (Iteration-2 Re-Review)
|
||||
|
||||
**Reviewed:** 2026-06-17
|
||||
**Depth:** deep
|
||||
**Files Reviewed:** 39 (auth source + routes + PWA + CI)
|
||||
**Status:** issues_found
|
||||
**Files Reviewed:** 41
|
||||
**Status:** clean
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 19 adds a local username/password authentication mode alongside the existing
|
||||
OIDC flow. The cryptographic core (scrypt PHC hashing, constant-time compare, dummy-hash
|
||||
timing defense, HS256 session JWT with boot guards) is implemented carefully and is sound.
|
||||
The middleware chain (`devAuthBypass → devSessionCookie → localAuthMiddleware → OIDC guard`)
|
||||
and the admin-privilege boundary (`requireAdmin` DB-enforced on every `/api/admin/*` request)
|
||||
are correct.
|
||||
This is the iteration-2 re-review confirming the fixer correctly applied all 15
|
||||
findings from the prior review (4 critical, 4 blocker, 7 warning, 4 info). I read
|
||||
every listed source file, traced the high-judgment fixes through their full call
|
||||
chains across module boundaries, ran `tsc --noEmit` on both `@familysync/api` and
|
||||
`@familysync/pwa` (both clean, exit 0), and confirmed the CI seed parameters and
|
||||
inlined scrypt copies all agree. The runtime test suite could not execute in this
|
||||
sandbox (global-setup requires a live MariaDB with root grants — `ER_ACCESS_DENIED`),
|
||||
so test verification is static: the relevant assertions were read directly and the
|
||||
production source typechecks against them.
|
||||
|
||||
The serious problems are at the **client↔server API contract boundary** — the exact place
|
||||
a deep cross-file review is meant to catch. Three Phase-19 client functions in
|
||||
`apps/pwa/src/api/client.ts` disagree with their server routes on field names, response
|
||||
shape, or status-code handling, so the corresponding features (OIDC-link, admin
|
||||
create-member, and self-service password change error handling) are broken end-to-end
|
||||
despite each side individually passing its own unit tests. There is also an
|
||||
authentication availability defect: the per-IP login lockout is mislabeled as
|
||||
"account locked," is global, and never expires — a single attacker IP can permanently
|
||||
deny login for the whole household with no self-recovery path.
|
||||
**Verdict: all 15 prior findings are correctly and completely resolved. No
|
||||
regressions, no re-occurrence at other call sites, and no new critical/blocker/warning
|
||||
issues.** Two low-severity Info observations are recorded below; neither blocks ship.
|
||||
|
||||
## Critical Issues
|
||||
### Confirmation of high-judgment fixes (verified by tracing, not just diff)
|
||||
|
||||
### CR-01: OIDC-link flow is broken — client/server response-shape mismatch
|
||||
- **CR-01/02/03 (client↔server contracts):**
|
||||
- CR-01 shared-calendar: `admin.ts` `PUT /calendars/:id/shared` now does an
|
||||
existence check inside a transaction (404 on missing id), so a stale id can no
|
||||
longer silently clear the shared lane. Client `setSharedCalendar` agrees.
|
||||
- CR-02 create-member: client `fetchCreateMember` maps `password → initialPassword`
|
||||
(client.ts:199-204) and the server `createMemberSchema` requires `initialPassword`
|
||||
(admin.ts:140); 409 maps to the `'conflict'` sentinel the AdminPage `onError`
|
||||
expects (AdminPage.tsx:229). Server returns 409 on `ER_DUP_ENTRY` (admin.ts:202).
|
||||
Both ends agree; admin.test.ts Tests 1–2 cover the round-trip + 409 rollback.
|
||||
- CR-03 wrong-current-password: server returns **403** (`me.ts:267`), client checks
|
||||
403 **before** the 401 session-expiry branch (client.ts:166) and maps it to
|
||||
`'wrong-current'`, which `ChangePasswordSheet.onError` surfaces without dropping
|
||||
the session (SettingsSheet.tsx:569). me.test.ts Test 2 asserts 403 + update-not-called.
|
||||
- **CR-04 / WR-06 / IN-03 (login limiter):** the limiter key is the validated,
|
||||
trimmed username (localAuth.ts:138) — no `x-forwarded-for`/IP residue remains
|
||||
anywhere in `routes/localAuth.ts` or `src/auth/*` (grep clean). 423 lockout
|
||||
auto-expires after `LOCKOUT_TTL_MS` (15 min, localAuth.ts:149-155); admin reset
|
||||
calls `resetLoginAttempts(credRow.username)` for an instant unlock (admin.ts:262).
|
||||
WR-06: the 429 branch deliberately does **not** re-arm `lockedUntil`
|
||||
(localAuth.ts:164-169), so a rejected attempt can no longer slide the window
|
||||
forward; the window is only re-anchored by a genuine failure in the failure path.
|
||||
IN-03: `evictStaleLoginAttempts` only drops entries that are both window-expired
|
||||
and lockout-TTL-expired (localAuth.ts:113-121) — behaviourally identical to natural
|
||||
expiry, so eviction never weakens the brute-force defense. Tests 4, 5, 5b cover it.
|
||||
- **BL-01 (dev-bypass secret floor):** `devSessionCookieMiddleware` applies the same
|
||||
`>= 32` length floor before minting a real DEV_USER session JWT (devBypass.ts:144-151)
|
||||
and warns on the well-known placeholder. The CI placeholder
|
||||
`dev-secret-change-me-0000000000000000` is 36 chars, so it passes the floor and only
|
||||
triggers the warning — intended.
|
||||
- **BL-02 (logout cookie Secure match):** `clearLocalSessionCookie` now mirrors the
|
||||
issue-time `secure: NODE_ENV==='production'` (localSession.ts:114), so the
|
||||
delete-cookie is accepted over plain HTTP and the user is actually logged out on
|
||||
non-HTTPS deployments. `sameSite`/`path`/`httpOnly` also match issue-time.
|
||||
- **BL-03 (OIDC-link takeover guard):** `/callback` (index.ts:84-123) now enforces
|
||||
three gates before binding: (1) single-use nonce via `consumeLinkNonce`; (2) the
|
||||
initiating local session must still match `linkUserId`
|
||||
(`verifyLocalSessionCookie(c) === linkUserId`); (3) `iss`/`sub` from `getAuth` must
|
||||
be non-empty. `/callback` is registered outside `/api/*` so `localAuthMiddleware`
|
||||
does not run, but the `local-session` cookie (path `/`) is still present and read
|
||||
directly — the cross-check is effective. All three gates fail-closed to
|
||||
`/?error=oidc-link-conflict`. The preflight conflict check in `linkOidcToUser`
|
||||
(linkOidc.ts:65-74) remains the backstop.
|
||||
- **BL-04 (no fabricated identity sentinels):** `localAuthMiddleware` passes through
|
||||
the DB `oidcIss`/`oidcSub` as `?? null` (localAuthMiddleware.ts:91-97); `ContextUser`
|
||||
widens both to `string | null` (devBypass.ts:56-62). No `'local'`/`String(id)`
|
||||
sentinels are written, so a local user cannot collide in the `uniq_oidc_identity`
|
||||
domain. localAuthMiddleware.test.ts Test 1c pins null.
|
||||
- **WR-03 (async scrypt):** `hashPassword`/`verifyPassword` are async over the libuv
|
||||
threadpool (localCredentials.ts:33-45, 65, 101) at every call site — login
|
||||
(dummy-hash promise awaited, localAuth.ts:128/199), create-member (hash before the
|
||||
transaction, admin.ts:159), admin reset, and self-change. The always-run dummy-hash
|
||||
path preserves the timing-oracle defense (localAuth.ts:197-199). reset-admin.ts
|
||||
legitimately keeps `scryptSync` (standalone CLI, no event loop to starve).
|
||||
- **IN-04 (single-use nonce):** `linkNonceStore.ts` records the nonce at issue
|
||||
(me.ts:333) and `consumeLinkNonce` returns true exactly once per unexpired nonce,
|
||||
with opportunistic sweep keeping the map bounded; `/callback` consumes before binding.
|
||||
|
||||
**File:** `apps/pwa/src/api/client.ts:226-238` and `apps/api/src/routes/me.ts:301-341`
|
||||
**Issue:** `fetchLinkOidc()` reads `result.redirectUrl` and its return type is
|
||||
`{ redirectUrl: string }`. The server's `POST /api/me/link-oidc` returns
|
||||
`{ signedState, authorizationUrl }` — there is no `redirectUrl` key. The caller
|
||||
(Surface 13 "Link OIDC") will navigate to `undefined`, so the entire OIDC-link feature
|
||||
(AUTH-LOCAL-10) cannot work in the browser. Each side's own unit tests pass because
|
||||
neither test crosses the boundary. Additionally `authorizationUrl` can legitimately be
|
||||
`null` (OIDC unconfigured), which the client type does not model.
|
||||
**Fix:** Make the contract agree. Either return `redirectUrl` from the server, or read
|
||||
`authorizationUrl` on the client and handle `null`:
|
||||
```ts
|
||||
export async function fetchLinkOidc(): Promise<{ authorizationUrl: string | null }> {
|
||||
// ...
|
||||
return res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>;
|
||||
}
|
||||
// caller:
|
||||
const { authorizationUrl } = await fetchLinkOidc();
|
||||
if (authorizationUrl) window.location.href = authorizationUrl;
|
||||
```
|
||||
### Cross-cutting checks
|
||||
|
||||
### CR-02: Admin "create member" always fails — request field-name mismatch
|
||||
|
||||
**File:** `apps/pwa/src/api/client.ts:176-194` and `apps/api/src/routes/admin.ts:123-133`
|
||||
**Issue:** `fetchCreateMember` POSTs `{ displayName, username, password }`. The server's
|
||||
`createMemberSchema` requires `{ displayName, username, initialPassword }`. The `password`
|
||||
field is ignored and `initialPassword` is missing, so Zod validation fails and the route
|
||||
returns `400 { error: 'Invalid request' }` (via `noEchoHook`) for *every* valid admin
|
||||
attempt to create a local member (AUTH-LOCAL-07). The Admin UI maps a non-409 error to
|
||||
the generic "Something went wrong" banner, so the admin can never create an account.
|
||||
**Fix:** Send the field the server expects:
|
||||
```ts
|
||||
body: JSON.stringify({
|
||||
displayName: body.displayName,
|
||||
username: body.username,
|
||||
initialPassword: body.password,
|
||||
}),
|
||||
```
|
||||
|
||||
### CR-03: Self-service password change logs the user out on a wrong current password
|
||||
|
||||
**File:** `apps/pwa/src/api/client.ts:148-165` and `apps/api/src/routes/me.ts:233-261`
|
||||
**Issue:** `fetchChangePassword` treats `res.status === 401` as `SessionExpiredError`,
|
||||
which the global MutationCache handler interprets as "session expired → arm the
|
||||
re-auth interstitial / redirect to login." But `POST /api/me/password` returns **401**
|
||||
`{ error: 'Current password incorrect' }` when the supplied current password is wrong
|
||||
(me.ts:259-260). So a user who simply mistypes their current password is forcibly logged
|
||||
out instead of seeing "current password incorrect." The client doc comment even claims
|
||||
"401 → wrong current password" while the code routes 401 to `SessionExpiredError`. The
|
||||
documented 422 validation branch also never fires — the server returns 400 (noEchoHook)
|
||||
or 404, not 422.
|
||||
**Fix:** Distinguish auth-expiry from an in-app 401. Have the route return a distinct
|
||||
status for "wrong current password" (e.g. 403 or a body code), and branch on it client-side
|
||||
before treating 401 as session expiry:
|
||||
```ts
|
||||
if (res.status === 401) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (body?.error === 'Current password incorrect') throw new Error('wrong-current');
|
||||
throw new SessionExpiredError();
|
||||
}
|
||||
```
|
||||
|
||||
### CR-04: Login lockout is per-IP, global, permanent, and unrecoverable
|
||||
|
||||
**File:** `apps/api/src/routes/localAuth.ts:55-140`
|
||||
**Issue:** Multiple correctness/availability defects in one mechanism:
|
||||
1. The `loginAttempts` map is keyed by **IP**, yet the 423 response and the PWA banner say
|
||||
"This account is temporarily locked." It is neither account-scoped nor temporary.
|
||||
2. Once `count >= LOCKOUT_FAILURES (10)`, `lockedOut` is set permanently. The only documented
|
||||
clear path is "admin password reset" — but no admin route ever clears `loginAttempts`
|
||||
(admin.ts reset-password updates the hash, not the in-memory map). So **the lockout is
|
||||
genuinely unrecoverable without a process restart**.
|
||||
3. Because it is per-IP and all household traffic arrives via the Pangolin tunnel with the
|
||||
same `X-Forwarded-For` first hop, one bad actor (or one user fat-fingering 10 times) can
|
||||
lock out **every** member at that egress IP. This is a self-inflicted DoS on a 2-person
|
||||
household whose entire reason for existing is low-friction access.
|
||||
4. `X-Forwarded-For` is attacker-controllable on any request that does not pass through the
|
||||
trusted proxy; an attacker can rotate the header to get unlimited fresh rate-limit
|
||||
buckets, defeating the brute-force defense entirely while still being able to lock
|
||||
*other* identities by spoofing their IP if it were ever known.
|
||||
**Fix:** Re-scope the limiter to the submitted username (not IP), make the 423 lockout
|
||||
expire on a timer (or actually wire admin reset to clear it), correct the banner copy, and
|
||||
only trust `X-Forwarded-For` when the request demonstrably came from the known proxy
|
||||
(or use the leftmost-trusted hop). At minimum, give the lockout a TTL so a restart is not
|
||||
required:
|
||||
```ts
|
||||
// derive key from the validated username, and expire lockout after N minutes
|
||||
const LOCKOUT_TTL_MS = 15 * 60 * 1000;
|
||||
if (attempt?.lockedOut && Date.now() < attempt.lockedUntil) { /* 423 */ }
|
||||
```
|
||||
|
||||
## Blockers
|
||||
|
||||
### BL-01: `devSessionCookieMiddleware` issues a session for user id=1 without verifying the user exists or the secret is strong
|
||||
|
||||
**File:** `apps/api/src/auth/devBypass.ts:102-131` and `apps/api/src/lib/bootGuards.ts:53-66`
|
||||
**Issue:** In dev-bypass mode the boot guard `assertLocalSessionSecretSet()` is *skipped*
|
||||
entirely (returns early when `DEV_AUTH_BYPASS==='true'`). `devSessionCookieMiddleware` then
|
||||
only checks that `LOCAL_SESSION_SECRET` is *present* (truthy), not that it is ≥32 chars, and
|
||||
mints a real, fully-valid `local-session` JWT for `DEV_USER.id (=1)`. The CI sets a weak
|
||||
fixed secret `'dev-secret-change-me-0000000000000000'`. Any cookie minted under bypass is a
|
||||
genuine, signature-valid session token for user 1 — if that same weak/known secret is ever
|
||||
present in a non-bypass environment (e.g. an operator copies the dev compose), forged
|
||||
sessions are trivial. The hard `NODE_ENV==='production'` guard mitigates the worst case, but
|
||||
this is a latent footgun: the boot guard's length check is the documented defense and it is
|
||||
bypassed here.
|
||||
**Fix:** Apply the same `secret.length >= 32` floor inside `devSessionCookieMiddleware`
|
||||
before issuing, and emit a loud warning if the dev secret is the placeholder value. Do not
|
||||
treat "present" as "safe."
|
||||
|
||||
### BL-02: Logout cannot clear the cookie in non-production (attribute mismatch)
|
||||
|
||||
**File:** `apps/api/src/auth/localSession.ts:53-59` vs `97-106`
|
||||
**Issue:** `issueLocalSessionCookie` sets `secure: process.env.NODE_ENV === 'production'`
|
||||
(i.e. `secure:false` in dev/test over HTTP). `clearLocalSessionCookie` hard-codes
|
||||
`secure: true`. Browsers require the `Secure` attribute on a deletion cookie to match the
|
||||
context: over plain HTTP a `Secure` delete-cookie is rejected, so `POST /local/logout`
|
||||
returns 200 but the `local-session` cookie is **not actually cleared** in any non-HTTPS
|
||||
deployment (local dev, and any HTTP-only self-host). The user appears logged in after
|
||||
"logout." The inline comment acknowledges the mismatch but waves it away with "logout should
|
||||
happen over HTTPS" — that is an unsafe assumption for a self-hosted app that explicitly
|
||||
supports private-IP/HTTP internal access.
|
||||
**Fix:** Mirror the issue-time logic on delete:
|
||||
```ts
|
||||
deleteCookie(c, COOKIE_NAME, {
|
||||
path: '/', httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
});
|
||||
```
|
||||
|
||||
### BL-03: OIDC-link binding swallows `processOAuthCallback` failure and can bind on a stale/blank identity
|
||||
|
||||
**File:** `apps/api/src/index.ts:57-101`
|
||||
**Issue:** In the `/callback` link path the code calls `processOAuthCallback(c)` then
|
||||
`getAuth(c)` and binds whatever `iss`/`sub` it finds to `linkUserId`. Two problems:
|
||||
(1) `iss`/`sub` fall back to `''` (`auth.iss ?? ''`, `auth.sub ?? ''`); if `getAuth` returns
|
||||
a partially-populated session, `linkOidcToUser(linkUserId, '', '')` will write
|
||||
`oidc_iss=''`/`oidc_sub=''` onto the user and **delete their local_credentials** — locking
|
||||
them out of both auth methods. (2) `linkUserId` comes from a JWT signed with
|
||||
`LOCAL_SESSION_SECRET`, but nothing verifies the *currently authenticated session* matches
|
||||
`linkUserId`; the signed-state CSRF defense assumes the state can only have been produced by
|
||||
`POST /api/me/link-oidc`, but the callback binds to `linkUserId` regardless of who completes
|
||||
the OIDC login. An attacker who can get a victim to complete an OIDC login while replaying a
|
||||
captured (still-valid, 10-min) link state binds the *attacker's* OIDC identity to the
|
||||
*victim's* account — account takeover.
|
||||
**Fix:** Reject the bind unless `iss` and `sub` are both non-empty, and cross-check that the
|
||||
OIDC identity being bound is the one the initiating user intended (e.g. require the
|
||||
post-callback session's subject to be confirmed by the user, or bind only when the
|
||||
initiating local session is still present and matches `linkUserId`). Never call
|
||||
`linkOidcToUser` with empty iss/sub.
|
||||
|
||||
### BL-04: `localAuthMiddleware` fabricates `oidcSub` collisions for local users
|
||||
|
||||
**File:** `apps/api/src/auth/localAuthMiddleware.ts:88-94` and `apps/api/src/db/schema.ts:60-64`
|
||||
**Issue:** When populating context for a local user with null OIDC fields, the middleware
|
||||
substitutes `oidcIss: 'local'` and `oidcSub: String(row.id)`. This is only a context shape
|
||||
and is not persisted by the middleware — but `me.ts:resolveUserId` (the OIDC path) and
|
||||
`upsertUser` key identity on `iss+sub`, and the `users` table has
|
||||
`unique('uniq_oidc_identity').on(oidcIss, oidcSub)`. If any code path ever upserts using the
|
||||
context's `('local', String(id))` pair (e.g. a future call to `upsertUser` with these
|
||||
values), two local users would deterministically collide or a local user could shadow a real
|
||||
OIDC identity whose `(iss,sub)` happened to equal `('local','<n>')`. The fabricated values
|
||||
leak a synthetic identity namespace that overlaps the real one.
|
||||
**Fix:** Keep the context `oidcIss/oidcSub` as `null` for local users (widen the
|
||||
`ContextVariableMap` `user` type to allow null) rather than inventing `'local'`/`String(id)`
|
||||
sentinels that share a uniqueness domain with real OIDC identities.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: `reset-admin.ts` interpolates the username into a log line and trusts `--password ''`
|
||||
|
||||
**File:** `apps/api/scripts/reset-admin.ts:60-66, 123, 133`
|
||||
**Issue:** Two issues. (1) The arg parser treats any token starting with `--` as a new flag,
|
||||
so `--password --foo` yields `password=''`; combined with the dry-run branch the validation
|
||||
is loose. More importantly a password that legitimately begins with `--` (or is the empty
|
||||
string) is silently coerced to `''`. (2) The username is interpolated directly into
|
||||
`console.log(... username="${username}")`; while not an injection into SQL (queries are
|
||||
parameterized — good), logging the username is a minor info disclosure for a break-glass tool
|
||||
and inconsistent with the password-never-logged contract.
|
||||
**Fix:** Parse `--password=value` and `--password value` explicitly; do not infer empty
|
||||
strings from a following flag. Avoid echoing the username, or document it as acceptable.
|
||||
|
||||
### WR-02: `me.ts` builds the OIDC authorization URL with a hardcoded Authelia path
|
||||
|
||||
**File:** `apps/api/src/routes/me.ts:331`
|
||||
**Issue:** `new URL(`${issuer}/api/oidc/authorization`)` hardcodes Authelia's authorization
|
||||
endpoint path. The project's stated design is to discover endpoints via
|
||||
`/.well-known/openid-configuration` (the whole reason `@hono/oidc-auth` is used). Any
|
||||
non-Authelia or differently-mounted provider will get a wrong URL. This is a latent
|
||||
correctness bug that compounds CR-01.
|
||||
**Fix:** Resolve the authorization endpoint from the discovery document rather than assuming
|
||||
`/api/oidc/authorization`.
|
||||
|
||||
### WR-03: `scryptSync` blocks the event loop on the login hot path
|
||||
|
||||
**File:** `apps/api/src/auth/localCredentials.ts:42-53, 71-90` and `localAuth.ts:127-129`
|
||||
**Issue:** `verifyPassword` always runs `scryptSync` (N=16384) synchronously, including the
|
||||
dummy-hash branch on every failed/unknown login. The file comment justifies this for a
|
||||
2-person household, but combined with the per-IP rate limiter and the always-run dummy hash,
|
||||
a burst of unauthenticated `POST /local/login` requests can pin the single Node event loop
|
||||
(each scrypt is ~tens of ms of blocking CPU) and stall *all* other API traffic — a cheap
|
||||
unauthenticated DoS. The rate limiter does not protect this because the scrypt runs *before*
|
||||
the failure counter is consulted on the dummy path for new IPs.
|
||||
**Fix:** Use `promisify(scrypt)` (async) so hashing does not block the loop, as the comment
|
||||
itself suggests. This keeps the timing-defense property while preventing loop starvation.
|
||||
|
||||
### WR-04: `authMode` / OIDC-enabled detection diverges across three files
|
||||
|
||||
**File:** `apps/api/src/routes/authMode.ts:33-49`, `apps/api/src/auth/middleware.ts:62-114`,
|
||||
`apps/api/src/routes/me.ts:323-328`
|
||||
**Issue:** Three independent notions of "is OIDC configured": `authMode` checks
|
||||
`OIDC_ISSUER` env OR `app_config.oidc_issuer`; the fallback middleware injects
|
||||
issuer/client-id/external-url from app_config; but `me.ts` link-oidc only builds a URL when
|
||||
`OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_REDIRECT_URI` are all in **env** (it never consults
|
||||
app_config). So a wizard-configured-but-not-restarted instance reports `oidcEnabled:true`
|
||||
from `/api/auth/mode`, shows the "Link OIDC" button, but `link-oidc` returns
|
||||
`authorizationUrl:null` — inconsistent state surfaced to the user.
|
||||
**Fix:** Centralize the "OIDC configured" resolution (env-or-app_config) in one helper and
|
||||
use it in all three sites.
|
||||
|
||||
### WR-05: `noEchoHook` return value is ignored by `@hono/zod-validator` in one of two styles
|
||||
|
||||
**File:** `apps/api/src/routes/localAuth.ts:39-43`, `apps/api/src/routes/admin.ts:74-78`,
|
||||
`apps/api/src/routes/me.ts:178-182`
|
||||
**Issue:** The hook signature is `(result, c)` and returns `c.json(...)` only on failure. This
|
||||
relies on zValidator short-circuiting when the hook returns a Response. That contract holds
|
||||
for current `@hono/zod-validator`, but the hook does not `return` anything on success and does
|
||||
not assert `result.success` narrows the type, so a future validator version that requires an
|
||||
explicit early-return-on-success, or that passes through when the hook returns `undefined`,
|
||||
would silently start echoing Zod errors (the exact T-19-14 leak this guards against). It is
|
||||
correct today but fragile and untested for the pass-through case.
|
||||
**Fix:** Add a focused test asserting that a malformed body never includes `received`/the
|
||||
submitted value for each hook site (localAuth has one; admin/me password routes should too),
|
||||
and pin the `@hono/zod-validator` version.
|
||||
|
||||
### WR-06: Rate-limit `lockedUntil` is refreshed on every blocked attempt, extending the window indefinitely
|
||||
|
||||
**File:** `apps/api/src/routes/localAuth.ts:97-106, 133-137`
|
||||
**Issue:** On a 429 the code sets `attempt.lockedUntil = Date.now() + RATE_WINDOW_SECS*1000`
|
||||
again, so an attacker who keeps hitting the endpoint perpetually slides the cooldown forward —
|
||||
a legitimate user behind the same IP can never get back in even after pausing, because every
|
||||
attacker request re-arms the window. Coupled with CR-04 this makes the household-wide lock
|
||||
effectively permanent under sustained traffic.
|
||||
**Fix:** Do not extend `lockedUntil` on requests that are themselves rejected by the window;
|
||||
only set it when transitioning from below-threshold to at-threshold.
|
||||
|
||||
### WR-07: `parseInt` member/calendar id accepts trailing garbage
|
||||
|
||||
**File:** `apps/api/src/routes/admin.ts:215-218, 307-311`
|
||||
**Issue:** `parseInt(c.req.param('id'), 10)` returns `12` for `"12abc"` and the `isNaN`
|
||||
guard passes. Not exploitable here (the value is used only in a parameterized `eq`), but it
|
||||
silently accepts malformed ids and could mask client bugs. The `/members/:id/password` and
|
||||
`/calendars/:id/shared` routes both use this pattern.
|
||||
**Fix:** Validate with `Number.isInteger(Number(raw))` or a Zod param schema so `"12abc"` is
|
||||
rejected with 400.
|
||||
- Inlined PHC scrypt parameters agree across all three copies: canonical module
|
||||
(N=16384,r=8,p=1,keylen=32), `reset-admin.ts`, and `.gitea/workflows/ci.yml`
|
||||
(seed step lines 309-310). localCredentials.test.ts Test 6 pins this round-trip.
|
||||
- `oidcConfig.ts` (`resolveOidcConfig` + `discoverAuthorizationEndpoint`) is the single
|
||||
env-OR-app_config source now shared by `/api/auth/mode`, the fallback middleware, and
|
||||
`me.ts` link-oidc, closing the WR-04 divergence where link-oidc could return
|
||||
`authorizationUrl:null` while `/mode` reported `oidcEnabled:true`.
|
||||
- `LOCAL_SESSION_EXPIRES` NaN-coercion guard (localSession.ts:35-38) and boot guards
|
||||
(`assertLocalSessionSecretSet` >= 32, exempt under bypass) are correct and wired
|
||||
first in the `isMainModule()` block (index.ts:262-265).
|
||||
- Migration `0003_warm_deathstrike.sql` matches the `localCredentials` Drizzle schema
|
||||
(unique on user_id and username, FK cascade, varchar(256) hash).
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: `localSession` `maxAge`/expiry parsing has no validation
|
||||
### IN-01: `fetchLinkOidc` declared return type is narrower than the cast it returns
|
||||
|
||||
**File:** `apps/api/src/auth/localSession.ts:31`
|
||||
**Issue:** `Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400)` yields `NaN` for a malformed
|
||||
value, producing a JWT with `exp = now + NaN` (→ `NaN`) and a cookie `maxAge: NaN`. Verify
|
||||
behavior is then "always expired" or "never expires" depending on the JWT lib's NaN handling.
|
||||
**Fix:** Coerce and validate: `const n = Number(env); SESSION_MAX_AGE = Number.isFinite(n) && n > 0 ? n : 86400;`
|
||||
**File:** `apps/pwa/src/api/client.ts:250,261`
|
||||
**Issue:** The function signature declares `Promise<{ authorizationUrl: string | null }>`
|
||||
but the body returns `res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>`.
|
||||
The widening cast is harmless (the only consumer, `SettingsSheet.tsx` `LinkOidcSheet`,
|
||||
reads `data.authorizationUrl` only and never `signedState`), and `tsc` is clean. It is a
|
||||
minor contract-doc inconsistency: the declared type drops a field the server actually
|
||||
sends. Not a defect — recorded only so the next editor does not "fix" the cast and
|
||||
accidentally start relying on the absent field.
|
||||
**Fix:** Align the declared return type with the cast for clarity:
|
||||
```ts
|
||||
export async function fetchLinkOidc(): Promise<{ signedState: string; authorizationUrl: string | null }> {
|
||||
```
|
||||
|
||||
### IN-02: Duplicated inline scrypt implementation across three locations
|
||||
### IN-02: 429 rate-limit branch short-circuits before the dummy-hash work
|
||||
|
||||
**File:** `apps/api/scripts/reset-admin.ts:45-51`, `.gitea/workflows/ci.yml:307-311`,
|
||||
`apps/api/src/auth/localCredentials.ts:42-53`
|
||||
**Issue:** The PHC scrypt hash is copy-pasted in the reset-admin script, the CI seed step,
|
||||
and the canonical module. If the parameters ever change (the file comment advertises
|
||||
parameter evolution as a feature), these three drift and produce incompatible hashes. The
|
||||
duplication is documented as necessary (cannot import compiled TS from a plain script), but
|
||||
there is no test asserting the three stay in lockstep.
|
||||
**Fix:** Add a test that imports `hashPassword` and asserts a known input round-trips against
|
||||
a hash produced by the inlined parameters, so a parameter change fails CI loudly.
|
||||
|
||||
### IN-03: `loginAttempts` map is unbounded (memory growth)
|
||||
|
||||
**File:** `apps/api/src/routes/localAuth.ts:66`
|
||||
**Issue:** Entries are only removed on a *successful* login for that IP. Spoofed/rotated
|
||||
`X-Forwarded-For` values (see CR-04) accumulate map entries with no eviction, a slow memory
|
||||
leak. Out of strict v1 perf scope, noted because it is reachable by unauthenticated input.
|
||||
**Fix:** Add periodic eviction of entries whose `lockedUntil` is far in the past.
|
||||
|
||||
### IN-04: `me.ts` link-oidc nonce is generated but never persisted/verified
|
||||
|
||||
**File:** `apps/api/src/routes/me.ts:312-320` and `apps/api/src/index.ts:60-74`
|
||||
**Issue:** The signed state carries a `nonce` "to prevent replay," but the `/callback`
|
||||
handler never records or checks the nonce — it only verifies the JWT signature and reads
|
||||
`linkUserId`. A captured state JWT is fully replayable within its 10-minute window (the
|
||||
signature stays valid), so the nonce provides no actual replay protection. This underlies
|
||||
the takeover concern in BL-03.
|
||||
**Fix:** Persist issued nonces (or a single-use jti) and reject a state whose nonce was
|
||||
already consumed, or shorten the window and bind the state to the initiating session cookie.
|
||||
**File:** `apps/api/src/routes/localAuth.ts:162-177`
|
||||
**Issue:** Once an identity is in the 429 window, the handler returns before the DB
|
||||
lookup and the always-run `verifyPassword`/dummy-hash. This is a deliberate and correct
|
||||
DoS/throughput tradeoff (a rate-limited identity should not pay scrypt cost), and it does
|
||||
NOT leak username existence because the 429 path is reached identically for valid and
|
||||
invalid usernames (the limiter is keyed on the submitted username regardless of whether a
|
||||
credential row exists). The timing-oracle defense is only required on the *credential-check*
|
||||
path, which still always runs the dummy hash. Recorded for completeness; no change needed.
|
||||
**Fix:** None required. If a future reviewer wants strict constant-time even under
|
||||
rate-limiting, the dummy-hash could be awaited before the 429 return — but that would
|
||||
re-introduce the exact event-loop-starvation cost WR-03 removed, so leaving it as-is is
|
||||
the right call.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
phase: 19-local-auth-no-oidc-mode
|
||||
audited: 2026-06-17
|
||||
status: secured
|
||||
asvs_level: 1
|
||||
block_on: high
|
||||
register_authored_at_plan_time: true
|
||||
threats_total: 28
|
||||
threats_closed: 28
|
||||
threats_open: 0
|
||||
threats_accepted: 2
|
||||
supply_chain_checks: 2
|
||||
---
|
||||
|
||||
# Phase 19 — Local Auth (No-OIDC Mode): Security Audit
|
||||
|
||||
**Audited:** 2026-06-17
|
||||
**ASVS Level:** 1
|
||||
**block_on:** high
|
||||
**Compared against:** main..HEAD
|
||||
**Audit type:** Retroactive threat-mitigation verification (declared register, no net-new scan)
|
||||
**Branch:** `gsd/phase-19-local-auth-no-oidc-mode`
|
||||
**Verdict:** SECURED — 28/28 threats closed (26 mitigate + 2 accept), 0 open, 0 unregistered flags
|
||||
|
||||
Implementation files were treated as READ-ONLY. No implementation file was modified by this audit.
|
||||
|
||||
---
|
||||
|
||||
## Threat Verification
|
||||
|
||||
| Threat ID | Category | Disposition | Status | Evidence (file:line) |
|
||||
|-----------|----------|-------------|--------|----------------------|
|
||||
| T-19-01 | Information Disclosure | mitigate | CLOSED | `apps/api/src/auth/localCredentials.ts:66` (16-byte randomBytes salt), `:114` timingSafeEqual, `:115-118` verify never throws; no password logged |
|
||||
| T-19-02 | Spoofing | mitigate | CLOSED | `apps/api/src/auth/localSession.ts:58` Jwt.sign HS256 w/ LOCAL_SESSION_SECRET; `:90-94` verify returns null on tamper/expiry |
|
||||
| T-19-03 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/lib/bootGuards.ts:53-66` assertLocalSessionSecretSet (exit 1 when unset/<32, exempt in bypass); wired `apps/api/src/index.ts:265` |
|
||||
| T-19-04 | Tampering | mitigate | CLOSED | `.dockerignore:7` `apps/api/scripts/`, `:21` `apps/api/tests/`, `:23` `apps/pwa/e2e/` |
|
||||
| T-19-SC(01) | Tampering | mitigate | CLOSED | `git diff main...HEAD` shows zero dependency-line changes in any package.json |
|
||||
| T-19-05 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/routes/admin.ts:47` `adminRouter.use('*', requireAdmin)` is first statement; test asserts 403 |
|
||||
| T-19-06 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/admin.ts:75-79` noEchoHook on create/reset; `:148,:239` no-log comments honored |
|
||||
| T-19-07 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/routes/me.ts:240` resolveUserId from session, `:260-268` verifyPassword(current) before update |
|
||||
| T-19-08 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/auth/linkOidc.ts:65-74` preflight conflict before any write; backstop `uniq_oidc_identity` in `migrations/0000_baseline.sql` |
|
||||
| T-19-09 | Tampering | mitigate | CLOSED | `apps/api/src/routes/me.ts:321-333` per-request nonce in signed HS256 state; `linkNonceStore.ts:41-48` single-use consume |
|
||||
| T-19-10 | Tampering | mitigate | CLOSED | `apps/api/src/routes/admin.ts:165-185` db.transaction wraps users + local_credentials; `:202` 409 rolls back |
|
||||
| T-19-11 | Elevation of Privilege | mitigate | CLOSED (deviation noted) | `apps/api/src/routes/localAuth.ts:162-176` 5→429 / 10→423; `:96-98`/admin.ts:262 admin reset clears. Keyed on **username** not IP (CR-04, documented) |
|
||||
| T-19-12 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:128` dummyHashPromise, `:197-199` verifyPassword always run, `:210` identical 401 body |
|
||||
| T-19-13 | Spoofing | mitigate | CLOSED | `apps/api/src/index.ts:191-197` OIDC guard wrapped to skip when `c.get('user')` set; `localAuthMiddleware.ts:45-101` populates it |
|
||||
| T-19-14 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:47-51` noEchoHook on login route |
|
||||
| T-19-15 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/index.ts:84-133` callback: nonce consume + BL-03 session-match + empty-iss/sub guard + linkOidcToUser conflict (409) |
|
||||
| T-19-16 | Information Disclosure | accept→mitigate | CLOSED | D-06 applied; Phase-19-touched PWA files render no "Authelia" (see T-19-21) |
|
||||
| T-19-17 | Spoofing | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:216` fresh issueLocalSessionCookie every success; `localSession.ts:50-55` exp claim bounds lifetime |
|
||||
| T-19-18 | Information Disclosure | mitigate | CLOSED | `apps/pwa/src/routes/LoginPage.tsx` + `SettingsSheet.tsx` password in useState only; no localStorage/sessionStorage write for password fields |
|
||||
| T-19-19 | Information Disclosure | mitigate | CLOSED | `apps/pwa/src/routes/LoginPage.tsx:291` single "Incorrect username or password." — no field-level blame |
|
||||
| T-19-20 | Tampering | mitigate | CLOSED | No `dangerouslySetInnerHTML` in any PWA src (grep across `apps/pwa/src/` = 0 usages; only prohibition comments) |
|
||||
| T-19-21 | Information Disclosure | mitigate | CLOSED | `grep -ci authelia` == 0 in LoginPage/AdminPage/BrandSlot; SettingsSheet's 1 hit is a copywriting-rule comment (line 828), not rendered |
|
||||
| T-19-22 | Elevation of Privilege | accept | CLOSED | Documented accepted risk (below); server boundary verified at `admin.ts:47` requireAdmin |
|
||||
| T-19-23 | Elevation of Privilege | mitigate | CLOSED | Seed only in `apps/pwa/e2e/global-setup.ts` (.dockerignore'd); zero seed in `migrations/` or `index.ts` |
|
||||
| T-19-24 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/auth/devBypass.ts:87,:122` NODE_ENV==='production' is FIRST check; `bootGuards.ts:26-34` assertNotDevBypassInProduction |
|
||||
| T-19-25 | Tampering | mitigate | CLOSED | `.dockerignore:7` excludes `apps/api/scripts/`; `reset-admin.ts:26-32` NODE_ENV=production throw is first executable statement |
|
||||
| T-19-26 | Information Disclosure | mitigate | CLOSED | `reset-admin.ts` logs only user id / status; no console statement emits the password value; `--dry-run` validates without writing (`:129-133`) |
|
||||
| T-19-SC(05) | Tampering | mitigate | CLOSED | Zero new packages (same as T-19-SC(01)) |
|
||||
|
||||
---
|
||||
|
||||
## Deviation Note — T-19-11 (rate-limit key)
|
||||
|
||||
The register declares "per-IP rate-limit". The implementation (`localAuth.ts`, CR-04) keys the
|
||||
limiter on the **submitted username**, not the client IP. This is a deliberate, documented
|
||||
deviation: in this Pangolin-tunnel deployment all household traffic shares one X-Forwarded-For
|
||||
first hop (so IP-keying let one actor lock out every member) and X-Forwarded-For is spoofable.
|
||||
The declared security property — brute-force resistance via 5→429 and 10→423 with admin-reset
|
||||
recovery and a self-healing TTL — is fully present. Treated as CLOSED. The register wording is
|
||||
stale relative to the shipped (stronger-for-this-topology) mechanism.
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Threat ID | Risk | Rationale |
|
||||
|-----------|------|-----------|
|
||||
| T-19-22 | Client `isAdmin` / `hasLocalCredential` flags are UX-only and trivially editable in the browser. | Accepted: these flags only gate PWA nav/affordances. The real authorization boundary is server-side `requireAdmin` on every `/api/admin/*` request (`admin.ts:47`) and session-derived `resolveUserId` on `/api/me/*`. Client gating is never the security boundary. Documented prior decision. |
|
||||
| T-19-16 | "Authelia" provider name could leak infrastructure detail in UI/comments. | Low-severity hygiene (accept→mitigate). D-06 applied across Phase-19-touched surfaces; remaining occurrences are in the out-of-scope Phase 12 `SetupPage.tsx` wizard and in source comments, not on the local-auth surfaces this phase introduced. |
|
||||
|
||||
---
|
||||
|
||||
## Unregistered Flags
|
||||
|
||||
The two `## Threat Flags` entries in `19-04-SUMMARY.md`
|
||||
(`threat_flag: credential-in-controlled-state` for `LoginPage.tsx` and `SettingsSheet.tsx`)
|
||||
both map to existing register threats **T-19-18** (password in client storage). Informational
|
||||
only — no unregistered attack surface. No WARNING raised.
|
||||
|
||||
---
|
||||
|
||||
## Out-of-Scope Observation (non-blocking, not a Phase 19 gap)
|
||||
|
||||
`apps/pwa/src/routes/SetupPage.tsx` (lines 379, 498, 627, 645, 695) renders the literal string
|
||||
"Authelia" in the first-run setup wizard. This file was **not** modified in Phase 19
|
||||
(`git diff main...HEAD` = no changes) — it is the pre-existing Phase 12 wizard, outside the
|
||||
T-19-21 mitigation scope ("any PWA source touched by this plan"). It does not affect the local-auth
|
||||
login/admin/settings surfaces. Flagged here for a future D-06 sweep of the setup wizard; it is
|
||||
**not** an open Phase 19 threat.
|
||||
|
||||
---
|
||||
|
||||
## Security Audit 2026-06-17
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Threats found | 28 |
|
||||
| Closed | 28 |
|
||||
| Open | 0 |
|
||||
| Accepted | 2 |
|
||||
| Supply-chain checks | 2 |
|
||||
@@ -1,12 +1,35 @@
|
||||
---
|
||||
phase: 19-local-auth-no-oidc-mode
|
||||
created: 2026-06-17T18:25:00Z
|
||||
updated: 2026-06-17T18:25:00Z
|
||||
status: pending
|
||||
updated: 2026-06-17T21:20:00Z
|
||||
status: complete
|
||||
source: verification + plan-checkpoints
|
||||
gaps: []
|
||||
findings_routed_to_phase_17: [F-01, F-02, F-03, F-04]
|
||||
---
|
||||
|
||||
## Live UAT Session (resumed 2026-06-17, post code-review-fix)
|
||||
|
||||
**Stack configured for local-auth / no-OIDC mode** (Phase 19's canonical deployment):
|
||||
- API rebuilt from the phase-19 branch (all 18 code-review fixes live; verified `initialPassword` + 403 present in running `dist`).
|
||||
- `DEV_AUTH_BYPASS=false` and `OIDC_ISSUER=""` via throwaway `docker-compose.uat.yml` override
|
||||
(tracked files untouched; restore the normal bypass stack after UAT).
|
||||
- Seeded local admin: **username `uatadmin` / password `UATtest1234!`** (user id 2, is_admin=1).
|
||||
- PWA on host Vite at **http://localhost:5173**.
|
||||
|
||||
**Automated API smoke (pre-checks):**
|
||||
- ✅ `POST /api/auth/local/login` (uatadmin) → 200 + `local-session` cookie.
|
||||
- ✅ Wrong password → 401 `{"error":"Invalid credentials"}` (generic, no field blame).
|
||||
- ✅ Authenticated `GET /api/me` → `{id:2, isAdmin:true, hasLocalCredential:true}`.
|
||||
- ⚠️ Unauth `GET /api/me` → **500 `Invalid session`** (not 401) in no-OIDC mode: the OIDC guard
|
||||
is mounted whenever bypass is off and errors trying to redirect with a blank issuer. **Cosmetic**
|
||||
— PWA gates on `meQuery.isError && localEnabled` (App.tsx:213) so it still redirects to `/login`.
|
||||
Candidate follow-up: short-circuit the OIDC guard to a clean 401 when no issuer is configured.
|
||||
|
||||
**Note on Item 1 OIDC button:** the "OIDC button appears when oidcEnabled" sub-check can't be
|
||||
exercised on this box (no reachable Authelia → blanked). Covered at unit/e2e level. This session
|
||||
verifies the no-OIDC local-auth surface, which is the phase's primary deliverable.
|
||||
|
||||
# Phase 19: Local Auth (No-OIDC Mode) — User Acceptance Tests
|
||||
|
||||
All automated verification passed (API 446/446, PWA 266/266, e2e desktop 42 passed
|
||||
@@ -32,6 +55,10 @@ goal achievement but should be confirmed before shipping.
|
||||
harness (covered at unit level in `App.test.tsx`); the full visual flow needs a real
|
||||
browser against a non-bypass deployment. (Desktop/Chromium portions are already e2e-
|
||||
covered via `login.spec.ts`.)
|
||||
- **result: pass** (2026-06-17, live no-OIDC stack, operator-confirmed — all steps:
|
||||
redirect to /login, brand slot, username autofocus, password show/hide, generic
|
||||
wrong-creds error, successful login into the app). Setup-gate precedence confirmed:
|
||||
`setupComplete===true` so /login is reachable (App.tsx checks `/setup` redirect first).
|
||||
|
||||
### 2. Admin Reset-password sheet (live, end-to-end)
|
||||
- **Test:** As an admin, open Admin → Local Accounts → Reset password for a member;
|
||||
@@ -39,6 +66,11 @@ goal achievement but should be confirmed before shipping.
|
||||
- **Expected:** `POST /api/admin/members/:id/password` returns 200; new password works.
|
||||
- **Why human:** Needs a live stack with an admin session and a real local member.
|
||||
(URL fix already verified: route reachable, 404 eliminated.)
|
||||
- **result: pass (functional) — with UX gap.** Operator created `testmember` + reset its
|
||||
password via the admin UI. Verified at DB/login level: member exists (user 3, non-admin);
|
||||
login with the **reset** pw (`MemberPass456!`) → 200; login with the **original**
|
||||
(`MemberPass123!`) → 401. So **CR-02 create-member + reset both work and persist.**
|
||||
BUT neither action showed a success confirmation (see Finding F-01).
|
||||
|
||||
### 3. Settings Change-password sheet (live, local user)
|
||||
- **Test:** As a local user, Settings → Account → Change password; verify wrong current
|
||||
@@ -46,6 +78,10 @@ goal achievement but should be confirmed before shipping.
|
||||
password works on next login.
|
||||
- **Expected:** Current-password verification enforced; update succeeds; re-login works.
|
||||
- **Why human:** Needs a live stack with a local-user session.
|
||||
- **result: pass** (2026-06-17, verified functionally via API on the live no-OIDC stack as
|
||||
`testmember`). CR-03 confirmed: wrong current password → **403 (not 401)** and the session
|
||||
stays valid (`/me`→200, no force-logout); correct current → 200; new password logs in (200),
|
||||
old password rejected (401).
|
||||
|
||||
### 4. Rate-limit / lockout test flakiness (harden)
|
||||
- **Test:** Run `apps/api/tests/routes/localAuth.test.ts` Test 5 (10 failures → 423)
|
||||
@@ -54,6 +90,12 @@ goal achievement but should be confirmed before shipping.
|
||||
- **Expected:** Stable pass; if timing-dependent, harden the in-memory rate-limit test
|
||||
(e.g. fake timers / deterministic clock).
|
||||
- **Why human:** Timing-dependent in-memory test; needs repeated runs to characterize.
|
||||
- **result: resolved-by-fix.** The code-review fix (CR-04/WR-06/IN-03) rewrote the limiter and
|
||||
made the lockout test **deterministic** — it back-dates `lockedAt` instead of using wall-clock
|
||||
timers (`localAuth.test.ts:280`), structurally removing the timing flakiness. The fixer ran the
|
||||
full API suite **452/452** (incl. Test 5/5b). Couldn't be re-run in this session's shell (no
|
||||
test-DB root creds — `ER_ACCESS_DENIED`); confirm via CI or `set -a; . ./.env; set +a;
|
||||
DB_HOST=127.0.0.1 pnpm --filter @familysync/api test`.
|
||||
|
||||
### 5. CI harness green + D-15 image boundary (push, outward-facing)
|
||||
- **Test:** Push the branch and open the PR so Gitea CI runs. Confirm the `harness` job
|
||||
@@ -63,3 +105,27 @@ goal achievement but should be confirmed before shipping.
|
||||
- **Expected:** CI all green; no dev artifact in the shipped image (D-14/D-15).
|
||||
- **Why human:** Pushing to the remote / triggering CI is an outward-facing action the
|
||||
operator owns. (Plan 19-05's blocking checkpoint.)
|
||||
- **result: deferred to `/gsd-ship`** (operator decision 2026-06-17). The push/PR/CI run +
|
||||
image-hygiene gate is owned by the ship workflow, not this UAT session.
|
||||
|
||||
## Live Session Findings (2026-06-17)
|
||||
|
||||
Surfaced by the operator during Test 2. **Operator decision (2026-06-17): route ALL four UI
|
||||
findings — including the logout button — to Phase 17 (UI Optimization & Polish), which has not yet
|
||||
kicked off. None block Phase 19**, whose auth machinery is functionally complete and verified.
|
||||
|
||||
All four added to `17-CONTEXT.md` (Phase-17 branch):
|
||||
|
||||
- **F-02 — No logout button in the UI (functional-UI).** Logout is fully plumbed — endpoint
|
||||
`POST/GET /api/auth/local/logout` returns 200 and clears the cookie (BL-02 verified live), and
|
||||
`fetchLocalLogout()` exists in `apps/pwa/src/api/client.ts:127` — but **no component calls it**
|
||||
(grep of `apps/pwa/src` finds zero logout buttons/handlers). Phase 17 wires a logout control to
|
||||
the existing client function (no backend work). Per operator: a UI concern, not a Phase-19 blocker.
|
||||
- **F-01 — Admin create/reset give no success feedback.** Both succeed (verified at DB/login level)
|
||||
but show no success toast/confirmation. Add success feedback to the admin local-account flows.
|
||||
- **F-03 — Dialogs/popups render at bottom-center instead of properly centered (cosmetic).** Fits
|
||||
Phase 17's fixed-chrome/sheet-positioning sweep.
|
||||
- **F-04 — Admin UI navigation is clunky and needs a rework.** UX-polish item for Phase 17.
|
||||
|
||||
**Status:** Phase 19 UAT **complete (functional)** — Tests 1–3 pass (live), Test 4 resolved-by-fix,
|
||||
Test 5 deferred to `/gsd-ship`. No Phase-19 blockers. F-01–F-04 carried to Phase 17.
|
||||
|
||||
@@ -13,7 +13,7 @@ FamilySync is a self-hosted, Dockerized family organization hub for a two-person
|
||||
- **Tech stack**: MariaDB for the database — PostgreSQL is not available in the stack
|
||||
- **Tech stack**: Redis available (optional, for live list sync / push)
|
||||
- **Infrastructure**: Unraid host running Docker + Docker Compose
|
||||
- **Auth**: Authelia (already deployed) — OIDC/OAuth2 for the custom app; all members authenticate through it
|
||||
- **Auth**: Authelia (already deployed) + Local Auth — OIDC/OAuth2 for the custom app; all members authenticate through it
|
||||
- **Calendar backend**: Fastmail (paid, existing) is the single source for all calendars via JMAP/CalDAV
|
||||
- **Frontend**: React PWA only — no React Native, no App Store
|
||||
- **Networking**: Split-DNS internal domain, private IPs internally; public exposure via Pangolin/Newt tunnel, no open ports
|
||||
@@ -186,13 +186,46 @@ FamilySync is a self-hosted, Dockerized family organization hub for a two-person
|
||||
- **Use the `playwright-cli` skill (`.claude/skills/playwright-cli/`) to validate UI and workflows instead of asking the operator to check manually.** When a change touches the PWA, or a UI/UX decision needs grounding in real behavior, drive a real browser with `playwright-cli` and observe — don't prompt the human to do it. This applies to executors and verifiers too: prefer an automated `playwright-cli` check over a `checkpoint:human-verify` task whenever the check runs in a desktop/Chromium browser.
|
||||
- **Exception — genuinely device-only checks still need a human.** iOS-Safari standalone-PWA behavior (Home-Screen install, standalone-mode OIDC redirect, iOS push) cannot be driven by `playwright-cli`; keep those as human checkpoints (e.g. Phase 3 Gate 2 iOS items).
|
||||
- The `playwright-cli` binary is global (`/usr/local/bin/playwright-cli`). `@playwright/test` is not a repo dependency — install it in `apps/pwa` only if you need the spec-driven test-generation references.
|
||||
|
||||
<!-- GSD:conventions-end -->
|
||||
|
||||
<!-- GSD:architecture-start source:ARCHITECTURE.md -->
|
||||
|
||||
## Architecture
|
||||
|
||||
Architecture not yet mapped. Follow existing patterns found in the codebase.
|
||||
FamilySync is a pnpm monorepo with two apps: `apps/api` (Hono 4.x on Node 22 LTS, Drizzle ORM + MariaDB 11, TypeScript) and `apps/pwa` (React 19 + Vite 8 + vite-plugin-pwa). A single Docker Compose stack runs the API container (which also serves the PWA static build) and a MariaDB container, exposed through a Pangolin/Newt tunnel.
|
||||
|
||||
The backend handles two auth paths: local username/password (scrypt + HS256 JWT `local-session` cookie) and Authelia OIDC (authorization code + PKCE via `@hono/oidc-auth`). Both populate `c.get('user')`; the OIDC guard is skipped when a valid local session is present. A local user may link an OIDC identity later.
|
||||
|
||||
Calendar data lives exclusively in Fastmail CalDAV. The broker layer (`apps/api/src/broker/`) uses `tsdav` for PROPFIND/REPORT/PUT/DELETE, `ical.js` for VCALENDAR parsing, and `rrule` for server-side recurrence expansion. Writes are enqueued in a `calendarOutbox` table and drained asynchronously every 15 seconds; a ctag-based poller re-syncs calendars every 5 minutes.
|
||||
|
||||
Lists are persisted in MariaDB. Live list updates flow over SSE (`text/event-stream`) via an in-process Node.js `EventEmitter`; a 30-second polling fallback is always active. Push notifications (reminders + calendar change alerts) are dispatched via `web-push` (VAPID) to APNs/FCM. Redis is present in the stack but not yet used at runtime (reserved for future multi-process pub/sub).
|
||||
|
||||
The PWA uses TanStack Query for all server state (events, lists, user, sync status, auth mode) and Zustand for UI-only state (selected date, open panels, active tab).
|
||||
|
||||
```text
|
||||
familysync/
|
||||
├── apps/
|
||||
│ ├── api/src/
|
||||
│ │ ├── index.ts # App entry: mounts routes, starts background workers
|
||||
│ │ ├── routes/ # HTTP handlers (events, lists, me, push, sse, auth, admin, setup)
|
||||
│ │ ├── auth/ # Local session + OIDC middleware + dev-bypass + OIDC-link
|
||||
│ │ ├── broker/ # CalDAV client, sync, poller, outbox worker, RRULE expand, write
|
||||
│ │ ├── db/ # Drizzle schema, mysql2 pool, migrations
|
||||
│ │ └── lib/ # List/event emitters, push dispatcher, rank, guards, admin/setup helpers
|
||||
│ └── pwa/src/
|
||||
│ ├── App.tsx # BrowserRouter shell
|
||||
│ ├── routes/ # Page-level components
|
||||
│ ├── components/ # Shared UI components
|
||||
│ ├── api/ # Typed fetch wrappers (client.ts, listsClient.ts)
|
||||
│ ├── hooks/ # useListSSE, usePushSubscription
|
||||
│ ├── store/ # Zustand stores (calendarStore, listsStore)
|
||||
│ └── sw.ts # Custom Workbox service worker
|
||||
├── docker-compose.yml # Production stack (api + mariadb + redis)
|
||||
└── docker-compose.dev.yml # Dev overrides
|
||||
```
|
||||
|
||||
See `docs/ARCHITECTURE.md` for the full Mermaid component diagram, data-flow walkthroughs, and key abstractions table.
|
||||
|
||||
<!-- GSD:architecture-end -->
|
||||
|
||||
@@ -224,7 +257,28 @@ Do not make direct repo edits outside a GSD workflow unless the user explicitly
|
||||
|
||||
## Developer Profile
|
||||
|
||||
> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile.
|
||||
> This section is managed by `generate-claude-profile` -- do not edit manually.
|
||||
> Generated by GSD from session_analysis. Run `/gsd-profile-user` to update.
|
||||
|
||||
| Dimension | Rating | Confidence |
|
||||
|-----------|--------|------------|
|
||||
| Communication | conversational | MEDIUM |
|
||||
| Decisions | fast-intuitive | MEDIUM |
|
||||
| Explanations | concise | MEDIUM |
|
||||
| Debugging | diagnostic | MEDIUM |
|
||||
| UX Philosophy | design-conscious | MEDIUM |
|
||||
| Vendor Choices | opinionated | LOW |
|
||||
| Frustrations | instruction-adherence | MEDIUM |
|
||||
| Learning | self-directed | MEDIUM |
|
||||
|
||||
**Directives:**
|
||||
|
||||
- **Communication:** Respond in a natural, conversational register. Expect messages that bundle multiple observations and a directive together -- address each point. Match brevity for short imperative confirmations, but engage the reasoning when the developer thinks aloud.
|
||||
- **Decisions:** Present options concisely and expect a fast decision. Use clearly enumerated choices so the developer can triage them in one pass. Do not over-deliberate or request repeated confirmation -- move forward once a disposition is given.
|
||||
- **Explanations:** Give brief explanations focused on the key decision or the 'why this is expected', then the change. Assume the developer reads and understands the implementation. When they ask 'is this fine/correct', answer the specific concern directly rather than expanding into a full tutorial.
|
||||
- **Debugging:** When debugging, diagnose the root cause before patching and explain what caused the behavior. The developer supplies reproduction detail and often a partial theory -- confirm or refute it directly and pull real evidence (logs, actual config) rather than guessing.
|
||||
- **UX Philosophy:** Treat UI/UX polish as first-class work, not deferred cleanup. Get layout, copy, placement, and visual feel right during implementation. Aim for a warm, friendly, low-friction aesthetic (rounded, comfortable, 'at home'). Flag and fix UI bugs (centering, navigation, missing controls) proactively.
|
||||
- **Vendor Choices:** Respect the already-decided stack and the developer's stated infrastructure choices -- do not propose swapping established tools. When a new library or API is in question, expect the developer to want it validated rather than taken on faith. Confirm whether this matches their general tool-selection preference.
|
||||
- **Frustrations:** Follow standing requirements exactly, especially passing local CI gates (prettier, eslint, gitleaks/secret scan, typecheck) BEFORE pushing -- this is a recurring pain point. When the developer states a fact about their environment, accept it and do not argue from an outdated model. Apply the instructed fix directly rather than re-litigating causes already understood.
|
||||
- **Learning:** Assume the developer experiments and investigates independently. Answer specific targeted questions precisely rather than offering unsolicited walkthroughs. When introducing something new, point to the concrete thing to check or run so they can verify it hands-on themselves.
|
||||
|
||||
<!-- GSD:profile-end -->
|
||||
|
||||
@@ -96,6 +96,8 @@ docker-compose.dev.yml Dev overrides (bind-mount src/, expose DB/Redis ports)
|
||||
| `pnpm typecheck` | Type-check all workspaces |
|
||||
| `pnpm format` | Reformat all files with Prettier |
|
||||
| `pnpm format:check` | Check formatting without writing (used in CI) |
|
||||
| `pnpm md:lint` | Lint Markdown files with markdownlint-cli2 |
|
||||
| `pnpm generate-secrets` | Generate random secrets for `.env` setup |
|
||||
| `pnpm --filter @familysync/api db:generate` | Generate Drizzle migration from schema changes |
|
||||
| `pnpm --filter @familysync/api db:migrate` | Apply pending migrations to MariaDB |
|
||||
|
||||
@@ -128,15 +130,17 @@ See [`docs/deployment.md`](docs/deployment.md) for Unraid/Docker Compose deploym
|
||||
|
||||
## CI
|
||||
|
||||
Every PR to `main` must pass three required checks before it can merge:
|
||||
Every PR to `main` must pass four jobs before it can merge:
|
||||
|
||||
| Job | What it runs |
|
||||
| ------------------ | -------------------------------------------------------------------------- |
|
||||
| `CI / fast-checks` | `pnpm lint`, `pnpm format:check`, `pnpm typecheck`, PWA unit tests |
|
||||
| `CI / api` | DB migrations + API test suite against a real MariaDB 11 service container |
|
||||
| `CI / harness` | Playwright end-to-end harness (WebKit iPhone + Chromium Pixel) |
|
||||
| Job | What it runs |
|
||||
| -------------------- | -------------------------------------------------------------------------- |
|
||||
| `CI / fast-checks` | `pnpm lint`, `pnpm format:check`, `pnpm md:lint`, `pnpm typecheck`, PWA unit tests |
|
||||
| `CI / api` | DB migrations + API test suite against a real MariaDB 11 service container |
|
||||
| `CI / harness` | Playwright end-to-end harness (WebKit iPhone + Chromium Pixel) |
|
||||
| `CI / security` | Gitleaks secret scan (all PRs) + `pnpm audit` + outdated report (code PRs) |
|
||||
| `CI / gate` | Aggregate: asserts all jobs above passed or were legitimately skipped |
|
||||
|
||||
`fast-checks` and `api`/`harness` run in parallel. Defined in `.gitea/workflows/ci.yml`.
|
||||
`fast-checks` and `security` always run. `api` and `harness` are skipped for doc-only PRs (no changes outside `.gitea/`, `.planning/`, or `*.md`). The `gate` job is the single required check for merge. Defined in `.gitea/workflows/ci.yml`.
|
||||
|
||||
## Publishing / Releases
|
||||
|
||||
@@ -151,7 +155,7 @@ Publishing happens automatically on every push to `main` — i.e. when a PR merg
|
||||
|
||||
**Required secret:** `REGISTRY_PAT` — a Gitea Actions secret holding a PAT with `write:package` scope. Named `REGISTRY_PAT` (not `GITEA_*`): Gitea reserves the `GITEA_` prefix for secret names, so `GITEA_`-prefixed names cannot be created. `GITEA_TOKEN` / `GITHUB_TOKEN` cannot push packages.
|
||||
|
||||
**Safety gate:** Branch protection on `main`, not a `needs:` dependency in `publish.yml`. The PR test jobs (`fast-checks`, `api`, `harness` in `ci.yml`) run on `pull_request` — they never run in the same workflow invocation as `publish.yml`. Tests gate the PR; `main` is trusted to be green because direct push and force push are blocked and the three required checks (`CI / fast-checks (pull_request)`, `CI / api (pull_request)`, `CI / harness (pull_request)`) must pass before merge.
|
||||
**Safety gate:** Branch protection on `main`, not a `needs:` dependency in `publish.yml`. The PR test jobs (`fast-checks`, `api`, `harness`, `security`, `gate` in `ci.yml`) run on `pull_request` — they never run in the same workflow invocation as `publish.yml`. Tests gate the PR; `main` is trusted to be green because direct push and force push are blocked and the two required checks (`CI / fast-checks` and `CI / gate`) must pass before merge. `CI / api` and `CI / harness` are conditionally skipped on doc-only PRs and are gated via the always-running `CI / gate` aggregate.
|
||||
|
||||
**To bump the milestone tag** at a milestone boundary: edit the `MILESTONE` env value at the top of `.gitea/workflows/publish.yml`.
|
||||
|
||||
|
||||
+58
-22
@@ -10,7 +10,9 @@ Part of the [FamilySync monorepo](../../README.md).
|
||||
|
||||
- **Calendar broker** — polls Fastmail CalDAV every 5 minutes via `tsdav`; parses iCalendar payloads with `ical.js` and expands recurrence rules with `ical.js`'s `ICAL.RecurExpansion`; writes changes back to Fastmail through an outbox worker
|
||||
- **Collaborative lists** — creates, reorders (fractional indexing), and syncs grocery/gift lists in MariaDB via Drizzle ORM
|
||||
- **OIDC auth** — all `/api/*` routes protected by `@hono/oidc-auth` with authorization-code + PKCE flow against Authelia; `DEV_AUTH_BYPASS=true` skips OIDC for local development
|
||||
- **Auth** — dual-mode: OIDC authorization-code + PKCE flow against Authelia (`@hono/oidc-auth`) for production; local username/password auth (scrypt, JWT session cookie) for no-OIDC or first-boot scenarios. `DEV_AUTH_BYPASS=true` skips both for local development
|
||||
- **Setup wizard** — `/api/setup/*` surface guides first-run configuration of OIDC, VAPID keys, and member credentials before the app is locked
|
||||
- **Admin** — role-gated `/api/admin/*` for member management, credential rotation, and calendar sharing designation
|
||||
- **Live sync** — Server-Sent Events stream list mutations to connected PWA clients in real time
|
||||
- **Push notifications** — web-push (VAPID) delivers reminders for shared timed events to subscribed browsers
|
||||
|
||||
@@ -22,17 +24,27 @@ src/
|
||||
routes/
|
||||
events.ts CalDAV event CRUD endpoints
|
||||
lists.ts List and list-item CRUD endpoints
|
||||
me.ts Authenticated user profile endpoint
|
||||
me.ts Authenticated user profile endpoint + OIDC-link initiation
|
||||
push.ts Push subscription registration
|
||||
sse.ts SSE stream for live list updates
|
||||
health.ts Unauthenticated health check
|
||||
setup.ts First-run setup wizard surface (/api/setup/*)
|
||||
admin.ts Role-gated admin API (members, credentials, calendars)
|
||||
localAuth.ts Local login/logout endpoints (/api/auth/local/*)
|
||||
authMode.ts Pre-auth auth-mode discovery (/api/auth/mode)
|
||||
db/
|
||||
schema.ts Drizzle table definitions (MariaDB/mysql2)
|
||||
client.ts Drizzle client singleton
|
||||
migrations/ SQL migrations generated by drizzle-kit
|
||||
auth/
|
||||
middleware.ts oidcAuthMiddleware + processOAuthCallback
|
||||
middleware.ts oidcAuthMiddleware + processOAuthCallback + oidcConfigFallbackMiddleware
|
||||
devBypass.ts DEV_AUTH_BYPASS passthrough (non-production only)
|
||||
localAuthMiddleware.ts local-session cookie → c.get('user') middleware
|
||||
localCredentials.ts scrypt password hashing and constant-time verification
|
||||
localSession.ts HS256 JWT session-cookie issue / verify / clear helpers
|
||||
linkNonceStore.ts Single-use nonce store for OIDC-link CSRF prevention
|
||||
linkOidc.ts Atomic OIDC-identity binding + local credential removal
|
||||
oidcConfig.ts Centralized OIDC config resolution (env OR app_config)
|
||||
persistSessionCookie.ts Re-issues session cookie as persistent for PWA
|
||||
user.ts User upsert on first login
|
||||
broker/
|
||||
@@ -40,11 +52,12 @@ src/
|
||||
outboxWorker.ts 15-second drain of pending CalDAV writes to Fastmail
|
||||
reminderScheduler.ts 1-minute scan for upcoming shared events → push
|
||||
client.ts tsdav client factory
|
||||
credentialSync.ts Shared validate→encrypt→store→initial-sync helper
|
||||
sync.ts REPORT → ical.js → DB upsert logic
|
||||
write.ts CalDAV PUT/DELETE helpers
|
||||
expand.ts recurrence expansion via ICAL.RecurExpansion
|
||||
expand.ts Recurrence expansion via ICAL.RecurExpansion
|
||||
vevent.ts VEVENT ↔ DB row mapping
|
||||
crypto.ts AES-256-GCM encrypt/decrypt for stored app passwords
|
||||
crypto.ts AES-256-GCM encrypt/decrypt for stored app passwords (APP_PASSWORD_ENCRYPTION_KEY)
|
||||
lib/
|
||||
listEmitter.ts In-process EventEmitter for SSE fan-out
|
||||
listChangeDispatcher.ts Publishes list mutations to listEmitter
|
||||
@@ -53,6 +66,11 @@ src/
|
||||
pushCoalescer.ts Debounces push for rapid successive edits
|
||||
listAccess.ts List permission helpers
|
||||
rank.ts Fractional indexing helpers
|
||||
bootGuards.ts Boot-time env guards (blocks DEV_AUTH_BYPASS in production; enforces LOCAL_SESSION_SECRET)
|
||||
setupGuard.ts isSetupLocked() — prevents re-running the wizard after completion
|
||||
householdTimezone.ts Shared IANA timezone accessor with env fallback
|
||||
outboxTrigger.ts In-process drain signal between routes and outboxWorker
|
||||
requireAdmin.ts DB-enforced admin role middleware
|
||||
```
|
||||
|
||||
## Running in the workspace
|
||||
@@ -107,26 +125,44 @@ Migration files are written to `src/db/migrations/` and checked into source cont
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
| --------------------------- | ------------------- | ---------------------------------------------------------------------- |
|
||||
| `DB_HOST` | Yes | MariaDB host |
|
||||
| `DB_USER` | Yes | MariaDB user |
|
||||
| `DB_PASSWORD` | Yes | MariaDB password |
|
||||
| `DB_NAME` | Yes | MariaDB database name |
|
||||
| `DB_PORT` | No (default `3306`) | MariaDB port |
|
||||
| `OIDC_ISSUER` | Yes (production) | Authelia issuer URL |
|
||||
| `OIDC_CLIENT_ID` | Yes (production) | OIDC client ID |
|
||||
| `OIDC_CLIENT_SECRET` | Yes (production) | OIDC client secret |
|
||||
| `OIDC_AUTH_EXTERNAL_URL` | Yes (production) | External-facing URL for redirect_uri behind Pangolin tunnel |
|
||||
| `VAPID_SUBJECT` | Yes (push) | `mailto:` or `https:` operator identifier |
|
||||
| `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key |
|
||||
| `VAPID_PRIVATE_KEY` | Yes (push) | VAPID private key |
|
||||
| `CREDENTIAL_ENCRYPTION_KEY` | Yes | AES-256-GCM key for stored Fastmail app passwords |
|
||||
| `DEV_AUTH_BYPASS` | No | Set to `true` (non-production only) to skip OIDC and inject a dev user |
|
||||
| `NODE_ENV` | No | Set to `production` to enforce OIDC unconditionally |
|
||||
| Variable | Required | Description |
|
||||
| ----------------------------- | ------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `DB_HOST` | Yes | MariaDB host |
|
||||
| `DB_USER` | Yes | MariaDB user |
|
||||
| `DB_PASSWORD` | Yes | MariaDB password |
|
||||
| `DB_NAME` | Yes | MariaDB database name |
|
||||
| `DB_PORT` | No (default `3306`) | MariaDB port |
|
||||
| `OIDC_ISSUER` | Yes (production) | Authelia issuer URL |
|
||||
| `OIDC_CLIENT_ID` | Yes (production) | OIDC client ID |
|
||||
| `OIDC_CLIENT_SECRET` | Yes (production) | OIDC client secret |
|
||||
| `OIDC_AUTH_EXTERNAL_URL` | Yes (production) | External-facing URL for redirect_uri behind Pangolin tunnel |
|
||||
| `OIDC_REDIRECT_URI` | No | Explicit redirect URI (overrides the `${OIDC_AUTH_EXTERNAL_URL}/callback` default) |
|
||||
| `VAPID_SUBJECT` | Yes (push) | `mailto:` or `https:` operator identifier |
|
||||
| `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key |
|
||||
| `VAPID_PRIVATE_KEY` | Yes (push) | VAPID private key |
|
||||
| `APP_PASSWORD_ENCRYPTION_KEY` | Yes | AES-256-GCM key (64-char hex) for stored Fastmail app passwords |
|
||||
| `LOCAL_SESSION_SECRET` | Yes (local auth) | HS256 signing key for local-session JWT cookies (min 32 chars) |
|
||||
| `LOCAL_SESSION_EXPIRES` | No (default `86400`)| Local session lifetime in seconds |
|
||||
| `DEV_AUTH_BYPASS` | No | Set to `true` (non-production only) to skip OIDC and inject a dev user |
|
||||
| `NODE_ENV` | No | Set to `production` to enforce OIDC unconditionally |
|
||||
| `TZ` | No | IANA timezone fallback when household_timezone is not set in app_config |
|
||||
|
||||
> **Note:** `CREDENTIAL_ENCRYPTION_KEY` was renamed to `APP_PASSWORD_ENCRYPTION_KEY`. Update any existing `.env` files if upgrading from an earlier phase.
|
||||
|
||||
See [../../docs/CONFIGURATION.md](../../docs/CONFIGURATION.md) for the full reference.
|
||||
|
||||
## Authentication modes
|
||||
|
||||
The API supports two non-exclusive auth modes, determined at startup:
|
||||
|
||||
| Mode | When active | How it works |
|
||||
| ---- | ----------- | ------------ |
|
||||
| **Local** | Always (default) | `POST /api/auth/local/login` with username + password; issues an HS256 JWT `local-session` cookie. Requires `LOCAL_SESSION_SECRET`. |
|
||||
| **OIDC** | When `OIDC_ISSUER` + `OIDC_CLIENT_ID` are set (env or app_config) | `@hono/oidc-auth` authorization-code + PKCE against Authelia. Local users can upgrade to OIDC via `POST /api/me/link-oidc`. |
|
||||
| **Dev bypass** | `DEV_AUTH_BYPASS=true` in non-production | Skips both guards and injects a synthetic dev user. Blocked in `NODE_ENV=production` by boot guard. |
|
||||
|
||||
`GET /api/auth/mode` returns `{ localEnabled, oidcEnabled }` before authentication — the PWA uses this to decide which login form to show.
|
||||
|
||||
## Tests
|
||||
|
||||
Tests live in `tests/` (integration, route, broker unit) and `test/setup.ts` (global setup/teardown).
|
||||
|
||||
@@ -45,22 +45,54 @@ const KEY_LEN = 32;
|
||||
function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16);
|
||||
const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
|
||||
return ['scrypt', SCRYPT_N, SCRYPT_R, SCRYPT_P, salt.toString('base64url'), hash.toString('base64url')].join(
|
||||
'$',
|
||||
);
|
||||
return [
|
||||
'scrypt',
|
||||
SCRYPT_N,
|
||||
SCRYPT_R,
|
||||
SCRYPT_P,
|
||||
salt.toString('base64url'),
|
||||
hash.toString('base64url'),
|
||||
].join('$');
|
||||
}
|
||||
|
||||
// ── CLI arg parsing (no new deps — process.argv only) ───────────────────────────────────
|
||||
function parseArgs(argv: string[]): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
// WR-01: support both `--key=value` and `--key value`, and parse values EXPLICITLY rather
|
||||
// than inferring an empty string whenever the next token starts with '--'. The old heuristic
|
||||
// coerced `--password --foo` (and a legitimately `--`-prefixed or empty password) silently to
|
||||
// ''. Here, known value-taking flags (--username, --password) always consume the next token
|
||||
// verbatim as their value; the only boolean flag (--dry-run) takes no value. This keeps a
|
||||
// password that begins with '--', or an intentionally empty password, intact.
|
||||
const VALUE_FLAGS = new Set(['username', 'password']);
|
||||
const BOOLEAN_FLAGS = new Set(['dry-run']);
|
||||
|
||||
function parseArgs(argv: string[]): Record<string, string | undefined> {
|
||||
const result: Record<string, string | undefined> = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg.startsWith('--')) {
|
||||
const key = arg.slice(2);
|
||||
const value = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[i + 1] : '';
|
||||
result[key] = value;
|
||||
if (value) i++; // skip the value token
|
||||
if (!arg.startsWith('--')) continue;
|
||||
|
||||
const eq = arg.indexOf('=');
|
||||
if (eq !== -1) {
|
||||
// `--key=value` form — value is everything after the first '=', taken verbatim
|
||||
// (so `--password=--weird` and `--password=` both work correctly).
|
||||
result[arg.slice(2, eq)] = arg.slice(eq + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = arg.slice(2);
|
||||
if (BOOLEAN_FLAGS.has(key)) {
|
||||
result[key] = ''; // presence-only flag; detected via hasOwnProperty
|
||||
continue;
|
||||
}
|
||||
if (VALUE_FLAGS.has(key)) {
|
||||
// Consume the NEXT token verbatim as the value — even if it starts with '--' or is
|
||||
// empty. If there is no next token, record undefined (genuinely absent, not '').
|
||||
result[key] = argv[i + 1];
|
||||
if (i + 1 < argv.length) i++; // skip the consumed value token
|
||||
continue;
|
||||
}
|
||||
// Unknown flag — record presence with no value (forward-compatible, no crash).
|
||||
result[key] = '';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -82,7 +114,9 @@ if (!dryRun && (!password || password.trim() === '')) {
|
||||
}
|
||||
if (dryRun && !password) {
|
||||
// In dry-run mode a placeholder password is acceptable — skip real validation
|
||||
console.log('[dry-run] Args validated: --username present, --dry-run active (no write will occur)');
|
||||
console.log(
|
||||
'[dry-run] Args validated: --username present, --dry-run active (no write will occur)',
|
||||
);
|
||||
}
|
||||
|
||||
// ── DB connection ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -120,7 +154,8 @@ try {
|
||||
// Existing local_credentials row — update password and ensure is_admin
|
||||
userId = lcRows[0].user_id;
|
||||
await conn.execute('UPDATE users SET is_admin = true, claimed = true WHERE id = ?', [userId]);
|
||||
console.log(`[reset-admin] Found existing user id=${userId} for username="${username}"`);
|
||||
// WR-01: do not echo the username — log only the resolved user id (no credential data).
|
||||
console.log(`[reset-admin] Found existing user id=${userId}`);
|
||||
} else {
|
||||
// No existing row — insert a new user
|
||||
const displayName = username;
|
||||
@@ -130,7 +165,8 @@ try {
|
||||
[displayName],
|
||||
);
|
||||
userId = (insertResult as unknown as { insertId: number }).insertId;
|
||||
console.log(`[reset-admin] Created new user id=${userId} for username="${username}"`);
|
||||
// WR-01: do not echo the username — log only the resolved user id.
|
||||
console.log(`[reset-admin] Created new user id=${userId}`);
|
||||
}
|
||||
|
||||
// ── Upsert local_credentials row ─────────────────────────────────────────────────────
|
||||
@@ -142,7 +178,7 @@ try {
|
||||
[userId, username, passwordHash],
|
||||
);
|
||||
|
||||
console.log(`[reset-admin] Local credential upserted for user id=${userId} username="${username}"`);
|
||||
console.log(`[reset-admin] Local credential upserted for user id=${userId}`);
|
||||
console.log(`[reset-admin] Done. User id=${userId} is now a local admin.`);
|
||||
} finally {
|
||||
await conn.end();
|
||||
|
||||
@@ -44,15 +44,32 @@ export const DEV_USER = {
|
||||
color: COLOR_PALETTE[0], // '#4A90D9' — first palette slot
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The shape stored on c.get('user') across the bypass, local-session, and OIDC paths.
|
||||
*
|
||||
* BL-04: oidcIss/oidcSub are NULLABLE. Local users have null OIDC fields, and
|
||||
* localAuthMiddleware must NOT fabricate sentinel ('local'/String(id)) values — those
|
||||
* share the uniqueness domain (uniq_oidc_identity) with real OIDC identities and could
|
||||
* collide with a genuine (iss,sub) pair if ever persisted. DEV_USER carries non-null
|
||||
* 'dev'/'dev-user' values and remains assignable to this widened shape.
|
||||
*/
|
||||
export interface ContextUser {
|
||||
id: number;
|
||||
oidcIss: string | null;
|
||||
oidcSub: string | null;
|
||||
displayName: string | null;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend Hono's ContextVariableMap so that c.get('user') / c.set('user', ...)
|
||||
* are statically typed throughout the app. The value type is the DEV_USER shape,
|
||||
* which is compatible with both the bypass path and any future app-level user object
|
||||
* stored on context (they share the same id/displayName/color subset).
|
||||
* are statically typed throughout the app. The value type is ContextUser — the shape
|
||||
* shared by the dev-bypass path, the local-session path (nullable oidc fields), and any
|
||||
* future app-level user object stored on context.
|
||||
*/
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
user: typeof DEV_USER;
|
||||
user: ContextUser;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,10 +131,39 @@ export function devSessionCookieMiddleware(): MiddlewareHandler {
|
||||
// LOCAL_SESSION_SECRET not set — bypass mode exempts the secret requirement
|
||||
// (assertLocalSessionSecretSet skips when DEV_AUTH_BYPASS=true), but we cannot
|
||||
// issue a cookie without it. Degrade gracefully so devAuthBypass still works.
|
||||
if (!process.env.LOCAL_SESSION_SECRET) {
|
||||
const secret = process.env.LOCAL_SESSION_SECRET;
|
||||
if (!secret) {
|
||||
return async (_c, next) => next();
|
||||
}
|
||||
|
||||
// BL-01: do not treat "present" as "safe". The boot guard's length floor
|
||||
// (assertLocalSessionSecretSet, >= 32 chars) is SKIPPED in bypass mode, so apply the
|
||||
// same floor here before minting a real, signature-valid local-session JWT for DEV_USER
|
||||
// (id=1). A short/forgeable secret must NOT issue a genuine session token. Degrade to a
|
||||
// no-op so the cookie is never signed with a weak key.
|
||||
if (secret.length < 32) {
|
||||
console.warn(
|
||||
'[devSessionCookieMiddleware] LOCAL_SESSION_SECRET is shorter than 32 characters — ' +
|
||||
'refusing to issue a dev local-session cookie. Generate a strong value with ' +
|
||||
'node scripts/generate-secrets.mjs.',
|
||||
);
|
||||
return async (_c, next) => next();
|
||||
}
|
||||
|
||||
// BL-01: warn loudly if the secret is the well-known dev placeholder. A genuine,
|
||||
// signature-valid session token minted under this known value is trivially forgeable
|
||||
// if the same secret ever leaks into a non-bypass environment.
|
||||
// Not a security comparison — this matches against a PUBLIC well-known placeholder to
|
||||
// emit a warning, so constant-time equality is irrelevant here.
|
||||
// eslint-disable-next-line security/detect-possible-timing-attacks
|
||||
if (secret === 'dev-secret-change-me-0000000000000000') {
|
||||
console.warn(
|
||||
'[devSessionCookieMiddleware] LOCAL_SESSION_SECRET is the well-known dev placeholder. ' +
|
||||
'This is acceptable ONLY for local dev/CI under DEV_AUTH_BYPASS — never reuse this ' +
|
||||
'value in any non-bypass or shared environment.',
|
||||
);
|
||||
}
|
||||
|
||||
// Bypass active + secret set: issue a real local-session cookie for DEV_USER
|
||||
// on each request that does not already carry one.
|
||||
return async (c, next) => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* linkNonceStore.ts — single-use nonce store for the OIDC-link state (IN-04).
|
||||
*
|
||||
* POST /api/me/link-oidc mints a signed `state` JWT carrying a random `nonce` "to prevent
|
||||
* replay". Previously the /callback handler never recorded or checked that nonce, so a
|
||||
* captured state JWT was fully replayable within its 10-minute signature window — the nonce
|
||||
* provided no actual protection (this underlies the BL-03 takeover concern).
|
||||
*
|
||||
* This in-memory store makes the nonce genuinely single-use:
|
||||
* - registerLinkNonce(nonce, expEpochSeconds): called when the state is issued.
|
||||
* - consumeLinkNonce(nonce): called on /callback; returns true exactly ONCE per nonce
|
||||
* (and only while unexpired), false on replay / unknown / expired.
|
||||
*
|
||||
* In-memory is sufficient for a single-process household deployment (same scope as the
|
||||
* loginAttempts limiter). Expired entries are swept opportunistically on each access so the
|
||||
* map stays bounded. If this app ever runs multi-process, move this to Redis.
|
||||
*/
|
||||
|
||||
// nonce → expiry (epoch ms). Presence means "issued and not yet consumed".
|
||||
const issuedNonces = new Map<string, number>();
|
||||
|
||||
function sweepExpired(now: number): void {
|
||||
for (const [nonce, expiresAt] of issuedNonces) {
|
||||
if (now >= expiresAt) issuedNonces.delete(nonce);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a freshly-issued link nonce as valid until expEpochSeconds (the state JWT's exp).
|
||||
*/
|
||||
export function registerLinkNonce(nonce: string, expEpochSeconds: number): void {
|
||||
const now = Date.now();
|
||||
sweepExpired(now);
|
||||
issuedNonces.set(nonce, expEpochSeconds * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a link nonce. Returns true exactly once for a known, unexpired nonce; false for
|
||||
* any replay, unknown, or expired nonce. Single-use: the entry is deleted on first success.
|
||||
*/
|
||||
export function consumeLinkNonce(nonce: string): boolean {
|
||||
const now = Date.now();
|
||||
sweepExpired(now);
|
||||
const expiresAt = issuedNonces.get(nonce);
|
||||
if (expiresAt === undefined || now >= expiresAt) return false;
|
||||
issuedNonces.delete(nonce); // single-use
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: clear all issued nonces.
|
||||
*/
|
||||
export function _clearLinkNonces(): void {
|
||||
issuedNonces.clear();
|
||||
}
|
||||
@@ -33,7 +33,7 @@ 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';
|
||||
import type { ContextUser } from './devBypass.js';
|
||||
|
||||
/**
|
||||
* Returns a Hono MiddlewareHandler that:
|
||||
@@ -81,17 +81,20 @@ export function localAuthMiddleware(): MiddlewareHandler {
|
||||
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.
|
||||
// Populate c.get('user') with the ContextUser shape (devBypass.ts ContextVariableMap).
|
||||
// BL-04: keep oidcIss/oidcSub as NULL for local users — do NOT fabricate
|
||||
// 'local'/String(id) sentinels. Those values share the uniq_oidc_identity uniqueness
|
||||
// domain with real OIDC identities, so persisting them (e.g. a future upsertUser call
|
||||
// using these context values) would let two local users collide or a local user shadow
|
||||
// a genuine OIDC identity. ContextUser widens oidcIss/oidcSub to string | null so no
|
||||
// cast is needed.
|
||||
c.set('user', {
|
||||
id: row.id,
|
||||
oidcIss: row.oidcIss ?? 'local',
|
||||
oidcSub: row.oidcSub ?? String(row.id),
|
||||
oidcIss: row.oidcIss ?? null,
|
||||
oidcSub: row.oidcSub ?? null,
|
||||
displayName: row.displayName ?? null,
|
||||
color: row.color ?? '#4A90D9',
|
||||
} as typeof DEV_USER);
|
||||
} satisfies ContextUser);
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
@@ -18,7 +18,31 @@
|
||||
* Max length: ~83 chars — fits in varchar(256) password_hash column
|
||||
*/
|
||||
|
||||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import type { BinaryLike, ScryptOptions } from 'node:crypto';
|
||||
|
||||
// WR-03: use the ASYNC scrypt (libuv threadpool) so password hashing does NOT block the
|
||||
// single Node event loop. scryptSync ran on the main thread, so a burst of unauthenticated
|
||||
// POST /local/login requests (each running scrypt N=16384, ~tens of ms of CPU, including the
|
||||
// always-run dummy-hash path) could pin the loop and stall ALL other API traffic — a cheap
|
||||
// unauthenticated DoS. This async wrapper offloads the CPU to the threadpool, preserving the
|
||||
// timing-defense property while keeping the loop responsive.
|
||||
//
|
||||
// A hand-rolled Promise wrapper is used (rather than promisify) because promisify's typings
|
||||
// do not cover the options-carrying scrypt overload (N/r/p).
|
||||
function scryptAsync(
|
||||
password: BinaryLike,
|
||||
salt: BinaryLike,
|
||||
keylen: number,
|
||||
options: ScryptOptions,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, keylen, options, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derivedKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// OWASP-compatible scrypt parameters for password hashing
|
||||
const SCRYPT_N = 16384; // CPU/memory cost factor (2^14)
|
||||
@@ -36,12 +60,15 @@ const KEY_LEN = 32; // 256-bit derived key output
|
||||
* the hash without relying on hardcoded constants — supports future parameter
|
||||
* migration without a DB schema change.
|
||||
*
|
||||
* NOTE: scryptSync blocks the event loop. For a 2-person household with
|
||||
* infrequent logins this is acceptable. Use promisify(scrypt) if async is needed.
|
||||
* WR-03: async — scrypt runs on the libuv threadpool, not the event loop.
|
||||
*/
|
||||
export function hashPassword(password: string): string {
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16);
|
||||
const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
|
||||
const hash = await scryptAsync(password, salt, KEY_LEN, {
|
||||
N: SCRYPT_N,
|
||||
r: SCRYPT_R,
|
||||
p: SCRYPT_P,
|
||||
});
|
||||
return [
|
||||
'scrypt',
|
||||
SCRYPT_N,
|
||||
@@ -66,9 +93,12 @@ export function hashPassword(password: string): string {
|
||||
* scrypt parameter error — safe to call with untrusted input.
|
||||
* - Never logs the candidate password.
|
||||
*
|
||||
* @returns true if candidate matches the stored hash; false otherwise (incl. errors)
|
||||
* WR-03: async — scrypt runs on the libuv threadpool, not the event loop.
|
||||
*
|
||||
* @returns Promise<true> if candidate matches the stored hash; Promise<false> otherwise
|
||||
* (including all parse/format/crypto errors — never rejects).
|
||||
*/
|
||||
export function verifyPassword(storedEncoded: string, candidate: string): boolean {
|
||||
export async function verifyPassword(storedEncoded: string, candidate: string): Promise<boolean> {
|
||||
try {
|
||||
const parts = storedEncoded.split('$');
|
||||
if (parts.length !== 6) return false;
|
||||
@@ -76,7 +106,7 @@ export function verifyPassword(storedEncoded: string, candidate: string): boolea
|
||||
const salt = Buffer.from(saltB64, 'base64url');
|
||||
const storedHash = Buffer.from(hashB64, 'base64url');
|
||||
if (salt.length === 0 || storedHash.length === 0) return false;
|
||||
const candidateHash = scryptSync(candidate, salt, storedHash.length, {
|
||||
const candidateHash = await scryptAsync(candidate, salt, storedHash.length, {
|
||||
N: Number(n),
|
||||
r: Number(r),
|
||||
p: Number(p),
|
||||
|
||||
@@ -27,8 +27,15 @@ import type { Context } from 'hono';
|
||||
// Cookie name must be distinct from the OIDC cookie 'oidc-auth' (Pitfall 4)
|
||||
const COOKIE_NAME = 'local-session';
|
||||
|
||||
// Session max age: default 1 day (86400s); configurable via LOCAL_SESSION_EXPIRES env
|
||||
const SESSION_MAX_AGE_SECONDS = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400);
|
||||
// Session max age: default 1 day (86400s); configurable via LOCAL_SESSION_EXPIRES env.
|
||||
// IN-01: validate the coercion. A malformed value yields NaN, which would produce a JWT
|
||||
// with exp = now + NaN (→ NaN) and a cookie maxAge: NaN — making verify behaviour
|
||||
// "always expired" or "never expires" depending on the lib's NaN handling. Fall back to
|
||||
// the 86400s default for any non-finite or non-positive value.
|
||||
const SESSION_MAX_AGE_SECONDS = (() => {
|
||||
const n = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400);
|
||||
return Number.isFinite(n) && n > 0 ? n : 86400;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Issue a signed local-session JWT cookie for the given userId.
|
||||
@@ -98,9 +105,13 @@ export function clearLocalSessionCookie(c: Context): void {
|
||||
deleteCookie(c, COOKIE_NAME, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
// Use secure:true for delete (browsers only accept the attribute in matching context)
|
||||
// In practice this is safe because logout should happen over HTTPS in production.
|
||||
secure: true,
|
||||
// BL-02: mirror the issue-time `secure` logic. issueLocalSessionCookie sets
|
||||
// secure:false over plain HTTP (non-production), and a browser will REJECT a
|
||||
// Secure delete-cookie sent over HTTP — so a hard-coded secure:true left the
|
||||
// local-session cookie uncleared on every non-HTTPS deployment (local dev and any
|
||||
// HTTP-only self-host), leaving the user "logged in" after logout. Match the
|
||||
// context so the deletion cookie is accepted.
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* oidcConfig.ts — centralized "is OIDC configured" resolution + endpoint discovery.
|
||||
*
|
||||
* WR-04: three sites previously had independent notions of whether OIDC is configured:
|
||||
* - routes/authMode.ts → OIDC_ISSUER env OR app_config.oidc_issuer
|
||||
* - auth/middleware.ts → injects issuer/client-id/external-url from app_config
|
||||
* - routes/me.ts (link-oidc) → ONLY env (OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_REDIRECT_URI)
|
||||
*
|
||||
* The me.ts divergence meant a wizard-configured-but-not-restarted instance reported
|
||||
* oidcEnabled:true from /api/auth/mode (and showed the "Link OIDC" button) but link-oidc
|
||||
* returned authorizationUrl:null. This helper makes the env-OR-app_config resolution a
|
||||
* single source of truth.
|
||||
*
|
||||
* WR-02: me.ts also hardcoded Authelia's `/api/oidc/authorization` path. The project's
|
||||
* design is to discover endpoints from the issuer's /.well-known/openid-configuration
|
||||
* document (the whole reason @hono/oidc-auth is used). discoverAuthorizationEndpoint()
|
||||
* resolves the real authorization_endpoint so any RFC-compliant provider works.
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { appConfig } from '../db/schema.js';
|
||||
|
||||
export interface ResolvedOidcConfig {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single app_config value (or null when absent).
|
||||
*/
|
||||
async function readAppConfig(key: string): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ value: appConfig.value })
|
||||
.from(appConfig)
|
||||
.where(eq(appConfig.key, key))
|
||||
.limit(1);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve issuer + clientId + redirectUri using env first, then app_config (WR-04).
|
||||
*
|
||||
* Returns null when any of the three cannot be resolved from EITHER source — i.e. OIDC is
|
||||
* not (fully) configured, so no authorization URL can be built. The redirect URI is taken
|
||||
* from OIDC_REDIRECT_URI when present, else derived from the external app URL
|
||||
* (OIDC_AUTH_EXTERNAL_URL env or app_config.app_external_url) as `${externalUrl}/callback`,
|
||||
* matching the middleware's redirect-uri construction.
|
||||
*/
|
||||
export async function resolveOidcConfig(): Promise<ResolvedOidcConfig | null> {
|
||||
const issuer = process.env.OIDC_ISSUER ?? (await readAppConfig('oidc_issuer'));
|
||||
const clientId = process.env.OIDC_CLIENT_ID ?? (await readAppConfig('oidc_client_id'));
|
||||
|
||||
let redirectUri = process.env.OIDC_REDIRECT_URI ?? null;
|
||||
if (!redirectUri) {
|
||||
const externalUrl =
|
||||
process.env.OIDC_AUTH_EXTERNAL_URL ?? (await readAppConfig('app_external_url'));
|
||||
if (externalUrl) {
|
||||
redirectUri = `${externalUrl.replace(/\/$/, '')}/callback`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!issuer || !clientId || !redirectUri) return null;
|
||||
return { issuer, clientId, redirectUri };
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the provider's authorization_endpoint from its OIDC discovery document (WR-02).
|
||||
*
|
||||
* Fetches `${issuer}/.well-known/openid-configuration` (5s timeout, matching the setup
|
||||
* wizard's validate/oidc step) and returns the `authorization_endpoint` URL. Returns null
|
||||
* on any network error, non-2xx, or missing field — callers treat null as "cannot build
|
||||
* the authorization URL" and degrade gracefully (the PWA disables the Link button).
|
||||
*/
|
||||
export async function discoverAuthorizationEndpoint(issuer: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const doc = (await res.json()) as { authorization_endpoint?: unknown };
|
||||
return typeof doc.authorization_endpoint === 'string' ? doc.authorization_endpoint : null;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[oidcConfig/discoverAuthorizationEndpoint]',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+42
-1
@@ -20,6 +20,8 @@ import {
|
||||
} from './auth/middleware.js';
|
||||
import { devAuthBypass, devSessionCookieMiddleware } from './auth/devBypass.js';
|
||||
import { localAuthMiddleware } from './auth/localAuthMiddleware.js';
|
||||
import { verifyLocalSessionCookie } from './auth/localSession.js';
|
||||
import { consumeLinkNonce } from './auth/linkNonceStore.js';
|
||||
import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
||||
import { startBrokerPoller } from './broker/poller.js';
|
||||
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
|
||||
@@ -58,6 +60,7 @@ 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;
|
||||
let linkNonce: string | null = null;
|
||||
const rawState = c.req.query('state');
|
||||
if (rawState) {
|
||||
const secret = process.env.LOCAL_SESSION_SECRET;
|
||||
@@ -66,6 +69,7 @@ app.get('/callback', async (c) => {
|
||||
const payload = await Jwt.verify(rawState, secret, 'HS256');
|
||||
if (typeof payload.linkUserId === 'number') {
|
||||
linkUserId = payload.linkUserId;
|
||||
linkNonce = typeof payload.nonce === 'string' ? payload.nonce : null;
|
||||
}
|
||||
} catch {
|
||||
// Not our signed link state — normal OIDC callback, proceed normally.
|
||||
@@ -79,10 +83,41 @@ app.get('/callback', async (c) => {
|
||||
// Link mode: after session is established, bind the OIDC identity to the local user.
|
||||
if (linkUserId !== null) {
|
||||
try {
|
||||
// IN-04: enforce SINGLE USE of the link nonce. The signed state JWT is otherwise
|
||||
// replayable for its full 10-minute signature lifetime; consuming the nonce here means
|
||||
// a captured state can be used at most once. A replay (already-consumed), unknown, or
|
||||
// expired nonce is rejected before any binding occurs.
|
||||
if (!linkNonce || !consumeLinkNonce(linkNonce)) {
|
||||
console.warn('[callback] OIDC-link rejected: link nonce missing, replayed, or expired.');
|
||||
return c.redirect('/?error=oidc-link-conflict');
|
||||
}
|
||||
|
||||
// BL-03: cross-check that the local session completing this callback is the SAME
|
||||
// user the link flow was initiated for. The signed `state` JWT proves the state was
|
||||
// minted by POST /api/me/link-oidc, but NOT that the person finishing the OIDC login
|
||||
// is that user. Without this check, an attacker who gets a victim to complete an OIDC
|
||||
// login while replaying a still-valid (10-min) captured link state would bind the
|
||||
// ATTACKER's OIDC identity onto the VICTIM's account (account takeover). Require the
|
||||
// initiating local session to still be present and to match linkUserId.
|
||||
const sessionUserId = await verifyLocalSessionCookie(c);
|
||||
if (sessionUserId !== linkUserId) {
|
||||
console.warn(
|
||||
'[callback] OIDC-link rejected: local session does not match link state (possible replay).',
|
||||
);
|
||||
return c.redirect('/?error=oidc-link-conflict');
|
||||
}
|
||||
|
||||
const auth = await getAuth(c);
|
||||
if (auth) {
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
// BL-03: never bind on a blank/partial identity. linkOidcToUser writes oidc_iss/
|
||||
// oidc_sub AND deletes the user's local_credentials — binding empty iss/sub would
|
||||
// both corrupt identity and lock the user out of BOTH auth methods. Reject instead.
|
||||
if (!iss || !sub) {
|
||||
console.warn('[callback] OIDC-link rejected: empty iss/sub from getAuth.');
|
||||
return c.redirect('/?error=oidc-link-conflict');
|
||||
}
|
||||
await linkOidcToUser(linkUserId, iss, sub);
|
||||
// On success: user is now OIDC-only; normal redirect via callbackResponse proceeds.
|
||||
}
|
||||
@@ -93,7 +128,10 @@ app.get('/callback', async (c) => {
|
||||
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));
|
||||
console.error(
|
||||
'[callback] linkOidcToUser unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +196,9 @@ if (!devBypassActive) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// oidcHandler's parameter is typed as the generic Hono Context; our wrapper's c is the
|
||||
// same runtime Context narrowed to '/api/*' — the structural mismatch is type-only.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
await oidcHandler(c, next);
|
||||
});
|
||||
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
CredentialValidationError,
|
||||
} from '../broker/credentialSync.js';
|
||||
import { hashPassword } from '../auth/localCredentials.js';
|
||||
import { resetLoginAttempts } from './localAuth.js';
|
||||
import { COLOR_PALETTE } from '../auth/user.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js';
|
||||
@@ -77,6 +78,19 @@ const noEchoHook = (result: { success: boolean }, c: Context) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* WR-07: strictly parse a positive-integer route param. parseInt('12abc', 10) returns 12
|
||||
* and passes an isNaN guard, silently accepting malformed ids. Number('12abc') is NaN, so
|
||||
* Number.isInteger(Number(raw)) rejects trailing garbage. Returns null for anything that is
|
||||
* not a whole positive integer (empty, '12abc', '1.5', '-3', '0', etc.) so the caller can 400.
|
||||
*/
|
||||
function parsePositiveIntParam(raw: string | undefined): number | null {
|
||||
if (raw === undefined || raw.trim() === '') return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n <= 0) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/admin/members
|
||||
//
|
||||
@@ -126,73 +140,73 @@ const createMemberSchema = z.object({
|
||||
initialPassword: z.string().min(8),
|
||||
});
|
||||
|
||||
adminRouter.post(
|
||||
'/members',
|
||||
zValidator('json', createMemberSchema, noEchoHook),
|
||||
async (c) => {
|
||||
const { displayName, username, initialPassword } = c.req.valid('json');
|
||||
// T-19-06: NEVER log request body, displayName, username, or initialPassword here
|
||||
adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook), async (c) => {
|
||||
const { displayName, username, initialPassword } = c.req.valid('json');
|
||||
// T-19-06: NEVER log request body, displayName, username, or initialPassword here
|
||||
|
||||
// Assign the first palette color not already in use (mirrors upsertUser color logic)
|
||||
const usedRows = await db.select({ color: users.color }).from(users);
|
||||
const usedColors = new Set(usedRows.map((r) => r.color));
|
||||
const color =
|
||||
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
|
||||
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
|
||||
// Assign the first palette color not already in use (mirrors upsertUser color logic)
|
||||
const usedRows = await db.select({ color: users.color }).from(users);
|
||||
const usedColors = new Set(usedRows.map((r) => r.color));
|
||||
const color =
|
||||
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
|
||||
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
|
||||
|
||||
try {
|
||||
let newUserId: number;
|
||||
// WR-03: hash the initial password BEFORE opening the transaction so the (now async,
|
||||
// threadpool) scrypt work does not hold the DB transaction open for its duration.
|
||||
const initialPasswordHash = await hashPassword(initialPassword);
|
||||
|
||||
// T-19-10: atomic transaction — both inserts succeed or both roll back
|
||||
await db.transaction(async (tx) => {
|
||||
// Insert the new users row (no oidcIss/oidcSub — local-only member)
|
||||
const [inserted] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
displayName,
|
||||
color,
|
||||
isAdmin: false,
|
||||
claimed: false, // no OIDC identity bound yet
|
||||
})
|
||||
.$returningId();
|
||||
newUserId = inserted.id;
|
||||
try {
|
||||
let newUserId: number;
|
||||
|
||||
// Insert local_credentials row with hashed initial password
|
||||
// If username is already in use, the UNIQUE constraint fires here and rolls back
|
||||
await tx.insert(localCredentials).values({
|
||||
userId: newUserId,
|
||||
username,
|
||||
passwordHash: hashPassword(initialPassword),
|
||||
});
|
||||
// T-19-10: atomic transaction — both inserts succeed or both roll back
|
||||
await db.transaction(async (tx) => {
|
||||
// Insert the new users row (no oidcIss/oidcSub — local-only member)
|
||||
const [inserted] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
displayName,
|
||||
color,
|
||||
isAdmin: false,
|
||||
claimed: false, // no OIDC identity bound yet
|
||||
})
|
||||
.$returningId();
|
||||
newUserId = inserted.id;
|
||||
|
||||
// Insert local_credentials row with hashed initial password
|
||||
// If username is already in use, the UNIQUE constraint fires here and rolls back
|
||||
await tx.insert(localCredentials).values({
|
||||
userId: newUserId,
|
||||
username,
|
||||
passwordHash: initialPasswordHash,
|
||||
});
|
||||
});
|
||||
|
||||
// Return the new user's id (the PWA uses it to navigate to the member)
|
||||
return c.json({ id: newUserId! }, 201);
|
||||
} catch (err) {
|
||||
// Username uniqueness violation — UNIQUE constraint on local_credentials.username.
|
||||
// Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY.
|
||||
const isDup =
|
||||
(err instanceof Error && err.message.includes('ER_DUP_ENTRY')) ||
|
||||
(err != null &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
(err as { code?: string }).code === 'ER_DUP_ENTRY') ||
|
||||
(err != null &&
|
||||
typeof err === 'object' &&
|
||||
'cause' in err &&
|
||||
(err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY');
|
||||
if (isDup) {
|
||||
return c.json({ error: 'Username already in use' }, 409);
|
||||
}
|
||||
// Unexpected errors — log message only, never the body or password
|
||||
console.error(
|
||||
'[admin/POST /members] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
// Return the new user's id (the PWA uses it to navigate to the member)
|
||||
return c.json({ id: newUserId! }, 201);
|
||||
} catch (err) {
|
||||
// Username uniqueness violation — UNIQUE constraint on local_credentials.username.
|
||||
// Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY.
|
||||
const isDup =
|
||||
(err instanceof Error && err.message.includes('ER_DUP_ENTRY')) ||
|
||||
(err != null &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
(err as { code?: string }).code === 'ER_DUP_ENTRY') ||
|
||||
(err != null &&
|
||||
typeof err === 'object' &&
|
||||
'cause' in err &&
|
||||
(err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY');
|
||||
if (isDup) {
|
||||
return c.json({ error: 'Username already in use' }, 409);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Unexpected errors — log message only, never the body or password
|
||||
console.error(
|
||||
'[admin/POST /members] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/admin/members/:id/password
|
||||
@@ -212,17 +226,18 @@ adminRouter.post(
|
||||
'/members/:id/password',
|
||||
zValidator('json', resetPasswordSchema, noEchoHook),
|
||||
async (c) => {
|
||||
const targetId = parseInt(c.req.param('id'), 10);
|
||||
if (isNaN(targetId)) {
|
||||
const targetId = parsePositiveIntParam(c.req.param('id'));
|
||||
if (targetId === null) {
|
||||
return c.json({ error: 'Invalid member id' }, 400);
|
||||
}
|
||||
|
||||
const { newPassword } = c.req.valid('json');
|
||||
// T-19-06: NEVER log newPassword or the request body
|
||||
|
||||
// Verify the target user has a local_credentials row (404 if not)
|
||||
// Verify the target user has a local_credentials row (404 if not).
|
||||
// Also read the username so we can clear any login lockout for it (CR-04).
|
||||
const [credRow] = await db
|
||||
.select({ id: localCredentials.id })
|
||||
.select({ id: localCredentials.id, username: localCredentials.username })
|
||||
.from(localCredentials)
|
||||
.where(eq(localCredentials.userId, targetId))
|
||||
.limit(1);
|
||||
@@ -234,9 +249,14 @@ adminRouter.post(
|
||||
try {
|
||||
await db
|
||||
.update(localCredentials)
|
||||
.set({ passwordHash: hashPassword(newPassword) })
|
||||
.set({ passwordHash: await hashPassword(newPassword) })
|
||||
.where(eq(localCredentials.userId, targetId));
|
||||
|
||||
// CR-04: an admin password reset must immediately clear any rate-limit / lockout
|
||||
// state for this username, so a locked-out member regains access at once rather than
|
||||
// waiting for the TTL. The lockout is keyed on username (not member id).
|
||||
resetLoginAttempts(credRow.username);
|
||||
|
||||
return c.json({ ok: true }, 200);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
@@ -305,8 +325,8 @@ adminRouter.get('/calendars', async (c) => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
adminRouter.put('/calendars/:id/shared', async (c) => {
|
||||
const targetId = parseInt(c.req.param('id'), 10);
|
||||
if (isNaN(targetId)) {
|
||||
const targetId = parsePositiveIntParam(c.req.param('id'));
|
||||
if (targetId === null) {
|
||||
return c.json({ error: 'Invalid calendar id' }, 400);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,16 @@
|
||||
* - 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.
|
||||
* - Per-USERNAME in-memory rate-limiting: 5 failures → 429; 10 failures → 423 (T-19-11).
|
||||
* - Lockout (423) auto-expires after LOCKOUT_TTL_MS (CR-04) so a single attacker cannot
|
||||
* permanently deny login for the whole household, and no process restart is needed to
|
||||
* recover. Admin password reset still clears it immediately (resetLoginAttempts).
|
||||
*
|
||||
* CR-04: the limiter is keyed on the submitted username, NOT the client IP. In this
|
||||
* deployment all household traffic egresses the Pangolin tunnel with the same
|
||||
* X-Forwarded-For first hop, so IP-keying made one bad actor (or one fat-fingered user)
|
||||
* able to lock out every member, and X-Forwarded-For is attacker-spoofable. Username-keying
|
||||
* scopes the lockout to the identity actually under attack and the TTL makes it self-healing.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
@@ -52,61 +60,122 @@ const loginSchema = z.object({
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-IP rate-limiting state (in-memory Map — household scale, no Redis needed).
|
||||
// Per-USERNAME rate-limiting state (in-memory Map — household scale, no Redis needed).
|
||||
//
|
||||
// State shape per IP: { count, lockedUntil (epoch ms), lockedOut (bool) }
|
||||
// State shape per username: { count, lockedUntil (epoch ms), lockedOut (bool), lockedAt (epoch ms) }
|
||||
// count >= RATE_WINDOW_FAILURES AND Date.now() < lockedUntil → 429
|
||||
// count >= LOCKOUT_FAILURES → 423 (permanent until admin reset)
|
||||
// count >= LOCKOUT_FAILURES → 423 (until LOCKOUT_TTL_MS elapses OR admin reset)
|
||||
// Success → delete entry (clears counter)
|
||||
//
|
||||
// RATE_WINDOW_FAILURES: 5 failures → 60s cooldown (429)
|
||||
// LOCKOUT_FAILURES: 10 failures → account locked (423)
|
||||
// LOCKOUT_TTL_MS: 15 min after which a 423 lockout auto-expires (CR-04)
|
||||
//
|
||||
// CR-04: keyed on the validated username (not the client IP) so a lockout is scoped to
|
||||
// the identity under attack, and self-heals after LOCKOUT_TTL_MS without a restart.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const loginAttempts = new Map<string, { count: number; lockedUntil: number; lockedOut: boolean }>();
|
||||
export const loginAttempts = new Map<
|
||||
string,
|
||||
{ count: number; lockedUntil: number; lockedOut: boolean; lockedAt: number }
|
||||
>();
|
||||
|
||||
const RATE_WINDOW_FAILURES = 5;
|
||||
const RATE_WINDOW_SECS = 60;
|
||||
const LOCKOUT_FAILURES = 10;
|
||||
const LOCKOUT_TTL_MS = 15 * 60 * 1000; // CR-04: 423 lockout auto-expires after 15 minutes
|
||||
|
||||
/**
|
||||
* Immediately clear any rate-limit / lockout state for a username (CR-04).
|
||||
*
|
||||
* Called by the admin password-reset path so a reset is an instant unlock and the
|
||||
* lockout is not "unrecoverable without a process restart". Safe to call for an
|
||||
* unknown username (no-op). Normalizes the username the same way the login schema
|
||||
* does (trim) so the key matches what the limiter stored.
|
||||
*/
|
||||
export function resetLoginAttempts(username: string): void {
|
||||
loginAttempts.delete(username.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* IN-03: evict stale rate-limit entries to bound the in-memory map size.
|
||||
*
|
||||
* An entry is stale (and safe to drop) when it is neither inside an active rate-limit
|
||||
* window nor inside an active lockout TTL:
|
||||
* - locked-out entries expire LOCKOUT_TTL_MS after lockedAt
|
||||
* - non-locked entries expire once their lockedUntil window has passed
|
||||
*
|
||||
* Called opportunistically at the top of each login request. Dropping an entry is
|
||||
* behaviourally identical to the entry having naturally expired, so eviction never
|
||||
* weakens the brute-force defense — it only reclaims memory for identities no longer
|
||||
* under active rate-limiting.
|
||||
*/
|
||||
function evictStaleLoginAttempts(now: number): void {
|
||||
for (const [k, v] of loginAttempts) {
|
||||
const lockoutExpired = v.lockedOut ? now - v.lockedAt >= LOCKOUT_TTL_MS : true;
|
||||
const windowExpired = now >= v.lockedUntil;
|
||||
if (lockoutExpired && windowExpired) {
|
||||
loginAttempts.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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');
|
||||
// WR-03: hashPassword is now async. Kick off the computation once at module load and keep
|
||||
// the PROMISE; the login handler awaits it. The value is never used for auth — only to make
|
||||
// the unknown-username path perform the same scrypt work as the known-username path.
|
||||
const dummyHashPromise: Promise<string> = 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';
|
||||
// CR-04: the rate-limit / lockout key is the validated, normalized username — NOT the
|
||||
// client IP. zValidator has already run, so c.req.valid('json') is available here.
|
||||
const { username, password } = c.req.valid('json');
|
||||
const key = username; // loginSchema .trim()s the username; the map key matches resetLoginAttempts
|
||||
|
||||
const attempt = loginAttempts.get(ip);
|
||||
const now = Date.now();
|
||||
// IN-03: opportunistically reclaim memory from entries that are no longer actively
|
||||
// rate-limited or locked out. Cheap at household scale; bounds the map under input churn.
|
||||
evictStaleLoginAttempts(now);
|
||||
const attempt = loginAttempts.get(key);
|
||||
|
||||
// 423: account locked (>= LOCKOUT_FAILURES total failures, admin must reset)
|
||||
// Check lockedOut FIRST — lockout takes precedence over rate window.
|
||||
// 423: account locked (>= LOCKOUT_FAILURES failures). CR-04: the lockout auto-expires
|
||||
// after LOCKOUT_TTL_MS so a single attacker cannot deny login indefinitely and no restart
|
||||
// is required to recover. On expiry, drop the entry so the next attempt starts clean.
|
||||
if (attempt?.lockedOut) {
|
||||
return c.json({ error: 'Account locked' }, 423);
|
||||
if (now - attempt.lockedAt >= LOCKOUT_TTL_MS) {
|
||||
loginAttempts.delete(key);
|
||||
} else {
|
||||
return c.json({ error: 'Account locked' }, 423);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read after a possible expiry-delete above.
|
||||
const live = loginAttempts.get(key);
|
||||
|
||||
// 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) {
|
||||
if (live && live.count >= RATE_WINDOW_FAILURES && now < live.lockedUntil) {
|
||||
live.count += 1;
|
||||
// WR-06: do NOT extend lockedUntil here. This request was itself REJECTED by the window;
|
||||
// re-arming the cooldown on every blocked attempt let an attacker who keeps hammering the
|
||||
// endpoint slide the window forward forever, so a legitimate user behind the same identity
|
||||
// could never get back in even after pausing. The window stays anchored to when it was
|
||||
// first armed (in the failure path below); it expires on schedule regardless of rejected
|
||||
// traffic. The lockout (423) still triggers once the failure count crosses the threshold.
|
||||
live.lockedOut = live.count >= LOCKOUT_FAILURES;
|
||||
if (live.lockedOut && live.lockedAt === 0) live.lockedAt = now;
|
||||
loginAttempts.set(key, live);
|
||||
if (live.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 {
|
||||
@@ -117,34 +186,47 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
|
||||
.limit(1);
|
||||
cred = found;
|
||||
} catch (err) {
|
||||
console.error('[localAuth/POST /local/login] DB error:', err instanceof Error ? err.message : String(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.
|
||||
// WR-03: verifyPassword is async (threadpool scrypt) — await it.
|
||||
const valid = cred
|
||||
? verifyPassword(cred.passwordHash, password)
|
||||
: verifyPassword(DUMMY_HASH, password);
|
||||
? await verifyPassword(cred.passwordHash, password)
|
||||
: await verifyPassword(await dummyHashPromise, password);
|
||||
|
||||
if (!valid || !cred) {
|
||||
// Increment failure counter
|
||||
const cur = loginAttempts.get(ip) ?? { count: 0, lockedUntil: 0, lockedOut: false };
|
||||
// Increment failure counter (keyed on username)
|
||||
const cur = loginAttempts.get(key) ?? {
|
||||
count: 0,
|
||||
lockedUntil: 0,
|
||||
lockedOut: false,
|
||||
lockedAt: 0,
|
||||
};
|
||||
cur.count += 1;
|
||||
cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000;
|
||||
cur.lockedOut = cur.count >= LOCKOUT_FAILURES;
|
||||
loginAttempts.set(ip, cur);
|
||||
if (cur.lockedOut && cur.lockedAt === 0) cur.lockedAt = Date.now();
|
||||
loginAttempts.set(key, 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);
|
||||
loginAttempts.delete(key);
|
||||
try {
|
||||
await issueLocalSessionCookie(c, cred.userId);
|
||||
} catch (err) {
|
||||
console.error('[localAuth/POST /local/login] Cookie issue error:', err instanceof Error ? err.message : String(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);
|
||||
@@ -155,7 +237,7 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
|
||||
// GET /local/logout → GET /api/auth/local/logout (browser-redirect alias)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleLogout(c: Context) {
|
||||
function handleLogout(c: Context) {
|
||||
clearLocalSessionCookie(c);
|
||||
return c.json({ ok: true }, 200);
|
||||
}
|
||||
|
||||
+73
-57
@@ -41,6 +41,8 @@ import {
|
||||
CredentialValidationError,
|
||||
} from '../broker/credentialSync.js';
|
||||
import { hashPassword, verifyPassword } from '../auth/localCredentials.js';
|
||||
import { resolveOidcConfig, discoverAuthorizationEndpoint } from '../auth/oidcConfig.js';
|
||||
import { registerLinkNonce } from '../auth/linkNonceStore.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
@@ -103,7 +105,9 @@ meRouter.get('/', async (c) => {
|
||||
// but isAdmin, needsProviderSetup, and hasLocalCredential are still resolved from the DB (T-10-05).
|
||||
const devUser = c.get('user');
|
||||
if (devUser) {
|
||||
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(devUser.id);
|
||||
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(
|
||||
devUser.id,
|
||||
);
|
||||
return c.json({
|
||||
user: {
|
||||
id: devUser.id,
|
||||
@@ -139,7 +143,9 @@ meRouter.get('/', async (c) => {
|
||||
return c.json({ error: 'Could not resolve user' }, 500);
|
||||
}
|
||||
|
||||
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(user.id);
|
||||
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(
|
||||
user.id,
|
||||
);
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
@@ -230,52 +236,53 @@ const mePasswordSchema = z.object({
|
||||
newPassword: z.string().min(8),
|
||||
});
|
||||
|
||||
meRouter.post(
|
||||
'/password',
|
||||
zValidator('json', mePasswordSchema, meNoEchoHook),
|
||||
async (c) => {
|
||||
// T-19-07: ALWAYS resolve userId from session — never from body
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (!currentUserId) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
meRouter.post('/password', zValidator('json', mePasswordSchema, meNoEchoHook), async (c) => {
|
||||
// T-19-07: ALWAYS resolve userId from session — never from body
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (!currentUserId) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = c.req.valid('json');
|
||||
// T-19-06: NEVER log currentPassword, newPassword, or the request body
|
||||
const { currentPassword, newPassword } = c.req.valid('json');
|
||||
// T-19-06: NEVER log currentPassword, newPassword, or the request body
|
||||
|
||||
// Look up the user's local_credentials row (404 if none — no local credential to change)
|
||||
const [credRow] = await db
|
||||
.select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId })
|
||||
.from(localCredentials)
|
||||
.where(eq(localCredentials.userId, currentUserId))
|
||||
.limit(1);
|
||||
// Look up the user's local_credentials row (404 if none — no local credential to change)
|
||||
const [credRow] = await db
|
||||
.select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId })
|
||||
.from(localCredentials)
|
||||
.where(eq(localCredentials.userId, currentUserId))
|
||||
.limit(1);
|
||||
|
||||
if (!credRow) {
|
||||
return c.json({ error: 'No local credential found' }, 404);
|
||||
}
|
||||
if (!credRow) {
|
||||
return c.json({ error: 'No local credential found' }, 404);
|
||||
}
|
||||
|
||||
// T-19-07: verify current password before any update
|
||||
const isCorrect = verifyPassword(credRow.passwordHash, currentPassword);
|
||||
if (!isCorrect) {
|
||||
return c.json({ error: 'Current password incorrect' }, 401);
|
||||
}
|
||||
// T-19-07: verify current password before any update (WR-03: async scrypt)
|
||||
const isCorrect = await verifyPassword(credRow.passwordHash, currentPassword);
|
||||
if (!isCorrect) {
|
||||
// CR-03: return 403 (NOT 401) for a wrong current password. The PWA's global
|
||||
// MutationCache treats any 401 as "session expired" and arms the re-auth
|
||||
// interstitial / login redirect — so a 401 here would force-log-out a user who
|
||||
// merely mistyped their current password. 403 is in-app authorization-failure and
|
||||
// lets the client surface "current password incorrect" without dropping the session.
|
||||
return c.json({ error: 'Current password incorrect' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(localCredentials)
|
||||
.set({ passwordHash: hashPassword(newPassword) })
|
||||
.where(eq(localCredentials.userId, currentUserId));
|
||||
try {
|
||||
await db
|
||||
.update(localCredentials)
|
||||
.set({ passwordHash: await hashPassword(newPassword) })
|
||||
.where(eq(localCredentials.userId, currentUserId));
|
||||
|
||||
return c.json({ ok: true }, 200);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[me/POST /password] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
},
|
||||
);
|
||||
return c.json({ ok: true }, 200);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[me/POST /password] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/me/link-oidc — initiate OIDC identity-link flow (AUTH-LOCAL-10, T-19-09)
|
||||
@@ -313,28 +320,37 @@ meRouter.post('/link-oidc', async (c) => {
|
||||
// T-19-09: nonce prevents state replay attacks (each link attempt gets a fresh nonce)
|
||||
const nonce = randomBytes(16).toString('hex');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp = now + 600; // 10-minute window
|
||||
const signedState = await Jwt.sign(
|
||||
{ linkUserId: currentUserId, nonce, iat: now, exp: now + 600 }, // 10-minute window
|
||||
{ linkUserId: currentUserId, nonce, iat: now, exp },
|
||||
secret,
|
||||
'HS256',
|
||||
);
|
||||
|
||||
// Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button)
|
||||
const issuer = process.env.OIDC_ISSUER ?? null;
|
||||
const clientId = process.env.OIDC_CLIENT_ID ?? null;
|
||||
const redirectUri = process.env.OIDC_REDIRECT_URI ?? null;
|
||||
// IN-04: record the nonce so /callback can enforce SINGLE USE. Without this the signed
|
||||
// state JWT is fully replayable for its 10-minute signature lifetime and the nonce is
|
||||
// decorative. registerLinkNonce keeps it valid only until the state's own exp.
|
||||
registerLinkNonce(nonce, exp);
|
||||
|
||||
// Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button).
|
||||
// WR-04: resolve issuer/clientId/redirectUri from env-OR-app_config (single source of truth,
|
||||
// consistent with /api/auth/mode and the OIDC fallback middleware) so a wizard-configured
|
||||
// instance does not report oidcEnabled:true while returning authorizationUrl:null here.
|
||||
// WR-02: discover the authorization_endpoint from the provider's discovery document instead
|
||||
// of hardcoding Authelia's /api/oidc/authorization path.
|
||||
let authorizationUrl: string | null = null;
|
||||
if (issuer && clientId && redirectUri) {
|
||||
// Construct the authorization URL. plan 19-03 will handle the full PKCE flow;
|
||||
// for now encode the signed state so the callback can read linkUserId.
|
||||
const url = new URL(`${issuer.replace(/\/$/, '')}/api/oidc/authorization`);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', clientId);
|
||||
url.searchParams.set('redirect_uri', redirectUri);
|
||||
url.searchParams.set('scope', 'openid profile email');
|
||||
url.searchParams.set('state', signedState);
|
||||
authorizationUrl = url.toString();
|
||||
const oidc = await resolveOidcConfig();
|
||||
if (oidc) {
|
||||
const authEndpoint = await discoverAuthorizationEndpoint(oidc.issuer);
|
||||
if (authEndpoint) {
|
||||
const url = new URL(authEndpoint);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', oidc.clientId);
|
||||
url.searchParams.set('redirect_uri', oidc.redirectUri);
|
||||
url.searchParams.set('scope', 'openid profile email');
|
||||
url.searchParams.set('state', signedState);
|
||||
authorizationUrl = url.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ signedState, authorizationUrl }, 200);
|
||||
|
||||
@@ -24,7 +24,13 @@
|
||||
|
||||
import { afterEach } from 'vitest';
|
||||
import { db } from '../src/db/client.js';
|
||||
import { lists, listItems, listShares, pushSubscriptions, localCredentials } from '../src/db/schema.js';
|
||||
import {
|
||||
lists,
|
||||
listItems,
|
||||
listShares,
|
||||
pushSubscriptions,
|
||||
localCredentials,
|
||||
} from '../src/db/schema.js';
|
||||
|
||||
/**
|
||||
* Truncate list and push tables in FK-safe order after each test.
|
||||
|
||||
@@ -73,7 +73,6 @@ function makeApp(middleware: ReturnType<typeof vi.fn>, presetUser?: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
app.use('/api/*', middleware());
|
||||
|
||||
let capturedUser: unknown = 'NOT_SET_SENTINEL';
|
||||
@@ -147,7 +146,13 @@ describe('localAuthMiddleware', () => {
|
||||
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 };
|
||||
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');
|
||||
@@ -155,6 +160,36 @@ describe('localAuthMiddleware', () => {
|
||||
expect(u.color).toBe('#FF5733');
|
||||
});
|
||||
|
||||
it('Test 1c (BL-04): local user with null DB oidc fields → context oidcIss/oidcSub are NULL (no fabricated sentinels)', async () => {
|
||||
mockVerifyResult = 9;
|
||||
mockDbSelectResult.push({
|
||||
id: 9,
|
||||
oidcIss: null, // local user — no OIDC identity bound
|
||||
oidcSub: null,
|
||||
displayName: 'Local Member',
|
||||
color: '#22AA88',
|
||||
});
|
||||
|
||||
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);
|
||||
const u = capturedUser as { id: number; oidcIss: string | null; oidcSub: string | null };
|
||||
expect(u.id).toBe(9);
|
||||
// BL-04: must be null — NOT 'local' / String(id) sentinels that share the
|
||||
// uniq_oidc_identity domain with real OIDC identities.
|
||||
expect(u.oidcIss).toBeNull();
|
||||
expect(u.oidcSub).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 2: no cookie → pure passthrough; c.get("user") remains unset (Pitfall-1 guard)', async () => {
|
||||
mockVerifyResult = null; // No cookie / invalid
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* Uses node:crypto scrypt under the hood; no external dependencies.
|
||||
* All tests run without MariaDB or any external service.
|
||||
*
|
||||
* WR-03: hashPassword / verifyPassword are now async (promisify(scrypt), threadpool) —
|
||||
* all assertions await them.
|
||||
*
|
||||
* Test suite (TDD RED → GREEN — Plan 19-01 Task 1):
|
||||
* Test 1: correct password verifies true
|
||||
* Test 2: wrong password verifies false
|
||||
@@ -13,36 +16,72 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { scryptSync, randomBytes } from 'node:crypto';
|
||||
import { hashPassword, verifyPassword } from '../../src/auth/localCredentials.js';
|
||||
|
||||
describe('hashPassword / verifyPassword', () => {
|
||||
it('Test 1: verifyPassword(hashPassword(pw), pw) === true (round-trip)', () => {
|
||||
const encoded = hashPassword('hunter2');
|
||||
const result = verifyPassword(encoded, 'hunter2');
|
||||
it('Test 1: verifyPassword(hashPassword(pw), pw) === true (round-trip)', async () => {
|
||||
const encoded = await hashPassword('hunter2');
|
||||
const result = await verifyPassword(encoded, 'hunter2');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 2: verifyPassword(hashPassword(pw), wrong) === false', () => {
|
||||
const encoded = hashPassword('hunter2');
|
||||
const result = verifyPassword(encoded, 'wrong-password');
|
||||
it('Test 2: verifyPassword(hashPassword(pw), wrong) === false', async () => {
|
||||
const encoded = await hashPassword('hunter2');
|
||||
const result = await verifyPassword(encoded, 'wrong-password');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 3: two hashPassword calls on same input produce different encoded strings (unique salt)', () => {
|
||||
const encoded1 = hashPassword('x');
|
||||
const encoded2 = hashPassword('x');
|
||||
it('Test 3: two hashPassword calls on same input produce different encoded strings (unique salt)', async () => {
|
||||
const encoded1 = await hashPassword('x');
|
||||
const encoded2 = await hashPassword('x');
|
||||
expect(encoded1).not.toBe(encoded2);
|
||||
});
|
||||
|
||||
it('Test 4: verifyPassword returns false (never throws) on a malformed stored hash', () => {
|
||||
expect(() => verifyPassword('not-a-valid-hash', 'x')).not.toThrow();
|
||||
expect(verifyPassword('not-a-valid-hash', 'x')).toBe(false);
|
||||
expect(verifyPassword('', 'x')).toBe(false);
|
||||
expect(verifyPassword('scrypt$bad$data', 'x')).toBe(false);
|
||||
it('Test 4: verifyPassword returns false (never throws) on a malformed stored hash', async () => {
|
||||
await expect(verifyPassword('not-a-valid-hash', 'x')).resolves.toBe(false);
|
||||
await expect(verifyPassword('', 'x')).resolves.toBe(false);
|
||||
await expect(verifyPassword('scrypt$bad$data', 'x')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('Test 5: encoded string has scrypt$N$r$p$salt$hash shape (6 $-delimited segments)', () => {
|
||||
const encoded = hashPassword('testpassword');
|
||||
it('Test 6 (IN-02): a hash produced by the INLINED scrypt parameters round-trips against the canonical verifyPassword', async () => {
|
||||
// IN-02: the PHC scrypt hash is copy-pasted in three places — the canonical module
|
||||
// (src/auth/localCredentials.ts), the break-glass CLI (scripts/reset-admin.ts), and the
|
||||
// CI seed step (.gitea/workflows/ci.yml). If the parameters ever drift, those inlined
|
||||
// copies would produce hashes the canonical verifyPassword cannot validate, silently
|
||||
// breaking login for seeded/reset accounts. This test pins the inlined parameter set:
|
||||
// if anyone changes N/r/p/KEY_LEN in the canonical module without updating the inlined
|
||||
// copies (or vice versa), this round-trip fails loudly in CI.
|
||||
//
|
||||
// These constants MUST match scripts/reset-admin.ts and .gitea/workflows/ci.yml exactly.
|
||||
const INLINE_N = 16384;
|
||||
const INLINE_R = 8;
|
||||
const INLINE_P = 1;
|
||||
const INLINE_KEY_LEN = 32;
|
||||
|
||||
const password = 'inline-roundtrip-pw';
|
||||
const salt = randomBytes(16);
|
||||
const hash = scryptSync(password, salt, INLINE_KEY_LEN, {
|
||||
N: INLINE_N,
|
||||
r: INLINE_R,
|
||||
p: INLINE_P,
|
||||
});
|
||||
const inlineEncoded = [
|
||||
'scrypt',
|
||||
INLINE_N,
|
||||
INLINE_R,
|
||||
INLINE_P,
|
||||
salt.toString('base64url'),
|
||||
hash.toString('base64url'),
|
||||
].join('$');
|
||||
|
||||
// The canonical verifyPassword must validate a hash produced by the inlined params.
|
||||
expect(await verifyPassword(inlineEncoded, password)).toBe(true);
|
||||
expect(await verifyPassword(inlineEncoded, 'wrong')).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 5: encoded string has scrypt$N$r$p$salt$hash shape (6 $-delimited segments)', async () => {
|
||||
const encoded = await hashPassword('testpassword');
|
||||
const segments = encoded.split('$');
|
||||
expect(segments).toHaveLength(6);
|
||||
expect(segments[0]).toBe('scrypt');
|
||||
|
||||
@@ -18,33 +18,6 @@ import { Hono } from 'hono';
|
||||
const TEST_SECRET = 'test-secret-that-is-at-least-32-characters-long-for-jwt';
|
||||
const TEST_USER_ID = 42;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a minimal Hono test app with an issue route and a verify route. */
|
||||
function makeTestApp(secret: string | undefined) {
|
||||
return {
|
||||
setup: async () => {
|
||||
// Import inside function to pick up modified env
|
||||
const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import(
|
||||
'../../src/auth/localSession.js'
|
||||
);
|
||||
const app = new Hono();
|
||||
|
||||
app.post('/issue', async (c) => {
|
||||
await issueLocalSessionCookie(c, TEST_USER_ID);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/verify', async (c) => {
|
||||
const userId = await verifyLocalSessionCookie(c);
|
||||
return c.json({ userId });
|
||||
});
|
||||
|
||||
return app;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -60,9 +33,8 @@ describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => {
|
||||
});
|
||||
|
||||
it('Test 1: issue then verify round-trips userId', async () => {
|
||||
const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import(
|
||||
'../../src/auth/localSession.js'
|
||||
);
|
||||
const { issueLocalSessionCookie, verifyLocalSessionCookie } =
|
||||
await import('../../src/auth/localSession.js');
|
||||
const app = new Hono();
|
||||
|
||||
app.post('/issue', async (c) => {
|
||||
|
||||
@@ -30,7 +30,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { users, memberCredentials, calendars, appConfig, localCredentials } from '../../src/db/schema.js';
|
||||
import {
|
||||
users,
|
||||
memberCredentials,
|
||||
calendars,
|
||||
appConfig,
|
||||
localCredentials,
|
||||
} from '../../src/db/schema.js';
|
||||
import { verifyPassword } from '../../src/auth/localCredentials.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -483,6 +489,19 @@ describe('PUT /api/admin/calendars/:id/shared', () => {
|
||||
.limit(1);
|
||||
expect(rowA.isShared).toBe(true);
|
||||
});
|
||||
|
||||
it('WR-07: rejects a calendar id with trailing garbage (e.g. "1abc") with 400', async () => {
|
||||
const adminId = await seedUser('admin-shared-badid', true);
|
||||
currentDevUserId = adminId;
|
||||
const app = await getApp();
|
||||
|
||||
// parseInt('1abc', 10) === 1 would have silently accepted this; the strict
|
||||
// Number.isInteger parse must reject it as a malformed id.
|
||||
const res = await app.fetch(jsonRequest('PUT', '/api/admin/calendars/1abc/shared'));
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe('Invalid calendar id');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
@@ -890,7 +909,7 @@ describe('POST /api/admin/members', () => {
|
||||
.where(eq(localCredentials.userId, body.id))
|
||||
.limit(1);
|
||||
expect(credRow).toBeDefined();
|
||||
expect(verifyPassword(credRow.passwordHash, initialPassword)).toBe(true);
|
||||
expect(await verifyPassword(credRow.passwordHash, initialPassword)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 2: duplicate username returns 409 — transaction rolls back (no orphaned users row)', async () => {
|
||||
@@ -936,7 +955,8 @@ describe('POST /api/admin/members', () => {
|
||||
|
||||
it('Test 3: admin can reset any member password without knowing the current one', async () => {
|
||||
const adminId = await seedUser('admin-reset-pw', true);
|
||||
const memberId = await seedUser('member-reset-target', false);
|
||||
// Seeded for DB-state parity; this test creates its own member via the admin API below.
|
||||
await seedUser('member-reset-target', false);
|
||||
currentDevUserId = adminId;
|
||||
const app = await getApp();
|
||||
|
||||
@@ -967,8 +987,8 @@ describe('POST /api/admin/members', () => {
|
||||
.where(eq(localCredentials.userId, newMemberId))
|
||||
.limit(1);
|
||||
expect(credRow).toBeDefined();
|
||||
expect(verifyPassword(credRow.passwordHash, newPassword)).toBe(true);
|
||||
expect(verifyPassword(credRow.passwordHash, 'old-password-123')).toBe(false);
|
||||
expect(await verifyPassword(credRow.passwordHash, newPassword)).toBe(true);
|
||||
expect(await verifyPassword(credRow.passwordHash, 'old-password-123')).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 4: non-admin gets 403 on POST /members and POST /members/:id/password', async () => {
|
||||
@@ -1012,7 +1032,9 @@ describe('POST /api/admin/members', () => {
|
||||
// GET /members should show hasLocalCredential:true for this member
|
||||
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
||||
expect(getRes.status).toBe(200);
|
||||
const body = (await getRes.json()) as { members: Array<{ id: number; hasLocalCredential: boolean }> };
|
||||
const body = (await getRes.json()) as {
|
||||
members: Array<{ id: number; hasLocalCredential: boolean }>;
|
||||
};
|
||||
|
||||
const memberRow = body.members.find((m) => m.id === newMemberId);
|
||||
expect(memberRow).toBeDefined();
|
||||
@@ -1023,4 +1045,29 @@ describe('POST /api/admin/members', () => {
|
||||
expect(adminRow).toBeDefined();
|
||||
expect(adminRow!.hasLocalCredential).toBe(false);
|
||||
});
|
||||
|
||||
it('WR-05 (no-echo): malformed create-member body never echoes the submitted password or Zod received field', async () => {
|
||||
const adminId = await seedUser('admin-create-noecho', true);
|
||||
currentDevUserId = adminId;
|
||||
const app = await getApp();
|
||||
|
||||
// initialPassword too short (< 8) → Zod rejects. The noEchoHook must return only
|
||||
// { error: 'Invalid request' } and NEVER leak the submitted password or Zod's
|
||||
// issues[].received field (T-19-06 / the T-19-14 leak this guards against).
|
||||
const submittedPassword = 'shortpw-secret';
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/admin/members', {
|
||||
displayName: 'No Echo',
|
||||
username: `noecho-${randomUUID()}`,
|
||||
initialPassword: submittedPassword.slice(0, 3), // 3 chars — fails min(8)
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const bodyText = await res.text();
|
||||
expect(bodyText).not.toContain('received');
|
||||
expect(bodyText).not.toContain('issues');
|
||||
expect(bodyText).not.toContain(submittedPassword.slice(0, 3));
|
||||
const parsed = JSON.parse(bodyText) as { error: string };
|
||||
expect(parsed.error).toBe('Invalid request');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,15 +47,13 @@ vi.mock('@hono/oidc-auth', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||
devAuthBypass:
|
||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
||||
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
||||
localAuthMiddleware:
|
||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
localAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
* 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 4: 5 consecutive failures for one username → 6th returns 429
|
||||
* Test 5: 10 failures → 423 (lockedOut); a cleared map resets the counter
|
||||
* Test 5b (CR-04): a 423 lockout auto-expires after LOCKOUT_TTL_MS (no admin reset needed)
|
||||
* 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' };
|
||||
@@ -33,9 +34,9 @@ vi.mock('../../src/db/client.js', () => ({
|
||||
select: vi.fn().mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(mockCredRow ? [mockCredRow] : [])
|
||||
),
|
||||
limit: vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(mockCredRow ? [mockCredRow] : [])),
|
||||
}),
|
||||
}),
|
||||
})),
|
||||
@@ -56,14 +57,12 @@ 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();
|
||||
}
|
||||
),
|
||||
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;
|
||||
}),
|
||||
@@ -75,15 +74,13 @@ vi.mock('../../src/auth/localSession.js', () => ({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||
devAuthBypass:
|
||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
||||
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
||||
localAuthMiddleware:
|
||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
localAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
}));
|
||||
|
||||
vi.mock('@hono/oidc-auth', () => ({
|
||||
@@ -163,7 +160,7 @@ async function getLocalCredentials() {
|
||||
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');
|
||||
const hash = await hashPassword('correcthorse');
|
||||
mockCredRow = { userId: 5, passwordHash: hash };
|
||||
|
||||
const app = await getApp();
|
||||
@@ -178,7 +175,7 @@ describe('POST /api/auth/local/login', () => {
|
||||
|
||||
it('Test 2: wrong password → 401 { error: "Invalid credentials" }', async () => {
|
||||
const { hashPassword } = await getLocalCredentials();
|
||||
const hash = hashPassword('correcthorse');
|
||||
const hash = await hashPassword('correcthorse');
|
||||
mockCredRow = { userId: 5, passwordHash: hash };
|
||||
|
||||
const app = await getApp();
|
||||
@@ -194,7 +191,9 @@ describe('POST /api/auth/local/login', () => {
|
||||
mockCredRow = undefined; // No credential row found
|
||||
|
||||
const app = await getApp();
|
||||
const res = await app.fetch(makeLoginRequest({ username: 'unknown-user', password: 'anypassword' }));
|
||||
const res = await app.fetch(
|
||||
makeLoginRequest({ username: 'unknown-user', password: 'anypassword' }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as { error: string };
|
||||
@@ -211,14 +210,14 @@ describe('POST /api/auth/local/login', () => {
|
||||
// 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')
|
||||
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')
|
||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'),
|
||||
);
|
||||
expect(res6.status).toBe(429);
|
||||
const body = (await res6.json()) as { error: string };
|
||||
@@ -232,29 +231,58 @@ describe('POST /api/auth/local/login', () => {
|
||||
|
||||
// 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')
|
||||
);
|
||||
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')
|
||||
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)
|
||||
// CR-04: the limiter is keyed on the USERNAME ('alice'), not the IP.
|
||||
const { loginAttempts } = await import('../../src/routes/localAuth.js');
|
||||
loginAttempts.delete('10.0.0.2');
|
||||
loginAttempts.delete('alice');
|
||||
|
||||
const resAfterReset = await app.fetch(
|
||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
|
||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'),
|
||||
);
|
||||
expect(resAfterReset.status).toBe(401);
|
||||
});
|
||||
|
||||
it('Test 5b (CR-04): a 423 lockout auto-expires after the TTL — no admin reset needed', async () => {
|
||||
mockCredRow = undefined;
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
// 10 failures → lockout for username 'bob'
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await app.fetch(makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'));
|
||||
}
|
||||
|
||||
// Confirm locked (423)
|
||||
const resLocked = await app.fetch(
|
||||
makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'),
|
||||
);
|
||||
expect(resLocked.status).toBe(423);
|
||||
|
||||
// Simulate the TTL elapsing by back-dating lockedAt well past LOCKOUT_TTL_MS (15 min).
|
||||
const { loginAttempts } = await import('../../src/routes/localAuth.js');
|
||||
const entry = loginAttempts.get('bob');
|
||||
expect(entry?.lockedOut).toBe(true);
|
||||
if (entry) entry.lockedAt = Date.now() - 16 * 60 * 1000;
|
||||
|
||||
// Next attempt: the lockout has expired → handler drops the entry and processes the
|
||||
// login normally, so a wrong password is a fresh 401 (not a 423). CR-04: self-healing.
|
||||
const resAfterTtl = await app.fetch(
|
||||
makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'),
|
||||
);
|
||||
expect(resAfterTtl.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)
|
||||
@@ -263,7 +291,7 @@ describe('POST /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);
|
||||
@@ -287,7 +315,7 @@ describe('POST /api/auth/local/logout', () => {
|
||||
new Request('http://localhost/api/auth/local/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'x-forwarded-for': '1.2.3.4' },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
@@ -304,7 +332,7 @@ describe('GET /api/auth/local/logout', () => {
|
||||
new Request('http://localhost/api/auth/local/logout', {
|
||||
method: 'GET',
|
||||
headers: { 'x-forwarded-for': '1.2.3.4' },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -274,7 +274,7 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
||||
|
||||
const oldPassword = 'old-password-correct-123';
|
||||
const newPassword = 'new-password-secure-456';
|
||||
const storedHash = hashPassword(oldPassword);
|
||||
const storedHash = await hashPassword(oldPassword);
|
||||
let updatedHash: string | null = null;
|
||||
|
||||
// Mock sequence: resolveUserId (devBypass sets user), then:
|
||||
@@ -290,16 +290,20 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]),
|
||||
}),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
// fallback for other selects
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
@@ -326,15 +330,15 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
||||
// The updatedHash must verify the new password
|
||||
expect(updatedHash).not.toBeNull();
|
||||
const { verifyPassword } = await import('../../src/auth/localCredentials.js');
|
||||
expect(verifyPassword(updatedHash!, newPassword)).toBe(true);
|
||||
expect(verifyPassword(updatedHash!, oldPassword)).toBe(false);
|
||||
expect(await verifyPassword(updatedHash!, newPassword)).toBe(true);
|
||||
expect(await verifyPassword(updatedHash!, oldPassword)).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 2: wrong currentPassword → 401 and update is NOT called', async () => {
|
||||
it('Test 2: wrong currentPassword → 403 and update is NOT called', async () => {
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
|
||||
const realPassword = 'real-password-correct-789';
|
||||
const storedHash = hashPassword(realPassword);
|
||||
const storedHash = await hashPassword(realPassword);
|
||||
let updateWasCalled = false;
|
||||
|
||||
let callCount = 0;
|
||||
@@ -346,7 +350,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]),
|
||||
}),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
@@ -354,7 +360,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
@@ -374,7 +382,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
||||
body: JSON.stringify({ currentPassword: 'WRONG-password', newPassword: 'new-pass-12345678' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
// CR-03: wrong current password returns 403 (in-app authz failure), NOT 401.
|
||||
// A 401 would be interpreted by the PWA as session expiry and log the user out.
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe('Current password incorrect');
|
||||
// Update must NOT have been called
|
||||
@@ -384,13 +394,18 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
||||
it('Test 3: user with no local_credentials row → 404', async () => {
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
|
||||
vi.mocked(db.select).mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any));
|
||||
vi.mocked(db.select).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
);
|
||||
|
||||
const { app } = await import('../../src/index.js');
|
||||
const res = await app.request('/api/me/password', {
|
||||
@@ -401,6 +416,27 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('Test 4 (WR-05 no-echo): malformed body never echoes the submitted password or Zod received field', async () => {
|
||||
// newPassword too short (< 8) → Zod rejects via meNoEchoHook. The response must be
|
||||
// ONLY { error: 'Invalid request' } and must NOT leak the submitted password or the
|
||||
// Zod issues[].received field (T-19-06).
|
||||
const { app } = await import('../../src/index.js');
|
||||
const submitted = 'my-secret-current-pw';
|
||||
const res = await app.request('/api/me/password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword: submitted, newPassword: 'short' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const bodyText = await res.text();
|
||||
expect(bodyText).not.toContain(submitted);
|
||||
expect(bodyText).not.toContain('received');
|
||||
expect(bodyText).not.toContain('issues');
|
||||
const parsed = JSON.parse(bodyText) as { error: string };
|
||||
expect(parsed.error).toBe('Invalid request');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -434,7 +470,9 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
@@ -460,7 +498,9 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
@@ -497,14 +537,14 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
||||
let deletedLocalCreds = false;
|
||||
|
||||
// Mock: SELECT users WHERE oidc_iss = iss AND oidc_sub = sub → no conflict (empty)
|
||||
let txSelectCount = 0;
|
||||
const mockTx = {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
txSelectCount++;
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // no conflict
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}),
|
||||
@@ -522,18 +562,25 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
||||
})),
|
||||
};
|
||||
|
||||
vi.mocked(db.select).mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any));
|
||||
vi.mocked(db.select).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise<void>) => {
|
||||
await fn(mockTx);
|
||||
});
|
||||
vi.mocked(db).transaction = vi
|
||||
.fn()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.mockImplementation(async (fn: (tx: any) => Promise<void>) => {
|
||||
await fn(mockTx);
|
||||
});
|
||||
|
||||
await linkOidcToUser(42, iss, sub);
|
||||
|
||||
@@ -551,15 +598,20 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
||||
let deletedLocalCreds = false;
|
||||
|
||||
// Preflight SELECT finds a conflicting user (id=99, different from target=42)
|
||||
vi.mocked(db.select).mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict!
|
||||
}),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any));
|
||||
vi.mocked(db.select).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict!
|
||||
}),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
);
|
||||
|
||||
// Transaction should NEVER be called on conflict
|
||||
const mockTx = {
|
||||
@@ -570,10 +622,12 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
||||
}),
|
||||
})),
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise<void>) => {
|
||||
await fn(mockTx);
|
||||
});
|
||||
vi.mocked(db).transaction = vi
|
||||
.fn()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.mockImplementation(async (fn: (tx: any) => Promise<void>) => {
|
||||
await fn(mockTx);
|
||||
});
|
||||
|
||||
// Should throw OidcLinkConflictError, not proceed to transaction
|
||||
await expect(linkOidcToUser(42, iss, sub)).rejects.toThrow(OidcLinkConflictError);
|
||||
@@ -588,13 +642,20 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
|
||||
// Mock db — not needed for route shape test but avoids errors
|
||||
vi.mocked(db.select).mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }),
|
||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any));
|
||||
vi.mocked(db.select).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi
|
||||
.fn()
|
||||
.mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
);
|
||||
|
||||
const { app } = await import('../../src/index.js');
|
||||
const res = await app.request('/api/me/link-oidc', {
|
||||
@@ -607,7 +668,8 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
||||
const body = (await res.json()) as { authorizationUrl?: string; state?: string };
|
||||
// The response must have at minimum a signedState field (or authorizationUrl)
|
||||
// — the exact shape depends on implementation; assert it's an object with a useful field
|
||||
const hasInitiationPayload = 'authorizationUrl' in body || 'state' in body || 'signedState' in body;
|
||||
const hasInitiationPayload =
|
||||
'authorizationUrl' in body || 'state' in body || 'signedState' in body;
|
||||
expect(hasInitiationPayload).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+23
-9
@@ -32,13 +32,17 @@ The API backend must also be running for most features. See [GETTING-STARTED.md]
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | What it does |
|
||||
| ----------------------------------------- | ------------------------------------------------------------- |
|
||||
| `pnpm --filter @familysync/pwa dev` | Start Vite dev server (HMR) |
|
||||
| `pnpm --filter @familysync/pwa build` | Type-check then build production bundle (`tsc && vite build`) |
|
||||
| `pnpm --filter @familysync/pwa preview` | Serve the production build locally |
|
||||
| `pnpm --filter @familysync/pwa typecheck` | Run `tsc --noEmit` without emitting files |
|
||||
| `pnpm --filter @familysync/pwa test` | Run Vitest test suite once (`vitest run`) |
|
||||
| Command | What it does |
|
||||
| ------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| `pnpm --filter @familysync/pwa dev` | Start Vite dev server (HMR) |
|
||||
| `pnpm --filter @familysync/pwa build` | Type-check then build production bundle (`tsc && vite build`) |
|
||||
| `pnpm --filter @familysync/pwa preview` | Serve the production build locally |
|
||||
| `pnpm --filter @familysync/pwa lint` | Run ESLint over `src/` and `e2e/` with zero warnings allowed |
|
||||
| `pnpm --filter @familysync/pwa typecheck` | Run `tsc --noEmit` for both `src/` and `e2e/` tsconfigs |
|
||||
| `pnpm --filter @familysync/pwa test` | Run Vitest unit/integration suite once (`vitest run`) |
|
||||
| `pnpm --filter @familysync/pwa test:e2e` | Run Playwright end-to-end tests headlessly |
|
||||
| `pnpm --filter @familysync/pwa test:e2e:ui` | Open the Playwright UI runner |
|
||||
| `pnpm --filter @familysync/pwa test:e2e:headed` | Run Playwright tests in a headed browser |
|
||||
|
||||
## Source layout
|
||||
|
||||
@@ -48,7 +52,7 @@ src/
|
||||
components/ # Shared UI components co-located with their *.test.tsx files
|
||||
hooks/ # Custom React hooks (useListSSE, usePushSubscription, useFocusTrap)
|
||||
lib/ # Pure helpers: calendarConfig, colorUtils, eventDateTime, hydrateEvents, loginRedirect
|
||||
routes/ # React Router route components with co-located tests (ListDetail, ListsIndex)
|
||||
routes/ # React Router route components with co-located tests (AdminPage, ListDetail, ListsIndex, LoginPage, SetupPage)
|
||||
store/ # Zustand stores: calendarStore, listsStore
|
||||
styles/ # Global CSS
|
||||
main.tsx # App entry point — React Query client, router, global error handlers
|
||||
@@ -72,7 +76,7 @@ In development the Vite proxy routes `/api` requests to the API server on port 3
|
||||
|
||||
## Testing
|
||||
|
||||
Tests are co-located with their source files (`*.test.tsx` / `*.test.ts`) and use React Testing Library + `@testing-library/jest-dom`. The test environment is `jsdom`.
|
||||
Unit and integration tests are co-located with their source files (`*.test.tsx` / `*.test.ts`) and use React Testing Library + `@testing-library/jest-dom`. The test environment is `jsdom`.
|
||||
|
||||
```bash
|
||||
# run once
|
||||
@@ -82,6 +86,16 @@ pnpm --filter @familysync/pwa test
|
||||
pnpm --filter @familysync/pwa exec vitest
|
||||
```
|
||||
|
||||
End-to-end tests live in the `e2e/` directory and run with Playwright (`@playwright/test` 1.60.0). They cover login, calendar, lists, layout, admin, and timezone verification flows.
|
||||
|
||||
```bash
|
||||
# headless
|
||||
pnpm --filter @familysync/pwa test:e2e
|
||||
|
||||
# with Playwright UI
|
||||
pnpm --filter @familysync/pwa test:e2e:ui
|
||||
```
|
||||
|
||||
No coverage threshold is configured. Run `pnpm --filter @familysync/pwa typecheck` separately — Vitest uses esbuild and will not surface TypeScript errors.
|
||||
|
||||
## PWA install notes
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
* pnpm --filter @familysync/pwa test:e2e --grep "login"
|
||||
* pnpm --filter @familysync/pwa exec playwright test --project=desktop login.spec.ts
|
||||
*/
|
||||
import { test, expect, type BrowserContext } from '@playwright/test';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Selectors derived from 19-UI-SPEC.md Surfaces 3-7 (locked by plan 04 implementation)
|
||||
const SELECTORS = {
|
||||
@@ -43,32 +43,6 @@ const SELECTORS = {
|
||||
errorMessage: '[role="status"]',
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an unauthenticated browser context by clearing all cookies and storage.
|
||||
* The devSessionCookieMiddleware issues a new local-session cookie on each API
|
||||
* request, so we need to clear the cookie from the BROWSER side. Navigating to
|
||||
* a page that clears the cookie header is the reliable approach in Playwright.
|
||||
*/
|
||||
async function makeUnauthContext(
|
||||
context: BrowserContext,
|
||||
baseURL: string,
|
||||
): Promise<void> {
|
||||
// Clear all cookies (removes the local-session cookie set by prior API calls)
|
||||
await context.clearCookies();
|
||||
// Also clear localStorage/sessionStorage to avoid any cached auth state
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
// Navigate somewhere to gain origin access, then clear storage
|
||||
await page.goto(baseURL, { waitUntil: 'domcontentloaded', timeout: 10_000 }).catch(() => {});
|
||||
await page.evaluate(() => {
|
||||
try { localStorage.clear(); } catch { /* cross-origin or unavailable */ }
|
||||
try { sessionStorage.clear(); } catch { /* cross-origin or unavailable */ }
|
||||
});
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Only run these specs on the desktop profile. The login form is a standard web
|
||||
// page (not PWA-specific) and Chromium handles cookies most consistently for this test.
|
||||
// iphone/pixel still reach the authed app via the bypass-issued cookie (unchanged behavior).
|
||||
@@ -78,9 +52,7 @@ test.describe('Login form — real auth round-trip (desktop/Chromium only)', ()
|
||||
'Login form tests only run on Chromium (desktop profile) — other profiles use the bypass cookie',
|
||||
);
|
||||
|
||||
test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', async ({ page }) => {
|
||||
// Navigate DIRECTLY to /login rather than asserting an unauthenticated root→/login
|
||||
// redirect: under the always-on DEV_AUTH_BYPASS, /api/me is authed via DEV_USER
|
||||
// injection regardless of the cookie, so visiting / lands on /calendar and a
|
||||
@@ -129,10 +101,7 @@ test.describe('Login form — real auth round-trip (desktop/Chromium only)', ()
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test('correct devuser/devpass logs in and navigates out of /login', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test('correct devuser/devpass logs in and navigates out of /login', async ({ page, context }) => {
|
||||
await context.clearCookies();
|
||||
|
||||
await page.goto('/login', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
@@ -195,10 +195,7 @@ export default function App() {
|
||||
Phase 19: shown when the user is unauthenticated AND localEnabled === true.
|
||||
The route itself always renders LoginPage (authMode gating is in the `*` route gate below).
|
||||
LoginPage receives authMode so it can show the optional OIDC button when oidcEnabled. */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={<LoginPage authMode={authModeQuery.data} />}
|
||||
/>
|
||||
<Route path="/login" element={<LoginPage authMode={authModeQuery.data} />} />
|
||||
|
||||
{/* All other routes are gated on setup completion */}
|
||||
<Route
|
||||
@@ -213,7 +210,10 @@ export default function App() {
|
||||
) : meQuery.isError && !meQuery.isLoading && authModeQuery.data?.localEnabled ? (
|
||||
// Unauthenticated + localEnabled: redirect to /login
|
||||
<Navigate to="/login" replace />
|
||||
) : meQuery.isError && !meQuery.isLoading && !authModeQuery.data?.localEnabled && authModeQuery.data?.oidcEnabled ? (
|
||||
) : meQuery.isError &&
|
||||
!meQuery.isLoading &&
|
||||
!authModeQuery.data?.localEnabled &&
|
||||
authModeQuery.data?.oidcEnabled ? (
|
||||
// Unauthenticated + OIDC-only mode: top-level redirect to /api/login (today's behavior)
|
||||
// Use a render side-effect via useEffect isn't available here; use a helper element
|
||||
<OidcRedirect />
|
||||
|
||||
+37
-16
@@ -103,10 +103,7 @@ export async function fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnab
|
||||
*
|
||||
* Throws nothing on 200 OK — the local-session cookie is set by the server.
|
||||
*/
|
||||
export async function fetchLocalLogin(body: {
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<void> {
|
||||
export async function fetchLocalLogin(body: { username: string; password: string }): Promise<void> {
|
||||
const res = await fetch('/api/auth/local/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -141,9 +138,14 @@ export async function fetchLocalLogout(): Promise<void> {
|
||||
* Requires the user's current password and a new password (min 8 chars).
|
||||
*
|
||||
* Status codes:
|
||||
* 401 → wrong current password (throws Error with code 'wrong-current')
|
||||
* 422 → validation failure (throws Error with code 'validation')
|
||||
* other non-ok → generic error
|
||||
* 403 → wrong current password (throws Error('wrong-current')) — NOT a session expiry
|
||||
* 401 / opaqueredirect → genuine session expiry (throws SessionExpiredError)
|
||||
* other non-ok → generic error (throws Error('server'))
|
||||
*
|
||||
* CR-03: the server returns 403 (not 401) for an incorrect current password so this
|
||||
* client can distinguish an in-app authorization failure from a real session expiry.
|
||||
* Treating that case as 401 would route it to the global MutationCache session-expiry
|
||||
* handler and forcibly log the user out for a simple mistyped password.
|
||||
*/
|
||||
export async function fetchChangePassword(body: {
|
||||
currentPassword: string;
|
||||
@@ -157,6 +159,8 @@ export async function fetchChangePassword(body: {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
// 403 → wrong current password (in-app). Check BEFORE the 401 session-expiry branch.
|
||||
if (res.status === 403) throw new Error('wrong-current');
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json().catch(() => ({}))) as { code?: string };
|
||||
@@ -168,10 +172,14 @@ export async function fetchChangePassword(body: {
|
||||
* POST /api/admin/members — create a new local member account (Phase 19, Surface 11A).
|
||||
* Admin-only; server enforces requireAdmin.
|
||||
*
|
||||
* Request contract: the server's createMemberSchema requires
|
||||
* { displayName, username, initialPassword }
|
||||
* (see apps/api/src/routes/admin.ts). The caller-facing `password` field is mapped to
|
||||
* `initialPassword` here so the request validates server-side.
|
||||
*
|
||||
* Status codes:
|
||||
* 409 → username already taken
|
||||
* 422 → validation failure (short password / mismatch)
|
||||
* other non-ok → generic error
|
||||
* 409 → username already taken (throws Error with message 'conflict')
|
||||
* other non-ok → generic error (throws Error('server'))
|
||||
*/
|
||||
export async function fetchCreateMember(body: {
|
||||
displayName: string;
|
||||
@@ -183,10 +191,19 @@ export async function fetchCreateMember(body: {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
body: JSON.stringify(body),
|
||||
// CR-02: the server expects `initialPassword`, not `password`. Send the field it
|
||||
// validates against — otherwise Zod rejects every create with a generic 400.
|
||||
body: JSON.stringify({
|
||||
displayName: body.displayName,
|
||||
username: body.username,
|
||||
initialPassword: body.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
|
||||
// 409 → username conflict. The server returns { error: 'Username already in use' } (no
|
||||
// `code` field), so map the status to the 'conflict' sentinel the AdminPage handler expects.
|
||||
if (res.status === 409) throw new Error('conflict');
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json().catch(() => ({}))) as { code?: string };
|
||||
throw new Error(detail.code ?? 'server');
|
||||
@@ -219,11 +236,15 @@ export async function fetchAdminResetPassword(
|
||||
/**
|
||||
* POST /api/me/link-oidc — initiate the OIDC-link flow for the current local user (Surface 13).
|
||||
*
|
||||
* The server returns a redirect URL to begin the OIDC authorization-code flow with a
|
||||
* state parameter encoding the linkUserId claim. The caller should follow the redirect
|
||||
* via top-level navigation (window.location.href = result.redirectUrl).
|
||||
* The server returns the OIDC authorization endpoint URL (with a signed `state` parameter
|
||||
* encoding the linkUserId claim) to begin the authorization-code flow. The caller should
|
||||
* follow it via top-level navigation (window.location.href = authorizationUrl) when present.
|
||||
*
|
||||
* authorizationUrl is null when OIDC is not configured in env (the server cannot build the
|
||||
* URL); callers MUST handle that case and surface an error instead of navigating to null.
|
||||
* The server contract is { signedState, authorizationUrl } (see apps/api/src/routes/me.ts).
|
||||
*/
|
||||
export async function fetchLinkOidc(): Promise<{ redirectUrl: string }> {
|
||||
export async function fetchLinkOidc(): Promise<{ authorizationUrl: string | null }> {
|
||||
const res = await fetch('/api/me/link-oidc', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
@@ -234,7 +255,7 @@ export async function fetchLinkOidc(): Promise<{ redirectUrl: string }> {
|
||||
if (!res.ok) {
|
||||
throw new Error(`fetchLinkOidc failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ redirectUrl: string }>;
|
||||
return res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>;
|
||||
}
|
||||
|
||||
// ── /api/me ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -28,7 +28,14 @@ vi.mock('../hooks/usePushSubscription.js', () => ({
|
||||
// Mock client so the test doesn't make real network calls.
|
||||
vi.mock('../api/client.js', () => ({
|
||||
fetchMe: vi.fn().mockResolvedValue({
|
||||
user: { id: 1, displayName: 'Test', color: '#4a90d9', isAdmin: false, needsProviderSetup: false, hasLocalCredential: false },
|
||||
user: {
|
||||
id: 1,
|
||||
displayName: 'Test',
|
||||
color: '#4a90d9',
|
||||
isAdmin: false,
|
||||
needsProviderSetup: false,
|
||||
hasLocalCredential: false,
|
||||
},
|
||||
}),
|
||||
fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }),
|
||||
fetchChangePassword: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -500,10 +500,7 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
||||
|
||||
{/* Surface 13 — Link OIDC confirmation sheet (hasLocalCredential + oidcEnabled gate) */}
|
||||
{linkOidcOpen && (
|
||||
<LinkOidcSheet
|
||||
isOpen={linkOidcOpen}
|
||||
onClose={() => setLinkOidcOpen(false)}
|
||||
/>
|
||||
<LinkOidcSheet isOpen={linkOidcOpen} onClose={() => setLinkOidcOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -859,9 +856,15 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
|
||||
const linkMutation = useMutation({
|
||||
mutationFn: fetchLinkOidc,
|
||||
onSuccess: (data) => {
|
||||
// Close the sheet and initiate OIDC link flow
|
||||
// authorizationUrl is null when OIDC is not configured in env (server could not
|
||||
// build the URL). Do NOT navigate to null — surface an error and keep the sheet open.
|
||||
if (!data.authorizationUrl) {
|
||||
setError('Something went wrong. Please try again.');
|
||||
return;
|
||||
}
|
||||
// Close the sheet and initiate the OIDC link flow via top-level navigation.
|
||||
onClose();
|
||||
window.location.href = data.redirectUrl;
|
||||
window.location.href = data.authorizationUrl;
|
||||
},
|
||||
onError: () => {
|
||||
setError('Something went wrong. Please try again.');
|
||||
@@ -929,7 +932,9 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
|
||||
color: 'var(--color-text-secondary, #6b7280)',
|
||||
}}
|
||||
>
|
||||
{"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."}
|
||||
{
|
||||
"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."
|
||||
}
|
||||
</p>
|
||||
|
||||
{/* Secondary note */}
|
||||
|
||||
@@ -1241,8 +1241,7 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps
|
||||
});
|
||||
|
||||
const isPending = resetMutation.isPending;
|
||||
const submitDisabled =
|
||||
isPending || newPassword.length === 0 || confirmPassword.length === 0;
|
||||
const submitDisabled = isPending || newPassword.length === 0 || confirmPassword.length === 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
||||
@@ -146,10 +146,7 @@ export function LoginPage({ authMode }: LoginPageProps) {
|
||||
const isLoading = loginMutation.isPending;
|
||||
const bothNonEmpty = username.trim().length > 0 && password.length > 0;
|
||||
const submitDisabled =
|
||||
isLoading ||
|
||||
!bothNonEmpty ||
|
||||
loginError === 'rate-limit' ||
|
||||
loginError === 'locked';
|
||||
isLoading || !bothNonEmpty || loginError === 'rate-limit' || loginError === 'locked';
|
||||
|
||||
// Derive whether inputs should show error state
|
||||
const inputHasError = loginError === 'invalid';
|
||||
@@ -332,7 +329,8 @@ export function LoginPage({ authMode }: LoginPageProps) {
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
|
||||
This account is temporarily locked. Contact your admin to reset access.
|
||||
Too many failed attempts for this account. Try again in about 15 minutes, or
|
||||
contact your admin to reset access.
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -94,11 +94,11 @@
|
||||
* never the BrandSlot component structure (see 19-UI-SPEC.md §Brand Slot).
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
--brand-logo-bg: var(--color-member-0); /* placeholder circle background */
|
||||
--brand-logo-text: #ffffff; /* placeholder initials color */
|
||||
--brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */
|
||||
--brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */
|
||||
--brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */
|
||||
--brand-logo-bg: var(--color-member-0); /* placeholder circle background */
|
||||
--brand-logo-text: #ffffff; /* placeholder initials color */
|
||||
--brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */
|
||||
--brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */
|
||||
--brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* BREAKPOINTS (reference; use in @media queries)
|
||||
|
||||
+519
-34
@@ -2,16 +2,22 @@
|
||||
|
||||
# API Reference
|
||||
|
||||
FamilySync exposes a Hono HTTP API served on port 3000. All `/api/*` routes require an authenticated session (OIDC via Authelia, or `DEV_AUTH_BYPASS=true` for local development). Unauthenticated requests to protected routes receive a `302` redirect to Authelia's authorize endpoint, not a `401`, except where noted.
|
||||
FamilySync exposes a Hono HTTP API served on port 3000. All `/api/*` routes require an authenticated session (OIDC via Authelia, local username/password, or `DEV_AUTH_BYPASS=true` for local development). Unauthenticated requests to protected routes receive a `302` redirect to Authelia's authorize endpoint, not a `401`, except where noted.
|
||||
|
||||
## Authentication
|
||||
|
||||
The API uses OIDC session cookies managed by `@hono/oidc-auth`. The authorization-code + PKCE flow is handled transparently:
|
||||
The API supports two session mechanisms that coexist on the same `/api/*` guard chain:
|
||||
|
||||
**OIDC session (Authelia):** Managed by `@hono/oidc-auth`. The authorization-code + PKCE flow is handled transparently:
|
||||
|
||||
1. The PWA navigates to `GET /api/login`. This route sits under `/api/*`, where the `@hono/oidc-auth` guard is mounted.
|
||||
2. For a request with no valid session cookie, the OIDC middleware intercepts it before the route handler runs and 302-redirects to Authelia's authorize endpoint.
|
||||
3. Authelia posts the authorization code to `GET /callback`, which exchanges it for tokens and sets an `httpOnly; Secure; SameSite` session cookie, then continues back to `/api/login` — whose handler now redirects to `/` (the app shell).
|
||||
4. All subsequent `/api/*` requests carry the session cookie automatically. (The root `/` and static assets are served outside the `/api/*` guard.)
|
||||
4. All subsequent `/api/*` requests carry the session cookie automatically.
|
||||
|
||||
**Local session (username + password):** Available when OIDC is not configured or for members who have not linked an OIDC identity. `POST /api/auth/local/login` issues a signed JWT `local-session` cookie. The local-session middleware validates it on every `/api/*` request and sets the user context, so OIDC guard is bypassed for already-authenticated local users.
|
||||
|
||||
**Session precedence:** Local-session middleware runs first. If `c.get('user')` is already set (local session or dev bypass), the OIDC guard is skipped entirely.
|
||||
|
||||
**Dev bypass:** When `DEV_AUTH_BYPASS=true` and `NODE_ENV != production`, the OIDC guard is disabled and a fixed dev user (id `1`) is injected into every request. Never enable in production.
|
||||
|
||||
@@ -19,31 +25,54 @@ No API key or `Authorization` header is used. Credentials are never included in
|
||||
|
||||
## Endpoints Overview
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
| ------ | -------------------------------- | ---- | ------------------------------------ |
|
||||
| GET | `/health` | None | DB liveness check |
|
||||
| GET | `/callback` | None | OIDC authorization-code exchange |
|
||||
| GET | `/api/login` | OIDC | Login entry point, redirects to `/` |
|
||||
| GET | `/api/me` | OIDC | Current user identity and color |
|
||||
| GET | `/api/events` | OIDC | Windowed calendar occurrences |
|
||||
| POST | `/api/events/create` | OIDC | Enqueue a new event write |
|
||||
| PATCH | `/api/events/:uid/edit` | OIDC | Enqueue an event update |
|
||||
| DELETE | `/api/events/:uid` | OIDC | Enqueue an event delete |
|
||||
| GET | `/api/events/sync-status` | OIDC | Outbox status for a UID |
|
||||
| GET | `/api/events/writable-calendars` | OIDC | Calendars the member can write to |
|
||||
| GET | `/api/lists` | OIDC | All lists accessible to the member |
|
||||
| POST | `/api/lists` | OIDC | Create a list |
|
||||
| PATCH | `/api/lists/:id` | OIDC | Update list name or sharing |
|
||||
| DELETE | `/api/lists/:id` | OIDC | Delete a list (owner only) |
|
||||
| GET | `/api/lists/:id/items` | OIDC | All items in a list |
|
||||
| POST | `/api/lists/:id/items` | OIDC | Add an item to a list |
|
||||
| PATCH | `/api/list-items/:itemId` | OIDC | Update a single list item field |
|
||||
| DELETE | `/api/list-items/:itemId` | OIDC | Delete a list item |
|
||||
| GET | `/api/sse/heartbeat` | OIDC | SSE heartbeat stream |
|
||||
| GET | `/api/sse/lists` | OIDC | Scoped live-list SSE stream |
|
||||
| GET | `/api/push/vapid-public-key` | OIDC | VAPID public key for push subscribe |
|
||||
| POST | `/api/push/subscription` | OIDC | Register a push subscription |
|
||||
| DELETE | `/api/push/subscription` | OIDC | Remove push subscriptions for caller |
|
||||
| Method | Path | Auth | Description |
|
||||
| ------ | ------------------------------------- | --------- | -------------------------------------------------- |
|
||||
| GET | `/health` | None | DB liveness check |
|
||||
| GET | `/callback` | None | OIDC authorization-code exchange |
|
||||
| GET | `/api/setup/status` | None | Setup wizard completion status |
|
||||
| POST | `/api/setup/config` | None | Store OIDC and VAPID config (wizard step 1) |
|
||||
| POST | `/api/setup/validate/db` | None | Validate DB connectivity (wizard step) |
|
||||
| POST | `/api/setup/validate/oidc` | None | Validate OIDC issuer discovery (wizard step) |
|
||||
| POST | `/api/setup/validate/vapid` | None | Validate VAPID key pair (wizard step) |
|
||||
| POST | `/api/setup/credential` | None | Store first admin's Fastmail credential (wizard) |
|
||||
| POST | `/api/setup/complete` | None | Lock the setup wizard |
|
||||
| GET | `/api/auth/mode` | None | Auth mode discovery (local vs OIDC enabled) |
|
||||
| POST | `/api/auth/local/login` | None | Local username+password login |
|
||||
| POST | `/api/auth/local/logout` | None | Clear local session cookie |
|
||||
| GET | `/api/auth/local/logout` | None | Clear local session cookie (browser redirect alias)|
|
||||
| GET | `/api/login` | OIDC | OIDC login entry point, redirects to `/` |
|
||||
| GET | `/api/me` | Session | Current user identity, role, and setup status |
|
||||
| POST | `/api/me/credential` | Session | Member self-service Fastmail credential update |
|
||||
| POST | `/api/me/password` | Session | Member self-change local password |
|
||||
| POST | `/api/me/link-oidc` | Session | Initiate OIDC identity link for a local user |
|
||||
| GET | `/api/events` | Session | Windowed calendar occurrences |
|
||||
| POST | `/api/events/create` | Session | Enqueue a new event write |
|
||||
| PATCH | `/api/events/:uid/edit` | Session | Enqueue an event update |
|
||||
| DELETE | `/api/events/:uid` | Session | Enqueue an event delete |
|
||||
| GET | `/api/events/sync-status` | Session | Outbox status for a UID |
|
||||
| GET | `/api/events/writable-calendars` | Session | Calendars the member can write to |
|
||||
| GET | `/api/lists` | Session | All lists accessible to the member |
|
||||
| POST | `/api/lists` | Session | Create a list |
|
||||
| PATCH | `/api/lists/:id` | Session | Update list name or sharing |
|
||||
| DELETE | `/api/lists/:id` | Session | Delete a list (owner only) |
|
||||
| GET | `/api/lists/:id/items` | Session | All items in a list |
|
||||
| POST | `/api/lists/:id/items` | Session | Add an item to a list |
|
||||
| PATCH | `/api/list-items/:itemId` | Session | Update a single list item field |
|
||||
| DELETE | `/api/list-items/:itemId` | Session | Delete a list item |
|
||||
| GET | `/api/sse/heartbeat` | Session | SSE heartbeat stream |
|
||||
| GET | `/api/sse/lists` | Session | Scoped live-list SSE stream |
|
||||
| GET | `/api/push/vapid-public-key` | Session | VAPID public key for push subscribe |
|
||||
| POST | `/api/push/subscription` | Session | Register a push subscription |
|
||||
| DELETE | `/api/push/subscription` | Session | Remove push subscriptions for caller |
|
||||
| GET | `/api/admin/members` | Admin | List members with credential status |
|
||||
| POST | `/api/admin/members` | Admin | Create a new local member |
|
||||
| POST | `/api/admin/members/:id/password` | Admin | Reset a local member's password |
|
||||
| POST | `/api/admin/credentials` | Admin | Validate and store a member's Fastmail credential |
|
||||
| GET | `/api/admin/calendars` | Admin | List synced calendars |
|
||||
| PUT | `/api/admin/calendars/:id/shared` | Admin | Designate the shared family calendar |
|
||||
| GET | `/api/admin/config/timezone` | Admin | Get household timezone |
|
||||
| PUT | `/api/admin/config/timezone` | Admin | Set household timezone |
|
||||
| POST | `/api/admin/config/timezone/seed` | Admin | Seed household timezone if not yet set |
|
||||
|
||||
---
|
||||
|
||||
@@ -67,13 +96,190 @@ Unauthenticated. Performs a `SELECT 1` against MariaDB to prove connectivity.
|
||||
|
||||
---
|
||||
|
||||
## Setup Wizard
|
||||
|
||||
The setup wizard surface is reachable pre-authentication. All setup routes return `423` once `isSetupLocked()` returns true (i.e., after `POST /api/setup/complete` has been called or the database has an effective OIDC configuration).
|
||||
|
||||
### `GET /api/setup/status`
|
||||
|
||||
Returns whether the first-run wizard has been completed. Always reachable (no 423 guard — the PWA needs this to decide whether to show the wizard).
|
||||
|
||||
**Response 200**
|
||||
|
||||
```json
|
||||
{ "setupComplete": false, "dbName": "familysync" }
|
||||
```
|
||||
|
||||
`dbName` is the value of the `DB_NAME` environment variable (non-secret, for display in the wizard UI). `setupComplete: true` indicates the wizard is locked.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/setup/config`
|
||||
|
||||
Stores non-secret OIDC and VAPID configuration into `app_config`. Returns `423` if setup is already locked.
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"oidcIssuer": "https://auth.example.com",
|
||||
"oidcClientId": "familysync",
|
||||
"vapidPublicKey": "BNF8dFt...",
|
||||
"appExternalUrl": "https://familysync.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
| ---------------- | ------ | -------- | ------------------------------ |
|
||||
| `oidcIssuer` | string | Yes | HTTPS URL |
|
||||
| `oidcClientId` | string | Yes | 1–256 characters |
|
||||
| `vapidPublicKey` | string | Yes | 1–512 characters |
|
||||
| `appExternalUrl` | string | Yes | HTTPS URL, max 512 characters |
|
||||
|
||||
**Response 200**
|
||||
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/setup/validate/db`
|
||||
|
||||
Proves DB connectivity via `SELECT 1`. Returns `423` if setup is locked.
|
||||
|
||||
**Response 200** — `{ "ok": true }`
|
||||
|
||||
**Response 503** — `{ "ok": false, "error": "DB unavailable" }`
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/setup/validate/oidc`
|
||||
|
||||
Fetches `{oidcIssuer}/.well-known/openid-configuration` (5-second timeout) to validate the issuer stored by `POST /api/setup/config`. Returns `423` if setup is locked.
|
||||
|
||||
**Response 200** — `{ "ok": true }`
|
||||
|
||||
**Response 400** — `{ "ok": false, "error": "OIDC discovery failed. Check the issuer URL." }`
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/setup/validate/vapid`
|
||||
|
||||
Validates the VAPID public key stored in `app_config` against the `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` environment variables. The submitted public key must exactly equal `process.env.VAPID_PUBLIC_KEY`. Returns `423` if setup is locked.
|
||||
|
||||
**Response 200** — `{ "ok": true }`
|
||||
|
||||
**Response 400** — `{ "ok": false, "error": "VAPID public key does not match..." }`
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/setup/credential`
|
||||
|
||||
Creates the first admin user (no OIDC identity yet, `claimed: false`) and validates/encrypts/stores their Fastmail CalDAV app password. Returns `423` if setup is locked; `409` if an unclaimed admin row already exists (concurrent wizard request).
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"fastmailEmail": "broker@fastmail.com",
|
||||
"appPassword": "xxxx-xxxx-xxxx-xxxx"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
| --------------- | ------ | -------- | ---------------- |
|
||||
| `fastmailEmail` | string | Yes | Valid email, max 256 characters |
|
||||
| `appPassword` | string | Yes | 1–500 characters |
|
||||
|
||||
Zod validation errors for this route never echo received values (the app password is never included in error responses).
|
||||
|
||||
**Response 200** — `{ "ok": true }`
|
||||
|
||||
**Error responses:** `400` invalid request; `409` setup already in progress; `423` setup locked; `503` service unavailable.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/setup/complete`
|
||||
|
||||
Locks the setup wizard by writing `setup_complete=true` to `app_config`. Requires an unclaimed admin user with a Fastmail credential to exist (guards against skipping the credential step). Returns `423` if already locked.
|
||||
|
||||
**Response 200** — `{ "ok": true }`
|
||||
|
||||
**Response 422** — `{ "error": "Cannot lock setup: no credential configured" }`
|
||||
|
||||
---
|
||||
|
||||
## Auth Mode
|
||||
|
||||
### `GET /api/auth/mode`
|
||||
|
||||
Pre-auth endpoint. Returns which authentication methods are currently enabled. Used by the PWA on app load to decide which login flow to present.
|
||||
|
||||
`oidcEnabled` is true when `OIDC_ISSUER` is set in the environment **or** when `app_config` has an `oidc_issuer` row (wizard-configured OIDC before container restart). `localEnabled` is always `true`.
|
||||
|
||||
**Response 200**
|
||||
|
||||
```json
|
||||
{ "localEnabled": true, "oidcEnabled": false }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Local Auth
|
||||
|
||||
### `POST /api/auth/local/login`
|
||||
|
||||
Validates a username + password against `local_credentials` and issues a signed `local-session` JWT cookie. Pre-auth — reachable without a session.
|
||||
|
||||
Rate limiting is per-username (not per-IP):
|
||||
- 5 failures within 60 seconds → `429 Too Many Requests`
|
||||
- 10 cumulative failures → `423 Account Locked` (auto-expires after 15 minutes or on admin password reset)
|
||||
|
||||
Timing-oracle defense: `verifyPassword` (scrypt) is always called, even for unknown usernames.
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{ "username": "alice", "password": "hunter2" }
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
| ---------- | ------ | -------- | ---------------- |
|
||||
| `username` | string | Yes | 1–128 characters (trimmed) |
|
||||
| `password` | string | Yes | 1–1000 characters |
|
||||
|
||||
Zod validation errors never echo received values.
|
||||
|
||||
**Response 200**
|
||||
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
Sets a `local-session` cookie (`httpOnly; Secure; SameSite`).
|
||||
|
||||
**Error responses:** `400` invalid request; `401` invalid credentials (same body for wrong password and unknown username — no field discrimination); `423` account locked; `429` too many attempts; `503` service unavailable.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/local/logout`
|
||||
|
||||
Clears the `local-session` cookie. Also available as `GET /api/auth/local/logout` for browser-redirect compatibility.
|
||||
|
||||
**Response 200** — `{ "ok": true }`
|
||||
|
||||
---
|
||||
|
||||
## Identity
|
||||
|
||||
### `GET /api/me`
|
||||
|
||||
Returns the authenticated member's identity and their assigned color.
|
||||
Returns the authenticated member's identity, admin role, and credential setup status.
|
||||
|
||||
Display name is derived from OIDC claims in priority order: `name` → `preferred_username` → `email` → `sub`. The user row is upserted on first visit (keyed on `oidc_iss` + `oidc_sub`).
|
||||
Display name is derived from OIDC claims in priority order: `name` → `preferred_username` → `email` → `sub`. The user row is upserted on first OIDC visit (keyed on `oidc_iss` + `oidc_sub`).
|
||||
|
||||
`isAdmin` is exposed for PWA navigation gating only — it is not the security boundary. The server enforces admin role via `requireAdmin` middleware on every `/api/admin/*` request.
|
||||
|
||||
**Response 200**
|
||||
|
||||
@@ -82,15 +288,96 @@ Display name is derived from OIDC claims in priority order: `name` → `preferre
|
||||
"user": {
|
||||
"id": 1,
|
||||
"displayName": "Lucas",
|
||||
"color": "#4A90D9"
|
||||
"color": "#4A90D9",
|
||||
"isAdmin": true,
|
||||
"needsProviderSetup": false,
|
||||
"hasLocalCredential": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------------- | ------- | ---------------------------------------------------------------- |
|
||||
| `id` | integer | Stable member ID |
|
||||
| `displayName` | string | Derived from OIDC claims or set by admin |
|
||||
| `color` | string | Member's assigned color (hex) |
|
||||
| `isAdmin` | boolean | Whether the member has the admin role |
|
||||
| `needsProviderSetup` | boolean | True when no Fastmail credential is stored for this member |
|
||||
| `hasLocalCredential` | boolean | True when a local username/password credential exists |
|
||||
|
||||
**Error responses:** `401` if the session is invalid.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/me/credential`
|
||||
|
||||
Member self-service endpoint to set or rotate their own Fastmail CalDAV app password. Validates against CalDAV (PROPFIND) before storing. Always writes to the authenticated member's record — the request body cannot specify a different user ID.
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"providerType": "caldav",
|
||||
"fastmailEmail": "member@fastmail.com",
|
||||
"appPassword": "xxxx-xxxx-xxxx-xxxx"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
| --------------- | ------ | -------- | ---------------- |
|
||||
| `providerType` | string | Yes | Must be `"caldav"` |
|
||||
| `fastmailEmail` | string | Yes | Valid email, max 256 characters |
|
||||
| `appPassword` | string | Yes | 1–500 characters |
|
||||
|
||||
**Response 200** — `{ "ok": true }`
|
||||
|
||||
**Error responses:** `400` invalid request or CalDAV validation failed; `401` unauthorized; `503` service unavailable.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/me/password`
|
||||
|
||||
Self-service password change for local-auth members. Requires the current password to be supplied. Returns `403` (not `401`) for a wrong current password to avoid triggering the PWA's global session-expiry handler.
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"currentPassword": "old-password",
|
||||
"newPassword": "new-password-min8"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
| ----------------- | ------ | -------- | ---------------- |
|
||||
| `currentPassword` | string | Yes | 1+ characters |
|
||||
| `newPassword` | string | Yes | Minimum 8 characters |
|
||||
|
||||
**Response 200** — `{ "ok": true }`
|
||||
|
||||
**Error responses:** `400` invalid request; `401` unauthorized; `403` current password incorrect; `404` no local credential found; `503` service unavailable.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/me/link-oidc`
|
||||
|
||||
Initiates the OIDC authorization-code flow for a locally-authenticated member. Returns a signed `state` JWT and the OIDC authorization URL. The PWA redirects the user to the authorization URL; on successful OIDC login, the `/callback` handler binds the OIDC identity to the local user and removes the local credential.
|
||||
|
||||
Returns `authorizationUrl: null` when OIDC is not configured.
|
||||
|
||||
**Response 200**
|
||||
|
||||
```json
|
||||
{
|
||||
"signedState": "eyJ...",
|
||||
"authorizationUrl": "https://auth.example.com/api/oidc/authorization?..."
|
||||
}
|
||||
```
|
||||
|
||||
**Error responses:** `401` unauthorized; `503` service unavailable (missing `LOCAL_SESSION_SECRET`).
|
||||
|
||||
---
|
||||
|
||||
## Calendar Events
|
||||
|
||||
Calendar data is read from a MariaDB cache populated by the CalDAV broker poller. Write operations enqueue outbox rows; the outbox worker dispatches them to Fastmail CalDAV asynchronously. Clients receive `202 Accepted` immediately and poll `GET /api/events/sync-status` to confirm settlement.
|
||||
@@ -615,6 +902,201 @@ Removes all push subscription rows for the authenticated member. Cannot affect a
|
||||
|
||||
---
|
||||
|
||||
## Admin
|
||||
|
||||
All `/api/admin/*` routes require the authenticated member to have the admin role (`users.isAdmin = true`). The `requireAdmin` middleware is the first statement on the admin router — no sub-route is reachable without passing this guard. Non-admins receive `403`.
|
||||
|
||||
### `GET /api/admin/members`
|
||||
|
||||
Returns all household members with their credential and local-auth status.
|
||||
|
||||
**Response 200**
|
||||
|
||||
```json
|
||||
{
|
||||
"members": [
|
||||
{
|
||||
"id": 1,
|
||||
"displayName": "Lucas",
|
||||
"color": "#4A90D9",
|
||||
"hasCredential": true,
|
||||
"hasLocalCredential": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/admin/members`
|
||||
|
||||
Creates a new local-auth member: inserts a `users` row and a `local_credentials` row with a hashed initial password in a single transaction. Returns `409` if the username is already in use.
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"displayName": "Alice",
|
||||
"username": "alice",
|
||||
"initialPassword": "minimum8chars"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
| ----------------- | ------ | -------- | ---------------- |
|
||||
| `displayName` | string | Yes | 1–256 characters |
|
||||
| `username` | string | Yes | 1–128 characters |
|
||||
| `initialPassword` | string | Yes | Minimum 8 characters |
|
||||
|
||||
Zod validation errors never echo received values (the initial password is never included in error responses).
|
||||
|
||||
**Response 201**
|
||||
|
||||
```json
|
||||
{ "id": 2 }
|
||||
```
|
||||
|
||||
**Error responses:** `400` invalid request; `403` not admin; `409` username already in use; `503` service unavailable.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/admin/members/:id/password`
|
||||
|
||||
Admin resets a local member's password without requiring the current password. Also clears any active rate-limit or lockout state for the member's username.
|
||||
|
||||
**Path parameter:** `id` — integer member ID.
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{ "newPassword": "minimum8chars" }
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
| ------------- | ------ | -------- | -------------------- |
|
||||
| `newPassword` | string | Yes | Minimum 8 characters |
|
||||
|
||||
**Response 200** — `{ "ok": true }`
|
||||
|
||||
**Error responses:** `400` invalid request; `403` not admin; `404` member not found or has no local credential; `503` service unavailable.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/admin/credentials`
|
||||
|
||||
Validates and stores a Fastmail CalDAV app password for any household member. Performs a PROPFIND against Fastmail CalDAV to verify the credential before encrypting and persisting it.
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": 2,
|
||||
"providerType": "caldav",
|
||||
"fastmailEmail": "member@fastmail.com",
|
||||
"appPassword": "xxxx-xxxx-xxxx-xxxx"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
| --------------- | ------- | -------- | ---------------- |
|
||||
| `userId` | integer | Yes | Positive integer |
|
||||
| `providerType` | string | Yes | Must be `"caldav"` |
|
||||
| `fastmailEmail` | string | Yes | Valid email, max 256 characters |
|
||||
| `appPassword` | string | Yes | 1–500 characters |
|
||||
|
||||
Zod validation errors and CalDAV validation failures return `400` with `{ "error": "Invalid request" }` — the app password is never echoed.
|
||||
|
||||
**Response 200** — `{ "ok": true }`
|
||||
|
||||
**Error responses:** `400` invalid request or CalDAV validation failed; `403` not admin; `503` service unavailable.
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/admin/calendars`
|
||||
|
||||
Lists all synced calendars with their shared-calendar designation.
|
||||
|
||||
**Response 200**
|
||||
|
||||
```json
|
||||
{
|
||||
"calendars": [
|
||||
{ "id": 1, "displayName": "Personal", "isShared": false },
|
||||
{ "id": 2, "displayName": "Family", "isShared": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/admin/calendars/:id/shared`
|
||||
|
||||
Exclusively designates one calendar as the household shared calendar. Clears `isShared` on any previously-shared calendar in the same transaction. Returns `404` if the target calendar does not exist.
|
||||
|
||||
**Path parameter:** `id` — integer calendar ID.
|
||||
|
||||
**Response 200** — `{ "ok": true }`
|
||||
|
||||
**Error responses:** `400` invalid calendar ID; `403` not admin; `404` calendar not found.
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/admin/config/timezone`
|
||||
|
||||
Returns the household IANA timezone and whether it has been explicitly configured (vs. using the system default fallback).
|
||||
|
||||
**Response 200**
|
||||
|
||||
```json
|
||||
{ "timezone": "America/Toronto", "isExplicitlySet": true }
|
||||
```
|
||||
|
||||
`isExplicitlySet: false` when no `household_timezone` row exists in `app_config`; `timezone` still contains the resolved fallback value.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/admin/config/timezone`
|
||||
|
||||
Validates and upserts the household IANA timezone into `app_config`.
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{ "timezone": "America/Toronto" }
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
| ---------- | ------ | -------- | -------------------------- |
|
||||
| `timezone` | string | Yes | Valid IANA timezone, 1–64 characters |
|
||||
|
||||
**Response 200** — `{ "ok": true }`
|
||||
|
||||
**Error responses:** `400` invalid timezone; `403` not admin.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/admin/config/timezone/seed`
|
||||
|
||||
Seeds the `household_timezone` key in `app_config` **only when it is not already set** (no-overwrite). Used by the setup wizard and browser timezone auto-detect to store the detected zone without clobbering an admin's explicit choice. Uses `INSERT IGNORE` so the operation is safe under concurrent requests.
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{ "timezone": "America/Toronto" }
|
||||
```
|
||||
|
||||
**Response 200**
|
||||
|
||||
```json
|
||||
{ "ok": true, "seeded": true }
|
||||
```
|
||||
|
||||
`seeded: true` when the row was inserted; `seeded: false` when it already existed (no change made).
|
||||
|
||||
**Error responses:** `400` invalid timezone; `403` not admin.
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
All error responses use a consistent JSON envelope.
|
||||
@@ -627,15 +1109,18 @@ All error responses use a consistent JSON envelope.
|
||||
| ----------- | ------------------------------------------------------------------------------ |
|
||||
| `400` | Invalid request parameters (e.g., malformed date window) |
|
||||
| `401` | Session missing or invalid |
|
||||
| `403` | Authenticated but not authorized (wrong owner, sharee attempted owner-only op) |
|
||||
| `403` | Authenticated but not authorized (wrong owner, sharee attempted owner-only op, non-admin on admin route) |
|
||||
| `404` | Resource not found |
|
||||
| `409` | Conflict (e.g., duplicate username) |
|
||||
| `422` | Valid request but cannot be fulfilled (e.g., user has no calendar configured) |
|
||||
| `423` | Locked (setup already complete, or account locked after too many failed logins)|
|
||||
| `429` | Too many requests (login rate limit exceeded for this username) |
|
||||
| `503` | DB or downstream service unavailable |
|
||||
|
||||
Zod validation failures return `400` with a structured body from `@hono/zod-validator` rather than the `{ "error": "..." }` envelope.
|
||||
Zod validation failures return `400` with a structured body from `@hono/zod-validator` rather than the `{ "error": "..." }` envelope. Exception: credential and password routes use a `noEchoHook` that always returns `{ "error": "Invalid request" }` to prevent echoing submitted secrets in error details.
|
||||
|
||||
---
|
||||
|
||||
## Rate Limits
|
||||
|
||||
No rate limiting is configured in the application layer. <!-- VERIFY: confirm whether Pangolin/Newt or Authelia enforce rate limits at the network edge -->
|
||||
No rate limiting is configured in the application layer for general API routes. Local auth login is rate-limited per-username: 5 failures within 60 seconds returns `429`; 10 cumulative failures locks the account with `423` for 15 minutes. See `POST /api/auth/local/login` for details. <!-- VERIFY: confirm whether Pangolin/Newt or Authelia enforce rate limits at the network edge -->
|
||||
|
||||
+57
-26
@@ -8,7 +8,7 @@ FamilySync is a self-hosted family organization hub — a unified, color-coded c
|
||||
|
||||
## System Overview
|
||||
|
||||
FamilySync follows a layered architecture with Fastmail CalDAV as the external calendar source of truth. The React PWA talks exclusively to a single Hono API backend. The backend handles authentication (Authelia OIDC), calendar read/write via CalDAV (Fastmail), list persistence (MariaDB), and real-time push delivery.
|
||||
FamilySync follows a layered architecture with Fastmail CalDAV as the external calendar source of truth. The React PWA talks exclusively to a single Hono API backend. The backend handles authentication (local username/password and/or Authelia OIDC), calendar read/write via CalDAV (Fastmail), list persistence (MariaDB), and real-time push delivery.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
@@ -18,8 +18,8 @@ graph TD
|
||||
end
|
||||
|
||||
subgraph "API (apps/api — Hono on Node 22)"
|
||||
AUTH["OIDC Auth\n(@hono/oidc-auth)"]
|
||||
ROUTES["API Routes\n/events /lists /me /push /sse"]
|
||||
AUTH["Auth Layer\n(local session + OIDC middleware)"]
|
||||
ROUTES["API Routes\n/events /lists /me /push /sse\n/admin /setup /auth"]
|
||||
BROKER["CalDAV Broker\n(tsdav + ical.js + rrule)"]
|
||||
OUTBOX["Outbox Worker\n(15s drain loop)"]
|
||||
POLLER["CalDAV Poller\n(5-min setInterval)"]
|
||||
@@ -33,7 +33,7 @@ graph TD
|
||||
end
|
||||
|
||||
subgraph "External Services"
|
||||
AUTHELIA["Authelia\n(OIDC / OAuth2 IdP)"]
|
||||
AUTHELIA["Authelia\n(OIDC / OAuth2 IdP — optional)"]
|
||||
FASTMAIL["Fastmail CalDAV\ncaldav.fastmail.com"]
|
||||
PUSH_SVC["Browser Push Services\n(APNs / FCM)"]
|
||||
end
|
||||
@@ -41,7 +41,7 @@ graph TD
|
||||
PWA -- "HTTPS (same-origin via Pangolin)" --> ROUTES
|
||||
SW -- "push events" --> PWA
|
||||
ROUTES --> AUTH
|
||||
AUTH -- "authorization-code + PKCE" --> AUTHELIA
|
||||
AUTH -- "authorization-code + PKCE (when oidcEnabled)" --> AUTHELIA
|
||||
ROUTES --> BROKER
|
||||
ROUTES --> SSE_LIB
|
||||
ROUTES --> DB
|
||||
@@ -68,7 +68,7 @@ familysync/
|
||||
│ │ └── src/
|
||||
│ │ ├── index.ts # App entry: mounts routes, starts background workers
|
||||
│ │ ├── routes/ # HTTP route handlers
|
||||
│ │ ├── auth/ # OIDC middleware + dev-bypass + session persistence
|
||||
│ │ ├── auth/ # OIDC middleware + local auth + dev-bypass + session persistence
|
||||
│ │ ├── broker/ # CalDAV integration layer
|
||||
│ │ ├── db/ # Drizzle schema, client, migrations
|
||||
│ │ └── lib/ # Shared dispatchers and utilities
|
||||
@@ -90,12 +90,12 @@ familysync/
|
||||
|
||||
| Directory | Purpose |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `apps/api/src/routes/` | One file per resource — `events.ts`, `lists.ts`, `me.ts`, `push.ts`, `sse.ts`, `health.ts` |
|
||||
| `apps/api/src/routes/` | One file per resource — `events.ts`, `lists.ts`, `me.ts`, `push.ts`, `sse.ts`, `health.ts`, `admin.ts`, `setup.ts`, `authMode.ts`, `localAuth.ts` |
|
||||
| `apps/api/src/broker/` | All CalDAV I/O: `client.ts` (tsdav factory), `sync.ts` (REPORT→DB), `poller.ts` (5-min ctag check), `outboxWorker.ts` (async write-back), `expand.ts` (RRULE expansion), `write.ts` (PUT/DELETE), `vevent.ts` (ICS builder), `crypto.ts` (AES-256-GCM for app passwords) |
|
||||
| `apps/api/src/auth/` | `middleware.ts` (re-exports `@hono/oidc-auth`), `devBypass.ts` (DEV_AUTH_BYPASS inject), `persistSessionCookie.ts` (session lifetime extension), `user.ts` (upsert on first OIDC login) |
|
||||
| `apps/api/src/auth/` | `middleware.ts` (re-exports `@hono/oidc-auth`), `devBypass.ts` (DEV_AUTH_BYPASS inject), `localAuthMiddleware.ts` (local-session cookie → user), `localCredentials.ts` (scrypt hash/verify), `localSession.ts` (JWT cookie issue/verify/clear), `oidcConfig.ts` (env+DB fallback for OIDC config), `linkNonceStore.ts` (single-use OIDC-link nonces), `linkOidc.ts` (bind OIDC identity to local user), `persistSessionCookie.ts` (session lifetime extension), `user.ts` (upsert on first OIDC login) |
|
||||
| `apps/api/src/db/` | `schema.ts` (Drizzle `mysqlTable` definitions), `client.ts` (mysql2 pool), `migrations/` (drizzle-kit output) |
|
||||
| `apps/api/src/lib/` | Stateless helpers: `listEmitter.ts` (EventEmitter fan-out), `listChangeDispatcher.ts`, `eventChangeDispatcher.ts`, `pushDispatcher.ts` (VAPID send), `pushCoalescer.ts`, `listAccess.ts`, `rank.ts` (fractional indexing) |
|
||||
| `apps/pwa/src/api/` | Thin typed fetch wrappers — `client.ts` (events, me, sync-status), `listsClient.ts` (lists and items) |
|
||||
| `apps/api/src/lib/` | Stateless helpers: `listEmitter.ts` (EventEmitter fan-out), `listChangeDispatcher.ts`, `eventChangeDispatcher.ts`, `pushDispatcher.ts` (VAPID send), `pushCoalescer.ts`, `listAccess.ts`, `rank.ts` (fractional indexing), `bootGuards.ts` (startup safety assertions), `requireAdmin.ts` (admin-role guard), `setupGuard.ts` (isSetupLocked check) |
|
||||
| `apps/pwa/src/api/` | Thin typed fetch wrappers — `client.ts` (events, me, sync-status, auth-mode, local login/logout), `listsClient.ts` (lists and items) |
|
||||
| `apps/pwa/src/store/` | `calendarStore.ts` and `listsStore.ts` — Zustand UI-only state (no server data) |
|
||||
| `apps/pwa/src/hooks/` | `useListSSE.ts` (bounded-backoff EventSource), `usePushSubscription.ts` (VAPID subscribe) |
|
||||
|
||||
@@ -103,19 +103,25 @@ familysync/
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
| Abstraction | File | Description |
|
||||
| ------------------------------------------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `app` (Hono) | `apps/api/src/index.ts` | Root Hono app; mounts all routes and serves the PWA static build |
|
||||
| Drizzle schema | `apps/api/src/db/schema.ts` | Single source of truth for all table definitions (`users`, `memberCredentials`, `calendars`, `calendarEvents`, `calendarOutbox`, `lists`, `listShares`, `listItems`, `pushSubscriptions`) |
|
||||
| `syncCalendar` | `apps/api/src/broker/sync.ts` | REPORT → ical.js parse → `onDuplicateKeyUpdate` upsert into MariaDB |
|
||||
| `expandOccurrences` | `apps/api/src/broker/expand.ts` | Server-side RRULE expansion using `ical.js` + `rrule`; never runs in the browser |
|
||||
| `CalendarOccurrence` | `apps/api/src/broker/expand.ts` | Wire type for a single concrete event occurrence; mirrored in the PWA's `api/client.ts` |
|
||||
| `calendarOutbox` table | `apps/api/src/db/schema.ts` | Transactional outbox pattern — CalDAV writes are enqueued here and drained asynchronously |
|
||||
| `runOutboxDrain` | `apps/api/src/broker/outboxWorker.ts` | Drains pending outbox rows every 15s; handles retry backoff, 412 conflict, dead-lettering, and edit-as-move ordering |
|
||||
| `publishListEvent` / `subscribeListEvents` | `apps/api/src/lib/listEmitter.ts` | In-process EventEmitter fan-out keyed per list; SSE route subscribes on open and unsubscribes on disconnect |
|
||||
| `dispatchPush` | `apps/api/src/lib/pushDispatcher.ts` | Centralised VAPID-signed push sender; handles 410/404 subscription pruning |
|
||||
| `SessionExpiredError` | `apps/pwa/src/api/client.ts` | Typed error thrown by all fetch wrappers on 401/opaqueredirect; global `QueryCache` handler arms the session-expiry interstitial |
|
||||
| Zustand stores | `apps/pwa/src/store/` | UI-only ephemeral state (open panels, selected date, active tab); server state always in TanStack Query |
|
||||
| Abstraction | File | Description |
|
||||
| ------------------------------------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `app` (Hono) | `apps/api/src/index.ts` | Root Hono app; mounts all routes and serves the PWA static build |
|
||||
| Drizzle schema | `apps/api/src/db/schema.ts` | Single source of truth for all table definitions (`users`, `memberCredentials`, `localCredentials`, `calendars`, `calendarEvents`, `calendarOutbox`, `lists`, `listShares`, `listItems`, `pushSubscriptions`, `appConfig`) |
|
||||
| `syncCalendar` | `apps/api/src/broker/sync.ts` | REPORT → ical.js parse → `onDuplicateKeyUpdate` upsert into MariaDB |
|
||||
| `expandOccurrences` | `apps/api/src/broker/expand.ts` | Server-side RRULE expansion using `ical.js` + `rrule`; never runs in the browser |
|
||||
| `CalendarOccurrence` | `apps/api/src/broker/expand.ts` | Wire type for a single concrete event occurrence; mirrored in the PWA's `api/client.ts` |
|
||||
| `calendarOutbox` table | `apps/api/src/db/schema.ts` | Transactional outbox pattern — CalDAV writes are enqueued here and drained asynchronously |
|
||||
| `runOutboxDrain` | `apps/api/src/broker/outboxWorker.ts` | Drains pending outbox rows every 15s; handles retry backoff, 412 conflict, dead-lettering, and edit-as-move ordering |
|
||||
| `publishListEvent` / `subscribeListEvents` | `apps/api/src/lib/listEmitter.ts` | In-process EventEmitter fan-out keyed per list; SSE route subscribes on open and unsubscribes on disconnect |
|
||||
| `dispatchPush` | `apps/api/src/lib/pushDispatcher.ts` | Centralised VAPID-signed push sender; handles 410/404 subscription pruning |
|
||||
| `issueLocalSessionCookie` / `verifyLocalSessionCookie` | `apps/api/src/auth/localSession.ts` | Issues and verifies the `local-session` JWT cookie used by local username/password auth |
|
||||
| `localAuthMiddleware` | `apps/api/src/auth/localAuthMiddleware.ts` | Reads `local-session` cookie → populates `c.get('user')`; no-op passthrough when cookie absent (OIDC guard fires for unauthenticated requests) |
|
||||
| `linkOidcToUser` / `OidcLinkConflictError` | `apps/api/src/auth/linkOidc.ts` | Binds an OIDC iss+sub to an existing local user; throws `OidcLinkConflictError` on identity collision |
|
||||
| `localCredentials` table | `apps/api/src/db/schema.ts` | Per-member local login credentials (scrypt PHC hash); a row exists iff the member can log in with username/password |
|
||||
| `appConfig` table | `apps/api/src/db/schema.ts` | Key/value store for setup wizard output (OIDC config, VAPID public key, setup_complete flag) |
|
||||
| `isSetupLocked` | `apps/api/src/lib/setupGuard.ts` | Returns true when the first-run wizard is complete; setup mutation routes call this as their first guard |
|
||||
| `SessionExpiredError` | `apps/pwa/src/api/client.ts` | Typed error thrown by all fetch wrappers on 401/opaqueredirect; global `QueryCache` handler arms the session-expiry interstitial |
|
||||
| Zustand stores | `apps/pwa/src/store/` | UI-only ephemeral state (open panels, selected date, active tab); server state always in TanStack Query |
|
||||
|
||||
---
|
||||
|
||||
@@ -156,11 +162,23 @@ familysync/
|
||||
|
||||
### Authentication
|
||||
|
||||
The app supports two auth modes, selectable per-deployment and per-user. `GET /api/auth/mode` (pre-auth) tells the PWA which modes are active.
|
||||
|
||||
**Local auth path (Phase 19):**
|
||||
|
||||
1. The PWA fetches `GET /api/auth/mode`; when `localEnabled === true` it renders `/login` (`LoginPage`).
|
||||
2. The user submits credentials; the PWA calls `POST /api/auth/local/login`.
|
||||
3. The route verifies the scrypt hash from `local_credentials`, then calls `issueLocalSessionCookie` — a signed HS256 JWT issued as a `local-session` HttpOnly cookie.
|
||||
4. On subsequent requests, `localAuthMiddleware` reads the cookie, verifies the JWT, fetches the users row, and populates `c.get('user')`. The OIDC guard is skipped when `c.get('user')` is already set.
|
||||
5. A local user can optionally link an OIDC identity via `POST /api/me/link-oidc`; on completion `linkOidcToUser` binds `oidc_iss`/`oidc_sub` to the users row and deletes the `local_credentials` row, converting the account to OIDC-only.
|
||||
|
||||
**OIDC path (Authelia):**
|
||||
|
||||
1. An unauthenticated browser navigates to `/api/login`.
|
||||
2. The `oidcAuthMiddleware` (`@hono/oidc-auth`) issues a `302` to Authelia's `/authorize` endpoint with PKCE (S256).
|
||||
3. After login, Authelia posts the authorization code to `/callback`; `processOAuthCallback` exchanges it for tokens and issues a signed JWT session cookie.
|
||||
4. `persistSessionCookie` middleware re-issues the cookie as persistent on every authenticated response so the PWA session survives browser close.
|
||||
5. All `/api/*` routes require the session cookie; a missing or expired session returns a `302` which the PWA's fetch wrappers detect as `opaqueredirect` and convert to a `SessionExpiredError`.
|
||||
5. All `/api/*` routes require a session (local or OIDC); a missing or expired session returns a `302` which the PWA's fetch wrappers detect as `opaqueredirect` and convert to a `SessionExpiredError`.
|
||||
|
||||
---
|
||||
|
||||
@@ -185,6 +203,16 @@ routes/lists.ts ──→ db (MariaDB)
|
||||
|
||||
routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents)
|
||||
──→ lib/listAccess.ts
|
||||
|
||||
routes/localAuth.ts ──→ auth/localCredentials.ts (scrypt verify)
|
||||
──→ auth/localSession.ts (issue cookie)
|
||||
|
||||
routes/admin.ts ──→ db (MariaDB: users, local_credentials, calendars)
|
||||
──→ broker/credentialSync.ts (validate+encrypt+store)
|
||||
──→ lib/requireAdmin.ts (role guard)
|
||||
|
||||
routes/setup.ts ──→ db (app_config)
|
||||
──→ lib/setupGuard.ts (isSetupLocked)
|
||||
```
|
||||
|
||||
### Frontend data ownership
|
||||
@@ -196,6 +224,8 @@ routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents)
|
||||
| Current user | TanStack Query `['me']` |
|
||||
| Writable calendars | TanStack Query `['writableCalendars']` |
|
||||
| Outbox sync status | TanStack Query `['syncStatus', uid]` |
|
||||
| Auth mode (local/OIDC flags) | TanStack Query `['authMode']` |
|
||||
| Setup completion status | TanStack Query `['setupStatus']` |
|
||||
| Selected calendar view + date | Zustand `calendarStore` |
|
||||
| Event form open/mode | Zustand `calendarStore` |
|
||||
| Active tab, create-list sheet | Zustand `listsStore` |
|
||||
@@ -210,11 +240,12 @@ routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents)
|
||||
| HTTP framework | Hono 4.x (`@hono/node-server`) |
|
||||
| Database | MariaDB 11 (Docker volume) |
|
||||
| ORM | Drizzle ORM 0.45.x (`mysql2` dialect) |
|
||||
| Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE |
|
||||
| Session middleware | `@hono/oidc-auth` — storage-less signed JWT cookies |
|
||||
| Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE; optional when local auth is enabled |
|
||||
| Session middleware | `@hono/oidc-auth` (OIDC session — storage-less signed JWT cookies) + custom `localSession.ts` (local-auth HS256 JWT cookie) |
|
||||
| Calendar source | Fastmail CalDAV (`caldav.fastmail.com`) — read via `tsdav`, write via transactional outbox |
|
||||
| Calendar parsing | `ical.js` (VCALENDAR/VEVENT parse) + `rrule` (RRULE expansion) |
|
||||
| App password storage | AES-256-GCM encrypted in `member_credentials.encrypted_password` |
|
||||
| Local auth storage | scrypt PHC hash in `local_credentials.password_hash`; session signed with `LOCAL_SESSION_SECRET` env var |
|
||||
| Push notifications | `web-push` (VAPID) → APNs (iOS) / FCM (Android) |
|
||||
| Live list sync | In-process Node.js `EventEmitter` → SSE (`text/event-stream`) |
|
||||
| Redis | Present in stack (image: `redis:7-alpine`); not used in current runtime (reserved for future multi-process pub/sub) |
|
||||
|
||||
+47
-12
@@ -18,8 +18,9 @@ All runtime configuration is supplied via environment variables. There are no JS
|
||||
| `DB_PASSWORD` | **Required** | _(none)_ | Database password. Also used by the `mariadb` service as `MARIADB_PASSWORD`. |
|
||||
| `DB_NAME` | No | `familysync` | Database name. |
|
||||
| `DB_ROOT_PASSWORD` | **Required** | _(none)_ | MariaDB root password. Used only by the `mariadb` Docker service (`MARIADB_ROOT_PASSWORD`). Not read by the API process. |
|
||||
| `DB_ROOT_USER` | No | `root` | MariaDB root username. Read only by `apps/api/test/global-setup.ts` during local test provisioning. Never used by the API or Docker Compose in production. |
|
||||
|
||||
Five of these variables — `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_PORT` — are read by `drizzle.config.ts` when running migrations (`db:generate` / `db:migrate`) and by the API process to build its connection pool. `DB_ROOT_PASSWORD` is **not** read by either; it is consumed only by the `mariadb` Docker service.
|
||||
Five of these variables — `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_PORT` — are read by `drizzle.config.ts` when running migrations (`db:generate` / `db:migrate`) and by the API process to build its connection pool. `DB_ROOT_PASSWORD` is **not** read by either; it is consumed only by the `mariadb` Docker service. `DB_ROOT_USER` is only used by the local Vitest global setup to create and grant the `familysync_test` database.
|
||||
|
||||
**Important:** Do not use `drizzle-kit push` against this MariaDB. The `mysql` dialect mis-reads MariaDB 11.x metadata and schedules false destructive operations. Always use `db:generate` + `db:migrate`.
|
||||
|
||||
@@ -50,6 +51,19 @@ Five of these variables — `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_
|
||||
|
||||
---
|
||||
|
||||
### Local Authentication (No-OIDC Mode)
|
||||
|
||||
These variables govern the stateless local-auth path introduced in Phase 19. Local auth issues a separate `local-session` JWT cookie (distinct from `oidc-auth`) signed with `LOCAL_SESSION_SECRET`.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
| ----------------------- | ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `LOCAL_SESSION_SECRET` | **Required** (non-bypass) | _(none)_ | 32+ character secret used to sign and verify `local-session` JWT cookies (HS256). Generate with `openssl rand -base64 32`. The API refuses to start with a fatal error if this is absent or shorter than 32 characters, unless `DEV_AUTH_BYPASS=true`. |
|
||||
| `LOCAL_SESSION_EXPIRES` | No | `86400` | `local-session` cookie `Max-Age` in seconds (default 1 day). Mirrors `OIDC_AUTH_EXPIRES` but applies to the local-auth cookie. Malformed (non-numeric) values silently fall back to the default. Source: `apps/api/src/auth/localSession.ts`. |
|
||||
|
||||
**Security note:** `LOCAL_SESSION_SECRET` must be a distinct value from `OIDC_AUTH_SECRET`. Both are JWT signing keys, but they govern different cookies and must not be shared.
|
||||
|
||||
---
|
||||
|
||||
### Broker Encryption
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
@@ -80,6 +94,19 @@ npx web-push generate-vapid-keys --json
|
||||
| ----------------- | -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NODE_ENV` | No | _(not set)_ | Set to `production` in the production Docker Compose. When `production`, the dev-auth bypass is unconditionally disabled regardless of `DEV_AUTH_BYPASS`. |
|
||||
| `DEV_AUTH_BYPASS` | No | _(not set)_ | Set to `true` to bypass OIDC authentication for local development without a live Authelia instance. **Only active when `NODE_ENV !== 'production'`.** The production `docker-compose.yml` must never include this variable. |
|
||||
| `TZ` | No | _(not set)_ | IANA timezone identifier (e.g. `America/Toronto`) used as the server-side fallback for the household timezone when no value is stored in `app_config`. The full fallback chain is: stored DB value → `TZ` env → `Intl.DateTimeFormat().resolvedOptions().timeZone`. Empty or whitespace values are ignored. Source: `apps/api/src/lib/householdTimezone.ts`. |
|
||||
|
||||
---
|
||||
|
||||
### Developer / Test-Only Variables
|
||||
|
||||
These variables are never needed in production and should not appear in the production `.env`.
|
||||
|
||||
| Variable | Scope | Default | Description |
|
||||
| ----------------------- | ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `FASTMAIL_EMAIL` | Dev spike script only | _(none)_ | Fastmail account email. Read only by `apps/api/src/broker/spike.ts`, a standalone dev script for enumerating CalDAV collections. Not imported by the API or Docker image. |
|
||||
| `FASTMAIL_APP_PASSWORD` | Dev spike script only | _(none)_ | Fastmail app password. Read only by `apps/api/src/broker/spike.ts`. **Never logged.** Not used by the API in any environment. |
|
||||
| `PLAYWRIGHT_BASE_URL` | E2E tests only | `http://localhost:5173` | Base URL for Playwright e2e tests. Overridden to `http://127.0.0.1:5173` in CI to avoid IPv6 resolution failures. Source: `apps/pwa/playwright.config.ts`. |
|
||||
|
||||
---
|
||||
|
||||
@@ -87,17 +114,18 @@ npx web-push generate-vapid-keys --json
|
||||
|
||||
Variables with non-empty defaults do not cause startup failure if absent, but should be reviewed for production:
|
||||
|
||||
| Variable | Default | Source |
|
||||
| ------------------- | ------------------------------------- | ------------------------------------------- |
|
||||
| `DB_HOST` | `localhost` | `apps/api/src/db/client.ts` |
|
||||
| `DB_PORT` | `3306` | `apps/api/src/db/client.ts` |
|
||||
| `DB_USER` | `familysync` | `apps/api/src/db/client.ts` |
|
||||
| `DB_NAME` | `familysync` | `apps/api/src/db/client.ts` |
|
||||
| `OIDC_CLIENT_ID` | `familysync` | `docker-compose.yml` |
|
||||
| `OIDC_SCOPES` | `openid profile email offline_access` | `docker-compose.yml` |
|
||||
| `OIDC_AUTH_EXPIRES` | `86400` | `apps/api/src/auth/persistSessionCookie.ts` |
|
||||
| `OIDC_COOKIE_NAME` | `oidc-auth` | `apps/api/src/auth/persistSessionCookie.ts` |
|
||||
| `OIDC_COOKIE_PATH` | `/` | `apps/api/src/auth/persistSessionCookie.ts` |
|
||||
| Variable | Default | Source |
|
||||
| ----------------------- | ------------------------------------- | ------------------------------------------- |
|
||||
| `DB_HOST` | `localhost` | `apps/api/src/db/client.ts` |
|
||||
| `DB_PORT` | `3306` | `apps/api/src/db/client.ts` |
|
||||
| `DB_USER` | `familysync` | `apps/api/src/db/client.ts` |
|
||||
| `DB_NAME` | `familysync` | `apps/api/src/db/client.ts` |
|
||||
| `OIDC_CLIENT_ID` | `familysync` | `docker-compose.yml` |
|
||||
| `OIDC_SCOPES` | `openid profile email offline_access` | `docker-compose.yml` |
|
||||
| `OIDC_AUTH_EXPIRES` | `86400` | `apps/api/src/auth/persistSessionCookie.ts` |
|
||||
| `OIDC_COOKIE_NAME` | `oidc-auth` | `apps/api/src/auth/persistSessionCookie.ts` |
|
||||
| `OIDC_COOKIE_PATH` | `/` | `apps/api/src/auth/persistSessionCookie.ts` |
|
||||
| `LOCAL_SESSION_EXPIRES` | `86400` | `apps/api/src/auth/localSession.ts` |
|
||||
|
||||
---
|
||||
|
||||
@@ -121,6 +149,9 @@ OIDC_CLIENT_SECRET=<plaintext secret>
|
||||
OIDC_REDIRECT_URI=https://familysync.DOMAIN/callback
|
||||
OIDC_AUTH_EXTERNAL_URL=https://familysync.DOMAIN
|
||||
|
||||
# Local auth (Phase 19)
|
||||
LOCAL_SESSION_SECRET=<openssl rand -base64 32>
|
||||
|
||||
# Broker encryption
|
||||
APP_PASSWORD_ENCRYPTION_KEY=<64-hex-chars>
|
||||
|
||||
@@ -151,6 +182,8 @@ The `DB_HOST` override is necessary because `.env` sets `DB_HOST=mariadb` (the D
|
||||
|
||||
**`DEV_AUTH_BYPASS=true` is a local-only option.** The API hard-checks `NODE_ENV === 'production'` before reading `DEV_AUTH_BYPASS` — the bypass has zero effect in a production container even if the variable is present.
|
||||
|
||||
The dev Docker Compose (`docker-compose.dev.yml`) sets `LOCAL_SESSION_SECRET` to a fixed dev placeholder value (`dev-secret-change-me-0000000000000000`). This value is intentionally weak and public — it is never used in production.
|
||||
|
||||
### Test
|
||||
|
||||
Integration tests targeting the real database require the dev MariaDB running with the host port exposed and the following overrides:
|
||||
@@ -159,6 +192,8 @@ Integration tests targeting the real database require the dev MariaDB running wi
|
||||
DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=familysync DB_NAME=familysync DB_PASSWORD=<value>
|
||||
```
|
||||
|
||||
The Vitest global setup (`apps/api/test/global-setup.ts`) also reads `DB_ROOT_USER` (default `root`) and `DB_ROOT_PASSWORD` (default `root`) to create and grant the `familysync_test` database on first run. These are local dev credentials only; CI uses hardcoded throwaway values (`familysync` / `testpass`) in ephemeral service containers.
|
||||
|
||||
See `docs/deployment.md` for the full `drizzle-kit migrate` command used to prepare the test database.
|
||||
|
||||
---
|
||||
|
||||
+26
-20
@@ -100,17 +100,19 @@ Vite serves the PWA with HMR on the configured dev port. The PWA's API calls tar
|
||||
|
||||
### Root workspace scripts
|
||||
|
||||
| Command | Description |
|
||||
| ------------------- | ------------------------------------------------------------- |
|
||||
| `pnpm dev:api` | Start API dev watcher (`node --watch dist/index.js`) |
|
||||
| `pnpm dev:pwa` | Start Vite dev server for the PWA |
|
||||
| `pnpm build` | Build both `apps/api` (tsc) and `apps/pwa` (tsc + vite build) |
|
||||
| `pnpm test` | Run API test suite (`vitest run` in `apps/api`) |
|
||||
| `pnpm test:e2e` | Run Playwright e2e harness (`apps/pwa`) |
|
||||
| `pnpm lint` | ESLint across all workspaces (`pnpm -r --if-present lint`) |
|
||||
| `pnpm format` | Reformat all files with Prettier (`prettier --write .`) |
|
||||
| `pnpm format:check` | Check formatting without writing (`prettier --check .`) |
|
||||
| `pnpm typecheck` | `tsc --noEmit` in all workspaces |
|
||||
| Command | Description |
|
||||
| ------------------------ | ------------------------------------------------------------- |
|
||||
| `pnpm dev:api` | Start API dev watcher (`node --watch dist/index.js`) |
|
||||
| `pnpm dev:pwa` | Start Vite dev server for the PWA |
|
||||
| `pnpm build` | Build both `apps/api` (tsc) and `apps/pwa` (tsc + vite build) |
|
||||
| `pnpm test` | Run API test suite (`vitest run` in `apps/api`) |
|
||||
| `pnpm test:e2e` | Run Playwright e2e harness (`apps/pwa`) |
|
||||
| `pnpm lint` | ESLint across all workspaces (`pnpm -r --if-present lint`) |
|
||||
| `pnpm format` | Reformat all files with Prettier (`prettier --write .`) |
|
||||
| `pnpm format:check` | Check formatting without writing (`prettier --check .`) |
|
||||
| `pnpm typecheck` | `tsc --noEmit` in all workspaces |
|
||||
| `pnpm md:lint` | Markdown lint (`markdownlint-cli2`) across the repo |
|
||||
| `pnpm generate-secrets` | Generate VAPID and session secret values via `scripts/generate-secrets.mjs` |
|
||||
|
||||
### `apps/api` scripts
|
||||
|
||||
@@ -148,14 +150,16 @@ CI gates every PR to `main` on these checks. Run them locally before pushing to
|
||||
pnpm lint # ESLint --max-warnings 0 across apps/api (src/ + tests/) and apps/pwa (src/ + e2e/)
|
||||
pnpm format:check # Prettier formatting check (use `pnpm format` to auto-fix)
|
||||
pnpm typecheck # tsc --noEmit in both apps (includes apps/pwa tsconfig.e2e.json)
|
||||
pnpm md:lint # Markdown lint (also runs in CI fast-checks)
|
||||
```
|
||||
|
||||
### ESLint
|
||||
|
||||
Config: `eslint.config.js` (root, flat ESLint 9 format). The config covers:
|
||||
|
||||
- **All `apps/**/\*.{ts,tsx}`** — `js.configs.recommended`+`tseslint.configs.recommendedTypeChecked`with`projectService: true`(type-aware rules, auto-discovers all`tsconfig.json` files)
|
||||
- **`apps/pwa/**/\*.{ts,tsx}`additionally** —`eslint-plugin-react`+`eslint-plugin-react-hooks` (React 19 flat config; React Compiler rules disabled — this codebase does not use the Compiler)
|
||||
- **All `apps/**/*.{ts,tsx}`** — `js.configs.recommended` + `tseslint.configs.recommendedTypeChecked` with `projectService: true` (type-aware rules, auto-discovers all `tsconfig.json` files)
|
||||
- **`apps/pwa/**/*.{ts,tsx}` additionally** — `eslint-plugin-react` + `eslint-plugin-react-hooks` (React 19 flat config; React Compiler rules disabled — this codebase does not use the Compiler)
|
||||
- **All `apps/**/*.{ts,tsx}`** — `eslint-plugin-security` (14 of 15 rules at error; `detect-object-injection` disabled due to high false-positive rate on schema-derived numeric keys)
|
||||
- **Tool configs + test dirs** (`drizzle.config.ts`, `vitest.config.ts`, `apps/api/tests/**`, `apps/pwa/e2e/**`) — type-aware rules disabled via `disableTypeChecked` (these files are outside the main tsconfig projects)
|
||||
- **Prettier integration** — `eslint-config-prettier` last in the config disables all formatting rules that conflict with Prettier
|
||||
|
||||
@@ -189,15 +193,17 @@ Run `pnpm typecheck` before opening a PR to catch errors that vitest and Vite bu
|
||||
|
||||
## CI Pipeline Overview
|
||||
|
||||
Every PR to `main` runs three parallel jobs (`.gitea/workflows/ci.yml`):
|
||||
Every PR to `main` runs through `.gitea/workflows/ci.yml`. A `changes` path-filter job determines whether code files changed; the `api` and `harness` jobs are skipped entirely for doc-only PRs (changes only to `.planning/**`, `.gitea/**`, or `*.md` files).
|
||||
|
||||
| Job | Checks |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `fast-checks` | `pnpm lint` → `pnpm format:check` → `pnpm typecheck` → `pnpm --filter @familysync/pwa test` |
|
||||
| `api` | DB migrations + `pnpm --filter @familysync/api test` (vitest against a MariaDB 11 service container) |
|
||||
| `harness` | DB migrations + API build + Playwright e2e (WebKit + Chromium) with `DEV_AUTH_BYPASS=true` |
|
||||
| Job | Runs on | Checks |
|
||||
| ------------- | ------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `fast-checks` | Every PR | `pnpm lint` → `pnpm format:check` → `pnpm md:lint` → `pnpm typecheck` → `pnpm --filter @familysync/pwa test` |
|
||||
| `api` | Code-change PRs only | DB migrations + `pnpm --filter @familysync/api test` (vitest against a MariaDB 11 service container) |
|
||||
| `harness` | Code-change PRs only | DB migrations + seed dev user + API build + Playwright e2e (WebKit + Chromium) with `DEV_AUTH_BYPASS=true` |
|
||||
| `security` | Every PR | Gitleaks secret scan (PR diff); `pnpm audit` (High+Critical blocking) + outdated report on code-change PRs |
|
||||
| `gate` | Always | Final aggregator — requires `fast-checks` and `security` to succeed; `api` and `harness` may be skipped |
|
||||
|
||||
All three jobs must pass before a PR can merge. See [docs/TESTING.md](TESTING.md) for test suite details.
|
||||
All five jobs must pass (or be legitimately skipped) before a PR can merge. See [docs/TESTING.md](TESTING.md) for test suite details.
|
||||
|
||||
## Drizzle Migration Workflow
|
||||
|
||||
|
||||
+16
-2
@@ -48,15 +48,23 @@ cp .env.example .env
|
||||
Open `.env` and fill in the required values. See [docs/CONFIGURATION.md](CONFIGURATION.md) for the full variable reference. At minimum for local development you need:
|
||||
|
||||
- `DB_PASSWORD` and `DB_ROOT_PASSWORD` — pick any local passwords
|
||||
- `APP_PASSWORD_ENCRYPTION_KEY` — 64 hex characters; generate with:
|
||||
- `APP_PASSWORD_ENCRYPTION_KEY`, `SESSION_SECRET`, `LOCAL_SESSION_SECRET`, and VAPID keys — generate all at once with:
|
||||
|
||||
```bash
|
||||
pnpm generate-secrets
|
||||
```
|
||||
|
||||
Paste the output into your `.env`. Alternatively, generate `APP_PASSWORD_ENCRYPTION_KEY` alone with:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
- `DEV_AUTH_BYPASS=true` — bypasses the live Authelia OIDC flow for local dev
|
||||
- `DEV_AUTH_BYPASS=true` — bypasses the live Authelia OIDC flow for local dev. When this is set, `LOCAL_SESSION_SECRET` is not required at startup (bypass mode skips the local-auth JWT path entirely).
|
||||
- `DB_HOST=localhost` — the dev Docker Compose exposes MariaDB on the host at `localhost:3306`
|
||||
|
||||
> **Note:** If you run without `DEV_AUTH_BYPASS=true` (local-auth mode), `LOCAL_SESSION_SECRET` must be set to a value of at least 32 characters. The API will refuse to start otherwise. `pnpm generate-secrets` always produces a valid value.
|
||||
|
||||
---
|
||||
|
||||
## First Run
|
||||
@@ -124,6 +132,11 @@ Or set `DB_HOST=localhost` directly in your `.env` for host-side dev.
|
||||
**API starts but all requests return 401 / redirect to Authelia**
|
||||
`DEV_AUTH_BYPASS` is not set or is not being exported to the process. Make sure you source `.env` with `set -a; source .env; set +a` or prefix the command with `DEV_AUTH_BYPASS=true`. The bypass only works when `NODE_ENV` is not `production`.
|
||||
|
||||
**`[FATAL] LOCAL_SESSION_SECRET is not set or is shorter than 32 characters`**
|
||||
The API refuses to start in non-bypass mode without a valid `LOCAL_SESSION_SECRET`. Either:
|
||||
- Set `DEV_AUTH_BYPASS=true` in `.env` for local dev (bypass mode exempts the requirement), or
|
||||
- Run `pnpm generate-secrets` and add the generated `LOCAL_SESSION_SECRET` value to `.env`.
|
||||
|
||||
**PWA shows a blank screen after first load**
|
||||
Run the API build step first (`pnpm --filter @familysync/api build`). The dev script runs `dist/index.js`; if `dist/` is missing or stale, the API process exits immediately.
|
||||
|
||||
@@ -136,4 +149,5 @@ Another local MySQL/MariaDB service is running. Stop it before starting Docker C
|
||||
|
||||
- [docs/ARCHITECTURE.md](ARCHITECTURE.md) — System design, component diagram, data flow
|
||||
- [docs/CONFIGURATION.md](CONFIGURATION.md) — All environment variables, defaults, and per-environment guidance
|
||||
- [docs/DEVELOPMENT.md](DEVELOPMENT.md) — Build commands, code style, and contribution workflow
|
||||
- [docs/deployment.md](deployment.md) — Production deployment on Unraid via Docker Compose
|
||||
|
||||
+64
-33
@@ -6,12 +6,14 @@
|
||||
|
||||
Both apps use **Vitest** (`^4.1.8`).
|
||||
|
||||
| App | Environment | Setup file |
|
||||
| ---------- | ----------- | ---------------------------- |
|
||||
| `apps/api` | `node` | `apps/api/test/setup.ts` |
|
||||
| `apps/pwa` | `jsdom` | `apps/pwa/src/test-setup.ts` |
|
||||
| App | Environment | Global setup | Per-file setup |
|
||||
| ---------- | ----------- | ----------------------------------- | ---------------------------- |
|
||||
| `apps/api` | `node` | `apps/api/test/global-setup.ts` | `apps/api/test/setup.ts` |
|
||||
| `apps/pwa` | `jsdom` | — | `apps/pwa/src/test-setup.ts` |
|
||||
|
||||
**apps/api setup** (`test/setup.ts`) registers a global `afterEach` that truncates `list_items`, `list_shares`, `push_subscriptions`, and `lists` in FK-safe order after every test. This keeps DB-backed integration tests isolated without requiring a full DB reset between runs. Parallel file execution is disabled (`fileParallelism: false`) to prevent FK violations when multiple test files share the same MariaDB.
|
||||
**apps/api global setup** (`test/global-setup.ts`) runs once before any test file. Locally it provisions an isolated `familysync_test` database (root connection → `CREATE DATABASE IF NOT EXISTS familysync_test` → GRANT → `drizzle migrate`) and then truncates every table to give each run a clean slate. Under CI (`process.env.CI` truthy) it returns immediately — the CI `api` job provisions its own `familysync` service container via `db:migrate`.
|
||||
|
||||
**apps/api per-file setup** (`test/setup.ts`) registers a global `afterEach` that truncates `list_items`, `list_shares`, `push_subscriptions`, `lists`, and `local_credentials` in FK-safe order after every test. This keeps DB-backed integration tests isolated without requiring a full DB reset between runs. The `users` table is intentionally left intact across tests within a single run — many tests seed user id=1 once and reuse it. Parallel file execution is disabled (`fileParallelism: false`) to prevent FK violations when multiple test files share the same MariaDB.
|
||||
|
||||
**apps/pwa setup** (`src/test-setup.ts`) imports `@testing-library/jest-dom` for extended matchers and polyfills `window.matchMedia` for jsdom (required because Zustand's `calendarStore` calls `window.matchMedia` at module initialisation time). The timezone is pinned to `UTC` via `env: { TZ: 'UTC' }` so date-extraction assertions are deterministic across developer machines and CI.
|
||||
|
||||
@@ -49,16 +51,17 @@ pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts
|
||||
|
||||
### End-to-end tests (Playwright)
|
||||
|
||||
The PWA has a Playwright harness configured in `apps/pwa/playwright.config.ts` with two device profiles:
|
||||
The PWA has a Playwright harness configured in `apps/pwa/playwright.config.ts` with three device profiles:
|
||||
|
||||
| Profile | Viewport | Engine | User-Agent |
|
||||
| -------- | -------- | -------- | ------------------------- |
|
||||
| `iphone` | 390×844 | WebKit | Mobile Safari (iPhone 14) |
|
||||
| `pixel` | 412×915 | Chromium | Chrome Android (Pixel 7) |
|
||||
| Profile | Viewport | Engine | User-Agent |
|
||||
| --------- | --------- | -------- | ------------------------- |
|
||||
| `iphone` | 390×844 | WebKit | Mobile Safari (iPhone 14) |
|
||||
| `pixel` | 412×915 | Chromium | Chrome Android (Pixel 7) |
|
||||
| `desktop` | 1280×720 | Chromium | Desktop Chrome |
|
||||
|
||||
Both profiles block the service worker (`serviceWorkers: 'block'`) so the Workbox SW does not intercept requests during tests. Auth is handled via `DEV_AUTH_BYPASS=true` on the API — never via stored browser state.
|
||||
All profiles block the service worker (`serviceWorkers: 'block'`) so the Workbox SW does not intercept requests during tests. Auth is handled via `DEV_AUTH_BYPASS=true` on the API — never via stored browser state.
|
||||
|
||||
**Run all e2e tests (both profiles):**
|
||||
**Run all e2e tests (all profiles):**
|
||||
|
||||
```bash
|
||||
pnpm test:e2e
|
||||
@@ -71,6 +74,7 @@ pnpm --filter @familysync/pwa test:e2e
|
||||
```bash
|
||||
pnpm --filter @familysync/pwa exec playwright test --project=pixel
|
||||
pnpm --filter @familysync/pwa exec playwright test --project=iphone
|
||||
pnpm --filter @familysync/pwa exec playwright test --project=desktop
|
||||
```
|
||||
|
||||
**Interactive UI mode:**
|
||||
@@ -79,6 +83,12 @@ pnpm --filter @familysync/pwa exec playwright test --project=iphone
|
||||
pnpm --filter @familysync/pwa test:e2e:ui
|
||||
```
|
||||
|
||||
**Headed mode (for local debugging):**
|
||||
|
||||
```bash
|
||||
pnpm --filter @familysync/pwa test:e2e:headed
|
||||
```
|
||||
|
||||
The `baseURL` is driven by `PLAYWRIGHT_BASE_URL` (default: `http://localhost:5173`). In local mode the config reuses a running Vite dev server; in CI it starts Vite itself. The API, MariaDB, and Redis must already be running via Docker Compose before launching e2e tests locally — see `docs/DEVELOPMENT.md`.
|
||||
|
||||
### Type checking (separate from tests — required)
|
||||
@@ -111,10 +121,11 @@ pnpm test:e2e
|
||||
| ---------------- | ------------------------------------ | -------------------------------------------------------- |
|
||||
| Lint | `pnpm lint` | ESLint `--max-warnings 0` across both apps (type-aware) |
|
||||
| Format check | `pnpm format:check` | Prettier — fails on any unformatted file |
|
||||
| Markdown lint | `pnpm md:lint` | markdownlint-cli2 across all `.md` files |
|
||||
| Typecheck | `pnpm typecheck` | `tsc --noEmit` across both apps (including e2e tsconfig) |
|
||||
| Unit / API tests | `pnpm test` | API integration tests via Vitest |
|
||||
| PWA unit tests | `pnpm --filter @familysync/pwa test` | Component and logic tests in jsdom |
|
||||
| E2E | `pnpm test:e2e` | Playwright iphone + pixel profiles |
|
||||
| E2E | `pnpm test:e2e` | Playwright iphone + pixel + desktop profiles |
|
||||
|
||||
A deliberate ESLint violation makes `pnpm lint` exit non-zero; a formatting deviation makes `pnpm format:check` exit non-zero. Both block the PR in CI.
|
||||
|
||||
@@ -126,7 +137,7 @@ pnpm format # prettier --write .
|
||||
|
||||
## Integration tests requiring a real database
|
||||
|
||||
Several API tests in `apps/api/tests/lib/` and `apps/api/tests/routes/` connect to the real dev MariaDB rather than mocking the DB layer. These tests require the dev Docker stack to be running with port 3306 exposed.
|
||||
Several API tests in `apps/api/tests/lib/` and `apps/api/tests/routes/` connect to a real MariaDB instance. Locally, the Vitest global setup (`test/global-setup.ts`) auto-provisions and migrates the `familysync_test` database — there is no need to manually set `DB_NAME`. The dev `familysync` database is never touched by the test suite.
|
||||
|
||||
**Start the dev stack:**
|
||||
|
||||
@@ -142,6 +153,8 @@ export DB_HOST=127.0.0.1 DB_PORT=3306
|
||||
pnpm --filter @familysync/api test
|
||||
```
|
||||
|
||||
The global setup requires root access to create and grant the test database. By default it reads `DB_ROOT_PASSWORD` from the environment (defaults to `root` to match the dev Docker Compose). The app user (`DB_USER`) is validated against `/^[A-Za-z0-9_]+$/` before the GRANT statement is interpolated.
|
||||
|
||||
DB-backed tests that require this setup include:
|
||||
|
||||
- `apps/api/tests/lib/listAccess.test.ts` — `getAccessibleListIds` access-scope queries
|
||||
@@ -162,17 +175,17 @@ Pure-logic tests (e.g. `apps/api/tests/broker/expand.test.ts`, `apps/api/tests/l
|
||||
|
||||
Test categories for `apps/api`:
|
||||
|
||||
- `apps/api/tests/auth/` — authentication middleware and session handling
|
||||
- `apps/api/tests/broker/` — CalDAV sync, outbox worker, event expansion, push dispatch
|
||||
- `apps/api/tests/lib/` — pure library functions and service logic
|
||||
- `apps/api/tests/routes/` — HTTP route integration tests
|
||||
- `apps/api/tests/auth/` — authentication middleware, session handling, local auth, admin guards, and bypass behaviour
|
||||
- `apps/api/tests/broker/` — CalDAV sync, outbox worker, event expansion, push dispatch, crypto utilities, and VEVENT parsing
|
||||
- `apps/api/tests/lib/` — pure library functions and service logic (list access, rank, push coalescer/dispatcher, SSE emitter, timezone, boot guards)
|
||||
- `apps/api/tests/routes/` — HTTP route integration tests (events, lists, push, admin, login, me, local auth, setup)
|
||||
- `apps/api/tests/health.test.ts` — health check endpoint
|
||||
- `apps/api/tests/fixtures/` — shared `.ics` fixture files and DB fixture helpers
|
||||
|
||||
### Test helpers
|
||||
|
||||
- `apps/api/tests/helpers/db.ts` — `createMockDb()` returns a Vitest mock of the Drizzle `db` singleton; also exports sample VEVENT strings (`SAMPLE_VEVENT_TIMED`, `SAMPLE_VEVENT_ALLDAY`, `SAMPLE_VEVENT_RECURRING_TIMED`, `SAMPLE_VEVENT_RECURRING_ALLDAY`) for broker tests.
|
||||
- `apps/api/tests/fixtures/*.ics` — Raw iCalendar fixture files for broker parsing tests (`allday-birthday.ics`, `exdate-series.ics`, `single-duration.ics`, `weekly-count3.ics`, `weekly-dst.ics`).
|
||||
- `apps/api/tests/fixtures/*.ics` — Raw iCalendar fixture files for broker parsing tests (`absolute-alarm.ics`, `allday-birthday.ics`, `exdate-series.ics`, `multi-alarm.ics`, `single-duration.ics`, `weekly-count3.ics`, `weekly-dst.ics`).
|
||||
- `apps/api/tests/fixtures/vapid.ts` — VAPID key fixture for push tests.
|
||||
- `apps/pwa/src/test-setup.ts` — Provides `matchMedia` polyfill and jest-dom matchers for all PWA tests automatically via `setupFiles`.
|
||||
|
||||
@@ -184,22 +197,25 @@ No coverage thresholds are configured in either `vitest.config.ts`. There is no
|
||||
|
||||
## CI integration
|
||||
|
||||
CI runs on a self-hosted Gitea Actions runner and triggers on every pull request targeting `main` (`.gitea/workflows/ci.yml`). Three jobs run in parallel:
|
||||
CI runs on a self-hosted Gitea Actions runner and triggers on every pull request targeting `main` (`.gitea/workflows/ci.yml`). A `changes` job using `dorny/paths-filter@v4` determines whether the PR touches code (as opposed to docs or planning files only). The `api` and `harness` jobs are skipped for doc-only PRs.
|
||||
|
||||
Five jobs run in total — `fast-checks` and `security` always run; `api`, `harness`, and `changes` run conditionally.
|
||||
|
||||
### `fast-checks`
|
||||
|
||||
Runs lint, format check, typecheck, and PWA unit tests — no external services required.
|
||||
Runs lint, format check, markdown lint, typecheck, and PWA unit tests — no external services required. Always runs regardless of the `changes` filter.
|
||||
|
||||
| Step | Command |
|
||||
| -------------- | ------------------------------------ |
|
||||
| Lint | `pnpm lint` |
|
||||
| Format check | `pnpm format:check` |
|
||||
| Markdown lint | `pnpm md:lint` |
|
||||
| Typecheck | `pnpm typecheck` |
|
||||
| PWA unit tests | `pnpm --filter @familysync/pwa test` |
|
||||
|
||||
### `api`
|
||||
|
||||
Runs the full API test suite against a `mariadb:11` service container.
|
||||
Runs the full API test suite against a `mariadb:11` service container. Skipped for doc-only PRs.
|
||||
|
||||
| Step | Detail |
|
||||
| ----------------- | ---------------------------------------------------------- |
|
||||
@@ -214,17 +230,32 @@ The throwaway credentials (`DB_USER=familysync`, `DB_PASSWORD=testpass`) are sco
|
||||
|
||||
### `harness`
|
||||
|
||||
Runs the Playwright mobile e2e harness (iphone + pixel) against a runner-hosted dev stack.
|
||||
Runs the Playwright mobile and desktop e2e harness (iphone + pixel + desktop) against a runner-hosted dev stack. Skipped for doc-only PRs.
|
||||
|
||||
| Step | Detail |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| MariaDB service | Same `mariadb:11` setup as the `api` job |
|
||||
| Schema migrations | `pnpm --filter @familysync/api db:migrate` |
|
||||
| Dev user seed | Inserts `users` row id=1 (`INSERT IGNORE`) for `DEV_AUTH_BYPASS` |
|
||||
| API build | `pnpm --filter @familysync/api build` (dist/ is gitignored) |
|
||||
| Playwright install | `npx playwright install --with-deps webkit chromium` (no cache) |
|
||||
| API start + tests | API started as a background process in the same step as `playwright test` to survive the step boundary; `DEV_AUTH_BYPASS=true`, `NODE_ENV=development` |
|
||||
| Base URL | `http://127.0.0.1:5173` (not `localhost` — runner resolves `localhost` to `::1` but Vite binds IPv4-only) |
|
||||
| Artifacts on fail | Traces, screenshots, videos, and HTML report uploaded via `ChristopherHX/gitea-upload-artifact@v4` (standard `upload-artifact` aborts on Gitea) |
|
||||
| Step | Detail |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MariaDB service | Same `mariadb:11` setup as the `api` job |
|
||||
| Schema migrations | `pnpm --filter @familysync/api db:migrate` |
|
||||
| Dev user seed | Inserts `users` row id=1 (`INSERT IGNORE`) for `DEV_AUTH_BYPASS` |
|
||||
| Local credentials seed | Inserts `local_credentials` row for dev user (username: `devuser`, password: `devpass`) via inline scrypt hash — Phase 19 requirement |
|
||||
| API build | `pnpm --filter @familysync/api build` (dist/ is gitignored) |
|
||||
| Playwright install | `npx playwright install --with-deps webkit chromium` (no cache) |
|
||||
| API start + tests | API started as a background process in the same step as `playwright test` to survive the step boundary; `DEV_AUTH_BYPASS=true`, `NODE_ENV=development` |
|
||||
| Base URL | `http://127.0.0.1:5173` (not `localhost` — runner resolves `localhost` to `::1` but Vite binds IPv4-only) |
|
||||
| Artifacts on fail | Traces, screenshots, videos, and HTML report uploaded via `ChristopherHX/gitea-upload-artifact@v4` (standard `upload-artifact` aborts on Gitea) |
|
||||
|
||||
The API process is started and the Playwright suite invoked within a single CI step. Starting the API in an earlier step causes it to be reaped at the step boundary before Playwright runs.
|
||||
|
||||
### `security`
|
||||
|
||||
Runs secret scanning and dependency audits. Always runs regardless of the `changes` filter (secrets can appear in doc-only commits). Dependency audit and outdated checks run only when code changes are detected.
|
||||
|
||||
| Step | Tool/Command | Detail |
|
||||
| ------------------- | ------------------------------- | -------------------------------------------------------------- |
|
||||
| Secret scan | `gitleaks` (v8.30.1) | Scans the PR diff range; blocks on any finding |
|
||||
| Dependency audit | `node scripts/check-audit.mjs` | Blocks on High or Critical severity vulnerabilities |
|
||||
| Outdated report | `node scripts/check-outdated.mjs` | Advisory only — always exits 0, logged but never gates |
|
||||
|
||||
### `gate`
|
||||
|
||||
A required final job that checks all other jobs passed or were legitimately skipped. `fast-checks` and `security` must succeed; `api` and `harness` may be skipped (doc-only PRs) but not failed.
|
||||
|
||||
+17
-11
@@ -30,21 +30,23 @@ FamilySync uses a self-hosted Gitea Actions runner. Two workflows govern the rel
|
||||
|
||||
### PR gate — `.gitea/workflows/ci.yml`
|
||||
|
||||
Triggered on every pull request targeting `main`. Three jobs run in parallel; all three must pass before the PR can be merged:
|
||||
Triggered on every pull request targeting `main`. The workflow runs a `changes` filter job first, then launches the following jobs in parallel:
|
||||
|
||||
| Job | What it checks |
|
||||
| ------------- | --------------------------------------------------------------------------------- |
|
||||
| `fast-checks` | Lint (`pnpm lint`), format check (`pnpm format:check`), typecheck, PWA unit tests |
|
||||
| `api` | DB migrations + API integration tests against a live MariaDB service container |
|
||||
| `harness` | Full Playwright E2E suite (iPhone + Pixel profiles) against the compiled API |
|
||||
| Job | Runs when | What it checks |
|
||||
| ------------- | -------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `fast-checks` | Always | Lint (`pnpm lint`), format check (`pnpm format:check`), markdown lint (`pnpm md:lint`), typecheck, PWA unit tests |
|
||||
| `api` | Code-changing PRs only | DB migrations + API integration tests against a live MariaDB service container |
|
||||
| `harness` | Code-changing PRs only | Full Playwright E2E suite (iPhone + Pixel + desktop profiles) against the compiled API |
|
||||
| `security` | Always | Secret scan (gitleaks, PR diff); dependency audit and outdated report on code-changing PRs |
|
||||
| `gate` | Always | Aggregates results — fails if any non-skipped required job did not succeed |
|
||||
|
||||
A PR with lint or format violations is blocked from merging by the `fast-checks` job.
|
||||
The `api` and `harness` jobs are **skipped on doc-only PRs** (changes confined to `.gitea/**`, `.planning/**`, or `*.md` files). A doc-only PR must pass `fast-checks` and `security`; the heavy jobs are not required.
|
||||
|
||||
Branch protection on `main` blocks direct push and force push. Only PRs with all three required checks (`CI / fast-checks`, `CI / api`, `CI / harness`) passing can merge.
|
||||
Branch protection on `main` blocks direct push and force push. Only PRs where both `CI / fast-checks` and `CI / gate` pass can merge.
|
||||
|
||||
### Publish — `.gitea/workflows/publish.yml`
|
||||
|
||||
Triggered on push to `main` (i.e., when any PR merges). Builds the `apps/api` Docker image and pushes it to the Gitea container registry.
|
||||
Triggered on push to `main` (i.e., when any PR merges). Skipped when every changed file is under `.gitea/**` or `.planning/**`. Builds the `apps/api` Docker image and pushes it to the Gitea container registry.
|
||||
|
||||
**Registry:** `git.bergerhouse.net/luckberg/familysync-api`
|
||||
|
||||
@@ -59,6 +61,10 @@ The current milestone prefix (`v1.1`) is set in the `MILESTONE` env var at the t
|
||||
|
||||
The immutable `:<milestone>-<sha>` tag is pushed first. `:latest` is only moved after the immutable tag has landed, so a failed second push can never leave `:latest` advanced without a corresponding rollback tag.
|
||||
|
||||
Before pushing, the workflow runs two image hygiene assertions:
|
||||
1. **Static assertions** — verifies `.dockerignore` contains all required exclusion patterns and that the build targets `--target production`.
|
||||
2. **Boot-smoke** — starts the image with `NODE_ENV=production` and `DEV_AUTH_BYPASS=true` and asserts that it refuses to start (confirming the D-08 guard fires in the shipped image).
|
||||
|
||||
**Authentication — `REGISTRY_PAT` secret:**
|
||||
|
||||
The workflow authenticates with the Gitea container registry using a PAT stored in the `REGISTRY_PAT` Actions secret. The secret must have `write:package` scope. It is named `REGISTRY_PAT` — not `GITEA_REGISTRY_PAT` or any `GITEA_`-prefixed name, because Gitea reserves the `GITEA_` prefix and will reject those names at secret-creation time. `GITEA_TOKEN` and `GITHUB_TOKEN` cannot push packages.
|
||||
@@ -178,7 +184,7 @@ See [docs/CONFIGURATION.md](CONFIGURATION.md) for the full variable reference in
|
||||
|
||||
The production image does **not** auto-migrate on startup. Migrations must be applied manually before the first container start and again after any schema change.
|
||||
|
||||
Migrations are run with `drizzle-kit`, which is a **devDependency**. The production image is built with `pnpm install --prod` (see `apps/api/Dockerfile`), so `drizzle-kit` is **not** present inside the running `api` container — you cannot migrate by exec-ing into it. Instead, run migrations from a host that has the full (dev) dependencies and can reach MariaDB.
|
||||
Migrations are run with `drizzle-kit`, which is a **devDependency**. The production image is built with `pnpm install --frozen-lockfile --prod` (see `apps/api/Dockerfile`), so `drizzle-kit` is **not** present inside the running `api` container — you cannot migrate by exec-ing into it. Instead, run migrations from a host that has the full (dev) dependencies and can reach MariaDB.
|
||||
|
||||
The production `docker-compose.yml` does not expose the MariaDB port externally, so bring the database up with the dev compose override (which binds port 3306), apply the migrations from the host, then start the rest of the stack:
|
||||
|
||||
@@ -265,7 +271,7 @@ The Dockerfile uses a multi-stage build:
|
||||
|
||||
1. `builder` — compiles the TypeScript API (`pnpm --filter @familysync/api build`).
|
||||
2. `pwa-builder` — builds the React PWA with Vite (`pnpm --filter @familysync/pwa build`).
|
||||
3. `production` — installs production-only dependencies, copies the compiled API and the built PWA into `./public`. The API serves the PWA at `/` via `serveStatic`.
|
||||
3. `production` — installs production-only dependencies (`pnpm install --frozen-lockfile --prod`), copies the compiled API and the built PWA into `./public`. The API serves the PWA at `/` via `serveStatic`.
|
||||
|
||||
Both `builder` and `pwa-builder` stages run in parallel under BuildKit.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user