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>
}
/>
);
};