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:
@@ -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;
|
||||
};
|
||||
+21
-8
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user