Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/brave-pandas-return.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/core/src/routes/experience/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export const experienceRoutes = Object.freeze({
verification: `${prefix}/verification`,
profile: `${prefix}/profile`,
mfa: `${prefix}/profile/mfa`,
abort: `${prefix}/abort`,
});
28 changes: 28 additions & 0 deletions packages/core/src/routes/experience/experience.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/routes/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
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';
Expand Down Expand Up @@ -196,6 +197,39 @@
}
);

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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
];

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions packages/experience/src/apis/experience/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
1 change: 1 addition & 0 deletions packages/experience/src/apis/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export {
submitInteraction,
identifyUser,
identifyAndSubmitInteraction,
abortInteraction,
} from './interaction';
export type { TrustedDeviceAvailability, TrustedDeviceInteractionData } from './interaction';

Expand Down
8 changes: 8 additions & 0 deletions packages/experience/src/apis/experience/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SubmitInteractionResponse>();
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -23,7 +23,7 @@ const useEmailBlockedErrorHandler = ({ onConfirm }: Options = {}): ErrorHandlers
});

if (onConfirm) {
onConfirm();
onConfirm(error.code);
return;
}

Expand Down
52 changes: 52 additions & 0 deletions packages/experience/src/hooks/use-navigate-to-sign-in.ts
Original file line number Diff line number Diff line change
@@ -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;
35 changes: 35 additions & 0 deletions packages/experience/src/hooks/use-prerendering.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions packages/experience/src/hooks/use-session-storages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 6 additions & 6 deletions packages/experience/src/hooks/use-social-register.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
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 = {}) => {
const handleError = useErrorHandler();
const asyncRegisterWithSocial = useApi(registerWithVerifiedIdentifier);
const redirectTo = useGlobalRedirectTo();
const { termsValidation, agreeToTermsPolicy } = useTerms();
const navigate = useNavigateWithPreservedSearchParams();
const navigateToSignIn = useNavigateToSignIn();

const preRegisterErrorHandler = useSubmitInteractionErrorHandler(InteractionEvent.Register, {
linkSocial: connectorId,
Expand All @@ -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;
}

Expand All @@ -56,7 +56,7 @@ const useSocialRegister = (connectorId: string, { replace, onEmailBlocked }: Opt
agreeToTermsPolicy,
asyncRegisterWithSocial,
handleError,
navigate,
navigateToSignIn,
preRegisterErrorHandler,
redirectTo,
termsValidation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import useRequiredProfileErrorHandler, {

type Options = Omit<UseRequiredProfileErrorHandlerOptions, 'interactionEvent'> &
UseMfaVerificationErrorHandlerOptions & {
readonly onEmailBlocked?: () => void;
readonly onEmailBlocked?: (errorCode: string) => void;
};

/**
Expand Down
13 changes: 13 additions & 0 deletions packages/experience/src/pages/DirectSignIn/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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 '.';

Expand Down Expand Up @@ -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', () => {
Expand All @@ -92,13 +97,15 @@ describe('DirectSignIn', () => {
search.mockReturnValue('?fallback=sign-in');
renderWithPageContext(<DirectSignIn />);
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)', () => {
useParams.mockReturnValue({ method: 'sso', target: 'something' });
search.mockReturnValue('?fallback=sign-in');
renderWithPageContext(<DirectSignIn />);
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)', () => {
Expand All @@ -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)', () => {
Expand All @@ -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}`
);
});
});
Loading
Loading