Skip to content

fix: refresh auth token for idle WS/SSE clients, stop reconnect spam on expired sessions#980

Open
TadMSTR wants to merge 6 commits into
siteboon:mainfrom
TadMSTR:fix/jwt-refresh-ws-sse-idle
Open

fix: refresh auth token for idle WS/SSE clients, stop reconnect spam on expired sessions#980
TadMSTR wants to merge 6 commits into
siteboon:mainfrom
TadMSTR:fix/jwt-refresh-ws-sse-idle

Conversation

@TadMSTR

@TadMSTR TadMSTR commented Jul 9, 2026

Copy link
Copy Markdown

Problem

Users who leave a CloudCLI tab open with an active WebSocket (and/or SSE) connection eventually
see a flood of failed reconnect attempts once their session JWT expires — even though the tab
was never actively closed. Closes #754.

Root cause

Two independent gaps, not one:

  1. The server only refreshes a token (X-Refreshed-Token response header, in
    server/middleware/auth.js) in response to an HTTP request made past the token's half-life.
    A tab with just an open WebSocket and no other user-triggered HTTP calls never sends one, so
    the token silently goes stale until the WS has to reconnect — at which point it's already
    expired.
  2. Background refreshes (via X-Refreshed-Token) only ever updated localStorage.
    AuthContext's in-memory token state — which WebSocketContext reads to build its
    reconnect URL — was never resynced, so even a tab that did get a fresh token via an HTTP
    call kept reconnecting its WebSocket with the original, now-stale token. This also explains a
    symptom some users report as "one tab authenticated, another not": each tab's WS reconnect
    logic was pinned to whatever token happened to be in memory at that tab's last mount.

On top of that, once the token genuinely expired, the WebSocket reconnect loop retried every 3s
forever against a token that could never succeed, which is what produces the failure flood.

