Skip to content

feat(core,experience): abort failed direct sign-in back to the client with access_denied - #9425

Open
feitianbubu wants to merge 1 commit into
logto-io:masterfrom
feitianbubu:feat/direct-sign-in-abort
Open

feat(core,experience): abort failed direct sign-in back to the client with access_denied#9425
feitianbubu wants to merge 1 commit into
logto-io:masterfrom
feitianbubu:feat/direct-sign-in-abort

Conversation

@feitianbubu

Copy link
Copy Markdown

Summary

When a sign-in initiated with the direct_sign_in authentication parameter fails (denied at the identity provider, connector error, invalid session, etc.), the hosted experience currently falls back to the universal sign-in page. For applications that use direct sign-in precisely because they own the sign-in UI, this is a dead end: the user lands on a page showing sign-in methods the application never offered, and the application is never told the sign-in failed.

With this PR, such interactions are 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. Sign-ins that entered through the hosted pages keep the existing fallback behavior.

Before: client sends /authorize?...&direct_sign_in=social:google → user cancels at Google → user is stranded on the hosted /sign-in page.

After: the user agent returns to https://client.example.com/callback?error=access_denied&error_description=...&state=... and the app can react (show its own error, offer a retry, etc.).

Changes

Core

  • New endpoint POST /api/experience/abort — finishes the current interaction via assignInteractionResults with error: 'access_denied' and an optional error_description taken from the request's reason field, and responds with the redirectTo URL that resumes the OIDC flow.
    • reason is validated against the character set RFC 6749 §4.1.2.1 allows for error_description (max 256 chars).
    • The route is whitelisted in koaExperienceInteraction so it works regardless of the interaction storage state — by the time an error surfaces, the interaction may not have been initialized or may be half-consumed.
    • The endpoint is deliberately generic (abort with access_denied) rather than direct-sign-in specific; the experience app decides when aborting is the right exit.

Experience

  • DirectSignIn marks the session in sessionStorage (StorageKeys.DirectSignIn) when it invokes a social or SSO connector, so the entry point survives the round trip to the identity provider.
  • New useNavigateToSignIn hook centralizes the terminal "give up and go to the sign-in page" fallback used by the social and SSO callback listeners (previously two copy-pasted inline callbacks):
    • For sessions marked as direct sign-in, it calls the abort endpoint and redirects back to the client, forwarding the error code as the reason. The marker is consumed on use; if aborting fails (e.g. the interaction has expired), it falls back to the hosted sign-in page as before.
    • For all other sessions, behavior is unchanged.
  • New usePrerendering hook (useSyncExternalStore over document.prerendering): social/SSO callback handling is now deferred while the page is being prerendered (e.g. Chrome address-bar preloading). A prerendered page may never be shown, but its network requests do reach the server — so consuming the one-time authorization code/state (and now potentially aborting the whole interaction) must wait for activation.

Breaking changes

None. The new endpoint is additive, hosted-page flows are unchanged, and a changeset is included (minor for @logto/core and @logto/experience).

Testing

  • New unit tests:
    • core: abort finishes the interaction with access_denied (with and without error_description), and rejects a reason outside the RFC 6749 charset.
    • experience: the direct sign-in marker is set on connector invocation and cleared on fallback; callback errors abort back to the client; the hosted sign-in page is used when aborting fails.
  • packages/core route tests and the full packages/experience suite pass locally.

Happy to adjust naming, the endpoint shape, or make the behavior opt-in per application based on your feedback.

@feitianbubu
feitianbubu requested a review from simeng-li as a code owner August 12, 2026 02:11
Copilot AI lite review requested due to automatic review settings August 12, 2026 02:11
@github-actions github-actions Bot added feature Cool stuff size/l labels Aug 12, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the direct sign-in UX by ensuring that failures during a direct_sign_in-initiated OIDC interaction are aborted back to the client app (as a standard OAuth access_denied), instead of falling back to the hosted universal sign-in page. It does this by adding a Core abort endpoint and teaching the Experience callbacks to detect direct sign-in entry and exit appropriately, while also deferring callback side-effects during browser prerendering.

