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,16 @@
/* @license Enterprise */
import { gql } from '@apollo/client';
export const AVAILABLE_SSO_IDENTITY_PROVIDERS_FRAGMENT = gql`
fragment AvailableSSOIdentityProvidersFragment on FindAvailableSSOIDPOutput {
id
issuer
name
status
workspace {
id
displayName
}
}
`;

View File

@ -0,0 +1,13 @@
/* @license Enterprise */
import { gql } from '@apollo/client';
export const FIND_AVAILABLE_SSO_IDENTITY_PROVIDERS = gql`
mutation FindAvailableSSOIdentityProviders(
$input: FindAvailableSSOIDPInput!
) {
findAvailableSSOIdentityProviders(input: $input) {
...AvailableSSOIdentityProvidersFragment
}
}
`;

View File

@ -3,8 +3,21 @@ import { gql } from '@apollo/client';
export const GENERATE_JWT = gql`
mutation GenerateJWT($workspaceId: String!) {
generateJWT(workspaceId: $workspaceId) {
tokens {
...AuthTokensFragment
... on GenerateJWTOutputWithAuthTokens {
success
reason
authTokens {
tokens {
...AuthTokensFragment
}
}
}
... on GenerateJWTOutputWithSSOAUTH {
success
reason
availableSSOIDPs {
...AvailableSSOIdentityProvidersFragment
}
}
}
}

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GET_AUTHORIZATION_URL = gql`
mutation GetAuthorizationUrl($input: GetAuthorizationUrlInput!) {
getAuthorizationUrl(input: $input) {
id
type
authorizationURL
}
}
`;

View File

@ -116,6 +116,7 @@ describe('useAuth', () => {
microsoft: false,
magicLink: false,
password: false,
sso: false,
});
expect(state.billing).toBeNull();
expect(state.isSignInPrefilled).toBe(false);

View File

@ -1,8 +1,6 @@
import styled from '@emotion/styled';
import React from 'react';
type FooterNoteProps = { children: React.ReactNode };
const StyledContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
@ -20,6 +18,24 @@ const StyledContainer = styled.div`
}
`;
export const FooterNote = ({ children }: FooterNoteProps) => (
<StyledContainer>{children}</StyledContainer>
export const FooterNote = () => (
<StyledContainer>
By using Twenty, you agree to the{' '}
<a
href="https://twenty.com/legal/terms"
target="_blank"
rel="noopener noreferrer"
>
Terms of Service
</a>{' '}
and{' '}
<a
href="https://twenty.com/legal/privacy"
target="_blank"
rel="noopener noreferrer"
>
Privacy Policy
</a>
.
</StyledContainer>
);

View File

@ -3,6 +3,7 @@ import styled from '@emotion/styled';
type HorizontalSeparatorProps = {
visible?: boolean;
text?: string;
};
const StyledSeparator = styled.div<HorizontalSeparatorProps>`
background-color: ${({ theme }) => theme.border.color.medium};
@ -12,8 +13,39 @@ const StyledSeparator = styled.div<HorizontalSeparatorProps>`
width: 100%;
`;
const StyledSeparatorContainer = styled.div`
align-items: center;
display: flex;
margin-bottom: ${({ theme }) => theme.spacing(3)};
margin-top: ${({ theme }) => theme.spacing(3)};
width: 100%;
`;
const StyledLine = styled.div<HorizontalSeparatorProps>`
background-color: ${({ theme }) => theme.border.color.medium};
height: ${({ visible }) => (visible ? '1px' : 0)};
flex-grow: 1;
`;
const StyledText = styled.span`
color: ${({ theme }) => theme.font.color.light};
margin: 0 ${({ theme }) => theme.spacing(2)};
white-space: nowrap;
`;
export const HorizontalSeparator = ({
visible = true,
text = '',
}: HorizontalSeparatorProps): JSX.Element => (
<StyledSeparator visible={visible} />
<>
{text ? (
<StyledSeparatorContainer>
<StyledLine visible={visible} />
{text && <StyledText>{text}</StyledText>}
<StyledLine visible={visible} />
</StyledSeparatorContainer>
) : (
<StyledSeparator visible={visible} />
)}
</>
);

View File

