feat(sso): add support for identityProviderId in SAML flow (#9411)

Updated SAML callback URLs and relevant logic to include
identityProviderId, ensuring better handling of multiple identity
providers. Refactored client and server-side code to streamline form
interactions and validation within the SSO module.

Fix https://github.com/twentyhq/twenty/issues/9323
https://github.com/twentyhq/twenty/issues/9325
This commit is contained in:
Antoine Moreaux
2025-01-07 10:30:13 +01:00
committed by GitHub
parent 9392acbee5
commit 00e71477d3
6 changed files with 31 additions and 30 deletions

View File

@ -7,7 +7,7 @@ import { SettingsSSOSAMLForm } from '@/settings/security/components/SettingsSSOS
import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider'; import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { ReactElement } from 'react'; import { ReactElement, useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form';
import { H2Title, IconComponent, IconKey, Section } from 'twenty-ui'; import { H2Title, IconComponent, IconKey, Section } from 'twenty-ui';
import { IdentityProviderType } from '~/generated/graphql'; import { IdentityProviderType } from '~/generated/graphql';
@ -27,7 +27,7 @@ const StyledInputsContainer = styled.div`
`; `;
export const SettingsSSOIdentitiesProvidersForm = () => { export const SettingsSSOIdentitiesProvidersForm = () => {
const { control, getValues } = const { control, watch } =
useFormContext<SettingSecurityNewSSOIdentityFormValues>(); useFormContext<SettingSecurityNewSSOIdentityFormValues>();
const IdentitiesProvidersMap: Record< const IdentitiesProvidersMap: Record<
@ -62,8 +62,10 @@ export const SettingsSSOIdentitiesProvidersForm = () => {
}, },
}; };
const getFormByType = (type: Uppercase<IdentityProviderType> | undefined) => { const selectedType = watch('type');
switch (type) {
const formByType = useMemo(() => {
switch (selectedType) {
case IdentityProviderType.Oidc: case IdentityProviderType.Oidc:
return IdentitiesProvidersMap.OIDC.form; return IdentitiesProvidersMap.OIDC.form;
case IdentityProviderType.Saml: case IdentityProviderType.Saml:
@ -71,7 +73,11 @@ export const SettingsSSOIdentitiesProvidersForm = () => {
default: default:
return null; return null;
} }
}; }, [
IdentitiesProvidersMap.OIDC.form,
IdentitiesProvidersMap.SAML.form,
selectedType,
]);
return ( return (
<SettingsPageContainer> <SettingsPageContainer>
@ -115,7 +121,7 @@ export const SettingsSSOIdentitiesProvidersForm = () => {
/> />
</StyledInputsContainer> </StyledInputsContainer>
</Section> </Section>
{getFormByType(getValues().type)} {formByType}
</SettingsPageContainer> </SettingsPageContainer>
); );
}; };

View File

@ -56,7 +56,7 @@ const StyledButtonCopy = styled.div`
export const SettingsSSOSAMLForm = () => { export const SettingsSSOSAMLForm = () => {
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const theme = useTheme(); const theme = useTheme();
const { setValue, getValues, watch } = useFormContext(); const { setValue, getValues, watch, trigger } = useFormContext();
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (isDefined(e.target.files)) { if (isDefined(e.target.files)) {
@ -72,11 +72,12 @@ export const SettingsSSOSAMLForm = () => {
setValue('ssoURL', samlMetadataParsed.data.ssoUrl); setValue('ssoURL', samlMetadataParsed.data.ssoUrl);
setValue('certificate', samlMetadataParsed.data.certificate); setValue('certificate', samlMetadataParsed.data.certificate);
setValue('issuer', samlMetadataParsed.data.entityID); setValue('issuer', samlMetadataParsed.data.entityID);
trigger();
} }
}; };
const entityID = `${REACT_APP_SERVER_BASE_URL}/auth/saml/login/${getValues('id')}`; const entityID = `${REACT_APP_SERVER_BASE_URL}/auth/saml/login/${getValues('id')}`;
const acsUrl = `${REACT_APP_SERVER_BASE_URL}/auth/saml/callback`; const acsUrl = `${REACT_APP_SERVER_BASE_URL}/auth/saml/callback/${getValues('id')}`;
const inputFileRef = useRef<HTMLInputElement>(null); const inputFileRef = useRef<HTMLInputElement>(null);

View File

@ -12,7 +12,7 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react'; import pick from 'lodash.pick';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@ -31,20 +31,19 @@ export const SettingsSecuritySSOIdentifyProvider = () => {
), ),
}); });
const selectedType = formConfig.watch('type');
useEffect(
() =>
formConfig.reset({
...sSOIdentityProviderDefaultValues[selectedType](),
name: formConfig.getValues('name'),
}),
[formConfig, selectedType],
);
const handleSave = async () => { const handleSave = async () => {
try { try {
await createSSOIdentityProvider(formConfig.getValues()); const type = formConfig.getValues('type');
await createSSOIdentityProvider(
SSOIdentitiesProvidersParamsSchema.parse(
pick(
formConfig.getValues(),
Object.keys(sSOIdentityProviderDefaultValues[type]()),
),
),
);
navigate(getSettingsPagePath(SettingsPath.Security)); navigate(getSettingsPagePath(SettingsPath.Security));
} catch (error) { } catch (error) {
enqueueSnackBar((error as Error).message, { enqueueSnackBar((error as Error).message, {

View File

@ -58,6 +58,7 @@ export class SSOAuthController {
type: IdentityProviderType.SAML, type: IdentityProviderType.SAML,
}), }),
callbackUrl: this.ssoService.buildCallbackUrl({ callbackUrl: this.ssoService.buildCallbackUrl({
id: req.params.identityProviderId,
type: IdentityProviderType.SAML, type: IdentityProviderType.SAML,
}), }),
}); });
@ -104,7 +105,7 @@ export class SSOAuthController {
} }
} }
@Post('saml/callback') @Post('saml/callback/:identityProviderId')
@UseGuards(SSOProviderEnabledGuard, SAMLAuthGuard) @UseGuards(SSOProviderEnabledGuard, SAMLAuthGuard)
async samlAuthCallback(@Req() req: any, @Res() res: Response) { async samlAuthCallback(@Req() req: any, @Res() res: Response) {
try { try {

View File

@ -20,12 +20,6 @@ export class SAMLAuthGuard extends AuthGuard('saml') {
try { try {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const RelayState =
'RelayState' in request.body ? JSON.parse(request.body.RelayState) : {};
request.params.identityProviderId =
request.params.identityProviderId ?? RelayState.identityProviderId;
if (!request.params.identityProviderId) { if (!request.params.identityProviderId) {
throw new AuthException( throw new AuthException(
'Invalid SAML identity provider', 'Invalid SAML identity provider',

View File

@ -155,11 +155,11 @@ export class SSOService {
} }
buildCallbackUrl( buildCallbackUrl(
identityProvider: Pick<WorkspaceSSOIdentityProvider, 'type'>, identityProvider: Pick<WorkspaceSSOIdentityProvider, 'type' | 'id'>,
) { ) {
const callbackURL = new URL(this.environmentService.get('SERVER_URL')); const callbackURL = new URL(this.environmentService.get('SERVER_URL'));
callbackURL.pathname = `/auth/${identityProvider.type.toLowerCase()}/callback`; callbackURL.pathname = `/auth/${identityProvider.type.toLowerCase()}/callback/${identityProvider.id}`;
return callbackURL.toString(); return callbackURL.toString();
} }