Changes:

  • Core: add POST /api/experience/abort to finish an interaction with access_denied (optional error_description) and return the redirectTo URL.
  • Experience: persist a “direct sign-in entry” marker in sessionStorage, centralize fallback logic in useNavigateToSignIn, and defer callback consumption while document.prerendering is true.
  • Tests + docs: add unit tests for the new behavior, and update OpenAPI + changeset.

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/experience/src/Providers/UserInteractionContextProvider/index.tsx Clears the new direct sign-in session marker when resetting user interaction context.
packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts Uses centralized fallback hook + prerender gating; forwards error codes to abort when applicable.
packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts Mirrors social callback changes for SSO: centralized fallback + prerender gating + error-code forwarding.
packages/experience/src/pages/SocialSignInWebCallback/social-callback.test.tsx Adds coverage for aborting back to client on direct sign-in, and fallback behavior when abort fails.
packages/experience/src/pages/DirectSignIn/index.tsx Writes the direct sign-in marker to sessionStorage before invoking social/SSO connectors.
packages/experience/src/pages/DirectSignIn/index.test.tsx Adds assertions that the marker is set for valid targets and absent for fallback cases.
packages/experience/src/hooks/use-submit-interaction-error-handler.ts Plumbs error codes through the “email blocked” callback contract.
packages/experience/src/hooks/use-social-register.ts Switches fallback navigation to useNavigateToSignIn and updates email-blocked callback typing.
packages/experience/src/hooks/use-session-storages.ts Introduces StorageKeys.DirectSignIn with guard validation.
packages/experience/src/hooks/use-prerendering.ts New hook wrapping document.prerendering via useSyncExternalStore for safe callback deferral.
packages/experience/src/hooks/use-navigate-to-sign-in.ts New centralized “terminal fallback” that aborts to client for direct sign-in sessions, else navigates to hosted sign-in.
packages/experience/src/hooks/use-email-blocked-error-handler.ts Passes the error code into the confirm callback to support abort reasons.
packages/experience/src/apis/experience/interaction.ts Adds abortInteraction(reason?) API wrapper calling the new endpoint.
packages/experience/src/apis/experience/index.ts Re-exports the new abort API function.
packages/experience/src/apis/experience/const.ts Adds the /api/experience/abort route constant.
packages/core/src/routes/experience/middleware/koa-experience-interaction.ts Whitelists the new abort endpoint to bypass interaction initialization requirements.
packages/core/src/routes/experience/index.ts Implements POST /experience/abort (mounted under /api) using assignInteractionResults with access_denied.
packages/core/src/routes/experience/index.test.ts Adds route tests covering redirect URL, optional description, and charset validation.
packages/core/src/routes/experience/experience.openapi.json Documents the new abort endpoint in Experience OpenAPI.
packages/core/src/routes/experience/const.ts Adds the abort route constant to the Experience routes map.
.changeset/brave-pandas-return.md Adds a minor changeset for @logto/core and @logto/experience describing the behavior change.
Suppressed comments (1)

packages/core/src/routes/experience/experience.openapi.json:146

  • The 400 response description is too narrow: this endpoint also returns 400 for request-body validation failures (e.g. reason outside the allowed RFC 6749 charset), not only for missing/invalid interaction sessions.
          "400": {
            "description": "The interaction session is not found or is in an invalid state."
          }

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +131 to +135
"properties": {
"reason": {
"description": "Optional human-readable reason, forwarded to the client as the `error_description` parameter."
}
}
Copilot AI review requested due to automatic review settings August 12, 2026 04:00
@github-actions github-actions Bot added size/l and removed size/l labels Aug 12, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.

// 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}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The marker can outlive the sign-in attempt it belongs to. It is only consumed in useNavigateToSignIn, but several error paths set it and then exit through other doors:

  • right here: if the authorization-URL request fails (invokeSocialSignInhandleError → toast) or the user declines the Manual terms modal, the marker stays while the user is stuck on the loading page;
  • the Google One Tap branch below redirects before any listener runs — with a missing/misconfigured experienceSettings.googleOneTap.connectorId it ends in SocialSignInWebCallback's connector-not-found <Navigate> to /sign-in, which does not consume the marker either;
  • the related-user path continues in hosted UI (/social/link/:connectorId) with the marker still set.

Since sessionStorage survives navigations, a later authorization in the same tab without direct_sign_in will then abort that fresh hosted interaction on its first callback error and bounce the user back to the client app, which is wrong for that session.

