Skip to content

fix: enforce CSRF session-binding, propagate refreshed token, server-deliver rate alerts (#486, #487, #469) - #672

Merged
therealjhay merged 10 commits into
Betta-Pay:mainfrom
stephanieoghenemega-eng:fix/csrf-binding-refresh-token-rate-alerts
Aug 31, 2026
Merged

fix: enforce CSRF session-binding, propagate refreshed token, server-deliver rate alerts (#486, #487, #469)#672
therealjhay merged 10 commits into
Betta-Pay:mainfrom
stephanieoghenemega-eng:fix/csrf-binding-refresh-token-rate-alerts

Conversation

@stephanieoghenemega-eng

@stephanieoghenemega-eng stephanieoghenemega-eng commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Resolves #486, Resolves #487, Resolves #469 — a full pass over CSRF session-binding, real access-token refresh, and server-backed FX rate alerts.

#486 — CSRF is now session-bound and enforced

  • verifyCsrfRequest() (lib/utils/csrf.ts) enforces two checks on every state-changing request to our own API routes:
    1. double-submitX-CSRF-Token header must equal the csrf_token cookie (constant-time compare);
    2. session binding — when a csrf_sid cookie exists it must equal sha256(csrf_token . sessionKey(auth_token)). A csrf_token minted for a different visitor or a pre-login page carries the wrong csrf_sid, so it is rejected once a real session is in play. Sessions predating the binding fall back to double-submit only and are upgraded on their next login/refresh (no lock-out).
  • Enforced on POST /api/rate-alerts, PATCH/DELETE /api/rate-alerts/:id, POST /api/rate-alerts/evaluate.
  • Rotation on logoutauthStore.logout() calls rotateCsrfToken() (GET /api/auth/csrf?rotate=1) after clearing the session, so the token changes after logout, not only after login.
  • Login (POST /api/auth/session) and refresh (POST /api/auth/refresh) already rotate both csrf_token and csrf_sid together.

Verify: capture csrf_token before login → log in → it changes and csrf_sid appears. Log out → it changes again. Set the old csrf_token cookie + header by hand and POST /api/rate-alerts403 CSRF validation failed.

#487 — the refresh route's new token now reaches the client

POST /api/auth/refresh returns { refreshed, token } on a real rotation. Previously nothing consumed token, so the axios request interceptor's local JWT-expiry pre-check kept rejecting follow-up requests against the stale in-memory token.

  • axios single-flight now reads the response and useAuthStore.getState().setToken(token) before replaying the queued requests.
  • SessionTimeoutModalextendSession() does the same, and a 401 from refresh (dead refresh token) logs out + redirects once — a hasRedirectedForExpiry guard stops a burst of queued 401s each firing their own redirect + toast.
  • Both callers send the CSRF header on the refresh call.

Verify: network tab — "Extend session" shows /api/auth/refresh returning a new token; the next API call carries the rotated cookie and succeeds. Kill the refresh token server-side → one redirect to /auth/login, one toast at most.

#469 — rate alerts are server-backed with real delivery

  • lib/server/rateAlerts.ts — shared per-merchant store + evaluateAlerts(): condition match, optional HH:MM time window (wraps past midnight), per-alert delivery dedupe (DELIVERY_DEDUPE_MS), one-time alerts deactivate after firing, recurring re-arm. In-app deliveries land in the notification store immediately (with its existing dedupe); email/webhook deliveries are recorded as queued for the feature-Add useCallback for event handlers in dashboard and settings #38 worker.
  • POST /api/rate-alerts/evaluate { pair, rate } — the FX page feeds the live rate; the server fires + delivers + dedupes and returns the authoritative list.
  • rateAlertStoreaddAlert optimistically inserts then POSTs and swaps in the server id; toggle/delete propagate for synced rows; hydrateFromServer() restores alerts on boot so they survive a reload; evaluate() reconciles from the server.
  • FX page — the create form gains one-time / recurring, in-app / email / webhook channels, and an optional fire-only-within time window; alert rows show all three. The old client-only trigger check remains as an offline fallback and never double-delivers (the server owns triggered for synced rows).

Verify: set an alert, reload → it's still there (from GET /api/rate-alerts). Move the rate past the target → one delivery (in-app notification appears once even across repeated evaluations); a one-time alert flips to disabled.


Notes

🤖 Generated with Claude Code

verifyCsrfRequest() enforces the double-submit check (X-CSRF-Token ==
csrf_token cookie) and, when a csrf_sid binding cookie exists, that it
equals sha256(csrf_token . sessionKey) — so a token minted for a
different visitor / a pre-login page can't be replayed once a real
session is in play. csrfHeader() lets same-origin fetch()s to our own
API routes carry the token; rotateCsrfToken() forces a fresh cookie at
identity-change moments. Compare is constant-time.

Refs Betta-Pay#486
GET /api/auth/csrf?rotate=1 mints a brand-new token even when a valid
one exists, so the client can force rotation after logout.
authStore.logout() calls rotateCsrfToken() after clearing the session
so a token minted during the session just ended cannot linger for
whoever uses the browser profile next. Acceptance: the token changes
after logout, not only after login.
…ay#469)

lib/server/rateAlerts.ts holds the per-merchant alert list plus
evaluateAlerts(): condition + time-window match, per-alert delivery
dedupe (DELIVERY_DEDUPE_MS), one-time alerts deactivate after firing,
in-app deliveries land in the notification store immediately and
email/webhook deliveries are queued for the worker.
…, Betta-Pay#486)

GET/POST /api/rate-alerts now use lib/server/rateAlerts and validate
recurrence, channels and an optional HH:MM time window. POST is
CSRF-verified.
…ta-Pay#469, Betta-Pay#486)

Share the server store; accept channel updates; clear the delivery
dedupe marker when an alert is un-triggered.
…#469)

POST /api/rate-alerts/evaluate { pair, rate } fires every matching
alert, delivers on each configured channel, dedupes, deactivates
one-time alerts, and returns the authoritative list for the client to
reconcile. CSRF-verified.
…ay#469)

addAlert optimistically inserts then POSTs and swaps in the server id;
toggle/delete propagate to the API for synced rows; evaluate() asks
the server to fire alerts and reconciles from its response. Alerts now
survive a reload because hydrateFromServer restores them.
The create-alert form gains one-time/recurring, in-app/email/webhook
channels and an optional fire-only-within window. The page hydrates
alerts from the backend on mount and routes trigger evaluation through
the server (with the existing client check as an offline fallback).
Alert rows show frequency, channels and window.
…ta-Pay#487)

POST /api/auth/refresh returns the new access token on a real
rotation; the axios single-flight path and the SessionTimeoutModal's
extend action now push it into the auth store so the request
interceptor's local JWT pre-check stops rejecting follow-up requests
against the stale token. A 401 from refresh (dead refresh token) logs
out and redirects exactly once, without toast spam.
@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

@stephanieoghenemega-eng is attempting to deploy a commit to the therealjhay's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Aug 30, 2026

Copy link
Copy Markdown

@stephanieoghenemega-eng Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@therealjhay
therealjhay merged commit 27a647c into Betta-Pay:main Aug 31, 2026
2 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants