feat(sso): allow to use OIDC and SAML (#7246)

## What it does
### Backend
- [x] Add a mutation to create OIDC and SAML configuration
- [x] Add a mutation to delete an SSO config
- [x] Add a feature flag to toggle SSO
- [x] Add a mutation to activate/deactivate an SSO config
- [x] Add a mutation to delete an SSO config
- [x] Add strategy to use OIDC or SAML
- [ ] Improve error management

### Frontend
- [x] Add section "security" in settings
- [x] Add page to list SSO configurations
- [x] Add page and forms to create OIDC or SAML configuration
- [x] Add field to "connect with SSO" in the signin/signup process
- [x] Trigger auth when a user switch to a workspace with SSO enable
- [x] Add an option on the security page to activate/deactivate the
global invitation link
- [ ] Add new Icons for SSO Identity Providers (okta, Auth0, Azure,
Microsoft)

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Antoine Moreaux
2024-10-21 20:07:08 +02:00
committed by GitHub
parent 11c3f1c399
commit 0f0a7966b1
132 changed files with 5245 additions and 306 deletions

View File

@ -0,0 +1,124 @@
/* @license Enterprise */
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsRadioCardContainer } from '@/settings/components/SettingsRadioCardContainer';
import { SettingsSSOOIDCForm } from '@/settings/security/components/SettingsSSOOIDCForm';
import { SettingsSSOSAMLForm } from '@/settings/security/components/SettingsSSOSAMLForm';
import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider';
import { TextInput } from '@/ui/input/components/TextInput';
import { Section } from '@/ui/layout/section/components/Section';
import styled from '@emotion/styled';
import { ReactElement } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { H2Title, IconComponent, IconKey } from 'twenty-ui';
import { IdpType } from '~/generated/graphql';
const StyledInputsContainer = styled.div`
display: grid;
gap: ${({ theme }) => theme.spacing(2, 4)};
grid-template-columns: 1fr 1fr;
grid-template-areas:
'input-1 input-1'
'input-2 input-3'
'input-4 input-5';
& :first-of-type {
grid-area: input-1;
}
`;
export const SettingsSSOIdentitiesProvidersForm = () => {
const { control, getValues } =
useFormContext<SettingSecurityNewSSOIdentityFormValues>();
const IdpMap: Record<
IdpType,
{
form: ReactElement;
option: {
Icon: IconComponent;
title: string;
value: string;
description: string;
};
}
> = {
OIDC: {
option: {
Icon: IconKey,
title: 'OIDC',
value: 'OIDC',
description: '',
},
form: <SettingsSSOOIDCForm />,
},
SAML: {
option: {
Icon: IconKey,
title: 'SAML',
value: 'SAML',
description: '',
},
form: <SettingsSSOSAMLForm />,
},
};
const getFormByType = (type: Uppercase<IdpType> | undefined) => {
switch (type) {
case IdpType.Oidc:
return IdpMap.OIDC.form;
case IdpType.Saml:
return IdpMap.SAML.form;
default:
return null;
}
};
return (
<SettingsPageContainer>
<Section>
<H2Title title="Name" description="The name of your connection" />
<StyledInputsContainer>
<Controller
name="name"
control={control}
render={({ field: { onChange, value } }) => (
<TextInput
autoComplete="off"
label="Name"
value={value}
onChange={onChange}
fullWidth
placeholder="Google OIDC"
/>
)}
/>
</StyledInputsContainer>
</Section>
<Section>
<H2Title
title="Type"
description="Choose between OIDC and SAML protocols"
/>
<StyledInputsContainer>
<Controller
name="type"
control={control}
render={({ field: { onChange, value } }) => (
<SettingsRadioCardContainer
value={value}
options={Object.values(IdpMap).map(
(identityProviderType) => identityProviderType.option,
)}
onChange={onChange}
/>
)}
/>
</StyledInputsContainer>
</Section>
{getFormByType(getValues().type)}
</SettingsPageContainer>
);
};
export default SettingsSSOIdentitiesProvidersForm;

View File

