fix(ai-chat): refresh JWT token on SSE reconnect to prevent login red… (#20176)

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>
This commit is contained in:
b3nito404
2026-06-03 15:27:30 +00:00
committed by GitHub
parent 164a5b1e8d
commit e64e5662e5
2 changed files with 81 additions and 8 deletions
@@ -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<boolean> | null = null;
export const ensureTokenRenewed = (
store: ReturnType<typeof createStore>,
): Promise<boolean> => {
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;
};
@@ -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,
);