fix(auth): use dynamic SSE headers and add token renewal retry logic (#18706)
## Summary
- **Dynamic SSE headers**: The `graphql-sse` client was created with a
static `Authorization` header captured at creation time. When the access
token refreshed, the SSE client kept using the expired token on every
reconnection attempt, causing up to 10 wasted retries before the client
was disposed and recreated. Now `headers` is passed as a function that
reads the latest token from the Jotai store on each connection attempt.
- **Token renewal retry with error classification**:
`handleTokenRenewal` previously had zero retry tolerance — any failure
during `renewToken` (including transient network errors, server 500s, or
timeouts) triggered an immediate full logout via
`onUnauthenticatedError()`. Now the renewal retries up to 3 times with
linear backoff for transient errors. Only explicit server rejections
(`CombinedGraphQLErrors`, e.g. expired/revoked refresh token) skip
retries and proceed to logout immediately.
- **Preserved error types in `renewTokenMutation`**: The old code caught
all errors and re-threw them as a generic `new Error('Something went
wrong...')`, destroying the original error type. Callers couldn't
distinguish a GraphQL auth rejection from a network failure. Now errors
propagate with their original type.
- **Simplified SSE retry handler**: With dynamic headers handling token
freshness automatically, the retry handler no longer needs the
`initialTokenForSseClient` comparison to detect token mismatches. It now
only resets the SSE client when the user has logged out (no token) or
after 10+ consecutive failures.
## Test plan
- [ ] Log in, wait for access token to expire (~30 min or configure
shorter expiry), verify no unexpected logout occurs
- [ ] Simulate transient network failure during token renewal (e.g.
throttle network in devtools), verify the retry logic recovers without
logging out
- [ ] Verify SSE real-time updates continue working after a token
refresh
- [ ] Verify genuine logout still works when refresh token is actually
invalid/expired
- [ ] Open multiple browser tabs, verify token refresh works correctly
across all tabs without "suspicious activity" revocation
Made with [Cursor](https://cursor.com)
This commit is contained in:
@@ -16,6 +16,7 @@ import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMembe
|
||||
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
|
||||
import { type AuthTokenPair } from '~/generated-metadata/graphql';
|
||||
import { logDebug } from '~/utils/logDebug';
|
||||
import { retryWithBackoff } from '~/utils/retryWithBackoff';
|
||||
|
||||
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
|
||||
import { type ApolloManager } from '@/apollo/types/apolloManager.interface';
|
||||
@@ -43,6 +44,9 @@ const logger = loggerLink(() => 'Twenty');
|
||||
// deduplicate into a single renewal request.
|
||||
let renewalPromise: Promise<void> | null = null;
|
||||
|
||||
const TOKEN_RENEWAL_MAX_RETRIES = 3;
|
||||
const TOKEN_RENEWAL_RETRY_DELAY_MS = 1000;
|
||||
|
||||
export interface Options {
|
||||
uri: string;
|
||||
cache: ApolloClient.Options['cache'];
|
||||
@@ -156,27 +160,35 @@ export class ApolloFactory implements ApolloManager {
|
||||
},
|
||||
});
|
||||
|
||||
const attemptTokenRenewal = async (): Promise<void> => {
|
||||
const graphqlUri = `${REACT_APP_SERVER_BASE_URL}/metadata`;
|
||||
|
||||
const tokens = await retryWithBackoff(
|
||||
() => renewToken(graphqlUri, getTokenPair()),
|
||||
{
|
||||
maxRetries: TOKEN_RENEWAL_MAX_RETRIES,
|
||||
baseDelayMs: TOKEN_RENEWAL_RETRY_DELAY_MS,
|
||||
shouldRetry: (error) =>
|
||||
!CombinedGraphQLErrors.is(error) && isDefined(getTokenPair()),
|
||||
},
|
||||
);
|
||||
|
||||
if (isDefined(tokens)) {
|
||||
onTokenPairChange?.(tokens);
|
||||
cookieStorage.setItem('tokenPair', JSON.stringify(tokens));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTokenRenewal = (
|
||||
operation: ApolloLink.Operation,
|
||||
forward: ApolloLink.ForwardFunction,
|
||||
) => {
|
||||
if (!renewalPromise) {
|
||||
// Always renew through /metadata since the RenewToken is only exposed there
|
||||
const graphqlUri = `${REACT_APP_SERVER_BASE_URL}/metadata`;
|
||||
|
||||
renewalPromise = renewToken(graphqlUri, getTokenPair())
|
||||
.then((tokens) => {
|
||||
if (isDefined(tokens)) {
|
||||
// oxlint-disable-next-line no-console
|
||||
console.log('setTokenPair from handleTokenRenewal');
|
||||
onTokenPairChange?.(tokens);
|
||||
cookieStorage.setItem('tokenPair', JSON.stringify(tokens));
|
||||
}
|
||||
})
|
||||
renewalPromise = attemptTokenRenewal()
|
||||
.catch(() => {
|
||||
// oxlint-disable-next-line no-console
|
||||
console.log(
|
||||
'Failed to renew token, triggering unauthenticated error from handleTokenRenewal',
|
||||
'Failed to renew token after retries, triggering unauthenticated error',
|
||||
);
|
||||
onUnauthenticatedError?.();
|
||||
})
|
||||
|
||||
@@ -22,34 +22,27 @@ const renewTokenMutation = async (
|
||||
) => {
|
||||
const httpLink = new HttpLink({ uri });
|
||||
|
||||
// Create new client to call refresh token graphql mutation
|
||||
const client = new ApolloClient({
|
||||
link: ApolloLink.from([logger, httpLink]),
|
||||
cache: new InMemoryCache({}),
|
||||
});
|
||||
|
||||
let data: RenewTokenMutation | null | undefined;
|
||||
try {
|
||||
const result = await client.mutate<
|
||||
RenewTokenMutation,
|
||||
RenewTokenMutationVariables
|
||||
>({
|
||||
mutation: RenewTokenDocument,
|
||||
variables: {
|
||||
appToken: refreshToken,
|
||||
},
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
data = result.data;
|
||||
} catch {
|
||||
throw new Error('Something went wrong during token renewal');
|
||||
const result = await client.mutate<
|
||||
RenewTokenMutation,
|
||||
RenewTokenMutationVariables
|
||||
>({
|
||||
mutation: RenewTokenDocument,
|
||||
variables: {
|
||||
appToken: refreshToken,
|
||||
},
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
|
||||
if (isUndefinedOrNull(result.data)) {
|
||||
throw new Error('Token renewal returned empty data');
|
||||
}
|
||||
|
||||
if (isUndefinedOrNull(data)) {
|
||||
throw new Error('Something went wrong during token renewal');
|
||||
}
|
||||
|
||||
return data;
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const renewToken = async (
|
||||
|
||||
@@ -33,19 +33,22 @@ export const SSEClientEffect = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAccessTokenPair && !isDefined(sseClient) && isDefined(tokenPair)) {
|
||||
const token = tokenPair?.accessOrWorkspaceAgnosticToken?.token;
|
||||
|
||||
const newSseClient = createClient({
|
||||
url: `${REACT_APP_SERVER_BASE_URL}/metadata`,
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : '',
|
||||
headers: () => {
|
||||
const currentTokenPair = store.get(tokenPairState.atom);
|
||||
const token = currentTokenPair?.accessOrWorkspaceAgnosticToken?.token;
|
||||
|
||||
return {
|
||||
Authorization: token ? `Bearer ${token}` : '',
|
||||
};
|
||||
},
|
||||
on: {
|
||||
connected: handleSSEClientConnected,
|
||||
},
|
||||
retryAttempts: Infinity,
|
||||
retry: (retryCount: number) =>
|
||||
handleSseClientConnectionRetry(retryCount, token),
|
||||
handleSseClientConnectionRetry(retryCount),
|
||||
});
|
||||
|
||||
setSseClient(newSseClient);
|
||||
@@ -55,6 +58,7 @@ export const SSEClientEffect = () => {
|
||||
hasAccessTokenPair,
|
||||
setSseClient,
|
||||
sseClient,
|
||||
store,
|
||||
tokenPair,
|
||||
handleSseClientConnectionRetry,
|
||||
]);
|
||||
|
||||
+2
-4
@@ -13,7 +13,7 @@ import { useStore } from 'jotai';
|
||||
export const useHandleSseClientConnectionRetry = () => {
|
||||
const store = useStore();
|
||||
const handleSseClientConnectionRetry = useCallback(
|
||||
async (retryCount: number, initialTokenForSseClient: string) => {
|
||||
async (retryCount: number) => {
|
||||
const sseClient = store.get(sseClientState.atom);
|
||||
|
||||
if (!isDefined(sseClient)) {
|
||||
@@ -28,9 +28,7 @@ export const useHandleSseClientConnectionRetry = () => {
|
||||
const currentAppToken = tokenPair?.accessOrWorkspaceAgnosticToken?.token;
|
||||
|
||||
const shouldResetSseClient =
|
||||
!isDefined(currentAppToken) ||
|
||||
currentAppToken !== initialTokenForSseClient ||
|
||||
retryCount > 10;
|
||||
!isDefined(currentAppToken) || retryCount > 10;
|
||||
|
||||
if (shouldResetSseClient) {
|
||||
await sleep(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { sleep } from '~/utils/sleep';
|
||||
|
||||
type ShouldRetryFn = (error: unknown) => boolean;
|
||||
|
||||
export const retryWithBackoff = async <T>(
|
||||
fn: () => Promise<T>,
|
||||
{
|
||||
maxRetries = 3,
|
||||
baseDelayMs = 1000,
|
||||
shouldRetry,
|
||||
}: {
|
||||
maxRetries?: number;
|
||||
baseDelayMs?: number;
|
||||
shouldRetry: ShouldRetryFn;
|
||||
},
|
||||
): Promise<T> => {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
const isLastAttempt = attempt === maxRetries;
|
||||
|
||||
if (!shouldRetry(error) || isLastAttempt) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sleep(baseDelayMs * (attempt + 1));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unreachable');
|
||||
};
|
||||
Reference in New Issue
Block a user