@ -0,0 +1,61 @@
/* @license Enterprise */
import { useNavigate } from 'react-router-dom';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { SettingsSSOIdentitiesProvidersListEmptyStateCard } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListEmptyStateCard';
import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/security/components/SettingsSSOIdentityProviderRowRightContainer';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilState } from 'recoil';
import { useListSsoIdentityProvidersByWorkspaceIdQuery } from '~/generated/graphql';
import { SettingsListCard } from '../../components/SettingsListCard';
import { guessSSOIdentityProviderIconByUrl } from '../utils/guessSSOIdentityProviderIconByUrl';
export const SettingsSSOIdentitiesProvidersListCard = () => {
const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar();
const [SSOIdentitiesProviders, setSSOIdentitiesProviders] = useRecoilState(
SSOIdentitiesProvidersState,
);
const { loading } = useListSsoIdentityProvidersByWorkspaceIdQuery({
onCompleted: (data) => {
setSSOIdentitiesProviders(
data?.listSSOIdentityProvidersByWorkspaceId ?? [],
);
},
onError: (error: Error) => {
enqueueSnackBar(error.message, {
variant: SnackBarVariant.Error,
});
},
});
return !SSOIdentitiesProviders.length && !loading ? (
<SettingsSSOIdentitiesProvidersListEmptyStateCard />
) : (
<SettingsListCard
items={SSOIdentitiesProviders}
getItemLabel={(SSOIdentityProvider) =>
`${SSOIdentityProvider.name} - ${SSOIdentityProvider.type}`
}
isLoading={loading}
RowIconFn={(SSOIdentityProvider) =>
guessSSOIdentityProviderIconByUrl(SSOIdentityProvider.issuer)
}
RowRightComponent={({ item: SSOIdp }) => (
<SettingsSSOIdentityProviderRowRightContainer SSOIdp={SSOIdp} />
)}
hasFooter
footerButtonLabel="Add SSO Identity Provider"
onFooterButtonClick={() =>
navigate(getSettingsPagePath(SettingsPath.NewSSOIdentityProvider))
}
/>
);
};

View File

@ -0,0 +1,38 @@
/* @license Enterprise */
import styled from '@emotion/styled';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { Button } from '@/ui/input/button/components/Button';
import { Card } from '@/ui/layout/card/components/Card';
import { CardContent } from '@/ui/layout/card/components/CardContent';
import { CardHeader } from '@/ui/layout/card/components/CardHeader';
import { IconKey } from 'twenty-ui';
const StyledHeader = styled(CardHeader)`
align-items: center;
display: flex;
height: ${({ theme }) => theme.spacing(6)};
`;
const StyledBody = styled(CardContent)`
display: flex;
justify-content: center;
`;
export const SettingsSSOIdentitiesProvidersListEmptyStateCard = () => {
return (
<Card>
<StyledHeader>{'No SSO Identity Providers Configured'}</StyledHeader>
<StyledBody>
<Button
Icon={IconKey}
title="Add SSO Identity Provider"
variant="secondary"
to={getSettingsPagePath(SettingsPath.NewSSOIdentityProvider)}
/>
</StyledBody>
</Card>
);
};

View File

@ -0,0 +1,31 @@
/* @license Enterprise */
import { SettingsSecuritySSORowDropdownMenu } from '@/settings/security/components/SettingsSecuritySSORowDropdownMenu';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import { getColorBySSOIdentityProviderStatus } from '@/settings/security/utils/getColorBySSOIdentityProviderStatus';
import { Status } from '@/ui/display/status/components/Status';
import styled from '@emotion/styled';
import { UnwrapRecoilValue } from 'recoil';
const StyledRowRightContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const SettingsSSOIdentityProviderRowRightContainer = ({
SSOIdp,
}: {
SSOIdp: UnwrapRecoilValue<typeof SSOIdentitiesProvidersState>[0];
}) => {
return (
<StyledRowRightContainer>
<Status
color={getColorBySSOIdentityProviderStatus[SSOIdp.status]}
text={SSOIdp.status}
weight="medium"
/>
<SettingsSecuritySSORowDropdownMenu SSOIdp={SSOIdp} />
</StyledRowRightContainer>
);
};

View File