@ -5,16 +5,12 @@ import { useMemo, useState } from 'react';
import { Controller } from 'react-hook-form';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { IconGoogle, IconMicrosoft } from 'twenty-ui';
import { IconGoogle, IconMicrosoft, IconKey } from 'twenty-ui';
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator';
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
import {
SignInUpMode,
SignInUpStep,
useSignInUp,
} from '@/auth/sign-in-up/hooks/useSignInUp';
import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle';
import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft';
@ -26,6 +22,7 @@ import { MainButton } from '@/ui/input/button/components/MainButton';
import { TextInput } from '@/ui/input/components/TextInput';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink';
import { isDefined } from '~/utils/isDefined';
import { SignInUpStep } from '@/auth/states/signInUpStepState';
const StyledContentContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(8)};
@ -64,9 +61,19 @@ export const SignInUpForm = () => {
signInUpMode,
continueWithCredentials,
continueWithEmail,
continueWithSSO,
submitCredentials,
submitSSOEmail,
} = useSignInUp(form);
const toggleSSOMode = () => {
if (signInUpStep === SignInUpStep.SSOEmail) {
continueWithEmail();
} else {
continueWithSSO();
}
};
const handleKeyDown = async (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
@ -86,6 +93,8 @@ export const SignInUpForm = () => {
setShowErrors(true);
form.handleSubmit(submitCredentials)();
}
} else if (signInUpStep === SignInUpStep.SSOEmail) {
submitSSOEmail(form.getValues('email'));
}
}
};
@ -99,6 +108,10 @@ export const SignInUpForm = () => {
return 'Continue';
}
if (signInUpStep === SignInUpStep.SSOEmail) {
return 'Continue with SSO';
}
return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up';
}, [signInUpMode, signInUpStep]);
@ -136,7 +149,7 @@ export const SignInUpForm = () => {
onClick={signInWithGoogle}
fullWidth
/>
<HorizontalSeparator visible={!authProviders.microsoft} />
<HorizontalSeparator visible={false} />
</>
)}
@ -148,17 +161,143 @@ export const SignInUpForm = () => {
onClick={signInWithMicrosoft}
fullWidth
/>
<HorizontalSeparator visible={authProviders.password} />
<HorizontalSeparator visible={false} />
</>
)}
{authProviders.sso && (
<>
<MainButton
Icon={() => <IconKey size={theme.icon.size.lg} />}
title={
signInUpStep === SignInUpStep.SSOEmail
? 'Continue with email'
: 'Single sign-on (SSO)'
}
onClick={toggleSSOMode}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
)}
{authProviders.password && (
<StyledForm
onSubmit={(event) => {
event.preventDefault();
}}
>
{signInUpStep !== SignInUpStep.Init && (
<HorizontalSeparator visible={true} />
{authProviders.password &&
(signInUpStep === SignInUpStep.Password ||
signInUpStep === SignInUpStep.Email ||
signInUpStep === SignInUpStep.Init) && (
<StyledForm
onSubmit={(event) => {
event.preventDefault();
}}
>
{signInUpStep !== SignInUpStep.Init && (
<StyledFullWidthMotionDiv
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{
type: 'spring',
stiffness: 800,
damping: 35,
}}
>
<Controller
name="email"
control={form.control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<StyledInputContainer>
<TextInput
autoFocus
value={value}
placeholder="Email"
onBlur={onBlur}
onChange={(value: string) => {
onChange(value);
if (signInUpStep === SignInUpStep.Password) {
continueWithEmail();
}
}}
error={showErrors ? error?.message : undefined}
fullWidth
disableHotkeys
onKeyDown={handleKeyDown}
/>
</StyledInputContainer>
)}
/>
</StyledFullWidthMotionDiv>
)}
{signInUpStep === SignInUpStep.Password && (
<StyledFullWidthMotionDiv
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{
type: 'spring',
stiffness: 800,
damping: 35,
}}
>
<Controller
name="password"
control={form.control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<StyledInputContainer>
<TextInput
autoFocus
value={value}
type="password"
placeholder="Password"
onBlur={onBlur}
onChange={onChange}
error={showErrors ? error?.message : undefined}
fullWidth
disableHotkeys
onKeyDown={handleKeyDown}
/>
</StyledInputContainer>
)}
/>
</StyledFullWidthMotionDiv>
)}
<MainButton
variant="secondary"
title={buttonTitle}
type="submit"
onClick={async () => {
if (signInUpStep === SignInUpStep.Init) {
continueWithEmail();
return;
}
if (signInUpStep === SignInUpStep.Email) {
if (isDefined(form?.formState?.errors?.email)) {
setShowErrors(true);
return;
}
continueWithCredentials();
return;
}
setShowErrors(true);
form.handleSubmit(submitCredentials)();
}}
Icon={() => form.formState.isSubmitting && <Loader />}
disabled={isSubmitButtonDisabled}
fullWidth
/>
</StyledForm>
)}
<StyledForm
onSubmit={(event) => {
event.preventDefault();
}}
>
{signInUpStep === SignInUpStep.SSOEmail && (
<>
<StyledFullWidthMotionDiv
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
@ -181,46 +320,6 @@ export const SignInUpForm = () => {
value={value}
placeholder="Email"
onBlur={onBlur}
onChange={(value: string) => {
onChange(value);
if (signInUpStep === SignInUpStep.Password) {
continueWithEmail();
}
}}
error={showErrors ? error?.message : undefined}
fullWidth
disableHotkeys
onKeyDown={handleKeyDown}
/>
</StyledInputContainer>
)}
/>
</StyledFullWidthMotionDiv>
)}
{signInUpStep === SignInUpStep.Password && (
<StyledFullWidthMotionDiv
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{
type: 'spring',
stiffness: 800,
damping: 35,
}}
>
<Controller
name="password"
control={form.control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<StyledInputContainer>
<TextInput
autoFocus
value={value}
type="password"
placeholder="Password"
onBlur={onBlur}
onChange={onChange}
error={showErrors ? error?.message : undefined}
fullWidth
@ -231,60 +330,28 @@ export const SignInUpForm = () => {
)}
/>
</StyledFullWidthMotionDiv>
)}
<MainButton
variant="secondary"
title={buttonTitle}
type="submit"
onClick={async () => {
if (signInUpStep === SignInUpStep.Init) {
continueWithEmail();
return;
}
if (signInUpStep === SignInUpStep.Email) {
if (isDefined(form?.formState?.errors?.email)) {
setShowErrors(true);
return;
}
continueWithCredentials();
return;
}
setShowErrors(true);
form.handleSubmit(submitCredentials)();
}}
Icon={() => form.formState.isSubmitting && <Loader />}
disabled={isSubmitButtonDisabled}
fullWidth
/>
</StyledForm>
)}
<MainButton
variant="secondary"
title={buttonTitle}
type="submit"
onClick={async () => {
setShowErrors(true);
submitSSOEmail(form.getValues('email'));
}}
Icon={() => form.formState.isSubmitting && <Loader />}
disabled={isSubmitButtonDisabled}
fullWidth
/>
</>
)}
</StyledForm>
</StyledContentContainer>
{signInUpStep === SignInUpStep.Password && (
<ActionLink onClick={handleResetPassword(form.getValues('email'))}>
Forgot your password?
</ActionLink>
)}
{signInUpStep === SignInUpStep.Init && (
<FooterNote>
By using Twenty, you agree to the{' '}
<a
href="https://twenty.com/legal/terms"
target="_blank"
rel="noopener noreferrer"
>
Terms of Service
</a>{' '}
and{' '}
<a
href="https://twenty.com/legal/privacy"
target="_blank"
rel="noopener noreferrer"
>
Privacy Policy
</a>
.
</FooterNote>
)}
{signInUpStep === SignInUpStep.Init && <FooterNote />}
</>
);
};

