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