@ -0,0 +1,154 @@
/* @license Enterprise */
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
import { Section } from '@/ui/layout/section/components/Section';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Controller, useFormContext } from 'react-hook-form';
import { H2Title, IconCopy } from 'twenty-ui';
const StyledInputsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2, 4)};
width: 100%;
`;
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
`;
const StyledLinkContainer = styled.div`
flex: 1;
margin-right: ${({ theme }) => theme.spacing(2)};
`;
const StyledButtonCopy = styled.div`
align-items: end;
display: flex;
`;
export const SettingsSSOOIDCForm = () => {
const { control } = useFormContext();
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
const authorizedUrl = window.location.origin;
const redirectionUrl = `${window.location.origin}/auth/oidc/callback`;
return (
<>
<Section>
<H2Title
title="Client Settings"
description="Provide your OIDC provider details"
/>
<StyledInputsContainer>
<StyledContainer>
<StyledLinkContainer>
<TextInput
readOnly={true}
label="Authorized URI"
value={authorizedUrl}
fullWidth
/>
</StyledLinkContainer>
<StyledButtonCopy>
<Button
Icon={IconCopy}
title="Copy"
onClick={() => {
enqueueSnackBar('Authorized Url copied to clipboard', {
variant: SnackBarVariant.Success,
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
});
navigator.clipboard.writeText(authorizedUrl);
}}
/>
</StyledButtonCopy>
</StyledContainer>
<StyledContainer>
<StyledLinkContainer>
<TextInput
readOnly={true}
label="Redirection URI"
value={redirectionUrl}
fullWidth
/>
</StyledLinkContainer>
<StyledButtonCopy>
<Button
Icon={IconCopy}
title="Copy"
onClick={() => {
enqueueSnackBar('Redirect Url copied to clipboard', {
variant: SnackBarVariant.Success,
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
});
navigator.clipboard.writeText(redirectionUrl);
}}
/>
</StyledButtonCopy>
</StyledContainer>
</StyledInputsContainer>
</Section>
<Section>
<H2Title
title="Identity Provider"
description="Enter the credentials to set the connection"
/>
<StyledInputsContainer>
<Controller
name="clientID"
control={control}
render={({ field: { onChange, value } }) => (
<TextInput
autoComplete="off"
label="Client ID"
value={value}
onChange={onChange}
fullWidth
placeholder="900960562328-36306ohbk8e3.apps.googleusercontent.com"
/>
)}
/>
<Controller
name="clientSecret"
control={control}
render={({ field: { onChange, value } }) => (
<TextInput
autoComplete="off"
type="password"
label="Client Secret"
value={value}
onChange={onChange}
fullWidth
placeholder="****************************"
/>
)}
/>
<Controller
name="issuer"
control={control}
render={({ field: { onChange, value } }) => (
<TextInput
autoComplete="off"
label="Issuer URI"
value={value}
onChange={onChange}
fullWidth
placeholder="https://accounts.google.com"
/>
)}
/>
</StyledInputsContainer>
</Section>
</>
);
};

View File

@ -0,0 +1,212 @@
/* @license Enterprise */
import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator';
import { parseSAMLMetadataFromXMLFile } from '@/settings/security/utils/parseSAMLMetadataFromXMLFile';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
import { Section } from '@/ui/layout/section/components/Section';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ChangeEvent, useRef } from 'react';
import { useFormContext } from 'react-hook-form';
import {
H2Title,
IconCheck,
IconCopy,
IconDownload,
IconUpload,
} from 'twenty-ui';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { isDefined } from '~/utils/isDefined';
const StyledUploadFileContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledFileInput = styled.input`
display: none;
`;
const StyledInputsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2, 4)};
width: 100%;
`;
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
`;
const StyledLinkContainer = styled.div`
flex: 1;
margin-right: ${({ theme }) => theme.spacing(2)};
`;
const StyledButtonCopy = styled.div`
align-items: end;
display: flex;
`;
export const SettingsSSOSAMLForm = () => {
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
const { setValue, getValues, watch } = useFormContext();
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (isDefined(e.target.files)) {
const text = await e.target.files[0].text();
const samlMetadataParsed = parseSAMLMetadataFromXMLFile(text);
if (!samlMetadataParsed.success) {
enqueueSnackBar('Invalid File', {
variant: SnackBarVariant.Error,
duration: 2000,
});
return;
}
setValue('ssoURL', samlMetadataParsed.data.ssoUrl);
setValue('certificate', samlMetadataParsed.data.certificate);
setValue('issuer', samlMetadataParsed.data.entityID);
}
};
const entityID = `${REACT_APP_SERVER_BASE_URL}/auth/saml/login/${getValues('id')}`;
const acsUrl = `${REACT_APP_SERVER_BASE_URL}/auth/saml/callback`;
const inputFileRef = useRef<HTMLInputElement>(null);
const handleUploadFileClick = () => {
inputFileRef?.current?.click?.();
};
const ssoURL = watch('ssoURL');
const certificate = watch('certificate');
const issuer = watch('issuer');
const isXMLMetadataValid = () => {
return [ssoURL, certificate, issuer].every(
(field) => isDefined(field) && field.length > 0,
);
};
const downloadMetadata = async () => {
const response = await fetch(
`${REACT_APP_SERVER_BASE_URL}/auth/saml/metadata/${getValues('id')}`,
);
if (!response.ok) {
return enqueueSnackBar('Metadata file generation failed', {
variant: SnackBarVariant.Error,
duration: 2000,
});
}
const text = await response.text();
const blob = new Blob([text], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'metadata.xml';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<>
<Section>
<H2Title
title="Identity Provider Metadata XML"
description="Upload the XML file with your connection infos"
/>
<StyledUploadFileContainer>
<StyledFileInput
ref={inputFileRef}
onChange={handleFileChange}
type="file"
accept=".xml"
/>
<Button
Icon={IconUpload}
onClick={handleUploadFileClick}
title="Upload file"
></Button>
{isXMLMetadataValid() && (
<IconCheck
size={theme.icon.size.md}
stroke={theme.icon.stroke.lg}
color={theme.color.blue}
/>
)}
</StyledUploadFileContainer>
</Section>
<Section>
<H2Title
title="Service Provider Details"
description="Enter the infos to set the connection"
/>
<StyledInputsContainer>
<StyledContainer>
<Button
Icon={IconDownload}
onClick={downloadMetadata}
title="Download file"
></Button>
</StyledContainer>
<HorizontalSeparator visible={true} text={'Or'} />
<StyledContainer>
<StyledLinkContainer>
<TextInput
disabled={true}
label="ACS Url"
value={acsUrl}
fullWidth
/>
</StyledLinkContainer>
<StyledButtonCopy>
<Button
Icon={IconCopy}
title="Copy"
onClick={() => {
enqueueSnackBar('ACS Url copied to clipboard', {
variant: SnackBarVariant.Success,
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
});
navigator.clipboard.writeText(acsUrl);
}}
/>
</StyledButtonCopy>
</StyledContainer>
<StyledContainer>
<StyledLinkContainer>
<TextInput
disabled={true}
label="Entity ID"
value={entityID}
fullWidth
/>
</StyledLinkContainer>
<StyledButtonCopy>
<Button
Icon={IconCopy}
title="Copy"
onClick={() => {
enqueueSnackBar('Entity ID copied to clipboard', {
variant: SnackBarVariant.Success,
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
});
navigator.clipboard.writeText(entityID);
}}
/>
</StyledButtonCopy>
</StyledContainer>
</StyledInputsContainer>
</Section>
</>
);
};

