diff --git a/.changeset/brave-pandas-return.md b/.changeset/brave-pandas-return.md new file mode 100644 index 000000000000..d620bfaacf52 --- /dev/null +++ b/.changeset/brave-pandas-return.md @@ -0,0 +1,8 @@ +--- +'@logto/core': minor +'@logto/experience': minor +--- + +return direct sign-in errors to the client application instead of the hosted sign-in page + +When a sign-in initiated with the `direct_sign_in` authentication parameter failed (connector error, invalid session, denied at the identity provider, etc.), the hosted experience fell back to the universal sign-in page, exposing sign-in methods the client application never offered. The interaction is now finished with a standard OAuth `access_denied` error and the user agent is redirected back to the client's `redirect_uri`, so the application that owns the sign-in UI handles the failure itself. A new `POST /api/experience/abort` endpoint backs this behavior. Sign-ins that entered through the hosted pages keep the existing fallback. diff --git a/packages/core/src/routes/experience/const.ts b/packages/core/src/routes/experience/const.ts index 1152fe966c03..1544bc47bb4d 100644 --- a/packages/core/src/routes/experience/const.ts +++ b/packages/core/src/routes/experience/const.ts @@ -7,4 +7,5 @@ export const experienceRoutes = Object.freeze({ verification: `${prefix}/verification`, profile: `${prefix}/profile`, mfa: `${prefix}/profile/mfa`, + abort: `${prefix}/abort`, }); diff --git a/packages/core/src/routes/experience/experience.openapi.json b/packages/core/src/routes/experience/experience.openapi.json index 65b754d381c6..73b15d38af29 100644 --- a/packages/core/src/routes/experience/experience.openapi.json +++ b/packages/core/src/routes/experience/experience.openapi.json @@ -119,6 +119,34 @@ } } }, + "/api/experience/abort": { + "post": { + "operationId": "AbortInteraction", + "summary": "Abort interaction", + "description": "Finish the current interaction with an `access_denied` OAuth error and return the URL that resumes the OIDC flow, which redirects the user agent back to the client's redirect URI with the error attached. Intended for flows where the client application owns the sign-in UI (e.g. direct sign-in) and errors should surface there instead of on the hosted experience.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "reason": { + "description": "Optional human-readable reason, forwarded to the client as the `error_description` parameter. Must be at most 256 characters and only contain the characters RFC 6749 allows for `error_description` (printable ASCII except `\"` and `\\`)." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The interaction has been aborted. The response contains the URL to redirect the user agent to." + }, + "400": { + "description": "The request body is invalid (e.g. `reason` contains characters outside the allowed set), or the interaction session is not found or is in an invalid state." + } + } + } + }, "/api/experience/interaction": { "get": { "operationId": "GetInteraction", diff --git a/packages/core/src/routes/experience/index.ts b/packages/core/src/routes/experience/index.ts index 1d87c28db9e6..da462f386d77 100644 --- a/packages/core/src/routes/experience/index.ts +++ b/packages/core/src/routes/experience/index.ts @@ -17,6 +17,7 @@ import type Router from 'koa-router'; import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; +import { assignInteractionResults } from '#src/libraries/session/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaInteractionDetails from '#src/middleware/koa-interaction-details.js'; import assertThat from '#src/utils/assert-that.js'; @@ -196,6 +197,39 @@ export default function experienceApiRoutes( } ); + experienceRouter.post( + experienceRoutes.abort, + koaGuard({ + body: z.object({ + // RFC 6749 §4.1.2.1 limits `error_description` to a subset of printable ASCII. + reason: z + .string() + .max(256) + .regex(/^[ !#-[\]-~]*$/) + .optional(), + }), + response: z.object({ + redirectTo: z.string(), + }), + status: [200, 400], + }), + async (ctx, next) => { + const { reason } = ctx.guard.body; + + // Finish the interaction with a standard OAuth error so the user agent returns to the + // client's redirect_uri instead of staying on the hosted experience — the only sensible + // exit when the client application owns the sign-in UI (e.g. direct sign-in). + await assignInteractionResults(ctx, provider, { + error: 'access_denied', + ...conditional(reason && { error_description: reason }), + }); + + ctx.status = 200; + + return next(); + } + ); + experienceRouter.get( `${experienceRoutes.interaction}`, koaGuard({ diff --git a/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts b/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts index a51cd32275e0..d196dfc7ed11 100644 --- a/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts +++ b/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts @@ -32,6 +32,11 @@ const whiteListedEndpoint = [ method: 'POST', path: `${experienceRoutes.prefix}/preflight/sign-in-passkey/authentication`, }, + // POST /experience/abort: Finish the interaction with an OAuth error; works regardless of the interaction storage state. + { + method: 'POST', + path: experienceRoutes.abort, + }, ]; /** diff --git a/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx b/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx index ea1535f80be2..26e2190e7f17 100644 --- a/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx +++ b/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx @@ -95,6 +95,7 @@ const UserInteractionContextProvider = ({ children }: Props) => { remove(StorageKeys.IdentifierInputValue); remove(StorageKeys.ForgotPasswordIdentifierInputValue); remove(StorageKeys.verificationIds); + remove(StorageKeys.DirectSignIn); }, [remove]); const setVerificationId = useCallback((type: VerificationType, id: string) => { diff --git a/packages/experience/src/apis/experience/const.ts b/packages/experience/src/apis/experience/const.ts index c9d1ae82ec93..f2eb96b49b12 100644 --- a/packages/experience/src/apis/experience/const.ts +++ b/packages/experience/src/apis/experience/const.ts @@ -5,6 +5,7 @@ export const experienceApiRoutes = Object.freeze({ interaction: `${prefix}/interaction`, identification: `${prefix}/identification`, submit: `${prefix}/submit`, + abort: `${prefix}/abort`, verification: `${prefix}/verification`, profile: `${prefix}/profile`, mfa: `${prefix}/profile/mfa`, diff --git a/packages/experience/src/apis/experience/index.ts b/packages/experience/src/apis/experience/index.ts index 170f8490f173..251e6a26dfb6 100644 --- a/packages/experience/src/apis/experience/index.ts +++ b/packages/experience/src/apis/experience/index.ts @@ -25,6 +25,7 @@ export { submitInteraction, identifyUser, identifyAndSubmitInteraction, + abortInteraction, } from './interaction'; export type { TrustedDeviceAvailability, TrustedDeviceInteractionData } from './interaction'; diff --git a/packages/experience/src/apis/experience/interaction.ts b/packages/experience/src/apis/experience/interaction.ts index c8f80c95679e..aff66aa6c304 100644 --- a/packages/experience/src/apis/experience/interaction.ts +++ b/packages/experience/src/apis/experience/interaction.ts @@ -67,3 +67,11 @@ export const identifyAndSubmitInteraction = async (payload?: IdentificationApiPa await identifyUser(payload); return submitInteraction(); }; + +/** + * Finishes the interaction with an `access_denied` OAuth error. The returned `redirectTo` + * resumes the OIDC flow and sends the user agent back to the client's redirect URI with the + * error attached. + */ +export const abortInteraction = async (reason?: string) => + api.post(experienceApiRoutes.abort, { json: { reason } }).json(); diff --git a/packages/experience/src/hooks/use-email-blocked-error-handler.ts b/packages/experience/src/hooks/use-email-blocked-error-handler.ts index 4d98f51bf0a7..f70203a94e4c 100644 --- a/packages/experience/src/hooks/use-email-blocked-error-handler.ts +++ b/packages/experience/src/hooks/use-email-blocked-error-handler.ts @@ -7,7 +7,7 @@ import { usePromiseConfirmModal } from './use-confirm-modal'; import { type ErrorHandlers } from './use-error-handler'; export type Options = { - readonly onConfirm?: () => void; + readonly onConfirm?: (errorCode: string) => void; }; const useEmailBlockedErrorHandler = ({ onConfirm }: Options = {}): ErrorHandlers => { @@ -23,7 +23,7 @@ const useEmailBlockedErrorHandler = ({ onConfirm }: Options = {}): ErrorHandlers }); if (onConfirm) { - onConfirm(); + onConfirm(error.code); return; } diff --git a/packages/experience/src/hooks/use-navigate-to-sign-in.ts b/packages/experience/src/hooks/use-navigate-to-sign-in.ts new file mode 100644 index 000000000000..1da7ec686e67 --- /dev/null +++ b/packages/experience/src/hooks/use-navigate-to-sign-in.ts @@ -0,0 +1,52 @@ +import { experience } from '@logto/schemas'; +import { useCallback } from 'react'; + +import { abortInteraction } from '@/apis/experience'; + +import useApi from './use-api'; +import useGlobalRedirectTo from './use-global-redirect-to'; +import useNavigateWithPreservedSearchParams from './use-navigate-with-preserved-search-params'; +import useSessionStorage, { StorageKeys } from './use-session-storages'; + +/** + * Returns the terminal fallback navigation for sign-in errors. + * + * A session that entered through direct sign-in has no meaningful hosted sign-in page to fall + * back to — the client application owns the sign-in UI. For those sessions, finish the + * interaction with an OAuth `access_denied` error so the user returns to the client that + * initiated it; fall back to the hosted sign-in page only when aborting fails (e.g. the + * interaction has expired). Sessions that entered through the hosted pages keep the original + * fallback behavior. + * + * The direct sign-in marker is consumed on use: after one abort attempt the user either left + * for the client or landed on the hosted sign-in page, and later errors in the same tab should + * fall back normally. + */ +const useNavigateToSignIn = () => { + const navigate = useNavigateWithPreservedSearchParams(); + const asyncAbortInteraction = useApi(abortInteraction); + const redirectTo = useGlobalRedirectTo(); + const { get, remove } = useSessionStorage(); + + return useCallback( + (errorCode?: string) => { + void (async () => { + if (get(StorageKeys.DirectSignIn)) { + remove(StorageKeys.DirectSignIn); + + const [, result] = await asyncAbortInteraction(errorCode); + + if (result?.redirectTo) { + await redirectTo(result.redirectTo); + return; + } + } + + navigate('/' + experience.routes.signIn, { replace: true }); + })(); + }, + [asyncAbortInteraction, get, navigate, redirectTo, remove] + ); +}; + +export default useNavigateToSignIn; diff --git a/packages/experience/src/hooks/use-prerendering.ts b/packages/experience/src/hooks/use-prerendering.ts new file mode 100644 index 000000000000..427e36c2d6d8 --- /dev/null +++ b/packages/experience/src/hooks/use-prerendering.ts @@ -0,0 +1,35 @@ +import { useSyncExternalStore } from 'react'; + +declare global { + // Declaration merging into the DOM lib requires an interface. + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Document { + /** + * Not in TypeScript's DOM lib yet. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/prerendering | MDN} + */ + readonly prerendering?: boolean; + } +} + +const isPrerendering = () => Boolean(document.prerendering); + +const subscribe = (onStoreChange: () => void) => { + document.addEventListener('prerenderingchange', onStoreChange); + return () => { + document.removeEventListener('prerenderingchange', onStoreChange); + }; +}; + +/** + * Returns whether the page is being prerendered (e.g. Chrome address-bar preloading). + * + * One-time side effects (consuming a social callback's code and state, aborting an interaction) + * must not run during prerendering: the prerendered page may never be shown to the user, but + * its network requests do reach the server. Gate such effects on this hook — it flips to + * `false` when the page is activated. + */ +const usePrerendering = () => useSyncExternalStore(subscribe, isPrerendering); + +export default usePrerendering; diff --git a/packages/experience/src/hooks/use-session-storages.ts b/packages/experience/src/hooks/use-session-storages.ts index 09d1d8fb7f6e..5d9ec8c93738 100644 --- a/packages/experience/src/hooks/use-session-storages.ts +++ b/packages/experience/src/hooks/use-session-storages.ts @@ -18,10 +18,12 @@ export enum StorageKeys { IdentifierInputValue = 'identifier-input-value', ForgotPasswordIdentifierInputValue = 'forgot-password-identifier-input-value', verificationIds = 'verification-ids', + DirectSignIn = 'direct-sign-in', } const valueGuard = Object.freeze({ [StorageKeys.SsoEmail]: s.string(), + [StorageKeys.DirectSignIn]: s.string(), [StorageKeys.SsoConnectors]: s.array(ssoConnectorMetadataGuard), [StorageKeys.IdentifierInputValue]: identifierInputValueGuard, [StorageKeys.ForgotPasswordIdentifierInputValue]: identifierInputValueGuard, diff --git a/packages/experience/src/hooks/use-social-register.ts b/packages/experience/src/hooks/use-social-register.ts index 05c9afe4a42b..38ceec9d1b72 100644 --- a/packages/experience/src/hooks/use-social-register.ts +++ b/packages/experience/src/hooks/use-social-register.ts @@ -1,18 +1,18 @@ -import { AgreeToTermsPolicy, experience, InteractionEvent } from '@logto/schemas'; +import { AgreeToTermsPolicy, InteractionEvent } from '@logto/schemas'; import { useCallback } from 'react'; import { registerWithVerifiedIdentifier } from '@/apis/experience'; -import useNavigateWithPreservedSearchParams from '@/hooks/use-navigate-with-preserved-search-params'; import useApi from './use-api'; import useErrorHandler from './use-error-handler'; import useGlobalRedirectTo from './use-global-redirect-to'; +import useNavigateToSignIn from './use-navigate-to-sign-in'; import useSubmitInteractionErrorHandler from './use-submit-interaction-error-handler'; import useTerms from './use-terms'; type Options = { readonly replace?: boolean; - readonly onEmailBlocked?: () => void; + readonly onEmailBlocked?: (errorCode: string) => void; }; const useSocialRegister = (connectorId: string, { replace, onEmailBlocked }: Options = {}) => { @@ -20,7 +20,7 @@ const useSocialRegister = (connectorId: string, { replace, onEmailBlocked }: Opt const asyncRegisterWithSocial = useApi(registerWithVerifiedIdentifier); const redirectTo = useGlobalRedirectTo(); const { termsValidation, agreeToTermsPolicy } = useTerms(); - const navigate = useNavigateWithPreservedSearchParams(); + const navigateToSignIn = useNavigateToSignIn(); const preRegisterErrorHandler = useSubmitInteractionErrorHandler(InteractionEvent.Register, { linkSocial: connectorId, @@ -36,7 +36,7 @@ const useSocialRegister = (connectorId: string, { replace, onEmailBlocked }: Opt * Therefore, skip the check for `Manual` policy. */ if (agreeToTermsPolicy !== AgreeToTermsPolicy.Manual && !(await termsValidation())) { - navigate('/' + experience.routes.signIn); + navigateToSignIn(); return; } @@ -56,7 +56,7 @@ const useSocialRegister = (connectorId: string, { replace, onEmailBlocked }: Opt agreeToTermsPolicy, asyncRegisterWithSocial, handleError, - navigate, + navigateToSignIn, preRegisterErrorHandler, redirectTo, termsValidation, diff --git a/packages/experience/src/hooks/use-submit-interaction-error-handler.ts b/packages/experience/src/hooks/use-submit-interaction-error-handler.ts index 4ae991abf219..f3a3b3a1f510 100644 --- a/packages/experience/src/hooks/use-submit-interaction-error-handler.ts +++ b/packages/experience/src/hooks/use-submit-interaction-error-handler.ts @@ -15,7 +15,7 @@ import useRequiredProfileErrorHandler, { type Options = Omit & UseMfaVerificationErrorHandlerOptions & { - readonly onEmailBlocked?: () => void; + readonly onEmailBlocked?: (errorCode: string) => void; }; /** diff --git a/packages/experience/src/pages/DirectSignIn/index.test.tsx b/packages/experience/src/pages/DirectSignIn/index.test.tsx index ef4291e02d43..14d171590c63 100644 --- a/packages/experience/src/pages/DirectSignIn/index.test.tsx +++ b/packages/experience/src/pages/DirectSignIn/index.test.tsx @@ -1,7 +1,9 @@ +import { renderHook } from '@testing-library/react'; import { useParams as useParamsMock } from 'react-router-dom'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import { mockSsoConnectors, socialConnectors } from '@/__mocks__/logto'; +import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages'; import DirectSignIn from '.'; @@ -65,8 +67,11 @@ afterAll(() => { }); describe('DirectSignIn', () => { + const { result: sessionStorageHook } = renderHook(() => useSessionStorage()); + beforeEach(() => { jest.clearAllMocks(); + sessionStorageHook.current.remove(StorageKeys.DirectSignIn); }); it('should fallback to the first screen when `directSignIn` is not provided', () => { @@ -92,6 +97,7 @@ describe('DirectSignIn', () => { search.mockReturnValue('?fallback=sign-in'); renderWithPageContext(); expect(replace).toBeCalledWith('/sign-in'); + expect(sessionStorageHook.current.get(StorageKeys.DirectSignIn)).toBeUndefined(); }); it('should fallback to the first screen when method is valid but target is invalid (sso)', () => { @@ -99,6 +105,7 @@ describe('DirectSignIn', () => { search.mockReturnValue('?fallback=sign-in'); renderWithPageContext(); expect(replace).toBeCalledWith('/sign-in'); + expect(sessionStorageHook.current.get(StorageKeys.DirectSignIn)).toBeUndefined(); }); it('should invoke social sign-in when method is social and target is valid (social)', () => { @@ -109,6 +116,9 @@ describe('DirectSignIn', () => { expect(replace).not.toBeCalled(); expect(assign).toBeCalledWith('/social-redirect-to'); + expect(sessionStorageHook.current.get(StorageKeys.DirectSignIn)).toBe( + `social:${socialConnectors[0]!.target}` + ); }); it('should invoke sso sign-in when method is sso and target is valid (sso)', () => { @@ -119,5 +129,8 @@ describe('DirectSignIn', () => { expect(replace).not.toBeCalled(); expect(assign).toBeCalledWith('/sso-redirect-to'); + expect(sessionStorageHook.current.get(StorageKeys.DirectSignIn)).toBe( + `sso:${mockSsoConnectors[0]!.id}` + ); }); }); diff --git a/packages/experience/src/pages/DirectSignIn/index.tsx b/packages/experience/src/pages/DirectSignIn/index.tsx index 641fd9c3c2bc..6225de273088 100644 --- a/packages/experience/src/pages/DirectSignIn/index.tsx +++ b/packages/experience/src/pages/DirectSignIn/index.tsx @@ -5,6 +5,7 @@ import { useParams } from 'react-router-dom'; import PageContext from '@/Providers/PageContextProvider/PageContext'; import useSocial from '@/containers/SocialSignInList/use-social'; import useFallbackRoute from '@/hooks/use-fallback-route'; +import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages'; import { useSieMethods } from '@/hooks/use-sie'; import useSingleSignOn from '@/hooks/use-single-sign-on'; import { LoadingIconWithContainer } from '@/shared/components/LoadingLayer'; @@ -19,6 +20,7 @@ const DirectSignIn = () => { const invokeSso = useSingleSignOn(); const fallback = useFallbackRoute(); const { experienceSettings } = useContext(PageContext); + const { set } = useSessionStorage(); // Prevent multiple invocations due to `invokeSocialSignIn` or `invokeSso` causing re-renders const hasSignInInvokedRef = useRef(false); @@ -35,6 +37,11 @@ const DirectSignIn = () => { const social = socialConnectors.find((connector) => connector.target === target); if (social) { + // Mark the session as entered through direct sign-in, so error handlers finish the + // interaction back at the client instead of falling back to the hosted sign-in page. + // The value records the entry point for debugging; its presence alone drives behavior. + set(StorageKeys.DirectSignIn, `${method}:${social.target}`); + // Redirect to the Google One Tap callback page if the social connector is Google and the logtoGoogleOneTapCookie is present (external Google One Tap). if (social.target === GoogleConnector.target && logtoGoogleOneTapCookie) { // eslint-disable-next-line @silverhand/fp/no-mutation @@ -51,6 +58,7 @@ const DirectSignIn = () => { const sso = ssoConnectors.find((connector) => connector.id === target); if (sso) { + set(StorageKeys.DirectSignIn, `${method}:${sso.id}`); void invokeSso(sso.id); return; } @@ -62,6 +70,7 @@ const DirectSignIn = () => { invokeSocialSignIn, invokeSso, method, + set, socialConnectors, ssoConnectors, target, diff --git a/packages/experience/src/pages/SocialSignInWebCallback/social-callback.test.tsx b/packages/experience/src/pages/SocialSignInWebCallback/social-callback.test.tsx index 8aa6c2b1e75f..c0a41eed0e76 100644 --- a/packages/experience/src/pages/SocialSignInWebCallback/social-callback.test.tsx +++ b/packages/experience/src/pages/SocialSignInWebCallback/social-callback.test.tsx @@ -10,6 +10,7 @@ import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider import { mockSignInExperienceSettings } from '@/__mocks__/logto'; import { socialConnectors } from '@/__mocks__/social-connectors'; import { + abortInteraction, identifyAndSubmitInteraction, registerWithVerifiedIdentifier, verifySocialVerification, @@ -36,6 +37,9 @@ jest.mock('@/apis/experience', () => ({ identifyAndSubmitInteraction: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }), registerWithVerifiedIdentifier: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }), signInWithSso: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }), + abortInteraction: jest.fn().mockResolvedValue({ + redirectTo: 'https://client.example.com/callback?error=access_denied', + }), })); jest.mock('@/hooks/use-global-redirect-to', () => ({ @@ -60,6 +64,7 @@ const mockedIdentifyAndSubmitInteraction = identifyAndSubmitInteraction as jest. const mockedRegisterWithVerifiedIdentifier = registerWithVerifiedIdentifier as jest.MockedFunction< typeof registerWithVerifiedIdentifier >; +const mockedAbortInteraction = abortInteraction as jest.MockedFunction; const verificationIdsMap = { [VerificationType.Social]: 'foo', @@ -89,6 +94,9 @@ describe('SocialCallbackPage — social sign-in', () => { mockedVerifySocialVerification.mockResolvedValue({ verificationId: 'foo' }); mockedIdentifyAndSubmitInteraction.mockResolvedValue({ redirectTo: `/sign-in` }); mockedRegisterWithVerifiedIdentifier.mockResolvedValue({ redirectTo: `/sign-in` }); + mockedAbortInteraction.mockResolvedValue({ + redirectTo: 'https://client.example.com/callback?error=access_denied', + }); }); describe('fallback', () => { @@ -277,6 +285,72 @@ describe('SocialCallbackPage — social sign-in', () => { }); }); + it('should abort back to the client when the session entered via direct sign-in', async () => { + const state = generateState(); + set(StorageKeys.DirectSignIn, `social:${socialConnectors[0]!.target}`); + + mockUseSearchParameters.mockReturnValue([ + new URLSearchParams(`state=${state}&code=foo`), + jest.fn(), + ]); + + renderWithPageContext( + + + + } /> + + + , + { initialEntries: [`/callback/social/${connectorId}`] } + ); + + await waitFor(() => { + expect(mockedAbortInteraction).toBeCalled(); + expect(mockRedirectTo).toBeCalledWith( + 'https://client.example.com/callback?error=access_denied' + ); + }); + + // The marker is consumed on use. + expect(result.current.get(StorageKeys.DirectSignIn)).toBeUndefined(); + }); + + it('should fall back to the sign-in page when aborting fails', async () => { + const state = generateState(); + set(StorageKeys.DirectSignIn, `social:${socialConnectors[0]!.target}`); + mockedAbortInteraction.mockRejectedValueOnce( + createRequestError({ + code: 'session.not_found', + message: 'Session not found.', + data: undefined, + }) + ); + + mockUseSearchParameters.mockReturnValue([ + new URLSearchParams(`state=${state}&code=foo`), + jest.fn(), + ]); + + const { getByText } = renderWithPageContext( + + + + } /> + Sign in page} /> + + + , + { initialEntries: [`/callback/social/${connectorId}`] } + ); + + await waitFor(() => { + expect(mockedAbortInteraction).toBeCalled(); + expect(getByText('Sign in page')).not.toBeNull(); + }); + expect(mockRedirectTo).not.toBeCalled(); + }); + it('should not consult fallback on state mismatch', async () => { const state = generateState(); const differentState = generateState(); 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 d4350b6b2c73..88a30ea51422 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,4 +1,4 @@ -import { AgreeToTermsPolicy, SignInMode, VerificationType, experience } from '@logto/schemas'; +import { AgreeToTermsPolicy, SignInMode, VerificationType } from '@logto/schemas'; import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; @@ -8,7 +8,8 @@ import useApi from '@/hooks/use-api'; import useEmailBlockedErrorHandler from '@/hooks/use-email-blocked-error-handler'; import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; -import useNavigateWithPreservedSearchParams from '@/hooks/use-navigate-with-preserved-search-params'; +import useNavigateToSignIn from '@/hooks/use-navigate-to-sign-in'; +import usePrerendering from '@/hooks/use-prerendering'; import useRedirectCallbackValidation from '@/hooks/use-redirect-callback-validation'; import { useSieMethods } from '@/hooks/use-sie'; import useTerms from '@/hooks/use-terms'; @@ -16,7 +17,7 @@ import useToast from '@/hooks/use-toast'; import { parseQueryParameters } from '@/utils'; type SingleSignOnRegisterOptions = { - readonly onEmailBlocked?: () => void; + readonly onEmailBlocked?: (errorCode: string) => void; }; const useSingleSignOnRegister = ({ onEmailBlocked }: SingleSignOnRegisterOptions = {}) => { @@ -25,7 +26,7 @@ const useSingleSignOnRegister = ({ onEmailBlocked }: SingleSignOnRegisterOptions const request = useApi(registerWithVerifiedIdentifier); const { termsValidation, agreeToTermsPolicy } = useTerms(); - const navigate = useNavigateWithPreservedSearchParams(); + const navigateToSignIn = useNavigateToSignIn(); const redirectTo = useGlobalRedirectTo(); return useCallback( @@ -36,7 +37,7 @@ const useSingleSignOnRegister = ({ onEmailBlocked }: SingleSignOnRegisterOptions * Therefore, skip the check for `Manual` policy. */ if (agreeToTermsPolicy !== AgreeToTermsPolicy.Manual && !(await termsValidation())) { - navigate('/' + experience.routes.signIn); + navigateToSignIn(); return; } @@ -56,7 +57,7 @@ const useSingleSignOnRegister = ({ onEmailBlocked }: SingleSignOnRegisterOptions agreeToTermsPolicy, emailBlockedErrorHandler, handleError, - navigate, + navigateToSignIn, redirectTo, request, termsValidation, @@ -89,14 +90,12 @@ const useSingleSignOnListener = (connectorId: string) => { verificationType: VerificationType.EnterpriseSso, }); + const prerendering = usePrerendering(); const handleError = useErrorHandler(); - const navigate = useNavigateWithPreservedSearchParams(); const singleSignOnAuthorizationRequest = useApi(signInWithSso); - const navigateToSignIn = useCallback(() => { - navigate('/' + experience.routes.signIn, { replace: true }); - }, [navigate]); + const navigateToSignIn = useNavigateToSignIn(); const registerSingleSignOnIdentity = useSingleSignOnRegister({ onEmailBlocked: navigateToSignIn, @@ -120,7 +119,7 @@ const useSingleSignOnListener = (connectorId: string) => { // Should not let user register new social account under sign-in only mode if (signInMode === SignInMode.SignIn) { setToast(error.message); - navigateToSignIn(); + navigateToSignIn(error.code); return; } @@ -129,7 +128,7 @@ const useSingleSignOnListener = (connectorId: string) => { // Redirect to sign-in page if error is not handled by the error handlers global: async (error) => { setToast(error.message); - navigateToSignIn(); + navigateToSignIn(error.code); }, }); return; @@ -152,7 +151,9 @@ const useSingleSignOnListener = (connectorId: string) => { // Single Sign On Callback Handler useEffect(() => { - if (isConsumed) { + // The callback consumes one-time data (authorization code, state, and possibly the whole + // interaction on error) — wait for activation when the page is only being prerendered. + if (prerendering || isConsumed) { return; } @@ -167,7 +168,7 @@ const useSingleSignOnListener = (connectorId: string) => { if (!result.valid) { setToast(t(`error.${result.error}`)); - navigateToSignIn(); + navigateToSignIn(result.error); return; } @@ -176,6 +177,7 @@ const useSingleSignOnListener = (connectorId: string) => { connectorId, isConsumed, navigateToSignIn, + prerendering, searchParameters, setSearchParameters, setToast, 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 6b6e5aa88056..9ff570a91825 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 @@ -3,7 +3,7 @@ import { isGoogleOneTap as isGoogleOneTapChecker, } from '@logto/connector-kit'; import type { RequestErrorBody } from '@logto/schemas'; -import { InteractionEvent, SignInMode, VerificationType, experience } from '@logto/schemas'; +import { InteractionEvent, SignInMode, VerificationType } from '@logto/schemas'; import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; @@ -20,7 +20,9 @@ import useApi from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; +import useNavigateToSignIn from '@/hooks/use-navigate-to-sign-in'; import useNavigateWithPreservedSearchParams from '@/hooks/use-navigate-with-preserved-search-params'; +import usePrerendering from '@/hooks/use-prerendering'; import useRedirectCallbackValidation from '@/hooks/use-redirect-callback-validation'; import { useSieMethods } from '@/hooks/use-sie'; import useSocialRegister from '@/hooks/use-social-register'; @@ -47,6 +49,7 @@ const useSocialSignInListener = (connectorId: string) => { verificationType: VerificationType.Social, }); + const prerendering = usePrerendering(); const navigate = useNavigateWithPreservedSearchParams(); const handleError = useErrorHandler(); const bindSocialRelatedUser = useBindSocialRelatedUser(); @@ -55,9 +58,7 @@ const useSocialSignInListener = (connectorId: string) => { const asyncInitInteraction = useApi(initInteraction); const redirectTo = useGlobalRedirectTo(); - const navigateToSignIn = useCallback(() => { - navigate('/' + experience.routes.signIn, { replace: true }); - }, [navigate]); + const navigateToSignIn = useNavigateToSignIn(); const registerWithSocial = useSocialRegister(connectorId, { replace: true, @@ -73,7 +74,7 @@ const useSocialSignInListener = (connectorId: string) => { // Redirect to sign-in page if the verificationId is not set properly if (!verificationId) { setToast(t('error.invalid_session')); - navigateToSignIn(); + navigateToSignIn('invalid_session'); return; } @@ -93,7 +94,7 @@ const useSocialSignInListener = (connectorId: string) => { // Should not let user register new social account under sign-in only mode if (signInMode === SignInMode.SignIn) { setToast(error.message); - navigateToSignIn(); + navigateToSignIn(error.code); return; } @@ -117,7 +118,7 @@ const useSocialSignInListener = (connectorId: string) => { const globalErrorHandler = useCallback( async (error: RequestErrorBody) => { setToast(error.message); - navigateToSignIn(); + navigateToSignIn(error.code); }, [navigateToSignIn, setToast] ); @@ -212,7 +213,9 @@ const useSocialSignInListener = (connectorId: string) => { // Social Sign-in Callback Handler useEffect(() => { - if (isConsumed) { + // The callback consumes one-time data (authorization code, state, and possibly the whole + // interaction on error) — wait for activation when the page is only being prerendered. + if (prerendering || isConsumed) { return; } @@ -237,7 +240,7 @@ const useSocialSignInListener = (connectorId: string) => { if (!result.valid) { setToast(t(`error.${result.error}`)); - navigate('/' + experience.routes.signIn); + navigateToSignIn(result.error); return; } } else { @@ -246,7 +249,7 @@ const useSocialSignInListener = (connectorId: string) => { if (!result.valid) { setToast(t(`error.${result.error}`)); - navigate('/' + experience.routes.signIn); + navigateToSignIn(result.error); return; } } @@ -256,7 +259,8 @@ const useSocialSignInListener = (connectorId: string) => { }, [ connectorId, isConsumed, - navigate, + navigateToSignIn, + prerendering, searchParameters, setSearchParameters, setToast,