From e64e5662e5d3ecb7f3b43db242ac63b7213b82d7 Mon Sep 17 00:00:00 2001 From: b3nito404 <259454949+b3nito404@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:27:30 +0000 Subject: [PATCH] =?UTF-8?q?fix(ai-chat):=20refresh=20JWT=20token=20on=20SS?= =?UTF-8?q?E=20reconnect=20to=20prevent=20login=20red=E2=80=A6=20(#20176)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #18928 ## Problem When a JWT access token expires while the AI chat is streaming a response, the SSE connection drops and `graphql-sse` calls the retry callback. The previous implementation would wait, then destroy the SSE client but never refreshed the token. On the next connection attempt the client reused the same expired token, eventually triggering an `UNAUTHENTICATED` error that redirected the user to the login screen. ## Solution Add proactive token renewal inside `useHandleSseClientConnectionRetry` before each reconnect attempt: - Uses a module-level `let renewalPromise` variable to deduplicate concurrent renewal requests , the exactpattern used in `ApolloFactory.ts` - Calls `renewToken` via `retryWithBackoff` against the `/metadata` endpoint - Writes the fresh token pair into the Jotai store ,the SSE client's `headers()` callback picks it up automatically on reconnect - If renewal fails -> falls back to destroying the SSE client as before ## Files changed - `packages/twenty-front/src/modules/sse-db-event/hooks/useHandleSseClientConnectionRetry.ts` ## Notes This addresses the two issues from the previous review: - No `useRef` using module-level variable instead - CI passing removed the `CombinedGraphQLErrors` import --------- Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> --- .../modules/auth/utils/ensureTokenRenewed.ts | 60 +++++++++++++++++++ .../useHandleSseClientConnectionRetry.ts | 29 ++++++--- 2 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 packages/twenty-front/src/modules/auth/utils/ensureTokenRenewed.ts diff --git a/packages/twenty-front/src/modules/auth/utils/ensureTokenRenewed.ts b/packages/twenty-front/src/modules/auth/utils/ensureTokenRenewed.ts new file mode 100644 index 0000000000..bb234cdb67 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/utils/ensureTokenRenewed.ts @@ -0,0 +1,60 @@ +import { renewToken } from '@/auth/services/AuthService'; +import { tokenPairState } from '@/auth/states/tokenPairState'; +import { type createStore } from 'jotai'; +import { isDefined } from 'twenty-shared/utils'; +import { REACT_APP_SERVER_BASE_URL } from '~/config'; +import { retryWithBackoff } from '~/utils/retryWithBackoff'; + +const TOKEN_RENEWAL_MAX_RETRIES = 3; +const TOKEN_RENEWAL_RETRY_DELAY_MS = 1000; + +let renewalPromise: Promise | null = null; + +export const ensureTokenRenewed = ( + store: ReturnType, +): Promise => { + if (isDefined(renewalPromise)) { + return renewalPromise; + } + + const tokenPair = store.get(tokenPairState.atom); + + if (!isDefined(tokenPair)) { + return Promise.resolve(false); + } + + const refreshTokenSnapshot = tokenPair.refreshToken.token; + + renewalPromise = retryWithBackoff( + () => + renewToken( + `${REACT_APP_SERVER_BASE_URL}/metadata`, + store.get(tokenPairState.atom), + ), + { + maxRetries: TOKEN_RENEWAL_MAX_RETRIES, + baseDelayMs: TOKEN_RENEWAL_RETRY_DELAY_MS, + shouldRetry: () => isDefined(store.get(tokenPairState.atom)), + }, + ) + .then((tokens) => { + if (!isDefined(tokens)) return true; + + const currentPair = store.get(tokenPairState.atom); + + if ( + isDefined(currentPair) && + currentPair.refreshToken.token === refreshTokenSnapshot + ) { + store.set(tokenPairState.atom, tokens); + } + + return true; + }) + .catch(() => false) + .finally(() => { + renewalPromise = null; + }); + + return renewalPromise; +}; diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useHandleSseClientConnectionRetry.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useHandleSseClientConnectionRetry.ts index e06d40b028..a08acf2a92 100644 --- a/packages/twenty-front/src/modules/sse-db-event/hooks/useHandleSseClientConnectionRetry.ts +++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useHandleSseClientConnectionRetry.ts @@ -1,17 +1,19 @@ import { tokenPairState } from '@/auth/states/tokenPairState'; +import { ensureTokenRenewed } from '@/auth/utils/ensureTokenRenewed'; import { SSE_CONNECTION_RETRY_MAX_WAIT_TIME_IN_MS } from '@/sse-db-event/constants/SseConnectionRetryMaxWaitTimeInMs'; import { SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_FOR_DEV_MODE } from '@/sse-db-event/constants/SseConnectionRetryWaitTimeInMsForDevMode'; import { SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_TO_AVOID_RACE_CONDITIONS } from '@/sse-db-event/constants/SseConnectionRetryWaitTimeInMsToAvoidRaceConditions'; import { shouldDestroyEventStreamState } from '@/sse-db-event/states/shouldDestroyEventStreamState'; import { sseClientState } from '@/sse-db-event/states/sseClientState'; +import { useStore } from 'jotai'; import { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { getIsDevelopmentEnvironment } from '~/utils/getIsDevelopmentEnvironment'; import { sleep } from '~/utils/sleep'; -import { useStore } from 'jotai'; export const useHandleSseClientConnectionRetry = () => { const store = useStore(); + const handleSseClientConnectionRetry = useCallback( async (retryCount: number) => { const sseClient = store.get(sseClientState.atom); @@ -20,27 +22,38 @@ export const useHandleSseClientConnectionRetry = () => { await sleep( SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_TO_AVOID_RACE_CONDITIONS, ); - return; } const tokenPair = store.get(tokenPairState.atom); - const currentAppToken = tokenPair?.accessOrWorkspaceAgnosticToken?.token; + const accessToken = tokenPair?.accessOrWorkspaceAgnosticToken; - const shouldResetSseClient = - !isDefined(currentAppToken) || retryCount > 10; - - if (shouldResetSseClient) { + if (!isDefined(accessToken) || retryCount > 10) { await sleep( SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_TO_AVOID_RACE_CONDITIONS, ); - sseClient.dispose(); store.set(shouldDestroyEventStreamState.atom, true); store.set(sseClientState.atom, null); return; } + const isTokenExpired = new Date(accessToken.expiresAt) <= new Date(); + + if (isTokenExpired) { + const renewed = await ensureTokenRenewed(store); + + if (!renewed) { + await sleep( + SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_TO_AVOID_RACE_CONDITIONS, + ); + sseClient.dispose(); + store.set(shouldDestroyEventStreamState.atom, true); + store.set(sseClientState.atom, null); + return; + } + } + const randomWaitTimeInMsToSpaceAllClientsReconnection = Math.round( Math.random() * SSE_CONNECTION_RETRY_MAX_WAIT_TIME_IN_MS, );