View File

@ -0,0 +1,62 @@
import { IconLink } from 'twenty-ui';
import { SettingsOptionCardContent } from '@/settings/components/SettingsOptionCardContent';
import { Card } from '@/ui/layout/card/components/Card';
import styled from '@emotion/styled';
import { Toggle } from '@/ui/input/components/Toggle';
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useRecoilState } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
const StyledToggle = styled(Toggle)`
margin-left: auto;
`;
export const SettingsSecurityOptionsList = () => {
const { enqueueSnackBar } = useSnackBar();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const [updateWorkspace] = useUpdateWorkspaceMutation();
const handleChange = async (value: boolean) => {
try {
if (!currentWorkspace?.id) {
throw new Error('User is not logged in');
}
await updateWorkspace({
variables: {
input: {
isPublicInviteLinkEnabled: value,
},
},
});
setCurrentWorkspace({
...currentWorkspace,
isPublicInviteLinkEnabled: value,
});
} catch (err: any) {
enqueueSnackBar(err?.message, {
variant: SnackBarVariant.Error,
});
}
};
return (
<Card>
<SettingsOptionCardContent
Icon={IconLink}
title="Invite by Link"
description="Allow the invitation of new users by sharing an invite link."
onClick={() =>
handleChange(!currentWorkspace?.isPublicInviteLinkEnabled)
}
>
<StyledToggle value={currentWorkspace?.isPublicInviteLinkEnabled} />
</SettingsOptionCardContent>
</Card>
);
};

View File