Fix

  • AuthContext proactively pings an authenticated endpoint (/api/auth/user — no new endpoint
    needed, it already triggers the server's existing half-life refresh logic) on an hourly
    interval and on tab visibility change, so idle-but-open tabs stay refreshed without depending
    on user activity.
  • api.js's refreshed-token handling now also dispatches a window event so AuthContext's
    token state stays in sync with localStorage instead of going stale after the initial
    mount.
  • WebSocketContext stops its reconnect loop and drops the session (instead of retrying every
    3s forever) once the token it holds is actually expired — with a 60s clock-skew allowance, so
    a client clock running slightly ahead of the server doesn't trigger a false-positive logout.
    LoginForm surfaces a "session expired" message for this case instead of a silent redirect.

Testing

Verified against a live isolated instance of this server (separate DB/port, real JWT secret):
confirmed X-Refreshed-Token only appears on requests past the token's half-life as expected,
and that the WS handshake accepts a valid-but-past-half-life token but rejects a genuinely
expired one — matching what the new client-side expiry check assumes. npm run typecheck,
npm run lint, and npm run build all pass clean.

Summary by CodeRabbit

  • New Features
    • Added session-expired handling to authentication, including a “session expired” login message and acknowledgement support.
    • Improved token refresh synchronization across the app and tabs, with proactive background checks.
  • Bug Fixes
    • Prevented endless WebSocket reconnect loops when the session token is expired.
    • Ensured file-tree uploads apply refreshed tokens consistently.
    • Centralized refreshed-token validation and persistence.
  • Tests
    • Added unit tests for JWT expiry detection, including skew and malformed-token cases.

TadMSTR and others added 2 commits July 9, 2026 11:47
…on expired sessions

Fixes siteboon#754. Root cause was two independent gaps, not one:

1. The server only refreshes a token (X-Refreshed-Token response header) in
   response to an HTTP request made past the token's half-life. A tab with
   an open WebSocket but no other user-triggered HTTP calls never sends
   one, so the token silently goes stale until the WS has to reconnect —
   at which point it's already expired.

2. Background refreshes only ever updated localStorage. AuthContext's
   in-memory `token` state — which WebSocketContext reads to build its
   reconnect URL — was never resynced, so even a tab that *did* trigger an
   HTTP refresh kept reconnecting its WS with the original, now-stale
   token. This also explains the "one pane authenticated, another not"
   symptom: each pane's WS reconnect logic was pinned to whatever token
   happened to be in memory at that pane's last mount/reconnect.

Fix:
- AuthContext proactively pings an authenticated endpoint on an interval
  (well under the 7d token's half-life) and on tab visibility change, so
  idle-but-open tabs stay refreshed without depending on user activity.
- api.js's refreshed-token handling now dispatches a window event so
  AuthContext's `token` state stays in sync with localStorage instead of
  going stale after the initial mount.
- WebSocketContext stops its reconnect loop and drops the session (instead
  of retrying every 3s forever) when the token it's holding is actually
  expired — this was the dominant contributor to the reconnect-spam
  flood, since a truly expired token can never succeed at the WS
  handshake. LoginForm surfaces a clear "session expired" message for
  this case instead of a silent redirect.

Verified against a live instance of this server (isolated DB/port): the
X-Refreshed-Token header appears only past half-life as expected, and the
WS handshake accepts a valid-but-past-half-life token but rejects a truly
expired one — confirming the client-side expiry check agrees with what
the server actually enforces.
Finding: F1 (Low) from cloudcli-upstream-auth-prs-2026-07 audit.

isTokenExpired() compared the JWT's exp claim directly against the
client's local clock with no tolerance. A client clock running ahead of
the server could read a still-server-valid token as expired on a plain
WS close, triggering endExpiredSession() and an unwanted logout — the
server's own jwt.verify has no such false positive since it doesn't
depend on the client's clock.

Add a 60s skew allowance before treating the token as expired.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR centralizes refreshed-token handling, adds periodic auth refresh checks and session-expired state, updates login messaging, and blocks websocket reconnects when JWTs are expired. It also adds JWT expiry utilities and tests.

Changes

JWT refresh synchronization and session expiry

Layer / File(s) Summary
Shared refreshed-token helper and event
src/utils/api.js
Adds AUTH_TOKEN_REFRESHED_EVENT and applyRefreshedToken, which validates refreshed tokens, persists them to localStorage, dispatches a CustomEvent, and replaces inline refresh handling in authenticatedFetch.
File upload consumes shared refresh helper
src/components/file-tree/hooks/useFileTreeUpload.ts
Replaces manual X-Refreshed-Token validation and localStorage writes with applyRefreshedToken.
AuthContext background refresh and session-expired state
src/components/auth/constants.ts, src/components/auth/context/AuthContext.tsx, src/components/auth/types.ts
Adds a refresh-check interval, sessionExpired state, expired-session callbacks, token-refresh event handling, periodic and visibility-triggered refresh checks, and exposes the new context fields.
LoginForm session-expired messaging
src/components/auth/view/LoginForm.tsx, src/i18n/locales/en/auth.json
Consumes the expired-session state, clears it on submit, renders translated session-expired messaging, and adds the translation string.
JWT expiry utility and tests
src/utils/jwt.ts, src/utils/jwt.test.ts
Adds JWT exp parsing with skew tolerance and tests the helper against future, expired, malformed, missing, non-finite, and null token inputs.
WebSocket reconnect guard for expired JWTs
src/contexts/WebSocketContext.tsx
Adds token-expiry checks, generation-based stale-close suppression, and expired-session termination instead of reconnect scheduling.

Sequence Diagram(s)

sequenceDiagram
  participant Tab as Browser Tab
  participant AuthProvider
  participant API as api.auth.user()
  participant BrowserEvent

  Tab->>AuthProvider: interval tick / visibilitychange
  AuthProvider->>API: call api.auth.user()
  API-->>BrowserEvent: applyRefreshedToken dispatches AUTH_TOKEN_REFRESHED_EVENT
  BrowserEvent-->>AuthProvider: listener updates token state
Loading
sequenceDiagram
  participant WebSocket
  participant WebSocketContext
  participant AuthContext

  WebSocket->>WebSocketContext: onclose
  WebSocketContext->>WebSocketContext: isTokenExpired(token)
  alt expired
    WebSocketContext->>AuthContext: endExpiredSession()
    WebSocketContext--xWebSocket: stop reconnect scheduling
  else valid
    WebSocketContext->>WebSocketContext: schedule reconnect
  end
Loading

Possibly related PRs

Suggested reviewers: blackmammoth, viper151

Poem

A bunny saw the tokens hop,
And taught them not to stop and drop.
When websockets yawned and drifted far,
The auth flow found each fading star.
Now expired sessions get a little nudge,
And sleepy logs no longer grudge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main auth token refresh and expired-session reconnect fix.
Linked Issues check ✅ Passed The changes address the linked issue by proactively refreshing idle auth, syncing refreshed tokens, stopping expired WS reconnects, and showing a session-expired state.
Out of Scope Changes check ✅ Passed The diff stays focused on auth/JWT refresh handling, reconnect behavior, and related UI/tests with no obvious unrelated additions.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/auth/context/AuthContext.tsx`:
- Around line 170-175: In checkAndRefreshToken (and the other background refresh
call site noted in AuthContext), handle unauthorized responses from
api.auth.user() explicitly instead of relying on the catch path. Update the
refresh flow to inspect the returned response/status and call
endExpiredSession() when it indicates 401/unauthorized, while keeping the
existing error logging for true transport failures. This should ensure expired
sessions are cleared and the session-expired UI is shown.
- Around line 147-157: AuthContext only updates token state from
AUTH_TOKEN_REFRESHED_EVENT in the current window, so other tabs can keep stale
auth state. Update the useEffect in AuthContext to also subscribe to the window
storage event and detect token changes coming from localStorage, then call
setToken with the refreshed value when the token key changes. Keep the existing
handleTokenRefreshed listener and add a matching storage-event cleanup alongside
the current removeEventListener logic.

In `@src/contexts/WebSocketContext.tsx`:
- Around line 77-84: The token-expiry check in WebSocketContext’s JWT parsing
still accepts non-finite numeric values, so a payload with an infinite exp can
be treated as valid and cause unnecessary reconnect retries. Update the token
validation logic in the payload parsing block to explicitly reject exp values
that are not finite before comparing against Date.now(), using the existing
payload parsing flow around payloadSegment/JSON.parse and the expiry check.
- Around line 168-179: The token-expiry branch in WebSocketContext’s close
handling is treating stale cleanup closes as terminal, which can incorrectly end
the session or drive reconnects from an old socket. Update the close logic
around the token check in WebSocketContext to ignore closes from inactive socket
generations by comparing against the current active connection/socket instance
before calling endExpiredSession() or scheduling reconnect, so only the latest
live connection can affect session state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0bf2b253-efc7-49fb-a569-3c4b0ff129bd

📥 Commits

Reviewing files that changed from the base of the PR and between 5884573 and f6b6fe9.

📒 Files selected for processing (8)
  • src/components/auth/constants.ts
  • src/components/auth/context/AuthContext.tsx
  • src/components/auth/types.ts
  • src/components/auth/view/LoginForm.tsx
  • src/components/file-tree/hooks/useFileTreeUpload.ts
  • src/contexts/WebSocketContext.tsx
  • src/i18n/locales/en/auth.json
  • src/utils/api.js

Comment thread src/components/auth/context/AuthContext.tsx
Comment thread src/components/auth/context/AuthContext.tsx
Comment thread src/contexts/WebSocketContext.tsx Outdated
Comment thread src/contexts/WebSocketContext.tsx
WebSocketContext.tsx has import-time side effects (import.meta.env,
React) that break outside a Vite build, so isTokenExpired() couldn't be
unit tested while it lived there. Extract it (and its skew constant)
into src/utils/jwt.ts, a dependency-free module, matching the repo's
existing pattern for testable pure logic (src/components/git-panel/utils/commitGraph.ts).
WebSocketContext.tsx now imports it instead of defining it locally —
behavior unchanged.

Covers: valid token, expired token, malformed token (unparseable
payload, wrong segment count), token missing the exp claim, null
token, and the clock-skew boundary from the previous commit (expired
within the 60s tolerance vs. just past it).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@TadMSTR

TadMSTR commented Jul 9, 2026

Copy link
Copy Markdown
Author

Added test coverage for isTokenExpired() in src/utils/jwt.test.ts (extracted the function into src/utils/jwt.ts since WebSocketContext.tsx has import-time side effects — import.meta.env, React — that break a plain Node test run; behavior is unchanged). Covers: valid token, expired token, malformed token, token missing exp, null token, and the clock-skew boundary (expired within the 60s tolerance vs. just past it).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/contexts/WebSocketContext.tsx (1)

135-155: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard against stale socket closes (previously flagged).

The onclose handler doesn't distinguish closes from the active socket vs. stale closes from a previous connection generation. During token refresh, the old socket's onclose fires with the stale closure-captured token and either incorrectly calls endExpiredSession() (if the old token is expired) or schedules a reconnect with the old connect() reference, creating a duplicate connection.

🔒 Proposed fix using a connection generation guard
   const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
+  const connectionGenerationRef = useRef(0);
   const { token, endExpiredSession } = useAuth();
-      const websocket = new WebSocket(wsUrl);
+      const connectionGeneration = ++connectionGenerationRef.current;
+      const websocket = new WebSocket(wsUrl);
+      wsRef.current = websocket;
       websocket.onclose = () => {
+        if (connectionGenerationRef.current !== connectionGeneration || unmountedRef.current) {
+          return;
+        }
+
         setIsConnected(false);
         wsRef.current = null;
     return () => {
       unmountedRef.current = true;
+      connectionGenerationRef.current += 1;
       if (reconnectTimeoutRef.current) {
         clearTimeout(reconnectTimeoutRef.current);
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/contexts/WebSocketContext.tsx` around lines 135 - 155, The websocket
onclose logic in WebSocketContext.tsx must ignore stale closes from earlier
connection generations. Add a connection-generation guard in the connect/onclose
flow so only the currently active socket can call endExpiredSession() or
schedule reconnects; stale onclose handlers captured by older tokens must return
early. Use the existing connect, websocket.onclose, wsRef, and
reconnectTimeoutRef paths to ensure old sockets cannot trigger duplicate
reconnects during token refresh.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/utils/jwt.ts`:
- Around line 14-29: The expiration check in isTokenExpired still accepts
non-finite exp values like Infinity or NaN, which can make invalid tokens appear
valid and keep reconnect retries going. Update the payload validation in
isTokenExpired to use Number.isFinite on payload.exp instead of only checking
typeof, so malformed exp values are treated as expired before comparing against
Date.now().

---

Duplicate comments:
In `@src/contexts/WebSocketContext.tsx`:
- Around line 135-155: The websocket onclose logic in WebSocketContext.tsx must
ignore stale closes from earlier connection generations. Add a
connection-generation guard in the connect/onclose flow so only the currently
active socket can call endExpiredSession() or schedule reconnects; stale onclose
handlers captured by older tokens must return early. Use the existing connect,
websocket.onclose, wsRef, and reconnectTimeoutRef paths to ensure old sockets
cannot trigger duplicate reconnects during token refresh.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: cd6cebab-5195-42c4-967a-6646abcbff4c

📥 Commits

Reviewing files that changed from the base of the PR and between f6b6fe9 and b8683ab.

📒 Files selected for processing (3)
  • src/contexts/WebSocketContext.tsx
  • src/utils/jwt.test.ts
  • src/utils/jwt.ts

Comment thread src/utils/jwt.ts
TadMSTR and others added 3 commits July 9, 2026 13:11
CodeRabbit review on siteboon#980: typeof payload.exp !== 'number' passes for
Infinity and NaN (typeof Infinity === 'number' in JS), so a malformed
token with e.g. {"exp":1e309} (parses to Infinity) would be treated as
never-expiring, and the WS reconnect loop would retry forever against a
token the server will always reject. Add Number.isFinite on top of the
existing typeof check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CodeRabbit review on siteboon#980, two findings in AuthContext.tsx:

1. AUTH_TOKEN_REFRESHED_EVENT only reaches the tab that made the
   request, so other open tabs kept their own stale in-memory token
   until their own proactive refresh happened to run — the same class
   of symptom (some panes authenticated, others not) this whole fix was
   for, just via a path the original fix didn't cover. Add a 'storage'
   event listener, which fires in every *other* tab when localStorage
   changes (the complement of the same-tab-only CustomEvent), so all
   open tabs stay in sync regardless of which one refreshed the token.

2. fetch() only rejects on network failure, never on a non-2xx status,
   so the proactive refresh's .catch() never ran for a hard 401 (token
   invalid for a reason other than plain expiry — e.g. JWT_SECRET
   rotated, user deleted server-side). The client-side isTokenExpired
   check wouldn't catch this either, since it only reads the exp claim
   and doesn't know the server considers the token invalid for another
   reason — reintroducing an infinite-reconnect case for a different
   root cause than the one siteboon#754 originally described. Call
   endExpiredSession() explicitly on a 401 response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CodeRabbit review on siteboon#980: WebSocket close is asynchronous, so when a
token change (or reconnect) closes the old socket and immediately opens
a new one, the *old* socket's onclose handler can still fire later, with
its own closure's now-stale token. Left unguarded, that stale onclose
could call endExpiredSession() (killing a perfectly healthy new
session, if the old token happened to be expired) or wsRef.current =
null (dropping the reference to the new, actually-connected socket) or
schedule a redundant reconnect.

Add a connectionGenerationRef counter, bumped on every new connect()
attempt and on cleanup/unmount, and bail out of onclose immediately if
it no longer matches the generation that socket was created for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/contexts/WebSocketContext.tsx (1)

122-153: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard sockets superseded before onopen.

wsRef.current is assigned only in onopen, so cleanup cannot close a still-CONNECTING socket. If a token changes or logout happens before onopen, that stale socket can later set wsRef.current, flip isConnected, or dispatch messages because only onclose checks the generation.

Suggested fix
       const connectionGeneration = ++connectionGenerationRef.current;
       const websocket = new WebSocket(wsUrl);
+      wsRef.current = websocket;
+
+      const isCurrentConnection = () =>
+        connectionGenerationRef.current === connectionGeneration &&
+        wsRef.current === websocket &&
+        !unmountedRef.current;
 
       websocket.onopen = () => {
+        if (!isCurrentConnection()) {
+          websocket.close();
+          return;
+        }
         setIsConnected(true);
-        wsRef.current = websocket;
         if (hasConnectedRef.current) {
           // This is a reconnect — signal so components can catch up on missed messages
           dispatch({ kind: 'websocket_reconnected', timestamp: Date.now() });
         }
         hasConnectedRef.current = true;
       };
 
       websocket.onmessage = (event) => {
+        if (!isCurrentConnection()) return;
         try {
           const data = JSON.parse(event.data) as ServerEvent;
           dispatch(data);
         } catch (error) {
           console.error('Error parsing WebSocket message:', error);
@@
-        if (connectionGenerationRef.current !== connectionGeneration) {
+        if (connectionGenerationRef.current !== connectionGeneration || wsRef.current !== websocket) {
           return;
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/contexts/WebSocketContext.tsx` around lines 122 - 153, The WebSocket
lifecycle in WebSocketContext.tsx can still let a superseded CONNECTING socket
become active because wsRef.current is only set in websocket.onopen. Update the
connection flow around the connectionGenerationRef, websocket.onopen, and
websocket.onclose handlers so stale sockets created before a token change/logout
are tracked and ignored or explicitly closed before they can set wsRef.current,
flip isConnected, or dispatch events. Keep the generation check authoritative
for all socket callbacks, and ensure cleanup handles sockets that have not
reached open yet.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/contexts/WebSocketContext.tsx`:
- Around line 122-153: The WebSocket lifecycle in WebSocketContext.tsx can still
let a superseded CONNECTING socket become active because wsRef.current is only
set in websocket.onopen. Update the connection flow around the
connectionGenerationRef, websocket.onopen, and websocket.onclose handlers so
stale sockets created before a token change/logout are tracked and ignored or
explicitly closed before they can set wsRef.current, flip isConnected, or
dispatch events. Keep the generation check authoritative for all socket
callbacks, and ensure cleanup handles sockets that have not reached open yet.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4e6a9cc8-f934-458b-9657-aa825f41dd5a

📥 Commits

Reviewing files that changed from the base of the PR and between b8683ab and bb6062c.

📒 Files selected for processing (4)
  • src/components/auth/context/AuthContext.tsx
  • src/contexts/WebSocketContext.tsx
  • src/utils/jwt.test.ts
  • src/utils/jwt.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/utils/jwt.ts
  • src/utils/jwt.test.ts
  • src/components/auth/context/AuthContext.tsx

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JWT auto-refresh misses SSE / WebSocket / idle clients, leaving dead 7d tokens that spam server logs

1 participant