diff --git a/packages/experience/src/apis/experience.ts b/packages/experience/src/apis/experience.ts index 4438b2778cd3..b2d424abf491 100644 --- a/packages/experience/src/apis/experience.ts +++ b/packages/experience/src/apis/experience.ts @@ -3,6 +3,7 @@ import { InteractionEvent, type PasswordVerificationPayload, SignInIdentifier, + type SocialVerificationCallbackPayload, type UpdateProfileApiPayload, type VerificationCodeIdentifier, } from '@logto/schemas'; @@ -51,11 +52,21 @@ const updateInteractionEvent = async (interactionEvent: InteractionEvent) => }, }); -const identifyAndSubmitInteraction = async (payload?: IdentificationApiPayload) => { +export const identifyAndSubmitInteraction = async (payload?: IdentificationApiPayload) => { await identifyUser(payload); return submitInteraction(); }; +export const registerWithVerifiedIdentifier = async (verificationId: string) => { + await updateInteractionEvent(InteractionEvent.Register); + return identifyAndSubmitInteraction({ verificationId }); +}; + +export const signInWithVerifiedIdentifier = async (verificationId: string) => { + await updateInteractionEvent(InteractionEvent.SignIn); + return identifyAndSubmitInteraction({ verificationId }); +}; + // Password APIs export const signInWithPasswordIdentifier = async (payload: PasswordVerificationPayload) => { await initInteraction(InteractionEvent.SignIn); @@ -113,16 +124,6 @@ export const identifyWithVerificationCode = async (json: VerificationCodePayload return identifyAndSubmitInteraction({ verificationId }); }; -export const registerWithVerifiedIdentifier = async (verificationId: string) => { - await updateInteractionEvent(InteractionEvent.Register); - return identifyAndSubmitInteraction({ verificationId }); -}; - -export const signInWithVerifiedIdentifier = async (verificationId: string) => { - await updateInteractionEvent(InteractionEvent.SignIn); - return identifyAndSubmitInteraction({ verificationId }); -}; - // Profile APIs export const updateProfileWithVerificationCode = async (json: VerificationCodePayload) => { @@ -149,3 +150,76 @@ export const resetPassword = async (password: string) => { return submitInteraction(); }; + +// Social and SSO APIs + +export const getSocialAuthorizationUrl = async ( + connectorId: string, + state: string, + redirectUri: string +) => { + await initInteraction(InteractionEvent.SignIn); + + return api + .post(`${experienceRoutes.verification}/social/${connectorId}/authorization-uri`, { + json: { + state, + redirectUri, + }, + }) + .json< + VerificationResponse & { + authorizationUri: string; + } + >(); +}; + +export const verifySocialVerification = async ( + connectorId: string, + payload: SocialVerificationCallbackPayload +) => + api + .post(`${experienceRoutes.verification}/social/${connectorId}/verify`, { + json: payload, + }) + .json(); + +export const bindSocialRelatedUser = async (verificationId: string) => { + await updateInteractionEvent(InteractionEvent.SignIn); + await identifyUser({ verificationId, linkSocialIdentity: true }); + return submitInteraction(); +}; + +export const getSsoConnectors = async (email: string) => + api + .get(`${experienceRoutes.verification}/sso/connectors`, { + searchParams: { + email, + }, + }) + .json<{ connectorIds: string[] }>(); + +export const getSsoAuthorizationUrl = async (connectorId: string, payload: unknown) => { + await initInteraction(InteractionEvent.SignIn); + + return api + .post(`${experienceRoutes.verification}/sso/${connectorId}/authorization-uri`, { + json: payload, + }) + .json< + VerificationResponse & { + authorizationUri: string; + } + >(); +}; + +export const signInWithSso = async ( + connectorId: string, + payload: SocialVerificationCallbackPayload & { verificationId: string } +) => { + await api.post(`${experienceRoutes.verification}/sso/${connectorId}/verify`, { + json: payload, + }); + + return identifyAndSubmitInteraction({ verificationId: payload.verificationId }); +}; diff --git a/packages/experience/src/containers/SocialLinkAccount/index.test.tsx b/packages/experience/src/containers/SocialLinkAccount/index.test.tsx index 98950a5ee30c..b19b6ab2a87e 100644 --- a/packages/experience/src/containers/SocialLinkAccount/index.test.tsx +++ b/packages/experience/src/containers/SocialLinkAccount/index.test.tsx @@ -4,7 +4,7 @@ import { fireEvent, waitFor } from '@testing-library/react'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import { mockSignInExperienceSettings } from '@/__mocks__/logto'; -import { registerWithVerifiedSocial, bindSocialRelatedUser } from '@/apis/interaction'; +import { bindSocialRelatedUser, registerWithVerifiedSocial } from '@/apis/interaction'; import SocialLinkAccount from '.'; @@ -30,7 +30,7 @@ describe('SocialLinkAccount', () => { it('should render bindUser Button', async () => { const { getByText } = renderWithPageContext( - + ); const bindButton = getByText('action.bind'); @@ -57,7 +57,7 @@ describe('SocialLinkAccount', () => { }, }} > - + ); @@ -77,7 +77,7 @@ describe('SocialLinkAccount', () => { }, }} > - + ); @@ -97,7 +97,7 @@ describe('SocialLinkAccount', () => { }, }} > - + ); @@ -108,7 +108,7 @@ describe('SocialLinkAccount', () => { it('should call registerWithVerifiedSocial when click create button', async () => { const { getByText } = renderWithPageContext( - + ); const createButton = getByText('action.create_account_without_linking'); diff --git a/packages/experience/src/containers/SocialLinkAccount/index.tsx b/packages/experience/src/containers/SocialLinkAccount/index.tsx index 5733b65b2832..118ed73fa4e3 100644 --- a/packages/experience/src/containers/SocialLinkAccount/index.tsx +++ b/packages/experience/src/containers/SocialLinkAccount/index.tsx @@ -17,6 +17,7 @@ import useBindSocialRelatedUser from './use-social-link-related-user'; type Props = { readonly className?: string; readonly connectorId: string; + readonly verificationId: string; readonly relatedUser: SocialRelatedUserInfo; }; @@ -39,7 +40,7 @@ const getCreateAccountActionText = (signUpMethods: string[]): TFuncKey => { return 'action.create_account_without_linking'; }; -const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => { +const SocialLinkAccount = ({ connectorId, verificationId, className, relatedUser }: Props) => { const { t } = useTranslation(); const { signUpMethods } = useSieMethods(); @@ -58,10 +59,7 @@ const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => { title="action.bind" i18nProps={{ address: type === 'email' ? maskEmail(value) : maskPhone(value) }} onClick={() => { - void bindSocialRelatedUser({ - connectorId, - ...(type === 'email' ? { email: value } : { phone: value }), - }); + void bindSocialRelatedUser(verificationId); }} /> @@ -72,7 +70,7 @@ const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => { { - void registerWithSocial(connectorId); + void registerWithSocial(verificationId); }} /> diff --git a/packages/experience/src/containers/SocialLinkAccount/use-social-link-related-user.ts b/packages/experience/src/containers/SocialLinkAccount/use-social-link-related-user.ts index dd035d4931e6..1bf7f188b819 100644 --- a/packages/experience/src/containers/SocialLinkAccount/use-social-link-related-user.ts +++ b/packages/experience/src/containers/SocialLinkAccount/use-social-link-related-user.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { bindSocialRelatedUser } from '@/apis/interaction'; +import { bindSocialRelatedUser } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; diff --git a/packages/experience/src/containers/SocialSignInList/use-social.ts b/packages/experience/src/containers/SocialSignInList/use-social.ts index 3ea08c1103f4..e97b58ebf89f 100644 --- a/packages/experience/src/containers/SocialSignInList/use-social.ts +++ b/packages/experience/src/containers/SocialSignInList/use-social.ts @@ -1,12 +1,14 @@ import { AgreeToTermsPolicy, ConnectorPlatform, + VerificationType, type ExperienceSocialConnector, } from '@logto/schemas'; import { useCallback, useContext } from 'react'; import PageContext from '@/Providers/PageContextProvider/PageContext'; -import { getSocialAuthorizationUrl } from '@/apis/interaction'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { getSocialAuthorizationUrl } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; @@ -20,6 +22,8 @@ const useSocial = () => { const handleError = useErrorHandler(); const asyncInvokeSocialSignIn = useApi(getSocialAuthorizationUrl); const { termsValidation, agreeToTermsPolicy } = useTerms(); + const { setVerificationId } = useContext(UserInteractionContext); + const redirectTo = useGlobalRedirectTo({ shouldClearInteractionContextSession: false, isReplace: false, @@ -69,19 +73,23 @@ const useSocial = () => { return; } - if (!result?.redirectTo) { + if (!result) { return; } + const { verificationId, authorizationUri } = result; + + setVerificationId(VerificationType.Social, verificationId); + // Invoke native social sign-in flow if (isNativeWebview()) { - nativeSignInHandler(result.redirectTo, connector); + nativeSignInHandler(authorizationUri, connector); return; } // Invoke web social sign-in flow - await redirectTo(result.redirectTo); + await redirectTo(authorizationUri); }, [ agreeToTermsPolicy, @@ -89,6 +97,7 @@ const useSocial = () => { handleError, nativeSignInHandler, redirectTo, + setVerificationId, termsValidation, ] ); diff --git a/packages/experience/src/hooks/use-check-single-sign-on.ts b/packages/experience/src/hooks/use-check-single-sign-on.ts index 2f358c7ab276..67a9077d804f 100644 --- a/packages/experience/src/hooks/use-check-single-sign-on.ts +++ b/packages/experience/src/hooks/use-check-single-sign-on.ts @@ -1,10 +1,10 @@ import { experience, type SsoConnectorMetadata } from '@logto/schemas'; -import { useCallback, useState, useContext } from 'react'; +import { useCallback, useContext, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; -import { getSingleSignOnConnectors } from '@/apis/single-sign-on'; +import { getSsoConnectors } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; @@ -13,7 +13,7 @@ import useSingleSignOn from './use-single-sign-on'; const useCheckSingleSignOn = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const request = useApi(getSingleSignOnConnectors); + const request = useApi(getSsoConnectors); const [errorMessage, setErrorMessage] = useState(); const { setSsoEmail, setSsoConnectors, availableSsoConnectorsMap } = useContext(UserInteractionContext); @@ -56,8 +56,8 @@ const useCheckSingleSignOn = () => { return; } - const connectors = result - ?.map((connectorId) => availableSsoConnectorsMap.get(connectorId)) + const connectors = result?.connectorIds + .map((connectorId) => availableSsoConnectorsMap.get(connectorId)) // eslint-disable-next-line unicorn/prefer-native-coercion-functions -- make the type more specific .filter((connector): connector is SsoConnectorMetadata => Boolean(connector)); diff --git a/packages/experience/src/hooks/use-single-sign-on-watch.ts b/packages/experience/src/hooks/use-single-sign-on-watch.ts index e7794bc8103d..a9c17f682f56 100644 --- a/packages/experience/src/hooks/use-single-sign-on-watch.ts +++ b/packages/experience/src/hooks/use-single-sign-on-watch.ts @@ -4,12 +4,12 @@ import { experience, type SsoConnectorMetadata, } from '@logto/schemas'; -import { useEffect, useCallback, useContext } from 'react'; +import { useCallback, useContext, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; -import { getSingleSignOnConnectors } from '@/apis/single-sign-on'; +import { getSsoConnectors } from '@/apis/experience'; import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField'; import useApi from '@/hooks/use-api'; import useSingleSignOn from '@/hooks/use-single-sign-on'; @@ -28,7 +28,7 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => { const { showSingleSignOnForm, setShowSingleSignOnForm } = useContext(SingleSignOnFormModeContext); - const request = useApi(getSingleSignOnConnectors, { silent: true }); + const request = useApi(getSsoConnectors, { silent: true }); const singleSignOn = useSingleSignOn(); @@ -43,7 +43,7 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => { return false; } - const connectors = result + const connectors = result.connectorIds .map((connectorId) => availableSsoConnectorsMap.get(connectorId)) // eslint-disable-next-line unicorn/prefer-native-coercion-functions -- make the type more specific .filter((connector): connector is SsoConnectorMetadata => Boolean(connector)); diff --git a/packages/experience/src/hooks/use-single-sign-on.ts b/packages/experience/src/hooks/use-single-sign-on.ts index a6b62109ca36..1ae2f3867e7d 100644 --- a/packages/experience/src/hooks/use-single-sign-on.ts +++ b/packages/experience/src/hooks/use-single-sign-on.ts @@ -1,6 +1,8 @@ -import { useCallback } from 'react'; +import { VerificationType } from '@logto/schemas'; +import { useCallback, useContext } from 'react'; -import { getSingleSignOnUrl } from '@/apis/single-sign-on'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { getSsoAuthorizationUrl } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; import { getLogtoNativeSdk, isNativeWebview } from '@/utils/native-sdk'; @@ -10,11 +12,12 @@ import useGlobalRedirectTo from './use-global-redirect-to'; const useSingleSignOn = () => { const handleError = useErrorHandler(); - const asyncInvokeSingleSignOn = useApi(getSingleSignOnUrl); + const asyncInvokeSingleSignOn = useApi(getSsoAuthorizationUrl); const redirectTo = useGlobalRedirectTo({ shouldClearInteractionContextSession: false, isReplace: false, }); + const { setVerificationId } = useContext(UserInteractionContext); /** * Native IdP Sign In Flow @@ -45,11 +48,10 @@ const useSingleSignOn = () => { const state = generateState(); storeState(state, connectorId); - const [error, redirectUrl] = await asyncInvokeSingleSignOn( - connectorId, + const [error, result] = await asyncInvokeSingleSignOn(connectorId, { state, - `${window.location.origin}/callback/${connectorId}` - ); + redirectUri: `${window.location.origin}/callback/${connectorId}`, + }); if (error) { await handleError(error); @@ -57,19 +59,23 @@ const useSingleSignOn = () => { return; } - if (!redirectUrl) { + if (!result) { return; } + const { authorizationUri, verificationId } = result; + + setVerificationId(VerificationType.EnterpriseSso, verificationId); + // Invoke Native Sign In flow if (isNativeWebview()) { - nativeSignInHandler(redirectUrl, connectorId); + nativeSignInHandler(authorizationUri, connectorId); } // Invoke Web Sign In flow - await redirectTo(redirectUrl); + await redirectTo(authorizationUri); }, - [asyncInvokeSingleSignOn, handleError, nativeSignInHandler, redirectTo] + [asyncInvokeSingleSignOn, handleError, nativeSignInHandler, redirectTo, setVerificationId] ); }; diff --git a/packages/experience/src/hooks/use-social-register.ts b/packages/experience/src/hooks/use-social-register.ts index 601780077800..1d340508bea5 100644 --- a/packages/experience/src/hooks/use-social-register.ts +++ b/packages/experience/src/hooks/use-social-register.ts @@ -1,22 +1,22 @@ import { useCallback } from 'react'; -import { registerWithVerifiedSocial } from '@/apis/interaction'; +import { registerWithVerifiedIdentifier } from '@/apis/experience'; import useApi from './use-api'; import useErrorHandler from './use-error-handler'; import useGlobalRedirectTo from './use-global-redirect-to'; import usePreSignInErrorHandler from './use-pre-sign-in-error-handler'; -const useSocialRegister = (connectorId?: string, replace?: boolean) => { +const useSocialRegister = (connectorId: string, replace?: boolean) => { const handleError = useErrorHandler(); - const asyncRegisterWithSocial = useApi(registerWithVerifiedSocial); + const asyncRegisterWithSocial = useApi(registerWithVerifiedIdentifier); const redirectTo = useGlobalRedirectTo(); const preSignInErrorHandler = usePreSignInErrorHandler({ linkSocial: connectorId, replace }); return useCallback( - async (connectorId: string) => { - const [error, result] = await asyncRegisterWithSocial(connectorId); + async (verificationId: string) => { + const [error, result] = await asyncRegisterWithSocial(verificationId); if (error) { await handleError(error, preSignInErrorHandler); diff --git a/packages/experience/src/pages/SocialLinkAccount/index.tsx b/packages/experience/src/pages/SocialLinkAccount/index.tsx index 15f094c2b867..ac60569813b8 100644 --- a/packages/experience/src/pages/SocialLinkAccount/index.tsx +++ b/packages/experience/src/pages/SocialLinkAccount/index.tsx @@ -1,9 +1,11 @@ -import { SignInIdentifier } from '@logto/schemas'; +import { SignInIdentifier, VerificationType } from '@logto/schemas'; import type { TFuncKey } from 'i18next'; -import { useParams, useLocation } from 'react-router-dom'; +import { useContext } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; import { is } from 'superstruct'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import SocialLinkAccountContainer from '@/containers/SocialLinkAccount'; import { useSieMethods } from '@/hooks/use-sie'; import ErrorPage from '@/pages/ErrorPage'; @@ -36,6 +38,8 @@ const SocialLinkAccount = () => { const { connectorId } = useParams(); const { state } = useLocation(); const { signUpMethods } = useSieMethods(); + const { verificationIdsMap } = useContext(UserInteractionContext); + const verificationId = verificationIdsMap[VerificationType.Social]; if (!is(state, socialAccountNotExistErrorDataGuard)) { return ; @@ -45,11 +49,19 @@ const SocialLinkAccount = () => { return ; } + if (!verificationId) { + return ; + } + const { relatedUser } = state; return ( - + ); }; diff --git a/packages/experience/src/pages/SocialSignInWebCallback/index.test.tsx b/packages/experience/src/pages/SocialSignInWebCallback/index.test.tsx index 8009817c2812..8b74c338050c 100644 --- a/packages/experience/src/pages/SocialSignInWebCallback/index.test.tsx +++ b/packages/experience/src/pages/SocialSignInWebCallback/index.test.tsx @@ -3,9 +3,9 @@ import { Navigate, Route, Routes, useSearchParams } from 'react-router-dom'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; -import { mockSsoConnectors, mockSignInExperienceSettings } from '@/__mocks__/logto'; +import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; import { socialConnectors } from '@/__mocks__/social-connectors'; -import { signInWithSocial } from '@/apis/interaction'; +import { verifySocialVerification } from '@/apis/experience'; import { singleSignOnAuthorization } from '@/apis/single-sign-on'; import { type SignInExperienceResponse } from '@/types'; import { generateState, storeState } from '@/utils/social-connectors'; @@ -17,8 +17,9 @@ jest.mock('i18next', () => ({ language: 'en', })); -jest.mock('@/apis/interaction', () => ({ - signInWithSocial: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }), +jest.mock('@/apis/experience', () => ({ + verifySocialVerification: jest.fn().mockResolvedValue({ verificationId: 'foo' }), + identifyAndSubmitInteraction: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }), })); jest.mock('@/apis/single-sign-on', () => ({ @@ -49,7 +50,7 @@ describe('SocialCallbackPage with code', () => { ); await waitFor(() => { - expect(signInWithSocial).not.toBeCalled(); + expect(verifySocialVerification).not.toBeCalled(); expect(mockNavigate.mock.calls[0][0].to).toBe('/sign-in'); }); }); @@ -76,12 +77,12 @@ describe('SocialCallbackPage with code', () => { ); await waitFor(() => { - expect(signInWithSocial).toBeCalled(); + expect(verifySocialVerification).toBeCalled(); }); }); it('callback with invalid state should not call signInWithSocial', async () => { - (signInWithSocial as jest.Mock).mockClear(); + (verifySocialVerification as jest.Mock).mockClear(); mockUseSearchParameters.mockReturnValue([ new URLSearchParams(`state=bar&code=foo`), @@ -98,7 +99,7 @@ describe('SocialCallbackPage with code', () => { ); await waitFor(() => { - expect(signInWithSocial).not.toBeCalled(); + expect(verifySocialVerification).not.toBeCalled(); }); }); }); diff --git a/packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts b/packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts index bfc0b8da19e4..846acfc19922 100644 --- a/packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts +++ b/packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts @@ -1,9 +1,10 @@ -import { AgreeToTermsPolicy, SignInMode, experience } from '@logto/schemas'; -import { useCallback, useEffect, useState } from 'react'; +import { AgreeToTermsPolicy, SignInMode, VerificationType, experience } from '@logto/schemas'; +import { useCallback, useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { singleSignOnAuthorization, singleSignOnRegistration } from '@/apis/single-sign-on'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { registerWithVerifiedIdentifier, signInWithSso } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; @@ -15,13 +16,13 @@ import { validateState } from '@/utils/social-connectors'; const useSingleSignOnRegister = () => { const handleError = useErrorHandler(); - const request = useApi(singleSignOnRegistration); + const request = useApi(registerWithVerifiedIdentifier); const { termsValidation, agreeToTermsPolicy } = useTerms(); const navigate = useNavigate(); const redirectTo = useGlobalRedirectTo(); return useCallback( - async (connectorId: string) => { + async (verificationId: string) => { /** * Agree to terms and conditions first before proceeding * If the agreement policy is `Manual`, the user must agree to the terms to reach this step. @@ -32,7 +33,7 @@ const useSingleSignOnRegister = () => { return; } - const [error, result] = await request(connectorId); + const [error, result] = await request(verificationId); if (error) { await handleError(error); @@ -66,19 +67,24 @@ const useSingleSignOnListener = (connectorId: string) => { const { setToast } = useToast(); const redirectTo = useGlobalRedirectTo(); const { signInMode } = useSieMethods(); + const { verificationIdsMap } = useContext(UserInteractionContext); + const verificationId = verificationIdsMap[VerificationType.EnterpriseSso]; const handleError = useErrorHandler(); const navigate = useNavigate(); - const singleSignOnAuthorizationRequest = useApi(singleSignOnAuthorization); + const singleSignOnAuthorizationRequest = useApi(signInWithSso); const registerSingleSignOnIdentity = useSingleSignOnRegister(); const singleSignOnHandler = useCallback( - async (connectorId: string, data: Record) => { + async (connectorId: string, verificationId: string, data: Record) => { const [error, result] = await singleSignOnAuthorizationRequest(connectorId, { - ...data, - // For connector validation use - redirectUri: `${window.location.origin}/callback/${connectorId}`, + verificationId, + connectorData: { + ...data, + // For connector validation use + redirectUri: `${window.location.origin}/callback/${connectorId}`, + }, }); if (error) { @@ -92,7 +98,7 @@ const useSingleSignOnListener = (connectorId: string) => { return; } - await registerSingleSignOnIdentity(connectorId); + await registerSingleSignOnIdentity(verificationId); }, // Redirect to sign-in page if error is not handled by the error handlers global: async (error) => { @@ -138,7 +144,14 @@ const useSingleSignOnListener = (connectorId: string) => { return; } - void singleSignOnHandler(connectorId, rest); + // Validate the verificationId + if (!verificationId) { + setToast(t('error.invalid_session')); + navigate('/' + experience.routes.signIn); + return; + } + + void singleSignOnHandler(connectorId, verificationId, rest); }, [ connectorId, isConsumed, @@ -148,6 +161,7 @@ const useSingleSignOnListener = (connectorId: string) => { setToast, singleSignOnHandler, t, + verificationId, ]); return { loading }; diff --git a/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts b/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts index 6f1c17b0596f..929714fa3fa4 100644 --- a/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts +++ b/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts @@ -1,12 +1,23 @@ import { GoogleConnector } from '@logto/connector-kit'; import type { RequestErrorBody } from '@logto/schemas'; -import { AgreeToTermsPolicy, InteractionEvent, SignInMode, experience } from '@logto/schemas'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + AgreeToTermsPolicy, + InteractionEvent, + SignInMode, + VerificationType, + experience, +} from '@logto/schemas'; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { validate } from 'superstruct'; -import { putInteraction, signInWithSocial } from '@/apis/interaction'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { + identifyAndSubmitInteraction, + initInteraction, + verifySocialVerification, +} from '@/apis/experience'; import useBindSocialRelatedUser from '@/containers/SocialLinkAccount/use-social-link-related-user'; import useApi from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; @@ -28,26 +39,37 @@ const useSocialSignInListener = (connectorId: string) => { const { termsValidation, agreeToTermsPolicy } = useTerms(); const [isConsumed, setIsConsumed] = useState(false); const [searchParameters, setSearchParameters] = useSearchParams(); + const { verificationIdsMap, setVerificationId } = useContext(UserInteractionContext); + const verificationId = verificationIdsMap[VerificationType.Social]; + + // Google One Tap will mutate the verificationId after the initial render + // We need to store a up to date reference of the verificationId + const verificationIdRef = useRef(verificationId); const navigate = useNavigate(); const handleError = useErrorHandler(); const bindSocialRelatedUser = useBindSocialRelatedUser(); const registerWithSocial = useSocialRegister(connectorId, true); - const asyncSignInWithSocial = useApi(signInWithSocial); - const asyncPutInteraction = useApi(putInteraction); + const verifySocial = useApi(verifySocialVerification); + const asyncSignInWithSocial = useApi(identifyAndSubmitInteraction); + const asyncInitInteraction = useApi(initInteraction); const accountNotExistErrorHandler = useCallback( async (error: RequestErrorBody) => { const [, data] = validate(error.data, socialAccountNotExistErrorDataGuard); const { relatedUser } = data ?? {}; + const verificationId = verificationIdRef.current; + + // Redirect to sign-in page if the verificationId is not set properly + if (!verificationId) { + setToast(t('error.invalid_session')); + navigate('/' + experience.routes.signIn); + return; + } if (relatedUser) { if (socialSignInSettings.automaticAccountLinking) { - const { type, value } = relatedUser; - await bindSocialRelatedUser({ - connectorId, - ...(type === 'email' ? { email: value } : { phone: value }), - }); + await bindSocialRelatedUser(verificationId); } else { navigate(`/social/link/${connectorId}`, { replace: true, @@ -59,17 +81,30 @@ const useSocialSignInListener = (connectorId: string) => { } // Register with social - await registerWithSocial(connectorId); + await registerWithSocial(verificationId); }, [ bindSocialRelatedUser, connectorId, navigate, registerWithSocial, + setToast, socialSignInSettings.automaticAccountLinking, + t, ] ); + const globalErrorHandler = useMemo( + () => ({ + // Redirect to sign-in page if error is not handled by the error handlers + global: async (error) => { + setToast(error.message); + navigate('/' + experience.routes.signIn); + }, + }), + [navigate, setToast] + ); + const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); const signInWithSocialErrorHandlers: ErrorHandlers = useMemo( @@ -95,14 +130,11 @@ const useSocialSignInListener = (connectorId: string) => { await accountNotExistErrorHandler(error); }, ...preSignInErrorHandler, - // Redirect to sign-in page if error is not handled by the error handlers - global: async (error) => { - setToast(error.message); - navigate('/' + experience.routes.signIn); - }, + ...globalErrorHandler, }), [ preSignInErrorHandler, + globalErrorHandler, signInMode, agreeToTermsPolicy, termsValidation, @@ -112,15 +144,15 @@ const useSocialSignInListener = (connectorId: string) => { ] ); - const signInWithSocialHandler = useCallback( + const verifySocialCallbackData = useCallback( async (connectorId: string, data: Record) => { // When the callback is called from Google One Tap, the interaction event was not set yet. if (data[GoogleConnector.oneTapParams.csrfToken]) { - await asyncPutInteraction(InteractionEvent.SignIn); + await asyncInitInteraction(InteractionEvent.SignIn); } - const [error, result] = await asyncSignInWithSocial({ - connectorId, + const [error, result] = await verifySocial(connectorId, { + verificationId: verificationIdRef.current, connectorData: { // For validation use only redirectUri: `${window.location.origin}/callback/${connectorId}`, @@ -128,6 +160,35 @@ const useSocialSignInListener = (connectorId: string) => { }, }); + if (error || !result) { + setLoading(false); + await handleError(error, globalErrorHandler); + return; + } + + const { verificationId } = result; + + // VerificationId might not be available in the UserInteractionContext (Google one tap) + // Always update the verificationId here + // eslint-disable-next-line @silverhand/fp/no-mutation + verificationIdRef.current = verificationId; + setVerificationId(VerificationType.Social, verificationId); + + return verificationId; + }, + [asyncInitInteraction, globalErrorHandler, handleError, setVerificationId, verifySocial] + ); + + const signInWithSocialHandler = useCallback( + async (connectorId: string, data: Record) => { + const verificationId = await verifySocialCallbackData(connectorId, data); + + // Exception occurred during verification drop the process + if (!verificationId) { + return; + } + const [error, result] = await asyncSignInWithSocial({ verificationId }); + if (error) { setLoading(false); await handleError(error, signInWithSocialErrorHandlers); @@ -139,7 +200,7 @@ const useSocialSignInListener = (connectorId: string) => { window.location.replace(result.redirectTo); } }, - [asyncPutInteraction, asyncSignInWithSocial, handleError, signInWithSocialErrorHandlers] + [asyncSignInWithSocial, handleError, signInWithSocialErrorHandlers, verifySocialCallbackData] ); // Social Sign-in Callback Handler @@ -152,18 +213,25 @@ const useSocialSignInListener = (connectorId: string) => { const { state, ...rest } = parseQueryParameters(searchParameters); + const isGoogleOneTap = validateGoogleOneTapCsrfToken( + rest[GoogleConnector.oneTapParams.csrfToken] + ); + // Cleanup the search parameters once it's consumed setSearchParameters({}, { replace: true }); - if ( - !validateState(state, connectorId) && - !validateGoogleOneTapCsrfToken(rest[GoogleConnector.oneTapParams.csrfToken]) - ) { + if (!validateState(state, connectorId) && !isGoogleOneTap) { setToast(t('error.invalid_connector_auth')); navigate('/' + experience.routes.signIn); return; } + if (!verificationIdRef.current && !isGoogleOneTap) { + setToast(t('error.invalid_session')); + navigate('/' + experience.routes.signIn); + return; + } + void signInWithSocialHandler(connectorId, rest); }, [ connectorId, diff --git a/packages/experience/src/pages/VerificationCode/index.tsx b/packages/experience/src/pages/VerificationCode/index.tsx index e488c0b5a8ce..cdf4b3507559 100644 --- a/packages/experience/src/pages/VerificationCode/index.tsx +++ b/packages/experience/src/pages/VerificationCode/index.tsx @@ -59,7 +59,7 @@ const VerificationCode = () => { // VerificationId not found const verificationId = verificationIdsMap[codeVerificationTypeMap[type]]; if (!verificationId) { - return ; + return ; } return (