From 801a764d81e3c08b0fe5ab39628e6b6f5f27d0f1 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:02:11 -0600 Subject: [PATCH 1/6] feat(nextjs,react): Add HandleSSOCallback component --- .changeset/vast-loops-open.md | 6 + .../src/client-boundary/uiComponents.tsx | 1 + .../src/components/HandleSSOCallback.tsx | 177 ++++++++++++++++++ packages/react/src/components/index.ts | 1 + 4 files changed, 185 insertions(+) create mode 100644 .changeset/vast-loops-open.md create mode 100644 packages/react/src/components/HandleSSOCallback.tsx diff --git a/.changeset/vast-loops-open.md b/.changeset/vast-loops-open.md new file mode 100644 index 00000000000..9efa5da8526 --- /dev/null +++ b/.changeset/vast-loops-open.md @@ -0,0 +1,6 @@ +--- +'@clerk/nextjs': minor +'@clerk/react': minor +--- + +Add `HandleSSOCallback` component which handles the SSO callback during custom flows, including support for sign-in-or-up. diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx index 6d032dc45c5..eef6fe05152 100644 --- a/packages/nextjs/src/client-boundary/uiComponents.tsx +++ b/packages/nextjs/src/client-boundary/uiComponents.tsx @@ -27,6 +27,7 @@ export { UserAvatar, UserButton, Waitlist, + HandleSSOCallback, } from '@clerk/react'; // The assignment of UserProfile with BaseUserProfile props is used diff --git a/packages/react/src/components/HandleSSOCallback.tsx b/packages/react/src/components/HandleSSOCallback.tsx new file mode 100644 index 00000000000..8109017a5be --- /dev/null +++ b/packages/react/src/components/HandleSSOCallback.tsx @@ -0,0 +1,177 @@ +import type { SetActiveNavigate } from '@clerk/shared/types'; +import { useEffect, useRef, type ReactNode } from 'react'; +import { useClerk, useSignIn, useSignUp } from '../hooks'; + +export interface HandleSSOCallbackProps { + /** + * Called when the SSO callback is complete and a session has been created. + */ + navigateToApp: (...params: Parameters) => void; + /** + * Called when a sign-in requires additional verification, or a sign-up is transfered to a sign-in that requires + * additional verification. + */ + navigateToSignIn: () => void; + /** + * Called when a sign-in is transfered to a sign-up that requires additional verification. + */ + navigateToSignUp: () => void; + /** + * Can be provided to render a custom component while the SSO callback is being processed. This component should, at + * a minimum, render a `
` element to handle captchas. + */ + render?: () => ReactNode; +} + +/** + * Use this component when building custom UI to handle the SSO callback and navigate to the appropriate page based on + * the status of the sign-in or sign-up. By default, this component might render a captcha element to handle captchas + * when required by the Clerk API. + * + * @example + * ```tsx + * import { HandleSSOCallback } from '@clerk/react'; + * import { useNavigate } from 'react-router'; + * + * export default function Page() { + * const navigate = useNavigate(); + * + * return ( + * { + * if (session?.currentTask) { + * const destination = decorateUrl(`/onboarding/${session?.currentTask.key}`); + * if (destination.startsWith('http')) { + * window.location.href = destination; + * return; + * } + * navigate(destination); + * return; + * } + * + * const destination = decorateUrl('/dashboard'); + * if (destination.startsWith('http')) { + * window.location.href = destination; + * return; + * } + * navigate(destination); + * }} + * navigateToSignIn={() => { + * navigate('/sign-in'); + * }} + * navigateToSignUp={() => { + * navigate('/sign-up'); + * }} + * /> + * ); + * } + * ``` + */ +export function HandleSSOCallback(props: HandleSSOCallbackProps): ReactNode { + const { navigateToApp, navigateToSignIn, navigateToSignUp, render } = props; + const clerk = useClerk(); + const { signIn } = useSignIn(); + const { signUp } = useSignUp(); + const hasRun = useRef(false); + + useEffect(() => { + (async () => { + if (!clerk.loaded || hasRun.current) { + return; + } + // Prevent re-running this effect if the page is re-rendered during session activation (such as on Next.js). + hasRun.current = true; + + // If this was a sign-in, and it's complete, there's nothing else to do. + // Note: We perform a cast + if ((signIn.status as string) === 'complete') { + await signIn.finalize({ + navigate: async (...params) => { + navigateToApp(...params); + }, + }); + return; + } + + // If the sign-up used an existing account, transfer it to a sign-in. + if (signUp.isTransferable) { + await signIn.create({ transfer: true }); + if (signIn.status === 'complete') { + await signIn.finalize({ + navigate: async (...params) => { + navigateToApp(...params); + }, + }); + return; + } + // The sign-in requires additional verification, so we need to navigate to the sign-in page. + return navigateToSignIn(); + } + + if ( + signIn.status === 'needs_first_factor' && + !signIn.supportedFirstFactors?.every(f => f.strategy === 'enterprise_sso') + ) { + // The sign-in requires the use of a configured first factor, so navigate to the sign-in page. + return navigateToSignIn(); + } + + // If the sign-in used an external account not associated with an existing user, create a sign-up. + if (signIn.isTransferable) { + await signUp.create({ transfer: true }); + if (signUp.status === 'complete') { + await signUp.finalize({ + navigate: async (...params) => { + navigateToApp(...params); + }, + }); + return; + } + return navigateToSignUp(); + } + + if (signUp.status === 'complete') { + await signUp.finalize({ + navigate: async (...params) => { + navigateToApp(...params); + }, + }); + return; + } + + if (signIn.status === 'needs_second_factor' || signIn.status === 'needs_new_password') { + // The sign-in requires a MFA token or a new password, so navigate to the sign-in page. + return navigateToSignIn(); + } + + // The external account used to sign-in or sign-up was already associated with an existing user and active + // session on this client, so activate the session and navigate to the application. + if (signIn.existingSession || signUp.existingSession) { + const sessionId = signIn.existingSession?.sessionId || signUp.existingSession?.sessionId; + if (sessionId) { + // Because we're activating a session that's not the result of a sign-in or sign-up, we need to use the + // Clerk `setActive` API instead of the `finalize` API. + await clerk.setActive({ + session: sessionId, + navigate: async (...params) => { + return navigateToApp(...params); + }, + }); + return; + } + } + })(); + }, [clerk, signIn, signUp]); + + if (render) { + return render(); + } + + return ( +
+ {/* Because a sign-in transferred to a sign-up might require captcha verification, make sure to render the + captcha element. */} +
+
+ ); +} diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index c200f386236..8daec8bb784 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -37,3 +37,4 @@ export { SignInButton } from './SignInButton'; export { SignInWithMetamaskButton } from './SignInWithMetamaskButton'; export { SignOutButton } from './SignOutButton'; export { SignUpButton } from './SignUpButton'; +export { HandleSSOCallback } from './HandleSSOCallback'; From 9cf0701935cc4fad8b99743196a695e7b2b83d70 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:23:40 -0600 Subject: [PATCH 2/6] fix(react): Import React --- packages/react/src/components/HandleSSOCallback.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/HandleSSOCallback.tsx b/packages/react/src/components/HandleSSOCallback.tsx index 8109017a5be..2d70af48f5a 100644 --- a/packages/react/src/components/HandleSSOCallback.tsx +++ b/packages/react/src/components/HandleSSOCallback.tsx @@ -1,5 +1,5 @@ import type { SetActiveNavigate } from '@clerk/shared/types'; -import { useEffect, useRef, type ReactNode } from 'react'; +import React, { useEffect, useRef, type ReactNode } from 'react'; import { useClerk, useSignIn, useSignUp } from '../hooks'; export interface HandleSSOCallbackProps { From 23104dce56f92357070d4e6c1b77b50fb80ad82c Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:39:09 -0600 Subject: [PATCH 3/6] fix(react): sort imports --- packages/react/src/components/HandleSSOCallback.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/HandleSSOCallback.tsx b/packages/react/src/components/HandleSSOCallback.tsx index 2d70af48f5a..538c0e339ad 100644 --- a/packages/react/src/components/HandleSSOCallback.tsx +++ b/packages/react/src/components/HandleSSOCallback.tsx @@ -1,5 +1,6 @@ import type { SetActiveNavigate } from '@clerk/shared/types'; -import React, { useEffect, useRef, type ReactNode } from 'react'; +import React, { type ReactNode, useEffect, useRef } from 'react'; + import { useClerk, useSignIn, useSignUp } from '../hooks'; export interface HandleSSOCallbackProps { @@ -171,7 +172,7 @@ export function HandleSSOCallback(props: HandleSSOCallbackProps): ReactNode {
{/* Because a sign-in transferred to a sign-up might require captcha verification, make sure to render the captcha element. */} -
+
); } From f04428f1996e75e14c5e6d770928d88dee0a7f14 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:40:48 -0600 Subject: [PATCH 4/6] fix(tanstack-react-start): update snapshot --- .../src/__tests__/__snapshots__/exports.test.ts.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 6e0d04985b8..903c5107080 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -30,6 +30,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "ClerkProvider", "CreateOrganization", "GoogleOneTap", + "HandleSSOCallback", "OrganizationList", "OrganizationProfile", "OrganizationSwitcher", From f0566318b3ebb270d128a1235e79ed4d0707df39 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:46:59 -0600 Subject: [PATCH 5/6] tests(chrome-extension,react-router): update snapshots --- .../src/__tests__/__snapshots__/exports.test.ts.snap | 1 + .../src/__tests__/__snapshots__/exports.test.ts.snap | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap index a3dc11760d2..dcf9f52e07a 100644 --- a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap @@ -11,6 +11,7 @@ exports[`public exports > should not include a breaking change 1`] = ` "ClerkProvider", "CreateOrganization", "GoogleOneTap", + "HandleSSOCallback", "OrganizationList", "OrganizationProfile", "OrganizationSwitcher", diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 87dc4845653..aaf05912db6 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -25,6 +25,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "ClerkProvider", "CreateOrganization", "GoogleOneTap", + "HandleSSOCallback", "OrganizationList", "OrganizationProfile", "OrganizationSwitcher", From 146a6c8147a97792dcb4ffc94b6a8331ddc534da Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:57:18 -0600 Subject: [PATCH 6/6] fix(chrome-extension): Re-export HandleSSOCallback --- .changeset/vast-loops-open.md | 1 + packages/chrome-extension/src/react/re-exports.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/.changeset/vast-loops-open.md b/.changeset/vast-loops-open.md index 9efa5da8526..baf4dc4e371 100644 --- a/.changeset/vast-loops-open.md +++ b/.changeset/vast-loops-open.md @@ -1,4 +1,5 @@ --- +'@clerk/chrome-extension': minor '@clerk/nextjs': minor '@clerk/react': minor --- diff --git a/packages/chrome-extension/src/react/re-exports.ts b/packages/chrome-extension/src/react/re-exports.ts index 0c4b6c83fdf..2ef5056cb2e 100644 --- a/packages/chrome-extension/src/react/re-exports.ts +++ b/packages/chrome-extension/src/react/re-exports.ts @@ -6,6 +6,7 @@ export { ClerkLoaded, ClerkLoading, CreateOrganization, + HandleSSOCallback, OrganizationList, OrganizationProfile, OrganizationSwitcher,