fix: harden token renewal and soften refresh token revocation (#19175)

## Summary
- **Apollo factory** (`apollo.factory.ts`): Bail early with `EMPTY` when
no token pair exists so no request is forwarded without credentials.
Propagate renewal success/failure as a boolean so failed renewals stop
the operation chain instead of forwarding with stale tokens.
- **Refresh token service** (`refresh-token.service.ts`): When a revoked
refresh token is reused past the grace period, reject only that token
instead of mass-revoking all user tokens. The most common cause is a
lost renewal response (e.g. navigation during refresh), not actual token
theft. This eliminates the "Suspicious activity detected" errors users
were seeing. Also switches to `findOneBy` since the `appTokens` relation
is no longer needed.

## Test plan
- [x] `refresh-token.service.spec.ts` — all 7 tests pass
- [ ] Verify login flow: sign in from `app.localhost`, get redirected to
workspace subdomain without "Suspicious activity" errors
- [ ] Verify token renewal: let access token expire, confirm silent
renewal works and operations resume
- [ ] Verify concurrent tabs: open multiple tabs, let tokens expire,
confirm no mass revocation cascade

Made with [Cursor](https://cursor.com)

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-04-02 14:01:31 +02:00
committed by GitHub
parent b8e7179a85
commit 2ebff5f4d7
5 changed files with 36 additions and 29 deletions
@@ -1,5 +1,4 @@
import { gql } from '@apollo/client';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { act, renderHook } from '@testing-library/react';
import fetchMock, { enableFetchMocks } from 'jest-fetch-mock';
import { MemoryRouter, useLocation } from 'react-router-dom';
@@ -8,6 +7,13 @@ import { useApolloFactory } from '@/apollo/hooks/useApolloFactory';
enableFetchMocks();
jest.mock('@/apollo/utils/getTokenPair', () => ({
getTokenPair: jest.fn().mockReturnValue({
accessOrWorkspaceAgnosticToken: { token: 'testAccessToken', expiresAt: '' },
refreshToken: { token: 'testRefreshToken', expiresAt: '' },
}),
}));
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => {
@@ -88,10 +94,7 @@ describe('useApolloFactory', () => {
});
});
} catch (error) {
expect(error).toBeInstanceOf(CombinedGraphQLErrors);
expect((error as CombinedGraphQLErrors).message).toBe(
'Error message not found.',
);
expect(error).toBeDefined();
expect(mockNavigate).toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith('/welcome');
@@ -28,6 +28,13 @@ jest.mock('@/auth/services/AuthService', () => {
};
});
jest.mock('@/apollo/utils/getTokenPair', () => ({
getTokenPair: jest.fn().mockReturnValue({
accessOrWorkspaceAgnosticToken: { token: 'testAccessToken', expiresAt: '' },
refreshToken: { token: 'testRefreshToken', expiresAt: '' },
}),
}));
const mockOnError = jest.fn();
const mockOnNetworkError = jest.fn();
const mockOnPayloadTooLarge = jest.fn();
@@ -7,7 +7,7 @@ import {
import { setContext } from '@apollo/client/link/context';
import { ErrorLink } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
import { from, switchMap } from 'rxjs';
import { EMPTY, from, switchMap } from 'rxjs';
import { RestLink } from 'apollo-link-rest';
import UploadHttpLink from 'apollo-upload-client/UploadHttpLink.mjs';
@@ -41,7 +41,7 @@ const logger = loggerLink(() => 'Twenty');
// Shared across all ApolloFactory instances so concurrent
// UNAUTHENTICATED errors from /graphql and /metadata clients
// deduplicate into a single renewal request.
let renewalPromise: Promise<void> | null = null;
let renewalPromise: Promise<boolean> | null = null;
const TOKEN_RENEWAL_MAX_RETRIES = 3;
const TOKEN_RENEWAL_RETRY_DELAY_MS = 1000;
@@ -181,21 +181,32 @@ export class ApolloFactory implements ApolloManager {
operation: ApolloLink.Operation,
forward: ApolloLink.ForwardFunction,
) => {
if (!getTokenPair()) {
onUnauthenticatedError?.();
return EMPTY;
}
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?.();
return false;
})
.finally(() => {
renewalPromise = null;
});
}
return from(renewalPromise).pipe(switchMap(() => forward(operation)));
return from(renewalPromise).pipe(
switchMap((succeeded) => (succeeded ? forward(operation) : EMPTY)),
);
};
const sendToSentry = ({