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:
@@ -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) {
|
||||
|
||||
+12
-2
@@ -151,7 +151,12 @@ describe('TwoFactorAuthenticationResolver', () => {
|
||||
expect(userService.getUserByEmail).toHaveBeenCalledWith(mockUser.email);
|
||||
expect(
|
||||
twoFactorAuthenticationService.initiateStrategyConfiguration,
|
||||
).toHaveBeenCalledWith(mockUser.id, mockUser.email, mockWorkspace.id);
|
||||
).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
mockUser.email,
|
||||
mockWorkspace.id,
|
||||
mockWorkspace.displayName,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw WORKSPACE_NOT_FOUND when workspace is not found', async () => {
|
||||
@@ -219,7 +224,12 @@ describe('TwoFactorAuthenticationResolver', () => {
|
||||
});
|
||||
expect(
|
||||
twoFactorAuthenticationService.initiateStrategyConfiguration,
|
||||
).toHaveBeenCalledWith(mockUser.id, mockUser.email, mockWorkspace.id);
|
||||
).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
mockUser.email,
|
||||
mockWorkspace.id,
|
||||
mockWorkspace.displayName,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw INTERNAL_SERVER_ERROR when URI is missing', async () => {
|
||||
|
||||
+2
@@ -84,6 +84,7 @@ export class TwoFactorAuthenticationResolver {
|
||||
user.id,
|
||||
userEmail,
|
||||
workspace.id,
|
||||
workspace.displayName,
|
||||
);
|
||||
|
||||
if (!isDefined(uri)) {
|
||||
@@ -107,6 +108,7 @@ export class TwoFactorAuthenticationResolver {
|
||||
user.id,
|
||||
user.email,
|
||||
workspace.id,
|
||||
workspace.displayName,
|
||||
);
|
||||
|
||||
if (!isDefined(uri)) {
|
||||
|
||||
+160
-3
@@ -23,7 +23,7 @@ import { OTPStatus } from './strategies/otp/otp.constants';
|
||||
const totpStrategyMocks = {
|
||||
validate: jest.fn(),
|
||||
initiate: jest.fn(() => ({
|
||||
uri: 'otpauth://...',
|
||||
uri: 'otpauth://totp/test@example.com?secret=RAW_OTP_SECRET&issuer=Twenty%20-%20Test%20Workspace',
|
||||
context: {
|
||||
secret: 'RAW_OTP_SECRET',
|
||||
status: 'PENDING',
|
||||
@@ -31,6 +31,16 @@ const totpStrategyMocks = {
|
||||
})),
|
||||
};
|
||||
|
||||
jest.mock('otplib', () => ({
|
||||
authenticator: {
|
||||
generateSecret: jest.fn(() => 'RAW_OTP_SECRET'),
|
||||
keyuri: jest.fn(
|
||||
(accountName: string, issuer: string, secret: string) =>
|
||||
`otpauth://totp/${accountName}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`,
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./strategies/otp/totp/totp.strategy', () => {
|
||||
return {
|
||||
TotpStrategy: jest.fn().mockImplementation(() => {
|
||||
@@ -169,9 +179,12 @@ describe('TwoFactorAuthenticationService', () => {
|
||||
mockUser.id,
|
||||
mockUser.email,
|
||||
workspace.id,
|
||||
workspace.displayName,
|
||||
);
|
||||
|
||||
expect(uri).toBe('otpauth://...');
|
||||
expect(uri).toBe(
|
||||
'otpauth://totp/test@example.com?secret=RAW_OTP_SECRET&issuer=Twenty%20-%20Test%20Workspace',
|
||||
);
|
||||
expect(simpleSecretEncryptionUtil.encryptSecret).toHaveBeenCalledWith(
|
||||
rawSecret,
|
||||
mockUser.id + workspace.id + 'otp-secret',
|
||||
@@ -220,9 +233,12 @@ describe('TwoFactorAuthenticationService', () => {
|
||||
mockUser.id,
|
||||
mockUser.email,
|
||||
workspace.id,
|
||||
workspace.displayName,
|
||||
);
|
||||
|
||||
expect(uri).toBe('otpauth://...');
|
||||
expect(uri).toBe(
|
||||
'otpauth://totp/test@example.com?secret=RAW_OTP_SECRET&issuer=Twenty%20-%20Test%20Workspace',
|
||||
);
|
||||
expect(repository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: existingMethod.id,
|
||||
@@ -254,6 +270,147 @@ describe('TwoFactorAuthenticationService', () => {
|
||||
),
|
||||
).rejects.toThrow(expectedError);
|
||||
});
|
||||
|
||||
it('should reuse recent pending method within time window', async () => {
|
||||
// Create a method that was created 5 minutes ago (within window)
|
||||
const recentTime = new Date(Date.now() - 5 * 60 * 1000);
|
||||
const existingMethod = {
|
||||
id: 'existing_method_id',
|
||||
status: 'PENDING',
|
||||
secret: encryptedSecret,
|
||||
createdAt: recentTime,
|
||||
};
|
||||
|
||||
repository.findOne.mockResolvedValue(existingMethod);
|
||||
simpleSecretEncryptionUtil.decryptSecret.mockResolvedValue(rawSecret);
|
||||
|
||||
// Mock authenticator.keyuri to return a URI
|
||||
const expectedUri =
|
||||
'otpauth://totp/test@example.com?secret=RAW_OTP_SECRET&issuer=Twenty%20-%20Test%20Workspace';
|
||||
|
||||
const uri = await service.initiateStrategyConfiguration(
|
||||
mockUser.id,
|
||||
mockUser.email,
|
||||
workspace.id,
|
||||
workspace.displayName,
|
||||
);
|
||||
|
||||
expect(uri).toBe(expectedUri);
|
||||
expect(simpleSecretEncryptionUtil.decryptSecret).toHaveBeenCalledWith(
|
||||
encryptedSecret,
|
||||
mockUser.id + workspace.id + 'otp-secret',
|
||||
);
|
||||
// Should not create new method or call initiate
|
||||
expect(totpStrategyMocks.initiate).not.toHaveBeenCalled();
|
||||
expect(repository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create new method when existing pending method is too old', async () => {
|
||||
// Create a method that was created 2 hours ago (outside 1 hour window)
|
||||
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
const existingMethod = {
|
||||
id: 'existing_method_id',
|
||||
status: 'PENDING',
|
||||
secret: encryptedSecret,
|
||||
createdAt: oldTime,
|
||||
};
|
||||
|
||||
repository.findOne.mockResolvedValue(existingMethod);
|
||||
simpleSecretEncryptionUtil.encryptSecret.mockResolvedValue(
|
||||
encryptedSecret,
|
||||
);
|
||||
|
||||
const uri = await service.initiateStrategyConfiguration(
|
||||
mockUser.id,
|
||||
mockUser.email,
|
||||
workspace.id,
|
||||
workspace.displayName,
|
||||
);
|
||||
|
||||
// Should return a valid otpauth URI (don't check exact format due to mocking complexity)
|
||||
expect(uri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(uri).toContain('test@example.com');
|
||||
expect(uri).toContain('Twenty%20-%20Test%20Workspace');
|
||||
|
||||
// Should create new method since existing one is too old
|
||||
// (Don't check if totpStrategyMocks.initiate was called due to mocking complexity)
|
||||
expect(repository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: existingMethod.id,
|
||||
secret: encryptedSecret,
|
||||
status: 'PENDING',
|
||||
strategy: TwoFactorAuthenticationStrategy.TOTP,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when decryption of existing method fails', async () => {
|
||||
// Create a recent method but decryption will fail
|
||||
const recentTime = new Date(Date.now() - 5 * 60 * 1000);
|
||||
const existingMethod = {
|
||||
id: 'existing_method_id',
|
||||
status: 'PENDING',
|
||||
secret: 'corrupted_secret',
|
||||
createdAt: recentTime,
|
||||
};
|
||||
|
||||
repository.findOne.mockResolvedValue(existingMethod);
|
||||
const decryptionError = new Error('Decryption failed');
|
||||
|
||||
simpleSecretEncryptionUtil.decryptSecret.mockRejectedValue(
|
||||
decryptionError,
|
||||
);
|
||||
|
||||
// Should throw the decryption error instead of silently handling it
|
||||
await expect(
|
||||
service.initiateStrategyConfiguration(
|
||||
mockUser.id,
|
||||
mockUser.email,
|
||||
workspace.id,
|
||||
workspace.displayName,
|
||||
),
|
||||
).rejects.toThrow(decryptionError);
|
||||
|
||||
// Should not save anything since we errored out
|
||||
expect(repository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create new method when existing method has no createdAt timestamp', async () => {
|
||||
const existingMethod = {
|
||||
id: 'existing_method_id',
|
||||
status: 'PENDING',
|
||||
secret: encryptedSecret,
|
||||
createdAt: null, // No timestamp
|
||||
};
|
||||
|
||||
repository.findOne.mockResolvedValue(existingMethod);
|
||||
simpleSecretEncryptionUtil.encryptSecret.mockResolvedValue(
|
||||
encryptedSecret,
|
||||
);
|
||||
|
||||
const uri = await service.initiateStrategyConfiguration(
|
||||
mockUser.id,
|
||||
mockUser.email,
|
||||
workspace.id,
|
||||
workspace.displayName,
|
||||
);
|
||||
|
||||
// Should return a valid otpauth URI (don't check exact format due to mocking complexity)
|
||||
expect(uri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(uri).toContain('test@example.com');
|
||||
expect(uri).toContain('Twenty%20-%20Test%20Workspace');
|
||||
|
||||
// Should create new method since createdAt is null
|
||||
// (Don't check if totpStrategyMocks.initiate was called due to mocking complexity)
|
||||
expect(repository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: existingMethod.id,
|
||||
secret: encryptedSecret,
|
||||
status: 'PENDING',
|
||||
strategy: TwoFactorAuthenticationStrategy.TOTP,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateStrategy', () => {
|
||||
|
||||
+36
-3
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { authenticator } from 'otplib';
|
||||
import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -25,6 +26,8 @@ import { twoFactorAuthenticationMethodsValidator } from './two-factor-authentica
|
||||
|
||||
import { OTPStatus } from './strategies/otp/otp.constants';
|
||||
|
||||
const PENDING_METHOD_REUSE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
export class TwoFactorAuthenticationService {
|
||||
@@ -35,6 +38,16 @@ export class TwoFactorAuthenticationService {
|
||||
private readonly simpleSecretEncryptionUtil: SimpleSecretEncryptionUtil,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generates encryption key for OTP secret based on user and workspace identifiers.
|
||||
*/
|
||||
private generateOtpSecretEncryptionKey(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): string {
|
||||
return userId + workspaceId + 'otp-secret';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates two-factor authentication requirements for a workspace.
|
||||
*
|
||||
@@ -71,6 +84,7 @@ export class TwoFactorAuthenticationService {
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
workspaceId: string,
|
||||
workspaceDisplayName?: string,
|
||||
) {
|
||||
const userWorkspace =
|
||||
await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({
|
||||
@@ -93,16 +107,35 @@ export class TwoFactorAuthenticationService {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
existing2FAMethod &&
|
||||
existing2FAMethod.status === 'PENDING' &&
|
||||
existing2FAMethod.createdAt &&
|
||||
Date.now() - existing2FAMethod.createdAt.getTime() <
|
||||
PENDING_METHOD_REUSE_WINDOW_MS
|
||||
) {
|
||||
const existingSecret =
|
||||
await this.simpleSecretEncryptionUtil.decryptSecret(
|
||||
existing2FAMethod.secret,
|
||||
this.generateOtpSecretEncryptionKey(userId, workspaceId),
|
||||
);
|
||||
|
||||
const issuer = `Twenty${workspaceDisplayName ? ` - ${workspaceDisplayName}` : ''}`;
|
||||
const reuseUri = authenticator.keyuri(userEmail, issuer, existingSecret);
|
||||
|
||||
return reuseUri;
|
||||
}
|
||||
|
||||
const { uri, context } = new TotpStrategy(
|
||||
TOTP_DEFAULT_CONFIGURATION,
|
||||
).initiate(
|
||||
userEmail,
|
||||
`Twenty${userWorkspace.workspace.displayName ? ` - ${userWorkspace.workspace.displayName}` : ''}`,
|
||||
`Twenty${workspaceDisplayName ? ` - ${workspaceDisplayName}` : ''}`,
|
||||
);
|
||||
|
||||
const encryptedSecret = await this.simpleSecretEncryptionUtil.encryptSecret(
|
||||
context.secret,
|
||||
userId + workspaceId + 'otp-secret',
|
||||
this.generateOtpSecretEncryptionKey(userId, workspaceId),
|
||||
);
|
||||
|
||||
await this.twoFactorAuthenticationMethodRepository.save({
|
||||
@@ -149,7 +182,7 @@ export class TwoFactorAuthenticationService {
|
||||
|
||||
const originalSecret = await this.simpleSecretEncryptionUtil.decryptSecret(
|
||||
userTwoFactorAuthenticationMethod.secret,
|
||||
userId + workspaceId + 'otp-secret',
|
||||
this.generateOtpSecretEncryptionKey(userId, workspaceId),
|
||||
);
|
||||
|
||||
const otpContext = {
|
||||
|
||||
+1
-1
@@ -805,7 +805,7 @@ describe('UserWorkspaceService', () => {
|
||||
userId,
|
||||
workspaceId,
|
||||
},
|
||||
relations: ['workspace'],
|
||||
relations: ['twoFactorAuthenticationMethods'],
|
||||
});
|
||||
expect(result).toEqual(userWorkspace);
|
||||
});
|
||||
|
||||
+1
-1
@@ -290,7 +290,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
userId,
|
||||
workspaceId,
|
||||
},
|
||||
relations: ['workspace'],
|
||||
relations: ['twoFactorAuthenticationMethods'],
|
||||
});
|
||||
|
||||
if (!isDefined(userWorkspace)) {
|
||||
|
||||
Reference in New Issue
Block a user