View File

@ -0,0 +1,68 @@
/* @license Enterprise */
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import {
FindAvailableSsoIdentityProvidersMutationVariables,
GetAuthorizationUrlMutationVariables,
useFindAvailableSsoIdentityProvidersMutation,
useGetAuthorizationUrlMutation,
} from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
export const useSSO = () => {
const { enqueueSnackBar } = useSnackBar();
const [findAvailableSSOProviderByEmailMutation] =
useFindAvailableSsoIdentityProvidersMutation();
const [getAuthorizationUrlMutation] = useGetAuthorizationUrlMutation();
const findAvailableSSOProviderByEmail = async ({
email,
}: FindAvailableSsoIdentityProvidersMutationVariables['input']) => {
return await findAvailableSSOProviderByEmailMutation({
variables: {
input: { email },
},
});
};
const getAuthorizationUrlForSSO = async ({
identityProviderId,
}: GetAuthorizationUrlMutationVariables['input']) => {
return await getAuthorizationUrlMutation({
variables: {
input: { identityProviderId },
},
});
};
const redirectToSSOLoginPage = async (identityProviderId: string) => {
const authorizationUrlForSSOResult = await getAuthorizationUrlForSSO({
identityProviderId,
});
if (
isDefined(authorizationUrlForSSOResult.errors) ||
!authorizationUrlForSSOResult.data ||
!authorizationUrlForSSOResult.data?.getAuthorizationUrl.authorizationURL
) {
return enqueueSnackBar(
authorizationUrlForSSOResult.errors?.[0]?.message ?? 'Unknown error',
{
variant: SnackBarVariant.Error,
},
);
}
window.location.href =
authorizationUrlForSSOResult.data?.getAuthorizationUrl.authorizationURL;
return;
};
return {
redirectToSSOLoginPage,
getAuthorizationUrlForSSO,
findAvailableSSOProviderByEmail,
};
};