@ -0,0 +1,102 @@
/* @license Enterprise */
import { IconArchive, IconDotsVertical, IconTrash } from 'twenty-ui';
import { useDeleteSSOIdentityProvider } from '@/settings/security/hooks/useDeleteSSOIdentityProvider';
import { useUpdateSSOIdentityProvider } from '@/settings/security/hooks/useUpdateSSOIdentityProvider';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { UnwrapRecoilValue } from 'recoil';
import { SsoIdentityProviderStatus } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
type SettingsSecuritySSORowDropdownMenuProps = {
SSOIdp: UnwrapRecoilValue<typeof SSOIdentitiesProvidersState>[0];
};
export const SettingsSecuritySSORowDropdownMenu = ({
SSOIdp,
}: SettingsSecuritySSORowDropdownMenuProps) => {
const dropdownId = `settings-account-row-${SSOIdp.id}`;
const { enqueueSnackBar } = useSnackBar();
const { closeDropdown } = useDropdown(dropdownId);
const { deleteSSOIdentityProvider } = useDeleteSSOIdentityProvider();
const { updateSSOIdentityProvider } = useUpdateSSOIdentityProvider();
const handleDeleteSSOIdentityProvider = async (
identityProviderId: string,
) => {
const result = await deleteSSOIdentityProvider({
identityProviderId,
});
if (isDefined(result.errors)) {
enqueueSnackBar('Error deleting SSO Identity Provider', {
variant: SnackBarVariant.Error,
duration: 2000,
});
}
};
const toggleSSOIdentityProviderStatus = async (
identityProviderId: string,
) => {
const result = await updateSSOIdentityProvider({
id: identityProviderId,
status:
SSOIdp.status === 'Active'
? SsoIdentityProviderStatus.Inactive
: SsoIdentityProviderStatus.Active,
});
if (isDefined(result.errors)) {
enqueueSnackBar('Error editing SSO Identity Provider', {
variant: SnackBarVariant.Error,
duration: 2000,
});
}
};
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
dropdownHotkeyScope={{ scope: dropdownId }}
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownComponents={
<DropdownMenu>
<DropdownMenuItemsContainer>
<MenuItem
accent="default"
LeftIcon={IconArchive}
text={SSOIdp.status === 'Active' ? 'Deactivate' : 'Activate'}
onClick={() => {
toggleSSOIdentityProviderStatus(SSOIdp.id);
closeDropdown();
}}
/>
<MenuItem
accent="danger"
LeftIcon={IconTrash}
text="Delete"
onClick={() => {
handleDeleteSSOIdentityProvider(SSOIdp.id);
closeDropdown();
}}
/>
</DropdownMenuItemsContainer>
</DropdownMenu>
}
/>
);
};

View File

@ -0,0 +1,15 @@
/* @license Enterprise */
import { gql } from '@apollo/client';
export const CREATE_OIDC_SSO_IDENTITY_PROVIDER = gql`
mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) {
createOIDCIdentityProvider(input: $input) {
id
type
issuer
name
status
}
}
`;

View File

@ -0,0 +1,15 @@
/* @license Enterprise */
import { gql } from '@apollo/client';
export const CREATE_SAML_SSO_IDENTITY_PROVIDER = gql`
mutation CreateSAMLIdentityProvider($input: SetupSAMLSsoInput!) {
createSAMLIdentityProvider(input: $input) {
id
type
issuer
name
status
}
}
`;

View File

@ -0,0 +1,11 @@
/* @license Enterprise */
import { gql } from '@apollo/client';
export const DELETE_SSO_IDENTITY_PROVIDER = gql`
mutation DeleteSSOIdentityProvider($input: DeleteSsoInput!) {
deleteSSOIdentityProvider(input: $input) {
identityProviderId
}
}
`;

View File

@ -0,0 +1,15 @@
/* @license Enterprise */
import { gql } from '@apollo/client';
export const EDIT_SSO_IDENTITY_PROVIDER = gql`
mutation EditSSOIdentityProvider($input: EditSsoInput!) {
editSSOIdentityProvider(input: $input) {
id
type
issuer
name
status
}
}
`;

View File

@ -0,0 +1,15 @@
/* @license Enterprise */
import { gql } from '@apollo/client';
export const LIST_WORKSPACE_SSO_IDENTITY_PROVIDERS = gql`
query ListSSOIdentityProvidersByWorkspaceId {
listSSOIdentityProvidersByWorkspaceId {
type
id
name
issuer
status
}
}
`;

View File

