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:
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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, {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user