fix: refresh auth token for idle WS/SSE clients, stop reconnect spam on expired sessions#980
fix: refresh auth token for idle WS/SSE clients, stop reconnect spam on expired sessions#980TadMSTR wants to merge 6 commits into
Conversation
…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>
📝 WalkthroughWalkthroughThis 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. ChangesJWT refresh synchronization and session expiry
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
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
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (8)
src/components/auth/constants.tssrc/components/auth/context/AuthContext.tsxsrc/components/auth/types.tssrc/components/auth/view/LoginForm.tsxsrc/components/file-tree/hooks/useFileTreeUpload.tssrc/contexts/WebSocketContext.tsxsrc/i18n/locales/en/auth.jsonsrc/utils/api.js
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>
|
Added test coverage for |
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
src/contexts/WebSocketContext.tsx (1)
135-155: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winGuard against stale socket closes (previously flagged).
The
onclosehandler doesn't distinguish closes from the active socket vs. stale closes from a previous connection generation. During token refresh, the old socket'sonclosefires with the stale closure-capturedtokenand either incorrectly callsendExpiredSession()(if the old token is expired) or schedules a reconnect with the oldconnect()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
📒 Files selected for processing (3)
src/contexts/WebSocketContext.tsxsrc/utils/jwt.test.tssrc/utils/jwt.ts
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>
There was a problem hiding this comment.
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 winGuard sockets superseded before
onopen.
wsRef.currentis assigned only inonopen, so cleanup cannot close a still-CONNECTING socket. If a token changes or logout happens beforeonopen, that stale socket can later setwsRef.current, flipisConnected, or dispatch messages because onlyonclosechecks 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
📒 Files selected for processing (4)
src/components/auth/context/AuthContext.tsxsrc/contexts/WebSocketContext.tsxsrc/utils/jwt.test.tssrc/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
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:
X-Refreshed-Tokenresponse header, inserver/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.
X-Refreshed-Token) only ever updatedlocalStorage.AuthContext's in-memorytokenstate — whichWebSocketContextreads to build itsreconnect 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
AuthContextproactively pings an authenticated endpoint (/api/auth/user— no new endpointneeded, 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 awindowevent soAuthContext'stokenstate stays in sync withlocalStorageinstead of going stale after the initialmount.
WebSocketContextstops its reconnect loop and drops the session (instead of retrying every3s 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.
LoginFormsurfaces 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-Tokenonly 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, andnpm run buildall pass clean.Summary by CodeRabbit