0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

refactor(experience): migrate social and sso

migrate social and sso
This commit is contained in:
simeng-li 2024-08-04 18:44:42 +08:00
parent c1b6d96105
commit dea41c96e8
No known key found for this signature in database
GPG key ID: 14EA7BB1541E8075
15 changed files with 329 additions and 114 deletions

View file

@ -4,6 +4,7 @@ import {
type InteractionIdentifier,
type PasswordVerificationPayload,
SignInIdentifier,
type SocialVerificationCallbackPayload,
type UpdateProfileApiPayload,
type VerificationCodeIdentifier,
} from '@logto/schemas';
@ -48,7 +49,7 @@ const updateInteractionEvent = async (interactionEvent: InteractionEvent) =>
},
});
const identifyAndSubmitInteraction = async (verificationId: string) => {
export const identifyAndSubmitInteraction = async (verificationId: string) => {
await identifyUser({ verificationId });
return submitInteraction();
};
@ -97,6 +98,16 @@ export const registerPassword = async (identifier: InteractionIdentifier, passwo
return identifyAndSubmitInteraction(verificationId);
};
export const resetPassword = async (password: string) => {
await api.put(`${experienceRoutes.profile}/password`, {
json: {
password,
},
});
return submitInteraction();
};
export const sendVerificationCode = async (
interactionEvent: InteractionEvent,
identifier: VerificationCodeIdentifier
@ -150,3 +161,74 @@ export const updateProfileWithVerificationCode = async (json: VerificationCodePa
verificationId,
});
};
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<VerificationResponse>();
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<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(payload.verificationId);
};

View file

@ -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(
<SettingsProvider>
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} verificationId="foo" />
</SettingsProvider>
);
const bindButton = getByText('action.bind');
@ -57,7 +57,7 @@ describe('SocialLinkAccount', () => {
},
}}
>
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} verificationId="foo" />
</SettingsProvider>
);
@ -77,7 +77,7 @@ describe('SocialLinkAccount', () => {
},
}}
>
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} verificationId="foo" />
</SettingsProvider>
);
@ -97,7 +97,7 @@ describe('SocialLinkAccount', () => {
},
}}
>
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} verificationId="foo" />
</SettingsProvider>
);
@ -108,7 +108,7 @@ describe('SocialLinkAccount', () => {
it('should call registerWithVerifiedSocial when click create button', async () => {
const { getByText } = renderWithPageContext(
<SettingsProvider>
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} verificationId="foo" />
</SettingsProvider>
);
const createButton = getByText('action.create_account_without_linking');

View file

@ -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) => {
<TextLink
text={actionText}
onClick={() => {
void registerWithSocial(connectorId);
void registerWithSocial(verificationId);
}}
/>
</div>

View file

@ -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';

View file

@ -1,18 +1,20 @@
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';
import useTerms from '@/hooks/use-terms';
import { getLogtoNativeSdk, isNativeWebview } from '@/utils/native-sdk';
import { generateState, storeState, buildSocialLandingUri } from '@/utils/social-connectors';
import { buildSocialLandingUri, generateState, storeState } from '@/utils/social-connectors';
const useSocial = () => {
const { experienceSettings, theme } = useContext(PageContext);
@ -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,
]
);

View file

@ -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<string | undefined>();
const { setSsoEmail, setSsoConnectors, availableSsoConnectorsMap } =
useContext(UserInteractionContext);

View file

@ -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();

View file

@ -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]
);
};

View file

@ -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);

View file

@ -4,11 +4,12 @@ import { useNavigate } from 'react-router-dom';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { setUserPassword } from '@/apis/interaction';
import { resetPassword } from '@/apis/experience';
import SetPassword from '@/containers/SetPassword';
import useApi from '@/hooks/use-api';
import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal';
import { type ErrorHandlers } from '@/hooks/use-error-handler';
import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action';
import useErrorHandler, { type ErrorHandlers } from '@/hooks/use-error-handler';
import usePasswordPolicyChecker from '@/hooks/use-password-policy-checker';
import { usePasswordPolicy } from '@/hooks/use-sie';
import useToast from '@/hooks/use-toast';
@ -22,6 +23,11 @@ const ResetPassword = () => {
const navigate = useNavigate();
const { show } = usePromiseConfirmModal();
const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
const checkPassword = usePasswordPolicyChecker({ setErrorMessage });
const asyncResetPassword = useApi(resetPassword);
const handleError = useErrorHandler();
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'session.verification_session_not_found': async (error) => {
@ -34,25 +40,38 @@ const ResetPassword = () => {
}),
[navigate, setErrorMessage, show]
);
const successHandler: SuccessHandler<typeof setUserPassword> = useCallback(
(result) => {
if (result) {
// Clear the forgot password identifier input value
setForgotPasswordIdentifierInputValue(undefined);
setToast(t('description.password_changed'));
navigate('/sign-in', { replace: true });
const onSubmitHandler = useCallback(
async (password: string) => {
const success = await checkPassword(password);
if (!success) {
return;
}
},
[navigate, setForgotPasswordIdentifierInputValue, setToast, t]
);
const [action] = usePasswordAction({
api: setUserPassword,
setErrorMessage,
errorHandlers,
successHandler,
});
const [error] = await asyncResetPassword(password);
if (error) {
await handleError(error, errorHandlers);
return;
}
// Clear the forgot password identifier input value
setForgotPasswordIdentifierInputValue(undefined);
setToast(t('description.password_changed'));
navigate('/sign-in', { replace: true });
},
[
asyncResetPassword,
checkPassword,
errorHandlers,
handleError,
navigate,
setForgotPasswordIdentifierInputValue,
setToast,
t,
]
);
const {
policy: {
@ -73,7 +92,7 @@ const ResetPassword = () => {
errorMessage={errorMessage}
maxLength={max}
clearErrorMessage={clearErrorMessage}
onSubmit={action}
onSubmit={onSubmitHandler}
/>
</SecondaryPageLayout>
);

View file

@ -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<Parameters>();
const { state } = useLocation();
const { signUpMethods } = useSieMethods();
const { verificationIdsMap } = useContext(UserInteractionContext);
const verificationId = verificationIdsMap[VerificationType.Social];
if (!is(state, socialAccountNotExistErrorDataGuard)) {
return <ErrorPage rawMessage="Missing relate account info" />;
@ -45,11 +49,19 @@ const SocialLinkAccount = () => {
return <ErrorPage rawMessage="Connector not found" />;
}
if (!verificationId) {
return <ErrorPage title="error.invalid_session" rawMessage="Verification id not found" />;
}
const { relatedUser } = state;
return (
<SecondaryPageLayout title={getPageTitle(signUpMethods)}>
<SocialLinkAccountContainer connectorId={connectorId} relatedUser={relatedUser} />
<SocialLinkAccountContainer
connectorId={connectorId}
verificationId={verificationId}
relatedUser={relatedUser}
/>
</SecondaryPageLayout>
);
};

View file

@ -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();
});
});
});

