Commit Graph
2 Commits
Author SHA1 Message Date
Marc-André Bombeck 185e626651 feat(withings): random-nonce OAuth state with single-use ledger row
The legacy OAuth state shape `${user.id}:${random16}` (v1.4.42-) put
the user id on both the URL state param and the `withings_state`
cookie. httpOnly + Secure + sameSite:lax shut the XSS exfiltration
surface, but the user id still leaked into any request-log entry
where the cookie is not redacted and into network-traffic captures
of the redirect. Cosmetic today; a future refactor flipping the
cookie to non-httpOnly would silently expose it.

The v1.4.43 security audit (L-1) recommended switching the state to
a fully-random nonce backed by a short-lived `(nonce -> userId)`
ledger row, with the callback resolving the user via the row rather
than parsing it out of the cookie. This change lands the migration
end-to-end:

- New `WithingsOAuthState` table (migration `0076`): nonce PK, userId
  FK with cascade, createdAt default-now, expiresAt + index for the
  cleanup sweep.
- `withings/connect` mints a 22-char base64url nonce
  (`randomBytes(16).toString("base64url")` — 128 bits, OAuth 2.0
  §10.10 recommended threshold), persists the row with a 10-minute
  TTL, and sets the cookie to JUST the nonce (no user id).
- `withings/callback` verifies the cookie/URL pair byte-for-byte
  (timing-safe), then looks up the row, asserts `expiresAt > now()`
  AND `row.userId === session.user.id` (defence-in-depth — the audit
  rec was to resolve user identity from the row; cross-checking it
  against the session covers a stolen-cookie + cross-user scenario),
  and deletes the row on every exit branch (single-use semantics, so
  a replay of the same nonce fails the second time).
- Daily cleanup cron at 03:20 Europe/Berlin slots between the
  audit-log cleanup (:15) and the mood-reminder cleanup (:25); the
  helper module mirrors the audit-log-cleanup shape so the worker
  contract is symmetric.

Tests:

- 3 unit cases pin the cleanup helper contract (deleteMany shape,
  default-now branch, idempotent re-run).
- 2 unit cases pin the mint helper + constants (22-char base64url,
  10-minute TTL, cookie name preserved across the deploy window).
- 5 integration cases pin the connect -> callback ledger lifecycle
  end-to-end against real Postgres: row mint, no-credentials short-
  circuit, single-use consumption + replay rejection, expired-row
  rejection, cross-user mismatch rejection.
- The existing `withings-oauth.test.ts` integration cases updated to
  reflect the new state shape; the legacy `${user.id}:${random32}`
  assertion was the only test relying on the old contract.

Files: 12. Tests: 5076 -> 5108 (+32 unit; integration adds 5 cases
plus 2 case updates on the existing file).
2026-05-22 09:16:30 +02:00
Marc-André Bombeck 02003428df test(integration): 24 critical-path tests across auth, withings, cache
Closes the highest-severity gaps from the v1.4.34.3 test-coverage
audit. Five new integration files, 24 new cases.

auth-password-change.test.ts (5 cases):
  Pins the v1.4.34.3+ `destroyAllSessions` contract end-to-end.
  Asserts that after a successful password change the user's
  Session.deleteMany ran, ApiToken.revoked flipped to true, and
  RefreshToken.revokedAt was set. Plus wrong-current-password (401)
  and weak-new-password (422) negative paths. A second
  concurrent-request that holds the old session cookie is rejected
  by the next session-protected endpoint.

admin-reset-password.test.ts (5 cases):
  Same three-transport revocation shape for the admin-driven
  reset. Non-admin auth rejects with 403. Audit-log row carries
  the action name `admin.user.reset-password` and the actor's
  user-id.

withings-oauth.test.ts (5 cases):
  GET /api/integrations/withings/connect returns the upstream
  authorise URL with the right scopes + redirect URI. GET
  /api/withings/callback exchanges code for tokens by stubbing
  the Withings token endpoint; the user row's encrypted token
  pair populates. POST /api/withings/disconnect clears them.
  State-token replay (wrong cookie) rejects with 400. The
  Set-Cookie header carries the URL-encoded form of the state
  string — the assertion compares on the encoded value, not the
  literal.

passkey-register.test.ts (4 cases):
  /api/auth/passkey/register-options issues a fresh challenge
  bound to the RP-ID. /api/auth/passkey/register-verify against a
  stubbed @simplewebauthn/server verifier creates the Passkey
  row. A tampered attestation rejects with 422 + a clear
  errorCode. The challenge is single-use.

cache-invalidation-coverage.test.ts (5 cases):
  Grids the v1.4.34 IW-G invalidation matrix across 5 write
  surfaces: measurement POST evicts caches.analytics; mood-entry
  POST evicts caches.moodAnalytics; medication POST evicts
  caches.medications; dashboard-widgets PUT evicts
  caches.dashboardWidgets; admin app-settings PATCH evicts
  caches.bugreportStatus. The integration setup's `isolate: false`
  parallel-DB-bleed quirk on app_settings was handled with a
  `vi.importActual("@/lib/db-compat").ensureDbCompatibility()`
  in beforeEach — pattern worth noting for any future test
  touching that table.

Run: `pnpm test:integration` reports 222/222 passing across 55
files (was 197 in 50 files before this batch); tsc + lint clean.
2026-05-17 00:49:57 +02:00