@ -0,0 +1,94 @@
/* @license Enterprise */
import { renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import { useCreateSSOIdentityProvider } from '@/settings/security/hooks/useCreateSSOIdentityProvider';
const mutationOIDCCallSpy = jest.fn();
const mutationSAMLCallSpy = jest.fn();
jest.mock('~/generated/graphql', () => ({
useCreateOidcIdentityProviderMutation: () => [mutationOIDCCallSpy],
useCreateSamlIdentityProviderMutation: () => [mutationSAMLCallSpy],
}));
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>{children}</RecoilRoot>
);
describe('useCreateSSOIdentityProvider', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('create OIDC sso identity provider', async () => {
const OIDCParams = {
type: 'OIDC' as const,
name: 'test',
clientID: 'test',
clientSecret: 'test',
issuer: 'test',
};
renderHook(
() => {
const { createSSOIdentityProvider } = useCreateSSOIdentityProvider();
createSSOIdentityProvider(OIDCParams);
},
{ wrapper: Wrapper },
);
// eslint-disable-next-line unused-imports/no-unused-vars
const { type, ...input } = OIDCParams;
expect(mutationOIDCCallSpy).toHaveBeenCalledWith({
onCompleted: expect.any(Function),
variables: {
input,
},
});
});
it('create SAML sso identity provider', async () => {
const SAMLParams = {
type: 'SAML' as const,
name: 'test',
metadata: 'test',
certificate: 'test',
id: 'test',
issuer: 'test',
ssoURL: 'test',
};
renderHook(
() => {
const { createSSOIdentityProvider } = useCreateSSOIdentityProvider();
createSSOIdentityProvider(SAMLParams);
},
{ wrapper: Wrapper },
);
// eslint-disable-next-line unused-imports/no-unused-vars
const { type, ...input } = SAMLParams;
expect(mutationOIDCCallSpy).not.toHaveBeenCalled();
expect(mutationSAMLCallSpy).toHaveBeenCalledWith({
onCompleted: expect.any(Function),
variables: {
input,
},
});
});
it('throw error if provider is not SAML or OIDC', async () => {
const OTHERParams = {
type: 'OTHER' as const,
};
renderHook(
async () => {
const { createSSOIdentityProvider } = useCreateSSOIdentityProvider();
await expect(
// @ts-expect-error - It's expected to throw an error
createSSOIdentityProvider(OTHERParams),
).rejects.toThrowError();
},
{ wrapper: Wrapper },
);
});
});

View File

@ -0,0 +1,40 @@
/* @license Enterprise */
import { renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import { useDeleteSSOIdentityProvider } from '@/settings/security/hooks/useDeleteSSOIdentityProvider';
const mutationDeleteSSOIDPCallSpy = jest.fn();
jest.mock('~/generated/graphql', () => ({
useDeleteSsoIdentityProviderMutation: () => [mutationDeleteSSOIDPCallSpy],
}));
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>{children}</RecoilRoot>
);
describe('useDeleteSsoIdentityProvider', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('delete SSO identity provider', async () => {
renderHook(
() => {
const { deleteSSOIdentityProvider } = useDeleteSSOIdentityProvider();
deleteSSOIdentityProvider({ identityProviderId: 'test' });
},
{ wrapper: Wrapper },
);
expect(mutationDeleteSSOIDPCallSpy).toHaveBeenCalledWith({
onCompleted: expect.any(Function),
variables: {
input: { identityProviderId: 'test' },
},
});
});
});

View File

@ -0,0 +1,49 @@
/* @license Enterprise */
import { renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import { useUpdateSSOIdentityProvider } from '@/settings/security/hooks/useUpdateSSOIdentityProvider';
import { SsoIdentityProviderStatus } from '~/generated/graphql';
const mutationEditSSOIDPCallSpy = jest.fn();
jest.mock('~/generated/graphql', () => {
const actual = jest.requireActual('~/generated/graphql');
return {
useEditSsoIdentityProviderMutation: () => [mutationEditSSOIDPCallSpy],
SsoIdentityProviderStatus: actual.SsoIdentityProviderStatus,
};
});
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>{children}</RecoilRoot>
);
describe('useEditSsoIdentityProvider', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('Deactivate SSO identity provider', async () => {
const params = {
id: 'test',
status: SsoIdentityProviderStatus.Inactive,
};
renderHook(
() => {
const { updateSSOIdentityProvider } = useUpdateSSOIdentityProvider();
updateSSOIdentityProvider(params);
},
{ wrapper: Wrapper },
);
expect(mutationEditSSOIDPCallSpy).toHaveBeenCalledWith({
onCompleted: expect.any(Function),
variables: {
input: params,
},
});
});
});

View File

