Fix 2fa auth and token format migration (#13523)

Fix the 2FA setup and also make some changes so that the transition
towards a new token format introduced in a previous PR happens more
smoothly
This commit is contained in:
Félix Malfait
2025-07-31 14:13:12 +02:00
committed by GitHub
parent c8128c4d3f
commit f52973d71d
13 changed files with 992 additions and 27 deletions
@@ -105,13 +105,13 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
};
}
const token = tokenPair.accessOrWorkspaceAgnosticToken?.token;
return {
headers: {
...headers,
...options.headers,
authorization: tokenPair.accessOrWorkspaceAgnosticToken.token
? `Bearer ${tokenPair.accessOrWorkspaceAgnosticToken.token}`
: '',
authorization: token ? `Bearer ${token}` : '',
...(this.currentWorkspaceMember?.locale
? { 'x-locale': this.currentWorkspaceMember.locale }
: { 'x-locale': i18n.locale }),
@@ -0,0 +1,261 @@
import { cookieStorage } from '~/utils/cookie-storage';
import { getTokenPair } from '../getTokenPair';
jest.mock('~/utils/cookie-storage', () => ({
cookieStorage: {
getItem: jest.fn(),
removeItem: jest.fn(),
},
}));
const mockCookieStorage = cookieStorage as jest.Mocked<typeof cookieStorage>;
describe('getTokenPair', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when tokenPair cookie does not exist', () => {
it('should return undefined when cookie is not set', () => {
mockCookieStorage.getItem.mockReturnValue(undefined);
const result = getTokenPair();
expect(result).toBeUndefined();
expect(mockCookieStorage.getItem).toHaveBeenCalledWith('tokenPair');
});
it('should return undefined when cookie is undefined', () => {
mockCookieStorage.getItem.mockReturnValue(undefined);
const result = getTokenPair();
expect(result).toBeUndefined();
expect(mockCookieStorage.getItem).toHaveBeenCalledWith('tokenPair');
});
});
describe('when tokenPair cookie has invalid JSON', () => {
it('should remove cookie and return undefined for malformed JSON', () => {
mockCookieStorage.getItem.mockReturnValue('invalid-json');
const result = getTokenPair();
expect(result).toBeUndefined();
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
});
it('should remove cookie and return undefined for partial JSON', () => {
mockCookieStorage.getItem.mockReturnValue('{"incomplete":');
const result = getTokenPair();
expect(result).toBeUndefined();
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
});
});
describe('when tokenPair cookie has invalid structure', () => {
it('should remove cookie and return undefined when tokenPair is null', () => {
mockCookieStorage.getItem.mockReturnValue('null');
const result = getTokenPair();
expect(result).toBeUndefined();
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
});
it('should remove cookie and return undefined when tokenPair is not an object', () => {
mockCookieStorage.getItem.mockReturnValue('"string-value"');
const result = getTokenPair();
expect(result).toBeUndefined();
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
});
it('should remove cookie and return undefined when accessOrWorkspaceAgnosticToken is missing', () => {
const invalidTokenPair = {
refreshToken: { token: 'refresh-token' },
};
mockCookieStorage.getItem.mockReturnValue(
JSON.stringify(invalidTokenPair),
);
const result = getTokenPair();
expect(result).toBeUndefined();
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
});
it('should remove cookie and return undefined when accessOrWorkspaceAgnosticToken is not an object', () => {
const invalidTokenPair = {
accessOrWorkspaceAgnosticToken: 'not-an-object',
refreshToken: { token: 'refresh-token' },
};
mockCookieStorage.getItem.mockReturnValue(
JSON.stringify(invalidTokenPair),
);
const result = getTokenPair();
expect(result).toBeUndefined();
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
});
it('should remove cookie and return undefined when token is missing', () => {
const invalidTokenPair = {
accessOrWorkspaceAgnosticToken: {
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: { token: 'refresh-token' },
};
mockCookieStorage.getItem.mockReturnValue(
JSON.stringify(invalidTokenPair),
);
const result = getTokenPair();
expect(result).toBeUndefined();
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
});
it('should remove cookie and return undefined when token is not a string', () => {
const invalidTokenPair = {
accessOrWorkspaceAgnosticToken: {
token: 123,
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: { token: 'refresh-token' },
};
mockCookieStorage.getItem.mockReturnValue(
JSON.stringify(invalidTokenPair),
);
const result = getTokenPair();
expect(result).toBeUndefined();
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
});
it('should accept empty string token as valid', () => {
const validTokenPair = {
accessOrWorkspaceAgnosticToken: {
token: '',
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: { token: 'refresh-token' },
};
mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair));
const result = getTokenPair();
expect(result).toEqual(validTokenPair);
expect(mockCookieStorage.removeItem).not.toHaveBeenCalled();
});
});
describe('when tokenPair cookie has valid structure', () => {
it('should return valid tokenPair with all required fields', () => {
const validTokenPair = {
accessOrWorkspaceAgnosticToken: {
token: 'valid-access-token',
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: {
token: 'valid-refresh-token',
expiresAt: '2024-01-02T00:00:00Z',
},
};
mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair));
const result = getTokenPair();
expect(result).toEqual(validTokenPair);
expect(mockCookieStorage.removeItem).not.toHaveBeenCalled();
});
it('should return valid tokenPair with minimal required fields', () => {
const validTokenPair = {
accessOrWorkspaceAgnosticToken: {
token: 'minimal-access-token',
},
};
mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair));
const result = getTokenPair();
expect(result).toEqual(validTokenPair);
expect(mockCookieStorage.removeItem).not.toHaveBeenCalled();
});
it('should return valid tokenPair with extra fields', () => {
const validTokenPair = {
accessOrWorkspaceAgnosticToken: {
token: 'access-token-with-extras',
expiresAt: '2024-01-01T00:00:00Z',
extraField: 'extra-value',
},
refreshToken: {
token: 'refresh-token',
},
additionalField: 'additional-value',
};
mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair));
const result = getTokenPair();
expect(result).toEqual(validTokenPair);
expect(mockCookieStorage.removeItem).not.toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('should handle JSON parsing error gracefully', () => {
mockCookieStorage.getItem.mockReturnValue('{"valid": "json"');
// Simulate JSON.parse throwing an error
const originalParse = JSON.parse;
JSON.parse = jest.fn(() => {
throw new SyntaxError('Unexpected end of JSON input');
});
const result = getTokenPair();
expect(result).toBeUndefined();
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
// Restore original JSON.parse
JSON.parse = originalParse;
});
it('should handle very long token strings', () => {
const longToken = 'a'.repeat(10000);
const validTokenPair = {
accessOrWorkspaceAgnosticToken: {
token: longToken,
},
};
mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair));
const result = getTokenPair();
expect(result).toEqual(validTokenPair);
expect(result?.accessOrWorkspaceAgnosticToken.token).toHaveLength(10000);
});
it('should handle unicode characters in token', () => {
const unicodeToken = 'token-with-unicode-🚀-characters-∑';
const validTokenPair = {
accessOrWorkspaceAgnosticToken: {
token: unicodeToken,
},
};
mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair));
const result = getTokenPair();
expect(result).toEqual(validTokenPair);
expect(result?.accessOrWorkspaceAgnosticToken.token).toBe(unicodeToken);
});
});
});
@@ -0,0 +1,182 @@
import { getTokenPair } from '../getTokenPair';
import { hasTokenPair } from '../hasTokenPair';
jest.mock('../getTokenPair');
const mockGetTokenPair = getTokenPair as jest.MockedFunction<
typeof getTokenPair
>;
describe('hasTokenPair', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when getTokenPair returns a valid token pair', () => {
it('should return true for a complete valid token pair', () => {
const validTokenPair = {
accessOrWorkspaceAgnosticToken: {
token: 'valid-access-token',
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: {
token: 'valid-refresh-token',
expiresAt: '2024-01-02T00:00:00Z',
},
};
mockGetTokenPair.mockReturnValue(validTokenPair);
const result = hasTokenPair();
expect(result).toBe(true);
expect(mockGetTokenPair).toHaveBeenCalledTimes(1);
});
it('should return true for a minimal valid token pair', () => {
const minimalTokenPair = {
accessOrWorkspaceAgnosticToken: {
token: 'minimal-token',
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: {
token: 'refresh-token',
expiresAt: '2024-01-02T00:00:00Z',
},
};
mockGetTokenPair.mockReturnValue(minimalTokenPair);
const result = hasTokenPair();
expect(result).toBe(true);
expect(mockGetTokenPair).toHaveBeenCalledTimes(1);
});
it('should return true even if token pair has extra fields', () => {
const tokenPairWithExtras = {
accessOrWorkspaceAgnosticToken: {
token: 'access-token',
expiresAt: '2024-01-01T00:00:00Z',
customField: 'custom-value',
},
refreshToken: {
token: 'refresh-token',
expiresAt: '2024-01-02T00:00:00Z',
},
extraField: 'extra-value',
};
mockGetTokenPair.mockReturnValue(tokenPairWithExtras);
const result = hasTokenPair();
expect(result).toBe(true);
expect(mockGetTokenPair).toHaveBeenCalledTimes(1);
});
});
describe('when getTokenPair returns undefined', () => {
it('should return false when no token pair exists', () => {
mockGetTokenPair.mockReturnValue(undefined);
const result = hasTokenPair();
expect(result).toBe(false);
expect(mockGetTokenPair).toHaveBeenCalledTimes(1);
});
});
describe('validation behavior', () => {
it('should rely on getTokenPair for all validation logic', () => {
// Test that hasTokenPair doesn't do its own validation
// and trusts getTokenPair's validation completely
mockGetTokenPair.mockReturnValue(undefined);
hasTokenPair();
expect(mockGetTokenPair).toHaveBeenCalledTimes(1);
// Reset and test with valid token
mockGetTokenPair.mockClear();
const validToken = {
accessOrWorkspaceAgnosticToken: {
token: 'valid-token',
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: {
token: 'refresh-token',
expiresAt: '2024-01-02T00:00:00Z',
},
};
mockGetTokenPair.mockReturnValue(validToken);
const result = hasTokenPair();
expect(result).toBe(true);
expect(mockGetTokenPair).toHaveBeenCalledTimes(1);
});
it('should not perform additional validation beyond getTokenPair', () => {
// This test ensures hasTokenPair doesn't duplicate validation logic
const tokenPair = {
accessOrWorkspaceAgnosticToken: {
token: 'any-token-that-getTokenPair-considers-valid',
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: {
token: 'refresh-token',
expiresAt: '2024-01-02T00:00:00Z',
},
};
mockGetTokenPair.mockReturnValue(tokenPair);
const result = hasTokenPair();
expect(result).toBe(true);
// Should only call getTokenPair once, no additional validation
expect(mockGetTokenPair).toHaveBeenCalledTimes(1);
});
});
describe('multiple calls', () => {
it('should call getTokenPair on each invocation', () => {
const tokenPair = {
accessOrWorkspaceAgnosticToken: {
token: 'test-token',
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: {
token: 'refresh-token',
expiresAt: '2024-01-02T00:00:00Z',
},
};
mockGetTokenPair.mockReturnValue(tokenPair);
hasTokenPair();
hasTokenPair();
hasTokenPair();
expect(mockGetTokenPair).toHaveBeenCalledTimes(3);
});
it('should return consistent results for consistent getTokenPair results', () => {
const tokenPair = {
accessOrWorkspaceAgnosticToken: {
token: 'consistent-token',
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: {
token: 'refresh-token',
expiresAt: '2024-01-02T00:00:00Z',
},
};
mockGetTokenPair.mockReturnValue(tokenPair);
const result1 = hasTokenPair();
const result2 = hasTokenPair();
const result3 = hasTokenPair();
expect(result1).toBe(true);
expect(result2).toBe(true);
expect(result3).toBe(true);
});
});
});
@@ -1,10 +1,35 @@
import { cookieStorage } from '~/utils/cookie-storage';
import { isDefined } from 'twenty-shared/utils';
import { AuthTokenPair } from '~/generated/graphql';
import { cookieStorage } from '~/utils/cookie-storage';
export const getTokenPair = () => {
const stringTokenPair = cookieStorage.getItem('tokenPair');
return isDefined(stringTokenPair)
? (JSON.parse(stringTokenPair) as AuthTokenPair)
: undefined;
const isValidAuthTokenPair = (tokenPair: any): tokenPair is AuthTokenPair => {
return (
tokenPair &&
typeof tokenPair === 'object' &&
tokenPair.accessOrWorkspaceAgnosticToken &&
typeof tokenPair.accessOrWorkspaceAgnosticToken === 'object' &&
typeof tokenPair.accessOrWorkspaceAgnosticToken.token === 'string'
);
};
export const getTokenPair = (): AuthTokenPair | undefined => {
const stringTokenPair = cookieStorage.getItem('tokenPair');
if (!isDefined(stringTokenPair)) {
return undefined;
}
try {
const parsedTokenPair = JSON.parse(stringTokenPair);
if (!isValidAuthTokenPair(parsedTokenPair)) {
cookieStorage.removeItem('tokenPair');
return undefined;
}
return parsedTokenPair;
} catch (error) {
cookieStorage.removeItem('tokenPair');
return undefined;
}
};
@@ -3,8 +3,5 @@ import { isDefined } from 'twenty-shared/utils';
export const hasTokenPair = () => {
const tokenPair = getTokenPair();
return (
isDefined(tokenPair) &&
isDefined(tokenPair.accessOrWorkspaceAgnosticToken?.token)
);
return isDefined(tokenPair);
};
@@ -0,0 +1,298 @@
import { renderHook } from '@testing-library/react';
import { createClient } from 'graphql-sse';
import { DatabaseEventAction } from '~/generated/graphql';
import { getTokenPair } from '~/modules/apollo/utils/getTokenPair';
import { useOnDbEvent } from '../useOnDbEvent';
jest.mock('~/modules/apollo/utils/getTokenPair');
jest.mock('graphql-sse');
const mockGetTokenPair = getTokenPair as jest.MockedFunction<
typeof getTokenPair
>;
const mockCreateClient = createClient as jest.MockedFunction<
typeof createClient
>;
// Mock environment variable
const mockServerBaseUrl = 'http://localhost:3000';
jest.mock('~/config', () => ({
REACT_APP_SERVER_BASE_URL: 'http://localhost:3000',
}));
describe('useOnDbEvent', () => {
const mockUnsubscribe = jest.fn();
const mockClient = {
subscribe: jest.fn(() => mockUnsubscribe),
dispose: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockCreateClient.mockReturnValue(mockClient as any);
});
describe('token safety checks', () => {
it('should handle undefined tokenPair gracefully', () => {
mockGetTokenPair.mockReturnValue(undefined);
renderHook(() =>
useOnDbEvent({
input: {
objectNameSingular: 'test',
action: DatabaseEventAction.CREATED,
},
onData: jest.fn(),
}),
);
expect(mockCreateClient).toHaveBeenCalledWith({
url: `${mockServerBaseUrl}/graphql`,
headers: {
Authorization: '',
},
});
});
it('should handle tokenPair with undefined accessOrWorkspaceAgnosticToken gracefully', () => {
mockGetTokenPair.mockReturnValue({
accessOrWorkspaceAgnosticToken: undefined,
refreshToken: {
token: 'refresh-token',
expiresAt: '2024-01-02T00:00:00Z',
},
} as any);
renderHook(() =>
useOnDbEvent({
input: {
objectNameSingular: 'test',
action: DatabaseEventAction.CREATED,
},
onData: jest.fn(),
}),
);
expect(mockCreateClient).toHaveBeenCalledWith({
url: `${mockServerBaseUrl}/graphql`,
headers: {
Authorization: '',
},
});
});
it('should handle tokenPair with accessOrWorkspaceAgnosticToken but undefined token gracefully', () => {
mockGetTokenPair.mockReturnValue({
accessOrWorkspaceAgnosticToken: {
token: undefined,
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: {
token: 'refresh-token',
expiresAt: '2024-01-02T00:00:00Z',
},
} as any);
renderHook(() =>
useOnDbEvent({
input: {
objectNameSingular: 'test',
action: DatabaseEventAction.CREATED,
},
onData: jest.fn(),
}),
);
expect(mockCreateClient).toHaveBeenCalledWith({
url: `${mockServerBaseUrl}/graphql`,
headers: {
Authorization: '',
},
});
});
it('should handle tokenPair with null token gracefully', () => {
mockGetTokenPair.mockReturnValue({
accessOrWorkspaceAgnosticToken: {
token: null,
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: {
token: 'refresh-token',
expiresAt: '2024-01-02T00:00:00Z',
},
} as any);
renderHook(() =>
useOnDbEvent({
input: {
objectNameSingular: 'test',
action: DatabaseEventAction.CREATED,
},
onData: jest.fn(),
}),
);
expect(mockCreateClient).toHaveBeenCalledWith({
url: `${mockServerBaseUrl}/graphql`,
headers: {
Authorization: '',
},
});
});
it('should properly set authorization header when token is valid', () => {
const validToken = 'valid-access-token';
mockGetTokenPair.mockReturnValue({
accessOrWorkspaceAgnosticToken: {
token: validToken,
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: {
token: 'refresh-token',
expiresAt: '2024-01-02T00:00:00Z',
},
});
renderHook(() =>
useOnDbEvent({
input: {
objectNameSingular: 'test',
action: DatabaseEventAction.CREATED,
},
onData: jest.fn(),
}),
);
expect(mockCreateClient).toHaveBeenCalledWith({
url: `${mockServerBaseUrl}/graphql`,
headers: {
Authorization: `Bearer ${validToken}`,
},
});
});
it('should handle empty string token gracefully', () => {
mockGetTokenPair.mockReturnValue({
accessOrWorkspaceAgnosticToken: {
token: '',
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: {
token: 'refresh-token',
expiresAt: '2024-01-02T00:00:00Z',
},
});
renderHook(() =>
useOnDbEvent({
input: {
objectNameSingular: 'test',
action: DatabaseEventAction.CREATED,
},
onData: jest.fn(),
}),
);
expect(mockCreateClient).toHaveBeenCalledWith({
url: `${mockServerBaseUrl}/graphql`,
headers: {
Authorization: '',
},
});
});
});
describe('basic functionality', () => {
const validToken = 'test-token';
beforeEach(() => {
mockGetTokenPair.mockReturnValue({
accessOrWorkspaceAgnosticToken: {
token: validToken,
expiresAt: '2024-01-01T00:00:00Z',
},
refreshToken: {
token: 'refresh-token',
expiresAt: '2024-01-02T00:00:00Z',
},
});
});
it('should create SSE client with correct URL and headers', () => {
renderHook(() =>
useOnDbEvent({
input: {
objectNameSingular: 'test',
action: DatabaseEventAction.CREATED,
},
onData: jest.fn(),
}),
);
expect(mockCreateClient).toHaveBeenCalledWith({
url: `${mockServerBaseUrl}/graphql`,
headers: {
Authorization: `Bearer ${validToken}`,
},
});
});
it('should not subscribe when skip is true', () => {
renderHook(() =>
useOnDbEvent({
input: {
objectNameSingular: 'test',
action: DatabaseEventAction.CREATED,
},
onData: jest.fn(),
skip: true,
}),
);
expect(mockClient.subscribe).not.toHaveBeenCalled();
});
it('should subscribe when skip is false or undefined', () => {
const mockOnData = jest.fn();
renderHook(() =>
useOnDbEvent({
input: {
objectNameSingular: 'test',
action: DatabaseEventAction.CREATED,
},
onData: mockOnData,
skip: false,
}),
);
expect(mockClient.subscribe).toHaveBeenCalled();
});
it('should pass correct parameters to subscription', () => {
const mockOnData = jest.fn();
renderHook(() =>
useOnDbEvent({
input: {
objectNameSingular: 'person',
action: DatabaseEventAction.CREATED,
},
onData: mockOnData,
}),
);
expect(mockClient.subscribe).toHaveBeenCalledWith(
expect.objectContaining({
query: expect.stringContaining('subscription'),
}),
expect.objectContaining({
next: expect.any(Function),
error: expect.any(Function),
}),
);
});
});
});
@@ -22,15 +22,15 @@ export const useOnDbEvent = ({
const tokenPair = getTokenPair();
const sseClient = useMemo(() => {
const token = tokenPair?.accessOrWorkspaceAgnosticToken?.token;
return createClient({
url: `${REACT_APP_SERVER_BASE_URL}/graphql`,
headers: {
Authorization: tokenPair?.accessOrWorkspaceAgnosticToken.token
? `Bearer ${tokenPair?.accessOrWorkspaceAgnosticToken.token}`
: '',
Authorization: token ? `Bearer ${token}` : '',
},
});
}, [tokenPair?.accessOrWorkspaceAgnosticToken.token]);
}, [tokenPair?.accessOrWorkspaceAgnosticToken?.token]);
useEffect(() => {
if (skip === true) {