fix(front): only sign out when token renewal is rejected by the server (#22983)

## Context

Users are frequently signed out when coming back to Twenty. The console
shows `Failed to renew token after retries, triggering unauthenticated
error`: the access token has expired and the `renewToken` call fails.
Today any renewal failure wipes the stored token pair and redirects to
sign-in, even when the refresh token is still valid, for example when
the renewal request hits a transient network failure (laptop waking up,
VPN reconnecting) or a server restart during a deploy. Since the token
pair state is synced across tabs, one failing tab signs out every tab.

## What this does

- Only triggers the unauthenticated flow when the server definitively
rejects the refresh token. The `renewToken` mutation maps those cases to
`UNAUTHENTICATED` (expired or invalid JWT), `FORBIDDEN` (revoked) and
`BAD_USER_INPUT` (unknown or malformed token).
- Keeps the session on any other renewal failure (network errors after
retries, server errors): the token pair stays in place and the next
request triggers a fresh renewal attempt, so the session recovers once
the server is reachable again.
- Signs out immediately when the stored pair has no refresh token
instead of attempting a renewal that cannot succeed.
- Logs the renewal error, which was previously swallowed and made this
class of logouts hard to diagnose.

## Tests

- renews and replays the operation after an access token rejection
- signs out when the server rejects the refresh token
- keeps the session on a network error (asserts all retry attempts ran)
and on a server error
- signs out without attempting renewal when the stored pair has no
refresh token

Test mocks now reset between tests so per-test overrides cannot leak
into other tests.

[[Review in
cubic](https://www.cubic.dev/buttons/review-in-cubic-dark.svg)](https://cubic.dev/pr/twentyhq/twenty/pull/22983?utm_source=github)
This commit is contained in:
Félix Malfait
2026-07-17 15:45:54 +02:00
committed by GitHub
parent f6612e5a85
commit dc0bb7760f
2 changed files with 146 additions and 20 deletions
@@ -3,12 +3,15 @@ import { CombinedGraphQLErrors } from '@apollo/client/errors';
import fetchMock, { enableFetchMocks } from 'jest-fetch-mock';
import { ApolloFactory, type Options } from '@/apollo/services/apollo.factory';
import { getTokenPair } from '@/apollo/utils/getTokenPair';
import { renewToken } from '@/auth/services/AuthService';
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
import {
AUTO_SELECT_FAST_MODEL_ID,
AUTO_SELECT_SMART_MODEL_ID,
} from 'twenty-shared/constants';
import {
type AuthTokenPair,
WorkspaceActivationStatus,
WorkspaceDiscoverability,
} from '~/generated-metadata/graphql';
@@ -19,28 +22,38 @@ jest.mock('@/auth/services/AuthService', () => {
const initialAuthService = jest.requireActual('@/auth/services/AuthService');
return {
...initialAuthService,
renewToken: jest.fn().mockReturnValue(
Promise.resolve({
accessOrWorkspaceAgnosticToken: {
token: 'newAccessToken',
expiresAt: '',
},
refreshToken: { token: 'newRefreshToken', expiresAt: '' },
}),
),
renewToken: jest.fn(),
};
});
jest.mock('@/apollo/utils/getTokenPair', () => ({
getTokenPair: jest.fn().mockReturnValue({
accessOrWorkspaceAgnosticToken: { token: 'testAccessToken', expiresAt: '' },
refreshToken: { token: 'testRefreshToken', expiresAt: '' },
}),
getTokenPair: jest.fn(),
}));
jest.mock('~/utils/sleep', () => ({
sleep: jest.fn().mockResolvedValue(undefined),
}));
const CURRENT_TOKEN_PAIR: AuthTokenPair = {
accessOrWorkspaceAgnosticToken: { token: 'testAccessToken', expiresAt: '' },
refreshToken: { token: 'testRefreshToken', expiresAt: '' },
};
const RENEWED_TOKEN_PAIR: AuthTokenPair = {
accessOrWorkspaceAgnosticToken: { token: 'newAccessToken', expiresAt: '' },
refreshToken: { token: 'newRefreshToken', expiresAt: '' },
};
const UNAUTHENTICATED_RESPONSE = JSON.stringify({
data: {},
errors: [{ extensions: { code: 'UNAUTHENTICATED' } }],
});
const mockOnError = jest.fn();
const mockOnNetworkError = jest.fn();
const mockOnPayloadTooLarge = jest.fn();
const mockOnTokenPairChange = jest.fn();
const mockOnUnauthenticatedError = jest.fn();
const mockWorkspaceMember = {
id: 'workspace-member-id',
@@ -103,6 +116,8 @@ const createMockOptions = (): Options => ({
onError: mockOnError,
onNetworkError: mockOnNetworkError,
onPayloadTooLarge: mockOnPayloadTooLarge,
onTokenPairChange: mockOnTokenPairChange,
onUnauthenticatedError: mockOnUnauthenticatedError,
appVersion: '1.0.0',
});
@@ -134,6 +149,13 @@ const makeRequest = async () => {
};
describe('ApolloFactory', () => {
beforeEach(() => {
jest.clearAllMocks();
fetchMock.resetMocks();
jest.mocked(renewToken).mockReset().mockResolvedValue(RENEWED_TOKEN_PAIR);
jest.mocked(getTokenPair).mockReset().mockReturnValue(CURRENT_TOKEN_PAIR);
});
it('should create an instance of ApolloFactory', () => {
const options = createMockOptions();
const apolloFactory = new ApolloFactory(options);
@@ -265,4 +287,83 @@ describe('ApolloFactory', () => {
);
}
}, 10000);
it('should renew tokens and replay the operation when the access token is rejected', async () => {
fetchMock.mockResponses(
UNAUTHENTICATED_RESPONSE,
JSON.stringify({ data: { trackAnalytics: { success: true } } }),
);
await makeRequest();
expect(renewToken).toHaveBeenCalledTimes(1);
expect(mockOnTokenPairChange).toHaveBeenCalledWith(RENEWED_TOKEN_PAIR);
expect(mockOnUnauthenticatedError).not.toHaveBeenCalled();
});
it('should trigger unauthenticated error when the server rejects the refresh token', async () => {
fetchMock.mockResponse(UNAUTHENTICATED_RESPONSE);
jest.mocked(renewToken).mockRejectedValue(
new CombinedGraphQLErrors({
errors: [
{
message: 'This refresh token has been revoked.',
extensions: { code: 'FORBIDDEN' },
},
],
}),
);
await expect(makeRequest()).rejects.toBeInstanceOf(CombinedGraphQLErrors);
expect(renewToken).toHaveBeenCalledTimes(1);
expect(mockOnUnauthenticatedError).toHaveBeenCalledTimes(1);
expect(mockOnTokenPairChange).not.toHaveBeenCalled();
});
it('should keep the session when token renewal fails on a network error', async () => {
fetchMock.mockResponse(UNAUTHENTICATED_RESPONSE);
jest.mocked(renewToken).mockRejectedValue(new Error('Failed to fetch'));
await expect(makeRequest()).rejects.toBeInstanceOf(CombinedGraphQLErrors);
expect(renewToken).toHaveBeenCalledTimes(4);
expect(mockOnUnauthenticatedError).not.toHaveBeenCalled();
expect(mockOnTokenPairChange).not.toHaveBeenCalled();
});
it('should keep the session when token renewal fails on a server error', async () => {
fetchMock.mockResponse(UNAUTHENTICATED_RESPONSE);
jest.mocked(renewToken).mockRejectedValue(
new CombinedGraphQLErrors({
errors: [
{
message: 'Internal server error',
extensions: { code: 'INTERNAL_SERVER_ERROR' },
},
],
}),
);
await expect(makeRequest()).rejects.toBeInstanceOf(CombinedGraphQLErrors);
expect(renewToken).toHaveBeenCalledTimes(1);
expect(mockOnUnauthenticatedError).not.toHaveBeenCalled();
expect(mockOnTokenPairChange).not.toHaveBeenCalled();
});
it('should trigger unauthenticated error without renewing when the stored pair has no refresh token', async () => {
jest.mocked(getTokenPair).mockReturnValue({
accessOrWorkspaceAgnosticToken: {
token: 'testAccessToken',
expiresAt: '',
},
} as unknown as AuthTokenPair);
fetchMock.mockResponse(UNAUTHENTICATED_RESPONSE);
await expect(makeRequest()).rejects.toBeInstanceOf(CombinedGraphQLErrors);
expect(renewToken).not.toHaveBeenCalled();
expect(mockOnUnauthenticatedError).toHaveBeenCalledTimes(1);
});
});
@@ -46,6 +46,22 @@ let renewalPromise: Promise<boolean> | null = null;
const TOKEN_RENEWAL_MAX_RETRIES = 3;
const TOKEN_RENEWAL_RETRY_DELAY_MS = 1000;
// Error codes returned by the renewToken mutation when the server
// definitively rejects the refresh token (expired, revoked or unknown).
const TOKEN_RENEWAL_REJECTION_CODES = [
'UNAUTHENTICATED',
'FORBIDDEN',
'BAD_USER_INPUT',
];
const isTokenRenewalRejection = (error: unknown): boolean =>
CombinedGraphQLErrors.is(error) &&
error.errors.some((graphQLError) =>
TOKEN_RENEWAL_REJECTION_CODES.includes(
graphQLError.extensions?.code as string,
),
);
export interface Options {
uri: string;
cache: ApolloClient.Options['cache'];
@@ -182,7 +198,7 @@ export class ApolloFactory implements ApolloManager {
forward: ApolloLink.ForwardFunction,
error: ErrorLike,
) => {
if (!getTokenPair()) {
if (!getTokenPair()?.refreshToken?.token) {
onUnauthenticatedError?.();
return throwError(() => error);
@@ -191,12 +207,21 @@ export class ApolloFactory implements ApolloManager {
if (!renewalPromise) {
renewalPromise = attemptTokenRenewal()
.then(() => true)
.catch(() => {
// oxlint-disable-next-line no-console
console.log(
'Failed to renew token after retries, triggering unauthenticated error',
);
onUnauthenticatedError?.();
.catch((renewalError) => {
if (isTokenRenewalRejection(renewalError)) {
// oxlint-disable-next-line no-console
console.log(
'Refresh token rejected by the server, triggering unauthenticated error',
renewalError,
);
onUnauthenticatedError?.();
} else {
// oxlint-disable-next-line no-console
console.log(
'Token renewal failed transiently, keeping session for retry',
renewalError,
);
}
return false;
})