@ -0,0 +1,63 @@
/* @license Enterprise */
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import { useSetRecoilState } from 'recoil';
import {
CreateOidcIdentityProviderMutationVariables,
CreateSamlIdentityProviderMutationVariables,
useCreateOidcIdentityProviderMutation,
useCreateSamlIdentityProviderMutation,
} from '~/generated/graphql';
export const useCreateSSOIdentityProvider = () => {
const [createOidcIdentityProviderMutation] =
useCreateOidcIdentityProviderMutation();
const [createSamlIdentityProviderMutation] =
useCreateSamlIdentityProviderMutation();
const setSSOIdentitiesProviders = useSetRecoilState(
SSOIdentitiesProvidersState,
);
const createSSOIdentityProvider = async (
input:
| ({
type: 'OIDC';
} & CreateOidcIdentityProviderMutationVariables['input'])
| ({
type: 'SAML';
} & CreateSamlIdentityProviderMutationVariables['input']),
) => {
if (input.type === 'OIDC') {
// eslint-disable-next-line unused-imports/no-unused-vars
const { type, ...params } = input;
return await createOidcIdentityProviderMutation({
variables: { input: params },
onCompleted: (data) => {
setSSOIdentitiesProviders((existingProvider) => [
...existingProvider,
data.createOIDCIdentityProvider,
]);
},
});
} else if (input.type === 'SAML') {
// eslint-disable-next-line unused-imports/no-unused-vars
const { type, ...params } = input;
return await createSamlIdentityProviderMutation({
variables: { input: params },
onCompleted: (data) => {
setSSOIdentitiesProviders((existingProvider) => [
...existingProvider,
data.createSAMLIdentityProvider,
]);
},
});
} else {
throw new Error('Invalid IdpType');
}
};
return {
createSSOIdentityProvider,
};
};

View File

@ -0,0 +1,40 @@
/* @license Enterprise */
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import { useSetRecoilState } from 'recoil';
import {
DeleteSsoIdentityProviderMutationVariables,
useDeleteSsoIdentityProviderMutation,
} from '~/generated/graphql';
export const useDeleteSSOIdentityProvider = () => {
const [deleteSsoIdentityProviderMutation] =
useDeleteSsoIdentityProviderMutation();
const setSSOIdentitiesProviders = useSetRecoilState(
SSOIdentitiesProvidersState,
);
const deleteSSOIdentityProvider = async ({
identityProviderId,
}: DeleteSsoIdentityProviderMutationVariables['input']) => {
return await deleteSsoIdentityProviderMutation({
variables: {
input: { identityProviderId },
},
onCompleted: (data) => {
setSSOIdentitiesProviders((SSOIdentitiesProviders) =>
SSOIdentitiesProviders.filter(
(identityProvider) =>
identityProvider.id !==
data.deleteSSOIdentityProvider.identityProviderId,
),
);
},
});
};
return {
deleteSSOIdentityProvider,
};
};

View File

@ -0,0 +1,40 @@
/* @license Enterprise */
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import { useSetRecoilState } from 'recoil';
import {
EditSsoIdentityProviderMutationVariables,
useEditSsoIdentityProviderMutation,
} from '~/generated/graphql';
export const useUpdateSSOIdentityProvider = () => {
const [editSsoIdentityProviderMutation] =
useEditSsoIdentityProviderMutation();
const setSSOIdentitiesProviders = useSetRecoilState(
SSOIdentitiesProvidersState,
);
const updateSSOIdentityProvider = async (
payload: EditSsoIdentityProviderMutationVariables['input'],
) => {
return await editSsoIdentityProviderMutation({
variables: {
input: payload,
},
onCompleted: (data) => {
setSSOIdentitiesProviders((SSOIdentitiesProviders) =>
SSOIdentitiesProviders.map((identityProvider) =>
identityProvider.id === data.editSSOIdentityProvider.id
? data.editSSOIdentityProvider
: identityProvider,
),
);
},
});
};
return {
updateSSOIdentityProvider,
};
};

View File

@ -0,0 +1,11 @@
/* @license Enterprise */
import { SSOIdentityProvider } from '@/settings/security/types/SSOIdentityProvider';
import { createState } from 'twenty-ui';
export const SSOIdentitiesProvidersState = createState<
Omit<SSOIdentityProvider, '__typename'>[]
>({
key: 'SSOIdentitiesProvidersState',
defaultValue: [],
});

View File

@ -0,0 +1,18 @@
/* @license Enterprise */
import { SSOIdentitiesProvidersParamsSchema } from '@/settings/security/validation-schemas/SSOIdentityProviderSchema';
import { z } from 'zod';
import { IdpType, SsoIdentityProviderStatus } from '~/generated/graphql';
export type SSOIdentityProvider = {
__typename: 'SSOIdentityProvider';
id: string;
type: IdpType;
issuer: string;
name?: string | null;
status: SsoIdentityProviderStatus;
};
export type SettingSecurityNewSSOIdentityFormValues = z.infer<
typeof SSOIdentitiesProvidersParamsSchema
>;

View File

@ -0,0 +1,39 @@
/* @license Enterprise */
import { parseSAMLMetadataFromXMLFile } from '../parseSAMLMetadataFromXMLFile';
describe('parseSAMLMetadataFromXMLFile', () => {
it('should parse SAML metadata from XML file', () => {
const xmlString = `<?xml version="1.0" encoding="UTF-8"?><md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://test.com" validUntil="2026-02-04T17:46:23.000Z">
<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>test</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://test.com"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://test.com"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>`;
const result = parseSAMLMetadataFromXMLFile(xmlString);
expect(result).toEqual({
success: true,
data: {
entityID: 'https://test.com',
ssoUrl: 'https://test.com',
certificate: 'test',
},
});
});
it('should return error if XML is invalid', () => {
const xmlString = 'invalid xml';
const result = parseSAMLMetadataFromXMLFile(xmlString);
expect(result).toEqual({
success: false,
error: new Error('Error parsing XML'),
});
});
});

View File

@ -0,0 +1,13 @@
/* @license Enterprise */
import { ThemeColor } from 'twenty-ui';
import { SsoIdentityProviderStatus } from '~/generated/graphql';
export const getColorBySSOIdentityProviderStatus: Record<
SsoIdentityProviderStatus,
ThemeColor
> = {
Active: 'green',
Inactive: 'gray',
Error: 'red',
};

View File

@ -0,0 +1,13 @@
/* @license Enterprise */
import { IconComponent, IconGoogle, IconKey } from 'twenty-ui';
export const guessSSOIdentityProviderIconByUrl = (
url: string,
): IconComponent => {
if (url.includes('google')) {
return IconGoogle;
}
return IconKey;
};

View File

@ -0,0 +1,59 @@
/* @license Enterprise */
import { z } from 'zod';
const validator = z.object({
entityID: z.string().url(),
ssoUrl: z.string().url(),
certificate: z.string().min(1),
});
export const parseSAMLMetadataFromXMLFile = (
xmlString: string,
):
| { success: true; data: z.infer<typeof validator> }
| { success: false; error: unknown } => {
try {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlString, 'application/xml');
if (xmlDoc.getElementsByTagName('parsererror').length > 0) {
throw new Error('Error parsing XML');
}
const entityDescriptor = xmlDoc.getElementsByTagName(
'md:EntityDescriptor',
)?.[0];
const idpSSODescriptor = xmlDoc.getElementsByTagName(
'md:IDPSSODescriptor',
)?.[0];
const keyDescriptor = xmlDoc.getElementsByTagName('md:KeyDescriptor')[0];
const keyInfo = keyDescriptor.getElementsByTagName('ds:KeyInfo')[0];
const x509Data = keyInfo.getElementsByTagName('ds:X509Data')[0];
const x509Certificate = x509Data
.getElementsByTagName('ds:X509Certificate')?.[0]
.textContent?.trim();
const singleSignOnServices = Array.from(
idpSSODescriptor.getElementsByTagName('md:SingleSignOnService'),
).map((service) => ({
Binding: service.getAttribute('Binding'),
Location: service.getAttribute('Location'),
}));
const result = {
ssoUrl: singleSignOnServices.find((singleSignOnService) => {
return (
singleSignOnService.Binding ===
'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
);
})?.Location,
certificate: x509Certificate,
entityID: entityDescriptor?.getAttribute('entityID'),
};
return { success: true, data: validator.parse(result) };
} catch (error) {
return { success: false, error };
}
};

View File

@ -0,0 +1,25 @@
/* @license Enterprise */
import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider';
import { IdpType } from '~/generated/graphql';
export const sSOIdentityProviderDefaultValues: Record<
IdpType,
() => SettingSecurityNewSSOIdentityFormValues
> = {
SAML: () => ({
type: 'SAML',
ssoURL: '',
name: '',
id: crypto.randomUUID(),
certificate: '',
issuer: '',
}),
OIDC: () => ({
type: 'OIDC',
name: '',
clientID: '',
clientSecret: '',
issuer: '',
}),
};

View File

@ -0,0 +1,34 @@
/* @license Enterprise */
import { z } from 'zod';
export const SSOIdentitiesProvidersOIDCParamsSchema = z
.object({
type: z.literal('OIDC'),
clientID: z.string().optional(),
clientSecret: z.string().optional(),
})
.required();
export const SSOIdentitiesProvidersSAMLParamsSchema = z
.object({
type: z.literal('SAML'),
id: z.string().optional(),
ssoURL: z.string().url().optional(),
certificate: z.string().optional(),
})
.required();
export const SSOIdentitiesProvidersParamsSchema = z
.discriminatedUnion('type', [
SSOIdentitiesProvidersOIDCParamsSchema,
SSOIdentitiesProvidersSAMLParamsSchema,
])
.and(
z
.object({
name: z.string().min(1),
issuer: z.string().url().optional(),
})
.required(),
);