Introduce SSO bypass permission. (#15417)

Closes [Core Issue
#1772](https://github.com/twentyhq/core-team-issues/issues/1772).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Introduces SSO bypass with a new permission flag and workspace-level
provider toggles, enabling permitted users to log in via
Google/Microsoft/Password when SSO-only, with backend enforcement and
frontend UI/hooks/queries.
> 
> - **Backend**:
> - **Permission & Enforcement**: Add `PermissionFlagType.SSO_BYPASS`;
update `AuthService` to allow login via non-SSO providers when workspace
bypass is enabled and user has `SSO_BYPASS`.
> - **Workspace Model**: Add `isGoogleAuthBypassEnabled`,
`isMicrosoftAuthBypassEnabled`, `isPasswordAuthBypassEnabled`
(migration, entity, update input, service validation).
> - **Public API**: Extend `PublicWorkspaceDataOutput` with
`authBypassProviders`; resolver computes it; permissions defaults
include `SSO_BYPASS`.
> - **Frontend**:
> - **GraphQL/State**: Generate new types/fields; add
`authBypassProviders` to `GetPublicWorkspaceDataByDomain`; new states
`workspaceAuthBypassProvidersState`, `workspaceBypassModeState`.
> - **Auth UI/Logic**: Add `useWorkspaceBypass`; update sign-in form and
footer to offer "Bypass SSO" and use merged providers when enabled;
remove auto-redirect when single SSO.
> - **Settings**: Add Security section to toggle bypass methods per
provider; conditionally show Change Password via `useCanChangePassword`.
> - **Tests/Mocks**: Update mocks and tests to include bypass
flags/providers.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
8c393b2bad387fb6e8b8f40027f8637dd6e85723. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Abdullah.
2025-11-03 15:40:09 +05:00
committed by GitHub
parent 604b3e50de
commit 5b2950c43a
34 changed files with 803 additions and 58 deletions
File diff suppressed because one or more lines are too long
@@ -240,6 +240,13 @@ export type ApprovedAccessDomain = {
isValidated: Scalars['Boolean'];
};
export type AuthBypassProviders = {
__typename?: 'AuthBypassProviders';
google: Scalars['Boolean'];
microsoft: Scalars['Boolean'];
password: Scalars['Boolean'];
};
export type AuthProviders = {
__typename?: 'AuthProviders';
google: Scalars['Boolean'];
@@ -2944,6 +2951,7 @@ export enum PermissionFlagType {
ROLES = 'ROLES',
SECURITY = 'SECURITY',
SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL',
SSO_BYPASS = 'SSO_BYPASS',
WORKFLOWS = 'WORKFLOWS',
WORKSPACE = 'WORKSPACE',
WORKSPACE_MEMBERS = 'WORKSPACE_MEMBERS'
@@ -3003,6 +3011,7 @@ export type PublicFeatureFlagMetadata = {
export type PublicWorkspaceDataOutput = {
__typename?: 'PublicWorkspaceDataOutput';
authBypassProviders?: Maybe<AuthBypassProviders>;
authProviders: AuthProviders;
displayName?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
@@ -4211,8 +4220,11 @@ export type UpdateWorkspaceInput = {
defaultRoleId?: InputMaybe<Scalars['UUID']>;
displayName?: InputMaybe<Scalars['String']>;
inviteHash?: InputMaybe<Scalars['String']>;
isGoogleAuthBypassEnabled?: InputMaybe<Scalars['Boolean']>;
isGoogleAuthEnabled?: InputMaybe<Scalars['Boolean']>;
isMicrosoftAuthBypassEnabled?: InputMaybe<Scalars['Boolean']>;
isMicrosoftAuthEnabled?: InputMaybe<Scalars['Boolean']>;
isPasswordAuthBypassEnabled?: InputMaybe<Scalars['Boolean']>;
isPasswordAuthEnabled?: InputMaybe<Scalars['Boolean']>;
isPublicInviteLinkEnabled?: InputMaybe<Scalars['Boolean']>;
isTwoFactorAuthenticationEnforced?: InputMaybe<Scalars['Boolean']>;
@@ -4535,8 +4547,11 @@ export type Workspace = {
id: Scalars['UUID'];
inviteHash?: Maybe<Scalars['String']>;
isCustomDomainEnabled: Scalars['Boolean'];
isGoogleAuthBypassEnabled: Scalars['Boolean'];
isGoogleAuthEnabled: Scalars['Boolean'];
isMicrosoftAuthBypassEnabled: Scalars['Boolean'];
isMicrosoftAuthEnabled: Scalars['Boolean'];
isPasswordAuthBypassEnabled: Scalars['Boolean'];
isPasswordAuthEnabled: Scalars['Boolean'];
isPublicInviteLinkEnabled: Scalars['Boolean'];
isTwoFactorAuthenticationEnforced: Scalars['Boolean'];
@@ -49,6 +49,9 @@ const mockWorkspace = {
isMicrosoftAuthEnabled: false,
isPasswordAuthEnabled: false,
isCustomDomainEnabled: false,
isGoogleAuthBypassEnabled: false,
isPasswordAuthBypassEnabled: false,
isMicrosoftAuthBypassEnabled: false,
hasValidEnterpriseKey: false,
subdomain: 'test',
customDomain: 'test.com',
@@ -23,6 +23,11 @@ export const GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN = gql`
password
microsoft
}
authBypassProviders {
google
password
microsoft
}
}
}
${WORKSPACE_URLS_FRAGMENT}
@@ -1,7 +1,10 @@
import styled from '@emotion/styled';
import { Trans } from '@lingui/react/macro';
const StyledContainer = styled.div`
import { useWorkspaceBypass } from '@/auth/sign-in-up/hooks/useWorkspaceBypass';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
const StyledCopyContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
@@ -18,24 +21,93 @@ const StyledContainer = styled.div`
}
`;
export const FooterNote = () => (
<StyledContainer>
<Trans>By using Twenty, you agree to the</Trans>{' '}
<a
href="https://twenty.com/legal/terms"
target="_blank"
rel="noopener noreferrer"
>
<Trans>Terms of Service</Trans>
</a>{' '}
<Trans>and</Trans>{' '}
<a
href="https://twenty.com/legal/privacy"
target="_blank"
rel="noopener noreferrer"
>
<Trans>Privacy Policy</Trans>
</a>
.
</StyledContainer>
);
const StyledLinksContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
flex-wrap: nowrap;
font-size: ${({ theme }) => theme.font.size.sm};
gap: ${({ theme }) => theme.spacing(2)};
justify-content: center;
max-width: 100%;
text-align: center;
white-space: nowrap;
& > a,
& > button {
background: none;
border: none;
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;
font: inherit;
padding: 0;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
`;
const StyledSeparator = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export const FooterNote = () => {
const isOnAWorkspace = useIsCurrentLocationOnAWorkspace();
const { shouldOfferBypass, shouldUseBypass, enableBypass } =
useWorkspaceBypass();
if (!isOnAWorkspace) {
return (
<StyledCopyContainer>
<Trans>By using Twenty, you agree to the</Trans>{' '}
<a
href="https://twenty.com/legal/terms"
target="_blank"
rel="noopener noreferrer"
>
<Trans>Terms of Service</Trans>
</a>{' '}
<Trans>and</Trans>{' '}
<a
href="https://twenty.com/legal/privacy"
target="_blank"
rel="noopener noreferrer"
>
<Trans>Privacy Policy</Trans>
</a>
.
</StyledCopyContainer>
);
}
return (
<StyledLinksContainer>
{shouldOfferBypass && !shouldUseBypass && (
<>
<button type="button" onClick={enableBypass}>
<Trans>Bypass SSO</Trans>
</button>
<StyledSeparator></StyledSeparator>
</>
)}
<a
href="https://twenty.com/legal/privacy"
target="_blank"
rel="noopener noreferrer"
>
<Trans>Privacy Policy</Trans>
</a>
<StyledSeparator></StyledSeparator>
<a
href="https://twenty.com/legal/terms"
target="_blank"
rel="noopener noreferrer"
>
<Trans>Terms of Service</Trans>
</a>
</StyledLinksContainer>
);
};
@@ -5,7 +5,9 @@ import { SignInUpWithSSO } from '@/auth/sign-in-up/components/internal/SignInUpW
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { useWorkspaceBypass } from '@/auth/sign-in-up/hooks/useWorkspaceBypass';
import { SignInUpStep } from '@/auth/states/signInUpStepState';
import { workspaceAuthBypassProvidersState } from '@/workspace/states/workspaceAuthBypassProvidersState';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import styled from '@emotion/styled';
import { Trans } from '@lingui/react/macro';
@@ -22,6 +24,10 @@ const StyledContentContainer = styled.div`
export const SignInUpWorkspaceScopeForm = () => {
const workspaceAuthProviders = useRecoilValue(workspaceAuthProvidersState);
const workspaceAuthBypassProviders = useRecoilValue(
workspaceAuthBypassProvidersState,
);
const { shouldOfferBypass, shouldUseBypass } = useWorkspaceBypass();
const { form } = useSignInUpForm();
@@ -33,26 +39,32 @@ export const SignInUpWorkspaceScopeForm = () => {
return null;
}
const providers =
shouldOfferBypass && shouldUseBypass
? {
...workspaceAuthBypassProviders,
sso: [],
}
: workspaceAuthProviders;
return (
<>
<StyledContentContainer>
{workspaceAuthProviders.google && (
<SignInUpWithGoogle action="join-workspace" />
)}
{providers.google && <SignInUpWithGoogle action="join-workspace" />}
{workspaceAuthProviders.microsoft && (
{providers.microsoft && (
<SignInUpWithMicrosoft action="join-workspace" />
)}
{workspaceAuthProviders.sso.length > 0 && <SignInUpWithSSO />}
{providers.sso.length > 0 && <SignInUpWithSSO />}
{(workspaceAuthProviders.google ||
workspaceAuthProviders.microsoft ||
workspaceAuthProviders.sso.length > 0) &&
workspaceAuthProviders.password ? (
{(providers.google ||
providers.microsoft ||
providers.sso.length > 0) &&
providers.password ? (
<HorizontalSeparator />
) : null}
{workspaceAuthProviders.password && (
{providers.password && (
// eslint-disable-next-line react/jsx-props-no-spreading
<FormProvider {...form}>
<SignInUpWithCredentials />
@@ -1,4 +1,3 @@
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import {
@@ -35,7 +34,6 @@ export const SignInUpWorkspaceScopeFormEffect = () => {
);
const { form } = useSignInUpForm();
const { redirectToSSOLoginPage } = useSSO();
const { signInUpStep, continueWithEmail, continueWithCredentials } =
useSignInUp(form);
@@ -55,11 +53,7 @@ export const SignInUpWorkspaceScopeFormEffect = () => {
if (hasOnlySSOProvidersEnabled && workspaceAuthProviders.sso.length > 1) {
return setSignInUpStep(SignInUpStep.SSOIdentityProviderSelection);
}
if (hasOnlySSOProvidersEnabled && workspaceAuthProviders.sso.length === 1) {
redirectToSSOLoginPage(workspaceAuthProviders.sso[0].id);
}
}, [redirectToSSOLoginPage, setSignInUpStep, workspaceAuthProviders]);
}, [setSignInUpStep, workspaceAuthProviders]);
useEffect(() => {
if (loadingStatus === LoadingStatus.Done) {
@@ -0,0 +1,55 @@
import { useRecoilState, useRecoilValue } from 'recoil';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { workspaceAuthBypassProvidersState } from '@/workspace/states/workspaceAuthBypassProvidersState';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { workspaceBypassModeState } from '@/workspace/states/workspaceBypassModeState';
export const useWorkspaceBypass = () => {
const workspaceAuthProviders = useRecoilValue(workspaceAuthProvidersState);
const workspaceAuthBypassProviders = useRecoilValue(
workspaceAuthBypassProvidersState,
);
const [workspaceBypassMode, setWorkspaceBypassMode] = useRecoilState(
workspaceBypassModeState,
);
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
const hasOnlySSOProvidersEnabled = (() => {
if (!workspaceAuthProviders) {
return false;
}
const { sso, google, microsoft, password } = workspaceAuthProviders;
return sso.length > 0 && !google && !microsoft && !password;
})();
const hasBypassProvidersAvailable = (() => {
if (!workspaceAuthBypassProviders) {
return false;
}
const { google, microsoft, password } = workspaceAuthBypassProviders;
return google || microsoft || password;
})();
const shouldOfferBypass =
isOnAWorkspace && hasOnlySSOProvidersEnabled && hasBypassProvidersAvailable;
const shouldUseBypass = shouldOfferBypass ? workspaceBypassMode : false;
const enableBypass = () => {
if (shouldOfferBypass) {
setWorkspaceBypassMode(true);
}
};
return {
shouldOfferBypass,
shouldUseBypass,
enableBypass,
};
};
@@ -15,8 +15,11 @@ export type CurrentWorkspace = Pick<
| 'workspaceMembersCount'
| 'isPublicInviteLinkEnabled'
| 'isGoogleAuthEnabled'
| 'isGoogleAuthBypassEnabled'
| 'isMicrosoftAuthEnabled'
| 'isMicrosoftAuthBypassEnabled'
| 'isPasswordAuthEnabled'
| 'isPasswordAuthBypassEnabled'
| 'isCustomDomainEnabled'
| 'hasValidEnterpriseKey'
| 'subdomain'
@@ -5,6 +5,7 @@ import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useI
import { useOrigin } from '@/domain-manager/hooks/useOrigin';
import { useRedirectToDefaultDomain } from '@/domain-manager/hooks/useRedirectToDefaultDomain';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { workspaceAuthBypassProvidersState } from '@/workspace/states/workspaceAuthBypassProvidersState';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useGetPublicWorkspaceDataByDomainQuery } from '~/generated-metadata/graphql';
@@ -16,6 +17,9 @@ export const useGetPublicWorkspaceDataByDomain = () => {
const setWorkspaceAuthProviders = useSetRecoilState(
workspaceAuthProvidersState,
);
const setWorkspaceAuthBypassProviders = useSetRecoilState(
workspaceAuthBypassProvidersState,
);
const workspacePublicData = useRecoilValue(workspacePublicDataState);
const { redirectToDefaultDomain } = useRedirectToDefaultDomain();
const setWorkspacePublicDataState = useSetRecoilState(
@@ -35,6 +39,9 @@ export const useGetPublicWorkspaceDataByDomain = () => {
setWorkspaceAuthProviders(
data.getPublicWorkspaceDataByDomain.authProviders,
);
setWorkspaceAuthBypassProviders(
data.getPublicWorkspaceDataByDomain.authBypassProviders ?? null,
);
setWorkspacePublicDataState(data.getPublicWorkspaceDataByDomain);
},
onError: (error) => {
@@ -29,6 +29,9 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
isMicrosoftAuthEnabled: false,
isPasswordAuthEnabled: true,
isCustomDomainEnabled: false,
isGoogleAuthBypassEnabled: false,
isMicrosoftAuthBypassEnabled: false,
isPasswordAuthBypassEnabled: false,
customDomain: 'my-custom-domain.com',
workspaceUrls: {
subdomainUrl: 'https://twenty.twenty.com',
@@ -0,0 +1,30 @@
import { useRecoilValue } from 'recoil';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { PermissionFlagType } from '~/generated-metadata/graphql';
export const useCanChangePassword = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const currentUserWorkspace = useRecoilValue(currentUserWorkspaceState);
const isPasswordAuthEnabled =
currentWorkspace?.isPasswordAuthEnabled === true;
if (isPasswordAuthEnabled) {
return { canChangePassword: true };
}
const hasBypassPermission = currentUserWorkspace?.permissionFlags?.includes(
PermissionFlagType.SSO_BYPASS,
);
if (!hasBypassPermission) {
return { canChangePassword: false };
}
const canChangePassword =
currentWorkspace?.isPasswordAuthBypassEnabled === true;
return { canChangePassword };
};
@@ -14,6 +14,7 @@ import {
IconLockOpen,
IconSettings,
IconSettingsAutomation,
IconShield,
IconSpy,
IconUsers,
} from 'twenty-ui/display';
@@ -90,6 +91,12 @@ export const SettingsRolePermissionsSettingsSection = ({
description: t`Manage workflows`,
Icon: IconSettingsAutomation,
},
{
key: PermissionFlagType.SSO_BYPASS,
name: t`SSO Bypass`,
description: t`Enable bypass options`,
Icon: IconShield,
},
{
key: PermissionFlagType.IMPERSONATE,
name: t`Impersonate`,
@@ -0,0 +1,119 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilState, useRecoilValue } from 'recoil';
import { capitalize } from 'twenty-shared/utils';
import { IconGoogle, IconMicrosoft, IconPassword } from 'twenty-ui/display';
import { Card } from 'twenty-ui/layout';
import {
type AuthProviders,
useUpdateWorkspaceMutation,
} from '~/generated-metadata/graphql';
const StyledSettingsSecurityOptionsList = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
`;
export const SettingsSecurityAuthBypassOptionsList = () => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const authProviders = useRecoilValue(authProvidersState);
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const [updateWorkspace] = useUpdateWorkspaceMutation();
const isValidAuthProvider = (
key: string,
): key is Exclude<keyof typeof currentWorkspace, '__typename'> => {
if (!currentWorkspace) return false;
return Reflect.has(currentWorkspace, key);
};
const toggleAuthBypassMethod = async (
authProvider: keyof Omit<AuthProviders, '__typename' | 'magicLink' | 'sso'>,
) => {
if (!currentWorkspace?.id) {
throw new Error(t`User is not logged in`);
}
const key = `is${capitalize(authProvider)}AuthBypassEnabled`;
if (!isValidAuthProvider(key)) {
throw new Error(t`Invalid auth bypass provider`);
}
setCurrentWorkspace({
...currentWorkspace,
[key]: !currentWorkspace[key],
});
updateWorkspace({
variables: {
input: {
[key]: !currentWorkspace[key],
},
},
}).catch((err) => {
setCurrentWorkspace({
...currentWorkspace,
[key]: currentWorkspace[key],
});
enqueueErrorSnackBar({
apolloError: err instanceof ApolloError ? err : undefined,
});
});
};
if (!currentWorkspace) {
return null;
}
return (
<StyledSettingsSecurityOptionsList>
<Card rounded>
{authProviders.google === true && (
<SettingsOptionCardContentToggle
Icon={IconGoogle}
title={t`Google`}
description={t`Allow Google-based login for users with SSO bypass permissions.`}
checked={currentWorkspace.isGoogleAuthBypassEnabled}
advancedMode
divider
onChange={() => toggleAuthBypassMethod('google')}
/>
)}
{authProviders.microsoft === true && (
<SettingsOptionCardContentToggle
Icon={IconMicrosoft}
title={t`Microsoft`}
description={t`Allow Microsoft-based login for users with SSO bypass permissions.`}
checked={currentWorkspace.isMicrosoftAuthBypassEnabled}
advancedMode
divider
onChange={() => toggleAuthBypassMethod('microsoft')}
/>
)}
{authProviders.password && (
<SettingsOptionCardContentToggle
Icon={IconPassword}
title={t`Password`}
description={t`Allow email & password login for SSO bypass users.`}
checked={currentWorkspace.isPasswordAuthBypassEnabled}
advancedMode
onChange={() => toggleAuthBypassMethod('password')}
/>
)}
</Card>
</StyledSettingsSecurityOptionsList>
);
};
@@ -53,6 +53,9 @@ export const USER_QUERY_FRAGMENT = gql`
isGoogleAuthEnabled
isMicrosoftAuthEnabled
isPasswordAuthEnabled
isGoogleAuthBypassEnabled
isMicrosoftAuthBypassEnabled
isPasswordAuthBypassEnabled
subdomain
hasValidEnterpriseKey
customDomain
@@ -4,12 +4,14 @@ import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceSta
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
import { useInitializeFormatPreferences } from '@/localization/hooks/useInitializeFormatPreferences';
import { coreViewsState } from '@/views/states/coreViewState';
import { workspaceAuthBypassProvidersState } from '@/workspace/states/workspaceAuthBypassProvidersState';
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { SOURCE_LOCALE, type APP_LOCALES } from 'twenty-shared/translations';
import { type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -36,6 +38,10 @@ export const useLoadCurrentUser = () => {
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const { initializeFormatPreferences } = useInitializeFormatPreferences();
const setCoreViews = useSetRecoilState(coreViewsState);
const setWorkspaceAuthBypassProviders = useSetRecoilState(
workspaceAuthBypassProvidersState,
);
const authProviders = useRecoilValue(authProvidersState);
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
@@ -103,6 +109,16 @@ export const useLoadCurrentUser = () => {
setCurrentWorkspace(workspace);
if (isDefined(workspace)) {
setWorkspaceAuthBypassProviders({
google: authProviders.google && workspace.isGoogleAuthBypassEnabled,
microsoft:
authProviders.microsoft && workspace.isMicrosoftAuthBypassEnabled,
password:
authProviders.password && workspace.isPasswordAuthBypassEnabled,
});
}
if (isDefined(workspace) && isOnAWorkspace) {
setLastAuthenticateWorkspaceDomain({
workspaceId: workspace.id,
@@ -132,6 +148,8 @@ export const useLoadCurrentUser = () => {
initializeFormatPreferences,
setLastAuthenticateWorkspaceDomain,
setCoreViews,
authProviders,
setWorkspaceAuthBypassProviders,
]);
return {
@@ -0,0 +1,8 @@
import { type AuthBypassProviders } from '~/generated/graphql';
import { createState } from 'twenty-ui/utilities';
export const workspaceAuthBypassProvidersState =
createState<AuthBypassProviders | null>({
key: 'workspaceAuthBypassProvidersState',
defaultValue: null,
});
@@ -0,0 +1,6 @@
import { createState } from 'twenty-ui/utilities';
export const workspaceBypassModeState = createState<boolean>({
key: 'workspaceBypassModeState',
defaultValue: false,
});
@@ -1,5 +1,3 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ChangePassword } from '@/settings/profile/components/ChangePassword';
@@ -7,8 +5,10 @@ import { DeleteAccount } from '@/settings/profile/components/DeleteAccount';
import { EmailField } from '@/settings/profile/components/EmailField';
import { NameFields } from '@/settings/profile/components/NameFields';
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
import { useCanChangePassword } from '@/settings/profile/hooks/useCanChangePassword';
import { useCurrentUserWorkspaceTwoFactorAuthentication } from '@/settings/two-factor-authentication/hooks/useCurrentUserWorkspaceTwoFactorAuthentication';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Trans, useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title, IconShield, Status } from 'twenty-ui/display';
@@ -25,6 +25,8 @@ export const SettingsProfile = () => {
currentUserWorkspaceTwoFactorAuthenticationMethods['TOTP']?.status ===
'VERIFIED';
const { canChangePassword } = useCanChangePassword();
return (
<SubMenuTopBarContainer
title={t`Profile`}
@@ -79,9 +81,11 @@ export const SettingsProfile = () => {
/>
</UndecoratedLink>
</Section>
<Section>
<ChangePassword />
</Section>
{canChangePassword && (
<Section>
<ChangePassword />
</Section>
)}
<Section>
<DeleteAccount />
</Section>
@@ -3,11 +3,14 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { useDebouncedCallback } from 'use-debounce';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard';
import { SettingsSecurityAuthBypassOptionsList } from '@/settings/security/components/SettingsSecurityAuthBypassOptionsList';
import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState';
import { ToggleImpersonate } from '@/settings/workspace/components/ToggleImpersonate';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
@@ -40,6 +43,8 @@ export const SettingsSecurity = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const authProviders = useRecoilValue(authProvidersState);
const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState);
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
@@ -82,6 +87,20 @@ export const SettingsSecurity = () => {
saveWorkspace(value);
};
const hasSsoIdentityProviders = SSOIdentitiesProviders.length > 0;
const hasDirectAuthEnabled =
currentWorkspace?.isGoogleAuthEnabled ||
currentWorkspace?.isMicrosoftAuthEnabled ||
currentWorkspace?.isPasswordAuthEnabled;
const hasBypassProviderAvailable =
authProviders?.google ||
authProviders?.microsoft ||
authProviders?.password;
const shouldShowBypassSection =
hasSsoIdentityProviders &&
!hasDirectAuthEnabled &&
hasBypassProviderAvailable;
return (
<SubMenuTopBarContainer
title={t`Security`}
@@ -120,6 +139,17 @@ export const SettingsSecurity = () => {
<SettingsSecurityAuthProvidersOptionsList />
</StyledContainer>
</Section>
{shouldShowBypassSection && (
<Section>
<StyledContainer>
<H2Title
title={t`SSO Bypass`}
description={t`Configure fallback login methods for users with SSO bypass permissions`}
/>
<SettingsSecurityAuthBypassOptionsList />
</StyledContainer>
</Section>
)}
{isMultiWorkspaceEnabled && (
<Section>
<H2Title
@@ -19,4 +19,10 @@ export const mockedPublicWorkspaceDataBySubdomain: GetPublicWorkspaceDataByDomai
password: true,
microsoft: false,
},
authBypassProviders: {
__typename: 'AuthBypassProviders',
google: false,
password: false,
microsoft: false,
},
};
@@ -65,12 +65,15 @@ export const mockCurrentWorkspace: Workspace = {
hasValidEnterpriseKey: false,
isGoogleAuthEnabled: true,
isPasswordAuthEnabled: true,
isMicrosoftAuthEnabled: false,
isCustomDomainEnabled: false,
isPasswordAuthBypassEnabled: false,
isGoogleAuthBypassEnabled: false,
isMicrosoftAuthBypassEnabled: false,
workspaceUrls: {
customUrl: undefined,
subdomainUrl: 'twenty.twenty.com',
},
isMicrosoftAuthEnabled: false,
featureFlags: [
{
key: FeatureFlagKey.IS_AIRTABLE_INTEGRATION_ENABLED,
@@ -0,0 +1,29 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddSsoBypassFlag1761651107128 implements MigrationInterface {
name = 'AddSsoBypassFlag1761651107128';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "isGoogleAuthBypassEnabled" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "isPasswordAuthBypassEnabled" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "isMicrosoftAuthBypassEnabled" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "isMicrosoftAuthBypassEnabled"`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "isPasswordAuthBypassEnabled"`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "isGoogleAuthBypassEnabled"`,
);
}
}
@@ -29,6 +29,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { AuthService } from './auth.service';
@@ -44,6 +45,10 @@ describe('AuthService', () => {
let authSsoService: AuthSsoService;
let userWorkspaceService: UserWorkspaceService;
let workspaceInvitationService: WorkspaceInvitationService;
let permissionsService: PermissionsService;
let signInUpServiceMock: jest.Mocked<
Pick<SignInUpService, 'validatePassword'>
>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -94,7 +99,10 @@ describe('AuthService', () => {
},
{
provide: SignInUpService,
useValue: {},
useValue: {
validatePassword: jest.fn().mockResolvedValue(undefined),
generateHash: jest.fn(),
},
},
{
provide: TwentyConfigService,
@@ -153,6 +161,14 @@ describe('AuthService', () => {
provide: AuditService,
useValue: {},
},
{
provide: PermissionsService,
useValue: {
userHasWorkspaceSettingPermission: jest
.fn()
.mockResolvedValue(false),
},
},
],
}).compile();
@@ -170,10 +186,15 @@ describe('AuthService', () => {
userRepository = module.get<Repository<UserEntity>>(
getRepositoryToken(UserEntity),
);
permissionsService = module.get<PermissionsService>(PermissionsService);
signInUpServiceMock = module.get(SignInUpService) as jest.Mocked<
Pick<SignInUpService, 'validatePassword'>
>;
});
beforeEach(() => {
twentyConfigServiceGetMock.mockReturnValue(false);
signInUpServiceMock.validatePassword.mockClear();
});
it('should be defined', async () => {
@@ -216,6 +237,92 @@ describe('AuthService', () => {
});
});
it('allows password login through SSO bypass when user has permission', async () => {
const workspace = {
id: 'workspace-id',
isPasswordAuthEnabled: false,
isPasswordAuthBypassEnabled: true,
} as WorkspaceEntity;
const userEntity = {
id: 'user-id',
email: 'email',
passwordHash: 'password-hash',
userWorkspaces: [
{
id: 'user-workspace-id',
workspaceId: workspace.id,
} as any,
],
} as unknown as UserEntity;
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
jest.spyOn(userRepository, 'findOne').mockResolvedValueOnce(userEntity);
jest
.spyOn(userWorkspaceService, 'checkUserWorkspaceExists')
.mockResolvedValueOnce({ id: 'user-workspace-id' } as any);
jest
.spyOn(permissionsService, 'userHasWorkspaceSettingPermission')
.mockResolvedValueOnce(true);
const response = await service.validateLoginWithPassword(
{
email: 'email',
password: 'password',
captchaToken: 'captcha-token',
},
workspace,
);
expect(response).toBe(userEntity);
});
it('throws when bypass permission is missing for disabled password auth', async () => {
const workspace = {
id: 'workspace-id',
isPasswordAuthEnabled: false,
isPasswordAuthBypassEnabled: true,
} as WorkspaceEntity;
const userEntity = {
id: 'user-id',
email: 'email',
passwordHash: 'password-hash',
userWorkspaces: [
{
id: 'user-workspace-id',
workspaceId: workspace.id,
} as any,
],
} as unknown as UserEntity;
jest.spyOn(userRepository, 'findOne').mockResolvedValueOnce(userEntity);
jest
.spyOn(userWorkspaceService, 'checkUserWorkspaceExists')
.mockResolvedValueOnce(null);
jest
.spyOn(permissionsService, 'userHasWorkspaceSettingPermission')
.mockResolvedValueOnce(false);
await expect(
service.validateLoginWithPassword(
{
email: 'email',
password: 'password',
captchaToken: 'captcha-token',
},
workspace,
),
).rejects.toThrow(
new AuthException(
'Email/Password auth is not enabled for this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
expect(signInUpServiceMock.validatePassword).not.toHaveBeenCalled();
});
it('challenge - user who have an invitation', async () => {
const user = {
email: 'email',
@@ -64,6 +64,8 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@@ -81,6 +83,7 @@ export class AuthService {
private readonly authSsoService: AuthSsoService,
private readonly userService: UserService,
private readonly signInUpService: SignInUpService,
private readonly permissionsService: PermissionsService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(UserEntity)
@@ -135,13 +138,6 @@ export class AuthService {
input: UserCredentialsInput,
targetWorkspace?: WorkspaceEntity,
) {
if (targetWorkspace && !targetWorkspace.isPasswordAuthEnabled) {
throw new AuthException(
'Email/Password auth is not enabled for this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const user = await this.userRepository.findOne({
where: {
email: input.email,
@@ -156,6 +152,21 @@ export class AuthService {
);
}
if (targetWorkspace && !targetWorkspace.isPasswordAuthEnabled) {
const canBypass = await this.canUserBypassAuthProvider({
user,
workspace: targetWorkspace,
provider: AuthProviderEnum.Password,
});
if (!canBypass) {
throw new AuthException(
'Email/Password auth is not enabled for this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
}
if (targetWorkspace) {
await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user);
}
@@ -230,10 +241,74 @@ export class AuthService {
}
if (isDefined(workspace)) {
const isProviderEnabled = workspaceValidator.isAuthEnabled(
authParams.provider,
workspace,
);
if (isProviderEnabled) {
return;
}
const existingUser =
userData.type === 'existingUser' ? userData.existingUser : undefined;
if (
existingUser &&
(await this.canUserBypassAuthProvider({
user: existingUser,
workspace,
provider: authParams.provider,
}))
) {
return;
}
workspaceValidator.isAuthEnabledOrThrow(authParams.provider, workspace);
}
}
private async canUserBypassAuthProvider({
user,
workspace,
provider,
}: {
user: UserEntity;
workspace: WorkspaceEntity;
provider: AuthProviderEnum;
}): Promise<boolean> {
const bypassEnabled = (() => {
switch (provider) {
case AuthProviderEnum.Password:
return workspace.isPasswordAuthBypassEnabled;
case AuthProviderEnum.Google:
return workspace.isGoogleAuthBypassEnabled;
case AuthProviderEnum.Microsoft:
return workspace.isMicrosoftAuthBypassEnabled;
default:
return false;
}
})();
if (!bypassEnabled) {
return false;
}
const userWorkspace = user.userWorkspaces?.find(
(userWorkspace) => userWorkspace.workspaceId === workspace.id,
);
if (!userWorkspace) {
return false;
}
return await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId: userWorkspace.id,
workspaceId: workspace.id,
setting: PermissionFlagType.SSO_BYPASS,
});
}
async signInUp(
params: SignInUpBaseParams &
ExistingUserOrNewUser &
@@ -775,7 +850,8 @@ export class AuthService {
? await this.countAvailableWorkspacesByEmail(email)
: 0;
const existingUser = await this.userService.findUserByEmail(email);
const existingUser =
await this.userService.findUserByEmailWithWorkspaces(email);
if (
!workspaceId &&
@@ -205,6 +205,15 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
});
}
async findUserByEmailWithWorkspaces(email: string) {
return await this.userRepository.findOne({
where: {
email,
},
relations: { userWorkspaces: true },
});
}
async findUserById(id: string) {
return await this.userRepository.findOne({
where: {
@@ -44,6 +44,18 @@ export class AuthProvidersDTO {
microsoft: boolean;
}
@ObjectType('AuthBypassProviders')
export class AuthBypassProvidersDTO {
@Field(() => Boolean)
google: boolean;
@Field(() => Boolean)
password: boolean;
@Field(() => Boolean)
microsoft: boolean;
}
@ObjectType('PublicWorkspaceDataOutput')
export class PublicWorkspaceDataOutput {
@Field(() => UUIDScalarType)
@@ -52,6 +64,9 @@ export class PublicWorkspaceDataOutput {
@Field(() => AuthProvidersDTO)
authProviders: AuthProvidersDTO;
@Field(() => AuthBypassProvidersDTO, { nullable: true })
authBypassProviders?: AuthBypassProvidersDTO;
@Field(() => String, { nullable: true })
logo: WorkspaceEntity['logo'];
@@ -67,6 +67,21 @@ export class UpdateWorkspaceInput {
@IsOptional()
isPasswordAuthEnabled?: boolean;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isGoogleAuthBypassEnabled?: boolean;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isMicrosoftAuthBypassEnabled?: boolean;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isPasswordAuthBypassEnabled?: boolean;
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
@@ -160,6 +160,30 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
);
}
if (payload.isGoogleAuthBypassEnabled && !authProvidersBySystem.google) {
throw new WorkspaceException(
'Google auth is not enabled in the system.',
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
);
}
if (
payload.isMicrosoftAuthBypassEnabled &&
!authProvidersBySystem.microsoft
) {
throw new WorkspaceException(
'Microsoft auth is not enabled in the system.',
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
);
}
if (
payload.isPasswordAuthBypassEnabled &&
!authProvidersBySystem.password
) {
throw new WorkspaceException(
'Password auth is not enabled in the system.',
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
);
}
try {
return await this.workspaceRepository.save({
@@ -0,0 +1,25 @@
import { type AuthBypassProvidersDTO } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
export const getAuthBypassProvidersByWorkspace = ({
workspace,
systemEnabledProviders,
}: {
workspace: Pick<
WorkspaceEntity,
| 'isGoogleAuthBypassEnabled'
| 'isPasswordAuthBypassEnabled'
| 'isMicrosoftAuthBypassEnabled'
>;
systemEnabledProviders: AuthBypassProvidersDTO;
}) => {
return {
google:
workspace.isGoogleAuthBypassEnabled && systemEnabledProviders.google,
password:
workspace.isPasswordAuthBypassEnabled && systemEnabledProviders.password,
microsoft:
workspace.isMicrosoftAuthBypassEnabled &&
systemEnabledProviders.microsoft,
};
};
@@ -230,6 +230,10 @@ export class WorkspaceEntity {
@Column({ default: true })
isGoogleAuthEnabled: boolean;
@Field()
@Column({ default: false })
isGoogleAuthBypassEnabled: boolean;
@Field()
@Column({ default: false })
isTwoFactorAuthenticationEnforced: boolean;
@@ -238,10 +242,18 @@ export class WorkspaceEntity {
@Column({ default: true })
isPasswordAuthEnabled: boolean;
@Field()
@Column({ default: false })
isPasswordAuthBypassEnabled: boolean;
@Field()
@Column({ default: true })
isMicrosoftAuthEnabled: boolean;
@Field()
@Column({ default: false })
isMicrosoftAuthBypassEnabled: boolean;
@Field()
@Column({ default: false })
isCustomDomainEnabled: boolean;
@@ -46,6 +46,7 @@ import {
import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
import { WorkspaceUrlsDTO } from 'src/engine/core-modules/workspace/dtos/workspace-urls.dto';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { getAuthBypassProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-bypass-providers-by-workspace.util';
import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util';
import { workspaceGraphqlApiExceptionHandler } from 'src/engine/core-modules/workspace/utils/workspace-graphql-api-exception-handler.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -367,6 +368,10 @@ export class WorkspaceResolver {
workspace,
systemEnabledProviders,
}),
authBypassProviders: getAuthBypassProvidersByWorkspace({
workspace,
systemEnabledProviders,
}),
};
} catch (err) {
workspaceGraphqlApiExceptionHandler(err);
@@ -9,6 +9,7 @@ export enum PermissionFlagType {
SECURITY = 'SECURITY',
WORKFLOWS = 'WORKFLOWS',
IMPERSONATE = 'IMPERSONATE',
SSO_BYPASS = 'SSO_BYPASS',
// Tool permissions
SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL',
@@ -105,6 +105,7 @@ export class PermissionsService {
[PermissionFlagType.IMPORT_CSV]: false,
[PermissionFlagType.EXPORT_CSV]: false,
[PermissionFlagType.IMPERSONATE]: false,
[PermissionFlagType.SSO_BYPASS]: false,
},
objectsPermissions: {},
}) as const satisfies UserWorkspacePermissions;