View file

@ -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<string, unknown>) => {
async (connectorId: string, verificationId: string, data: Record<string, unknown>) => {
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 };

View file

@ -1,12 +1,20 @@
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, verifySocialVerification } from '@/apis/experience';
import { putInteraction } from '@/apis/interaction';
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 +36,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 update the verificationId after the initial render
// We need to store a real-time reference to 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 verifySocial = useApi(verifySocialVerification);
const asyncSignInWithSocial = useApi(identifyAndSubmitInteraction);
const asyncPutInteraction = useApi(putInteraction);
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 +78,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<ErrorHandlers>(
() => ({
// 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 +127,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,6 +141,39 @@ const useSocialSignInListener = (connectorId: string) => {
]
);
const verifySocialCallbackData = useCallback(
async (connectorId: string, data: Record<string, unknown>) => {
// 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);
}
const [error, result] = await verifySocial(connectorId, {
verificationId: verificationIdRef.current,
connectorData: {
// For validation use only
redirectUri: `${window.location.origin}/callback/${connectorId}`,
...data,
},
});
if (error || !result) {
setLoading(false);
await handleError(error, globalErrorHandler);
return;
}
const { verificationId } = result;
// eslint-disable-next-line @silverhand/fp/no-mutation
verificationIdRef.current = verificationId;
setVerificationId(VerificationType.Social, verificationId);
return result.verificationId;
},
[asyncPutInteraction, globalErrorHandler, handleError, setVerificationId, verifySocial]
);
const signInWithSocialHandler = useCallback(
async (connectorId: string, data: Record<string, unknown>) => {
// When the callback is called from Google One Tap, the interaction event was not set yet.
@ -119,14 +181,11 @@ const useSocialSignInListener = (connectorId: string) => {
await asyncPutInteraction(InteractionEvent.SignIn);
}
const [error, result] = await asyncSignInWithSocial({
connectorId,
connectorData: {
// For validation use only
redirectUri: `${window.location.origin}/callback/${connectorId}`,
...data,
},
});
const verificationId = await verifySocialCallbackData(connectorId, data);
if (!verificationId) {
return;
}
const [error, result] = await asyncSignInWithSocial(verificationId);
if (error) {
setLoading(false);
@ -139,7 +198,13 @@ const useSocialSignInListener = (connectorId: string) => {
window.location.replace(result.redirectTo);
}
},
[asyncPutInteraction, asyncSignInWithSocial, handleError, signInWithSocialErrorHandlers]
[
asyncPutInteraction,
asyncSignInWithSocial,
handleError,
signInWithSocialErrorHandlers,
verifySocialCallbackData,
]
);
// Social Sign-in Callback Handler
@ -152,18 +217,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 (!verificationId && !isGoogleOneTap) {
setToast(t('error.invalid_session'));
navigate('/' + experience.routes.signIn);
return;
}
void signInWithSocialHandler(connectorId, rest);
}, [
connectorId,
@ -174,6 +246,8 @@ const useSocialSignInListener = (connectorId: string) => {
setToast,
signInWithSocialHandler,
t,
verificationId,
verificationIdsMap,
]);
return { loading };

View file

@ -63,7 +63,7 @@ const VerificationCode = () => {
// VerificationId not found
const verificationId = verificationIdsMap[codeVerificationTypeMap[type]];
if (!verificationId) {
return <ErrorPage title="error.invalid_session" />;
return <ErrorPage title="error.invalid_session" rawMessage="Verification id not found" />;
}
return (