From e68637f12d08d15cba2514e2053a47e481d59bfd Mon Sep 17 00:00:00 2001 From: simeng-li Date: Fri, 17 Nov 2023 15:41:35 +0800 Subject: [PATCH] feat(experience): block email sign-in and register (#4886) * feat(experience): block email sign-in and register block email sign-in and register if SSO is enabled for that domain address * test(experience): add ut add ut --- packages/experience/src/App.tsx | 144 +++++++++--------- .../SingleSignOnContextProvider/index.tsx | 11 +- .../use-check-single-sign-on.ts} | 13 +- .../IdentifierRegisterForm/index.test.tsx | 107 ++++++++++++- .../IdentifierRegisterForm/use-on-submit.ts | 18 ++- .../IdentifierSignInForm/index.test.tsx | 143 ++++++++++++++++- .../IdentifierSignInForm/use-on-submit.ts | 58 ++++--- .../src/pages/SingleSignOnEmail/index.tsx | 2 +- 8 files changed, 388 insertions(+), 108 deletions(-) rename packages/experience/src/{pages/SingleSignOnEmail/use-on-submit.ts => hooks/use-check-single-sign-on.ts} (85%) diff --git a/packages/experience/src/App.tsx b/packages/experience/src/App.tsx index 07ce3b943..24bc72e16 100644 --- a/packages/experience/src/App.tsx +++ b/packages/experience/src/App.tsx @@ -49,82 +49,84 @@ const App = () => { - - - - } /> - }> - } - /> - } /> + + + + + } /> + }> + } + /> + } /> - }> - {/* Sign-in */} - - } /> - } /> - } /> + }> + {/* Sign-in */} + + } /> + } /> + } /> + + + {/* Register */} + + } /> + } /> + + + {/* Forgot password */} + + } /> + } /> + + + {/* Passwordless verification code */} + } /> + + {/* Mfa binding */} + + } /> + } /> + } /> + } /> + + + {/* Mfa verification */} + + } /> + } /> + } /> + } /> + + + {/* Continue set up missing profile */} + + } /> + + + {/* Social sign-in pages */} + + } /> + } /> + + } /> - {/* Register */} - - } /> - } /> - + {/* Single sign on */} + {isDevelopmentFeaturesEnabled && ( + + } /> + } /> + + )} - {/* Forgot password */} - - } /> - } /> - - - {/* Passwordless verification code */} - } /> - - {/* Mfa binding */} - - } /> - } /> - } /> - } /> - - - {/* Mfa verification */} - - } /> - } /> - } /> - } /> - - - {/* Continue set up missing profile */} - - } /> - - - {/* Social sign-in pages */} - - } /> - } /> - - } /> + } /> - - {/* Single sign on */} - {isDevelopmentFeaturesEnabled && ( - }> - } /> - } /> - - )} - - } /> - - - - + + + + diff --git a/packages/experience/src/Providers/SingleSignOnContextProvider/index.tsx b/packages/experience/src/Providers/SingleSignOnContextProvider/index.tsx index eb585e589..d360d5716 100644 --- a/packages/experience/src/Providers/SingleSignOnContextProvider/index.tsx +++ b/packages/experience/src/Providers/SingleSignOnContextProvider/index.tsx @@ -1,13 +1,16 @@ import { type SsoConnectorMetadata } from '@logto/schemas'; -import { useEffect, useMemo, useState } from 'react'; -import { Outlet } from 'react-router-dom'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages'; import { useSieMethods } from '@/hooks/use-sie'; import SingleSignOnContext, { type SingleSignOnContextType } from './SingleSignOnContext'; -const SingleSignOnContextProvider = () => { +type Props = { + children: ReactNode; +}; + +const SingleSignOnContextProvider = ({ children }: Props) => { const { ssoConnectors } = useSieMethods(); const { get, set, remove } = useSessionStorage(); const [email, setEmail] = useState(get(StorageKeys.SsoEmail)); @@ -51,7 +54,7 @@ const SingleSignOnContextProvider = () => { return ( - + {children} ); }; diff --git a/packages/experience/src/pages/SingleSignOnEmail/use-on-submit.ts b/packages/experience/src/hooks/use-check-single-sign-on.ts similarity index 85% rename from packages/experience/src/pages/SingleSignOnEmail/use-on-submit.ts rename to packages/experience/src/hooks/use-check-single-sign-on.ts index feee1e062..df907f927 100644 --- a/packages/experience/src/pages/SingleSignOnEmail/use-on-submit.ts +++ b/packages/experience/src/hooks/use-check-single-sign-on.ts @@ -5,10 +5,11 @@ import { useNavigate } from 'react-router-dom'; import SingleSignOnContext from '@/Providers/SingleSignOnContextProvider/SingleSignOnContext'; import { getSingleSignOnConnectors } from '@/apis/single-sign-on'; +import { singleSignOnPath } from '@/constants/env'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; -const useOnSubmit = () => { +const useCheckSingleSignOn = () => { const { t } = useTranslation(); const navigate = useNavigate(); const request = useApi(getSingleSignOnConnectors); @@ -29,6 +30,11 @@ const useOnSubmit = () => { setSsoConnectors([]); }, [setEmail, setSsoConnectors]); + /** + * Check if the email is registered with any SSO connectors + * @param {string} email + * @returns {Promise} - true if the email is registered with any SSO connectors + */ const onSubmit = useCallback( async (email: string) => { clearContext(); @@ -57,7 +63,8 @@ const useOnSubmit = () => { setSsoConnectors(connectors); setEmail(email); - navigate('../connectors'); + navigate(`/${singleSignOnPath}/connectors`); + return true; }, [ availableSsoConnectorsMap, @@ -78,4 +85,4 @@ const useOnSubmit = () => { }; }; -export default useOnSubmit; +export default useCheckSingleSignOn; diff --git a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.test.tsx b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.test.tsx index ad09a3d3b..2efbe9c29 100644 --- a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.test.tsx +++ b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.test.tsx @@ -1,18 +1,22 @@ -import { SignInIdentifier } from '@logto/schemas'; +import { SignInIdentifier, type SsoConnectorMetadata } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; import { fireEvent, act, waitFor } from '@testing-library/react'; import ConfirmModalProvider from '@/Providers/ConfirmModalProvider'; +import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; +import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; import { registerWithUsernamePassword } from '@/apis/interaction'; import { sendVerificationCodeApi } from '@/apis/utils'; +import { singleSignOnPath } from '@/constants/env'; import { UserFlow } from '@/types'; import { getDefaultCountryCallingCode } from '@/utils/country-code'; import IdentifierRegisterForm from '.'; const mockedNavigate = jest.fn(); +const getSingleSignOnConnectorsMock = jest.fn(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -33,11 +37,25 @@ jest.mock('@/apis/interaction', () => ({ registerWithUsernamePassword: jest.fn(async () => ({})), })); -const renderForm = (signUpMethods: SignInIdentifier[] = [SignInIdentifier.Username]) => { +jest.mock('@/apis/single-sign-on', () => ({ + getSingleSignOnConnectors: () => getSingleSignOnConnectorsMock(), +})); + +const renderForm = ( + signUpMethods: SignInIdentifier[] = [SignInIdentifier.Username], + ssoConnectors: SsoConnectorMetadata[] = [] +) => { return renderWithPageContext( - + - + + + ); @@ -282,4 +300,85 @@ describe('', () => { }); } ); + + describe('single sign on register form', () => { + const email = 'foo@email.com'; + + it('should not call check single sign-on connector when no single sign-on connector is enabled', async () => { + const { getByText, container } = renderForm([SignInIdentifier.Email]); + const submitButton = getByText('action.create_account'); + const emailInput = container.querySelector('input[name="identifier"]'); + const termsButton = getByText('description.agree_with_terms'); + + assert(emailInput, new Error('username input not found')); + + act(() => { + fireEvent.change(emailInput, { target: { value: email } }); + fireEvent.click(termsButton); + }); + + act(() => { + fireEvent.submit(submitButton); + }); + + await waitFor(() => { + expect(getSingleSignOnConnectorsMock).not.toBeCalled(); + expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, { + email, + }); + }); + }); + + it('should call check single sign-on connector when the identifier is email, but process to password sign-in if no sso connector is matched', async () => { + getSingleSignOnConnectorsMock.mockRejectedValueOnce([]); + + const { getByText, container } = renderForm([SignInIdentifier.Email], mockSsoConnectors); + const submitButton = getByText('action.create_account'); + const emailInput = container.querySelector('input[name="identifier"]'); + const termsButton = getByText('description.agree_with_terms'); + + assert(emailInput, new Error('username input not found')); + + act(() => { + fireEvent.change(emailInput, { target: { value: email } }); + fireEvent.click(termsButton); + }); + + act(() => { + fireEvent.submit(submitButton); + }); + + await waitFor(() => { + expect(getSingleSignOnConnectorsMock).toBeCalled(); + expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, { + email, + }); + }); + }); + + it('should call check single sign-on connector when the identifier is email, but process to password sign-in if no sso connector is matched', async () => { + getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id)); + + const { getByText, container } = renderForm([SignInIdentifier.Email], mockSsoConnectors); + const submitButton = getByText('action.create_account'); + const emailInput = container.querySelector('input[name="identifier"]'); + const termsButton = getByText('description.agree_with_terms'); + + assert(emailInput, new Error('username input not found')); + + act(() => { + fireEvent.change(emailInput, { target: { value: email } }); + fireEvent.click(termsButton); + }); + + act(() => { + fireEvent.submit(submitButton); + }); + + await waitFor(() => { + expect(getSingleSignOnConnectorsMock).toBeCalled(); + expect(mockedNavigate).toBeCalledWith(`/${singleSignOnPath}/connectors`); + }); + }); + }); }); diff --git a/packages/experience/src/pages/Register/IdentifierRegisterForm/use-on-submit.ts b/packages/experience/src/pages/Register/IdentifierRegisterForm/use-on-submit.ts index 895ac9e54..c1fda2787 100644 --- a/packages/experience/src/pages/Register/IdentifierRegisterForm/use-on-submit.ts +++ b/packages/experience/src/pages/Register/IdentifierRegisterForm/use-on-submit.ts @@ -1,14 +1,17 @@ import { SignInIdentifier } from '@logto/schemas'; import { useCallback } from 'react'; +import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on'; import useSendVerificationCode from '@/hooks/use-send-verification-code'; +import { useSieMethods } from '@/hooks/use-sie'; import { UserFlow } from '@/types'; import useRegisterWithUsername from './use-register-with-username'; -// TODO: extract the errorMessage and clear method from useRegisterWithUsername and useSendVerificationCode - const useOnSubmit = () => { + const { ssoConnectors } = useSieMethods(); + const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn(); + const { errorMessage: usernameRegisterErrorMessage, clearErrorMessage: clearUsernameRegisterErrorMessage, @@ -34,9 +37,18 @@ const useOnSubmit = () => { return; } + // Check if the email is registered with any SSO connectors. If the email is registered with any SSO connectors, we should not proceed to the next step + if (identifier === SignInIdentifier.Email && ssoConnectors.length > 0) { + const result = await checkSingleSignOn(value); + + if (result) { + return; + } + } + await sendVerificationCode({ identifier, value }); }, - [registerWithUsername, sendVerificationCode] + [checkSingleSignOn, registerWithUsername, sendVerificationCode, ssoConnectors.length] ); return { diff --git a/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx b/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx index 907bc45ed..11c7b6c88 100644 --- a/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx +++ b/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx @@ -1,11 +1,18 @@ -import type { SignIn } from '@logto/schemas'; +import type { SignIn, SsoConnectorMetadata } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; import { fireEvent, act, waitFor } from '@testing-library/react'; +import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; -import { mockSignInMethodSettingsTestCases } from '@/__mocks__/logto'; +import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; +import { + mockSignInMethodSettingsTestCases, + mockSignInExperienceSettings, + mockSsoConnectors, +} from '@/__mocks__/logto'; import { sendVerificationCodeApi } from '@/apis/utils'; +import { singleSignOnPath } from '@/constants/env'; import { UserFlow } from '@/types'; import { getDefaultCountryCallingCode } from '@/utils/country-code'; @@ -18,6 +25,7 @@ jest.mock('i18next', () => ({ })); const mockedNavigate = jest.fn(); +const getSingleSignOnConnectorsMock = jest.fn(); jest.mock('@/apis/utils', () => ({ sendVerificationCodeApi: jest.fn(), @@ -28,12 +36,27 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockedNavigate, })); +jest.mock('@/apis/single-sign-on', () => ({ + getSingleSignOnConnectors: () => getSingleSignOnConnectorsMock(), +})); + const username = 'foo'; const email = 'foo@email.com'; const phone = '8573333333'; -const renderForm = (signInMethods: SignIn['methods']) => - renderWithPageContext(); +const renderForm = (signInMethods: SignIn['methods'], ssoConnectors: SsoConnectorMetadata[] = []) => + renderWithPageContext( + + + + + + ); describe('IdentifierSignInForm', () => { afterEach(() => { @@ -152,4 +175,116 @@ describe('IdentifierSignInForm', () => { }); } ); + + describe('email single sign-on tests', () => { + it('should not call check single sign-on connector when the identifier is not email', async () => { + const { getByText, container } = renderForm( + mockSignInMethodSettingsTestCases[0]!, + mockSsoConnectors + ); + + const inputField = container.querySelector('input[name="identifier"]'); + const submitButton = getByText('action.sign_in'); + + if (inputField) { + act(() => { + fireEvent.change(inputField, { target: { value: username } }); + }); + } + + act(() => { + fireEvent.submit(submitButton); + }); + + await waitFor(() => { + expect(getSingleSignOnConnectorsMock).not.toBeCalled(); + expect(mockedNavigate).toBeCalledWith( + { pathname: 'password' }, + { state: { identifier: SignInIdentifier.Username, value: username } } + ); + }); + }); + + it('should not call check single sign-on connector when no single sign-on connector is enabled', async () => { + const { getByText, container } = renderForm(mockSignInMethodSettingsTestCases[0]!); + + const inputField = container.querySelector('input[name="identifier"]'); + const submitButton = getByText('action.sign_in'); + + if (inputField) { + act(() => { + fireEvent.change(inputField, { target: { value: email } }); + }); + } + + act(() => { + fireEvent.submit(submitButton); + }); + + await waitFor(() => { + expect(getSingleSignOnConnectorsMock).not.toBeCalled(); + expect(mockedNavigate).toBeCalledWith( + { pathname: 'password' }, + { state: { identifier: SignInIdentifier.Email, value: email } } + ); + }); + }); + + it('should call check single sign-on connector when the identifier is email, but process to password sign-in if no sso connector is matched', async () => { + getSingleSignOnConnectorsMock.mockRejectedValueOnce([]); + + const { getByText, container } = renderForm( + mockSignInMethodSettingsTestCases[0]!, + mockSsoConnectors + ); + + const inputField = container.querySelector('input[name="identifier"]'); + const submitButton = getByText('action.sign_in'); + + if (inputField) { + act(() => { + fireEvent.change(inputField, { target: { value: email } }); + }); + } + + act(() => { + fireEvent.submit(submitButton); + }); + + await waitFor(() => { + expect(getSingleSignOnConnectorsMock).toBeCalled(); + expect(mockedNavigate).toBeCalledWith( + { pathname: 'password' }, + { state: { identifier: SignInIdentifier.Email, value: email } } + ); + }); + }); + + it('should call check single sign-on connector when the identifier is email, and process to single sign-on if a sso connector is matched', async () => { + getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id)); + + const { getByText, container } = renderForm( + mockSignInMethodSettingsTestCases[0]!, + mockSsoConnectors + ); + + const inputField = container.querySelector('input[name="identifier"]'); + const submitButton = getByText('action.sign_in'); + + if (inputField) { + act(() => { + fireEvent.change(inputField, { target: { value: email } }); + }); + } + + act(() => { + fireEvent.submit(submitButton); + }); + + await waitFor(() => { + expect(getSingleSignOnConnectorsMock).toBeCalled(); + expect(mockedNavigate).toBeCalledWith(`/${singleSignOnPath}/connectors`); + }); + }); + }); }); diff --git a/packages/experience/src/pages/SignIn/IdentifierSignInForm/use-on-submit.ts b/packages/experience/src/pages/SignIn/IdentifierSignInForm/use-on-submit.ts index da3b07b75..df182689b 100644 --- a/packages/experience/src/pages/SignIn/IdentifierSignInForm/use-on-submit.ts +++ b/packages/experience/src/pages/SignIn/IdentifierSignInForm/use-on-submit.ts @@ -3,11 +3,15 @@ import { SignInIdentifier } from '@logto/schemas'; import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; +import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on'; import useSendVerificationCode from '@/hooks/use-send-verification-code'; +import { useSieMethods } from '@/hooks/use-sie'; import { UserFlow } from '@/types'; const useOnSubmit = (signInMethods: SignIn['methods']) => { const navigate = useNavigate(); + const { ssoConnectors } = useSieMethods(); + const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn(); const signInWithPassword = useCallback( (identifier: SignInIdentifier, value: string) => { @@ -27,31 +31,49 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => { onSubmit: sendVerificationCode, } = useSendVerificationCode(UserFlow.SignIn); - const onSubmit = async (identifier: SignInIdentifier, value: string) => { - const method = signInMethods.find((method) => method.identifier === identifier); + const onSubmit = useCallback( + async (identifier: SignInIdentifier, value: string) => { + const method = signInMethods.find((method) => method.identifier === identifier); - if (!method) { - throw new Error(`Cannot find method with identifier type ${identifier}`); - } + if (!method) { + throw new Error(`Cannot find method with identifier type ${identifier}`); + } - const { password, isPasswordPrimary, verificationCode } = method; + const { password, isPasswordPrimary, verificationCode } = method; - if (identifier === SignInIdentifier.Username) { - signInWithPassword(identifier, value); + if (identifier === SignInIdentifier.Username) { + signInWithPassword(identifier, value); - return; - } + return; + } - if (password && (isPasswordPrimary || !verificationCode)) { - signInWithPassword(identifier, value); + // Check if the email is registered with any SSO connectors. If the email is registered with any SSO connectors, we should not proceed to the next step + if (identifier === SignInIdentifier.Email && ssoConnectors.length > 0) { + const result = await checkSingleSignOn(value); - return; - } + if (result) { + return; + } + } - if (verificationCode) { - await sendVerificationCode({ identifier, value }); - } - }; + if (password && (isPasswordPrimary || !verificationCode)) { + signInWithPassword(identifier, value); + + return; + } + + if (verificationCode) { + await sendVerificationCode({ identifier, value }); + } + }, + [ + checkSingleSignOn, + sendVerificationCode, + signInMethods, + signInWithPassword, + ssoConnectors.length, + ] + ); return { errorMessage, diff --git a/packages/experience/src/pages/SingleSignOnEmail/index.tsx b/packages/experience/src/pages/SingleSignOnEmail/index.tsx index d86159450..fe354bdd9 100644 --- a/packages/experience/src/pages/SingleSignOnEmail/index.tsx +++ b/packages/experience/src/pages/SingleSignOnEmail/index.tsx @@ -9,10 +9,10 @@ import ErrorMessage from '@/components/ErrorMessage'; import SmartInputField, { type IdentifierInputValue, } from '@/components/InputFields/SmartInputField'; +import useOnSubmit from '@/hooks/use-check-single-sign-on'; import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form'; import * as styles from './index.module.scss'; -import useOnSubmit from './use-on-submit'; type FormState = { identifier: IdentifierInputValue;