From dc0bb7760fe213f1f4f8fd4ce8c32c80b38ed086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 17 Jul 2026 15:45:54 +0200 Subject: [PATCH] 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) --- .../services/__tests__/apollo.factory.test.ts | 127 ++++++++++++++++-- .../modules/apollo/services/apollo.factory.ts | 39 +++++- 2 files changed, 146 insertions(+), 20 deletions(-) diff --git a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts index 59eafd69ba..24ee8ef7b0 100644 --- a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts +++ b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts @@ -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); + }); }); diff --git a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts index 905b756d0a..564b27c275 100644 --- a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts +++ b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts @@ -46,6 +46,22 @@ let renewalPromise: Promise | 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; })