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,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,
};
};