Can we clear the marker whenever it can no longer be consumed — e.g. on hosted sign-in page mount, or at least on the failure paths above? Whatever cleanup we settle on should also cover the enterprise SSO branch below (invokeSso failures leak the marker the same way) so social and SSO behave consistently.

A more robust alternative that removes this class of bugs entirely: the interaction already carries direct_sign_in in its OIDC params (ExtraParamsKey.DirectSignIn is stored with the authorization request), so "entered via direct sign-in" could be derived per interaction on the server side instead of relying on a tab-scoped client marker.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.

Suppressed comments (3)

packages/experience/src/pages/DirectSignIn/index.tsx:61

  • The marker is consulted only by callback fallback handling. If getSsoAuthorizationUrl fails before redirecting, useSingleSignOn only displays the error and returns, so this page remains on its loading spinner and leaves the marker stale; useSocial has the same behavior. This does not cover the connector-initiation failures described by the PR and can also make a later hosted flow in this tab abort unexpectedly. Route launcher failures through the abort fallback (and clear the marker if aborting cannot proceed).
        set(StorageKeys.DirectSignIn, `${method}:${sso.id}`);

packages/experience/src/pages/DirectSignIn/index.tsx:43

  • This provenance is stored only in sessionStorage, but both social and SSO launchers explicitly maintain a localStorage redirect fallback because external/in-app-browser redirects can lose sessionStorage (use-social.ts:86-95, use-single-sign-on.ts:72-81). In that supported recovery path, validateAndRestore() restores the verification successfully while this marker is absent, so a later callback error still sends a direct-sign-in user to the hosted page. Include direct-sign-in provenance in the state-keyed redirect fallback bundle and restore it with the other callback context.

This issue also appears on line 61 of the same file.

        // 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}`);

packages/experience/src/hooks/use-prerendering.ts:33

  • The new activation-sensitive behavior has no test. Please cover an initial document.prerendering === true, verify callback work is not started, dispatch prerenderingchange after setting it to false, and verify processing starts exactly once. This guards the one-time authorization data that this hook was introduced to protect.
const usePrerendering = () => useSyncExternalStore(subscribe, isPrerendering);

reason: z
.string()
.max(256)
.regex(/^[ !#-[\]-~]*$/)
Copilot AI review requested due to automatic review settings August 21, 2026 05:20
@charIeszhao
charIeszhao force-pushed the feat/direct-sign-in-abort branch from da2cc21 to 2d1e109 Compare August 21, 2026 05:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot removed the size/l label Aug 21, 2026
@github-actions

Copy link
Copy Markdown

COMPARE TO master

Total Size Diff ⚠️ 📈 +11.38 KB

Diff by File
Name Diff
.changeset/brave-pandas-return.md 📈 +790 Bytes
packages/core/src/routes/experience/const.ts 📈 +28 Bytes
packages/core/src/routes/experience/experience.openapi.json 📈 +1.44 KB
packages/core/src/routes/experience/index.ts 📈 +1.02 KB
packages/core/src/routes/experience/middleware/koa-experience-interaction.ts 📈 +188 Bytes
packages/experience/src/Providers/UserInteractionContextProvider/index.tsx 📈 +38 Bytes
packages/experience/src/apis/experience/const.ts 📈 +28 Bytes
packages/experience/src/apis/experience/index.ts 📈 +20 Bytes
packages/experience/src/apis/experience/interaction.ts 📈 +366 Bytes
packages/experience/src/hooks/use-email-blocked-error-handler.ts 📈 +27 Bytes
packages/experience/src/hooks/use-navigate-to-sign-in.ts 📈 +1.93 KB
packages/experience/src/hooks/use-prerendering.ts 📈 +1.21 KB
packages/experience/src/hooks/use-session-storages.ts 📈 +77 Bytes
packages/experience/src/hooks/use-social-register.ts 📈 +59 Bytes
packages/experience/src/hooks/use-submit-interaction-error-handler.ts 📈 +17 Bytes
packages/experience/src/pages/DirectSignIn/index.test.tsx 📈 +700 Bytes
packages/experience/src/pages/DirectSignIn/index.tsx 📈 +541 Bytes
packages/experience/src/pages/SocialSignInWebCallback/social-callback.test.tsx 📈 +2.61 KB
packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts 📈 +160 Bytes
packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts 📈 +320 Bytes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Development

Successfully merging this pull request may close these issues.

4 participants