Implement Two-Factor Authentication (2FA) (#13141)

Implementation is very simple

Established authentication dynamic is intercepted at
getAuthTokensFromLoginToken. If 2FA is required, a pattern similar to
EmailVerification is executed. That is, getAuthTokensFromLoginToken
mutation fails with either of the following errors:

1. TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED
2. TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED

UI knows how to respond accordingly.

2FA provisioning occurs at the 2FA resolver.
2FA verification, currently only OTP, is handled by auth.resolver's
getAuthTokensFromOTP

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Jean-Baptiste Ronssin <65334819+jbronssin@users.noreply.github.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
oliver
2025-07-23 06:42:01 -06:00
committed by GitHub
parent dd5ae66449
commit 4d3124f840
106 changed files with 5103 additions and 103 deletions
@@ -31,6 +31,42 @@ jest.mock('@/object-metadata/hooks/useRefreshObjectMetadataItem', () => ({
})),
}));
jest.mock('@/domain-manager/hooks/useOrigin', () => ({
useOrigin: jest.fn().mockImplementation(() => ({
origin: 'http://localhost',
})),
}));
jest.mock('@/captcha/hooks/useRequestFreshCaptchaToken', () => ({
useRequestFreshCaptchaToken: jest.fn().mockImplementation(() => ({
requestFreshCaptchaToken: jest.fn(),
})),
}));
jest.mock('@/auth/sign-in-up/hooks/useSignUpInNewWorkspace', () => ({
useSignUpInNewWorkspace: jest.fn().mockImplementation(() => ({
createWorkspace: jest.fn(),
})),
}));
jest.mock('@/domain-manager/hooks/useRedirectToWorkspaceDomain', () => ({
useRedirectToWorkspaceDomain: jest.fn().mockImplementation(() => ({
redirectToWorkspaceDomain: jest.fn(),
})),
}));
jest.mock('@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace', () => ({
useIsCurrentLocationOnAWorkspace: jest.fn().mockImplementation(() => ({
isOnAWorkspace: true,
})),
}));
jest.mock('@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain', () => ({
useLastAuthenticatedWorkspaceDomain: jest.fn().mockImplementation(() => ({
setLastAuthenticateWorkspaceDomain: jest.fn(),
})),
}));
const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={Object.values(mocks)} addTypename={false}>
<RecoilRoot>
@@ -20,6 +20,7 @@ import {
AuthTokenPair,
useCheckUserExistsLazyQuery,
useGetAuthTokensFromLoginTokenMutation,
useGetAuthTokensFromOtpMutation,
useGetCurrentUserLazyQuery,
useGetLoginTokenFromCredentialsMutation,
useGetLoginTokenFromEmailVerificationTokenMutation,
@@ -74,12 +75,15 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { APP_LOCALES } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { iconsState } from 'twenty-ui/display';
import { AuthToken } from '~/generated/graphql';
import { cookieStorage } from '~/utils/cookie-storage';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { loginTokenState } from '../states/loginTokenState';
export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState);
const setLoginToken = useSetRecoilState(loginTokenState);
const setCurrentUser = useSetRecoilState(currentUserState);
const setAvailableWorkspaces = useSetRecoilState(availableWorkspacesState);
const setCurrentWorkspaceMember = useSetRecoilState(
@@ -114,6 +118,7 @@ export const useAuth = () => {
const [getLoginTokenFromEmailVerificationToken] =
useGetLoginTokenFromEmailVerificationTokenMutation();
const [getCurrentUser] = useGetCurrentUserLazyQuery();
const [getAuthTokensFromOtp] = useGetAuthTokensFromOtpMutation();
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
@@ -368,26 +373,16 @@ export const useAuth = () => {
[setTokenPair],
);
const handleGetAuthTokensFromLoginToken = useCallback(
async (loginToken: string) => {
const getAuthTokensResult = await getAuthTokensFromLoginToken({
variables: {
loginToken,
origin,
},
});
const handleSetLoginToken = useCallback(
(token: AuthToken['token']) => {
setLoginToken(token);
},
[setLoginToken],
);
if (isDefined(getAuthTokensResult.errors)) {
throw getAuthTokensResult.errors;
}
if (!getAuthTokensResult.data?.getAuthTokensFromLoginToken) {
throw new Error('No getAuthTokensFromLoginToken result');
}
handleSetAuthTokens(
getAuthTokensResult.data.getAuthTokensFromLoginToken.tokens,
);
const handleLoadWorkspaceAfterAuthentication = useCallback(
async (authTokens: AuthTokenPair) => {
handleSetAuthTokens(authTokens);
// TODO: We can't parallelize this yet because when loadCurrentUSer is loaded
// then UserProvider updates its children and PrefetchDataProvider is triggered
@@ -395,12 +390,59 @@ export const useAuth = () => {
await refreshObjectMetadataItems();
await loadCurrentUser();
},
[loadCurrentUser, handleSetAuthTokens, refreshObjectMetadataItems],
);
const handleGetAuthTokensFromLoginToken = useCallback(
async (loginToken: string) => {
try {
const getAuthTokensResult = await getAuthTokensFromLoginToken({
variables: {
loginToken: loginToken,
origin,
},
});
if (isDefined(getAuthTokensResult.errors)) {
throw getAuthTokensResult.errors;
}
if (!getAuthTokensResult.data?.getAuthTokensFromLoginToken) {
throw new Error('No getAuthTokensFromLoginToken result');
}
await handleLoadWorkspaceAfterAuthentication(
getAuthTokensResult.data.getAuthTokensFromLoginToken.tokens,
);
} catch (error) {
if (
error instanceof ApolloError &&
error.graphQLErrors[0]?.extensions?.subCode ===
'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED'
) {
handleSetLoginToken(loginToken);
navigate(AppPath.SignInUp);
setSignInUpStep(SignInUpStep.TwoFactorAuthenticationProvision);
}
if (
error instanceof ApolloError &&
error.graphQLErrors[0]?.extensions?.subCode ===
'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED'
) {
handleSetLoginToken(loginToken);
navigate(AppPath.SignInUp);
setSignInUpStep(SignInUpStep.TwoFactorAuthenticationVerification);
}
}
},
[
handleSetLoginToken,
getAuthTokensFromLoginToken,
loadCurrentUser,
origin,
handleSetAuthTokens,
refreshObjectMetadataItems,
handleLoadWorkspaceAfterAuthentication,
setSignInUpStep,
navigate,
],
);
@@ -654,6 +696,32 @@ export const useAuth = () => {
[buildRedirectUrl, redirect],
);
const handleGetAuthTokensFromOTP = useCallback(
async (otp: string, loginToken: string, captchaToken?: string) => {
const getAuthTokensFromOtpResult = await getAuthTokensFromOtp({
variables: {
captchaToken,
origin,
otp,
loginToken,
},
});
if (isDefined(getAuthTokensFromOtpResult.errors)) {
throw getAuthTokensFromOtpResult.errors;
}
if (!getAuthTokensFromOtpResult.data?.getAuthTokensFromOTP) {
throw new Error('No getAuthTokensFromLoginToken result');
}
await handleLoadWorkspaceAfterAuthentication(
getAuthTokensFromOtpResult.data.getAuthTokensFromOTP.tokens,
);
},
[getAuthTokensFromOtp, origin, handleLoadWorkspaceAfterAuthentication],
);
return {
getLoginTokenFromCredentials: handleGetLoginTokenFromCredentials,
getLoginTokenFromEmailVerificationToken:
@@ -672,5 +740,6 @@ export const useAuth = () => {
signInWithGoogle: handleGoogleLogin,
signInWithMicrosoft: handleMicrosoftLogin,
setAuthTokens: handleSetAuthTokens,
getAuthTokensFromOTP: handleGetAuthTokensFromOTP,
};
};