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