View File

@ -9,25 +9,34 @@ import { AppPath } from '@/types/AppPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { isDefined } from '~/utils/isDefined';
import { useAuth } from '../../hooks/useAuth';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO';
export enum SignInUpMode {
SignIn = 'sign-in',
SignUp = 'sign-up',
}
export enum SignInUpStep {
Init = 'init',
Email = 'email',
Password = 'password',
}
export const useSignInUp = (form: UseFormReturn<Form>) => {
const { enqueueSnackBar } = useSnackBar();
const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState);
const isMatchingLocation = useIsMatchingLocation();
const { redirectToSSOLoginPage, findAvailableSSOProviderByEmail } = useSSO();
const setAvailableWorkspacesForSSOState = useSetRecoilState(
availableSSOIdentityProvidersState,
);
const workspaceInviteHash = useParams().workspaceInviteHash;
const [searchParams] = useSearchParams();
const workspacePersonalInviteToken =
@ -35,10 +44,6 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
const [isInviteMode] = useState(() => isMatchingLocation(AppPath.Invite));
const [signInUpStep, setSignInUpStep] = useState<SignInUpStep>(
SignInUpStep.Init,
);
const [signInUpMode, setSignInUpMode] = useState<SignInUpMode>(() => {
return isMatchingLocation(AppPath.SignInUp)
? SignInUpMode.SignIn
@ -62,7 +67,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
? SignInUpMode.SignIn
: SignInUpMode.SignUp,
);
}, [isMatchingLocation, requestFreshCaptchaToken]);
}, [isMatchingLocation, requestFreshCaptchaToken, setSignInUpStep]);
const continueWithCredentials = useCallback(async () => {
const token = await readCaptchaToken();
@ -95,8 +100,48 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
checkUserExistsQuery,
enqueueSnackBar,
requestFreshCaptchaToken,
setSignInUpStep,
]);
const continueWithSSO = () => {
setSignInUpStep(SignInUpStep.SSOEmail);
};
const submitSSOEmail = async (email: string) => {
const result = await findAvailableSSOProviderByEmail({
email,
});
if (isDefined(result.errors)) {
return enqueueSnackBar(result.errors[0].message, {
variant: SnackBarVariant.Error,
});
}
if (
!result.data?.findAvailableSSOIdentityProviders ||
result.data?.findAvailableSSOIdentityProviders.length === 0
) {
enqueueSnackBar('No workspaces with SSO found', {
variant: SnackBarVariant.Error,
});
return;
}
// If only one workspace, redirect to SSO
if (result.data?.findAvailableSSOIdentityProviders.length === 1) {
return redirectToSSOLoginPage(
result.data.findAvailableSSOIdentityProviders[0].id,
);
}
if (result.data?.findAvailableSSOIdentityProviders.length > 1) {
setAvailableWorkspacesForSSOState(
result.data.findAvailableSSOIdentityProviders,
);
setSignInUpStep(SignInUpStep.SSOWorkspaceSelection);
}
};
const submitCredentials: SubmitHandler<Form> = useCallback(
async (data) => {
const token = await readCaptchaToken();
@ -144,6 +189,8 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
signInUpMode,
continueWithCredentials,
continueWithEmail,
continueWithSSO,
submitSSOEmail,
submitCredentials,
};
};

View File

@ -0,0 +1,11 @@
import { createState } from 'twenty-ui';
import { FindAvailableSsoIdentityProvidersMutationResult } from '~/generated/graphql';
export const availableSSOIdentityProvidersState = createState<
NonNullable<
FindAvailableSsoIdentityProvidersMutationResult['data']
>['findAvailableSSOIdentityProviders']
>({
key: 'availableSSOIdentityProviders',
defaultValue: [],
});

View File

@ -13,6 +13,7 @@ export type CurrentWorkspace = Pick<
| 'activationStatus'
| 'currentBillingSubscription'
| 'workspaceMembersCount'
| 'isPublicInviteLinkEnabled'
| 'metadataVersion'
>;

View File

@ -0,0 +1,14 @@
import { createState } from 'twenty-ui';
export enum SignInUpStep {
Init = 'init',
Email = 'email',
Password = 'password',
SSOEmail = 'SSOEmail',
SSOWorkspaceSelection = 'SSOWorkspaceSelection',
}
export const signInUpStepState = createState<SignInUpStep>({
key: 'signInUpStepState',
defaultValue: SignInUpStep.Init,
});