From b422e2ee9c99f45279c99707f2973dd60cba761e Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Thu, 1 Oct 2026 10:09:09 -0400 Subject: [PATCH 1/4] fix(ui): continue combined-flow SSO callbacks to sign-in steps --- ...ombined-flow-sso-callback-sign-in-steps.md | 5 ++ .../buildOAuthCallbackParams.test.ts | 30 ++++++++ .../combinedFlowSSOCallbackRouting.test.tsx | 77 +++++++++++++++++++ .../SignIn/buildOAuthCallbackParams.ts | 10 +++ packages/ui/src/components/SignIn/index.tsx | 4 +- 5 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 .changeset/combined-flow-sso-callback-sign-in-steps.md create mode 100644 packages/ui/src/components/SignIn/__tests__/combinedFlowSSOCallbackRouting.test.tsx diff --git a/.changeset/combined-flow-sso-callback-sign-in-steps.md b/.changeset/combined-flow-sso-callback-sign-in-steps.md new file mode 100644 index 00000000000..2f7c77860f6 --- /dev/null +++ b/.changeset/combined-flow-sso-callback-sign-in-steps.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Fixed `` in the combined sign-in-or-up flow restarting the sign-in after an OAuth or SAML redirect when the sign-in needed a Clerk Protect check, a first or second factor, or a password reset. The callback now continues to that step instead of returning to the start of the flow, which previously caused a loop for users who were asked to complete a Protect check. diff --git a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts index 1f960816b16..1a66046d18a 100644 --- a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts +++ b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + buildCombinedFlowOAuthCallbackParams, buildSignInOAuthCallbackParams, buildSignInOAuthTransportCallbackParams, buildSignUpOAuthCallbackParams, @@ -154,6 +155,35 @@ describe('buildSignUpOAuthCallbackParams', () => { }); }); +describe('buildCombinedFlowOAuthCallbackParams', () => { + it('routes sign-in steps from the combined-flow create/sso-callback route back to the SignIn routes', () => { + const ctx = { + signUpUrl: '/sign-in#/create', + signInUrl: '/sign-in', + afterSignUpUrl: '/after-up', + afterSignInUrl: '/after-in', + secondFactorUrl: '/sign-in#/factor-two', + unsafeMetadata: { b: 2 }, + } as any; + + expect(buildCombinedFlowOAuthCallbackParams(ctx)).toEqual({ + signUpUrl: '/sign-in#/create', + signInUrl: '/sign-in', + signUpForceRedirectUrl: '/after-up', + signInForceRedirectUrl: '/after-in', + firstFactorUrl: '../../factor-one', + secondFactorUrl: '../../factor-two', + resetPasswordUrl: '../../reset-password', + signInProtectCheckUrl: '../../protect-check', + continueSignUpUrl: '../continue', + verifyEmailAddressUrl: '../verify-email-address', + verifyPhoneNumberUrl: '../verify-phone-number', + signUpProtectCheckUrl: '../protect-check', + unsafeMetadata: { b: 2 }, + }); + }); +}); + describe('buildSignUpOAuthTransportCallbackParams', () => { it('uses paths relative to the SignUp start route for transport callbacks', () => { const ctx = { diff --git a/packages/ui/src/components/SignIn/__tests__/combinedFlowSSOCallbackRouting.test.tsx b/packages/ui/src/components/SignIn/__tests__/combinedFlowSSOCallbackRouting.test.tsx new file mode 100644 index 00000000000..b72d621f201 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/combinedFlowSSOCallbackRouting.test.tsx @@ -0,0 +1,77 @@ +import type { Clerk } from '@clerk/shared/types'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PathRouter, Route, useRouter } from '../../../router'; +import { buildCombinedFlowOAuthCallbackParams } from '../buildOAuthCallbackParams'; + +const mockNavigate = vi.fn(); + +vi.mock('@clerk/shared/react', () => { + return { + useClerk: () => { + return { + navigate: (to: string) => { + mockNavigate(to); + return Promise.resolve(); + }, + } as Clerk; + }, + }; +}); + +const params = buildCombinedFlowOAuthCallbackParams({ + signUpUrl: '/sign-in#/create', + signInUrl: '/sign-in', +} as any); + +const NavigateButton = ({ to }: { to: string }) => { + const router = useRouter(); + return ( + + ); +}; + +const CombinedFlowSSOCallback = () => ( + + + + + + + + + + + +); + +describe('combined-flow create/sso-callback route', () => { + beforeEach(() => { + mockNavigate.mockReset(); + window.history.replaceState({}, '', '/sign-in/create/sso-callback'); + }); + + it.each([ + [params.signInProtectCheckUrl!, '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/sign-in/protect-check'], + [params.firstFactorUrl!, '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/sign-in/factor-one'], + [params.secondFactorUrl!, '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/sign-in/factor-two'], + [params.resetPasswordUrl!, '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/sign-in/reset-password'], + [params.continueSignUpUrl!, '/sign-in/create/continue'], + ])('resolves %s to %s', async (to, expected) => { + render(); + + await userEvent.click(screen.getByRole('button', { name: to })); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(expected); + }); + }); +}); diff --git a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts index e78b09401fe..22c062b0861 100644 --- a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts +++ b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts @@ -63,6 +63,16 @@ export function buildSignUpOAuthCallbackParams(ctx: SignUpContextType): HandleOA }; } +export function buildCombinedFlowOAuthCallbackParams(ctx: SignUpContextType): HandleOAuthCallbackParams { + return { + ...buildSignUpOAuthCallbackParams(ctx), + firstFactorUrl: '../../factor-one', + secondFactorUrl: '../../factor-two', + resetPasswordUrl: '../../reset-password', + signInProtectCheckUrl: '../../protect-check', + }; +} + export function buildSignUpOAuthTransportCallbackParams(ctx: SignUpContextType): HandleOAuthCallbackParams { return { ...buildSignUpOAuthCallbackParams(ctx), diff --git a/packages/ui/src/components/SignIn/index.tsx b/packages/ui/src/components/SignIn/index.tsx index 01c6a532497..0e900345cf4 100644 --- a/packages/ui/src/components/SignIn/index.tsx +++ b/packages/ui/src/components/SignIn/index.tsx @@ -20,7 +20,7 @@ import type { SignUpCtx } from '@/types'; import { SignInFactorOneSolanaWalletsCard } from '@/ui/components/SignIn/SignInFactorOneSolanaWalletsCard'; import { normalizeRoutingOptions } from '@/utils/normalizeRoutingOptions'; -import { buildSignInOAuthCallbackParams, buildSignUpOAuthCallbackParams } from './buildOAuthCallbackParams'; +import { buildCombinedFlowOAuthCallbackParams, buildSignInOAuthCallbackParams } from './buildOAuthCallbackParams'; import { LazySignUpContinue, LazySignUpProtectCheck, @@ -112,7 +112,7 @@ function SignInRoutes(): JSX.Element { - + Date: Thu, 1 Oct 2026 10:30:14 -0400 Subject: [PATCH 2/4] fix(ui): resume combined-flow OAuth transfers into embedded sign-up routes after a Protect check --- ...ombined-flow-sso-callback-sign-in-steps.md | 2 +- .../components/SignIn/SignInProtectCheck.tsx | 4 +- .../SignInCombinedFlowSSOCallback.test.tsx | 143 ++++++++++++++++++ .../__tests__/SignInProtectCheck.test.tsx | 29 ++++ .../buildOAuthCallbackParams.test.ts | 33 ++++ .../combinedFlowSSOCallbackRouting.test.tsx | 76 +++++++--- .../SignIn/buildOAuthCallbackParams.ts | 14 ++ 7 files changed, 274 insertions(+), 27 deletions(-) create mode 100644 packages/ui/src/components/SignIn/__tests__/SignInCombinedFlowSSOCallback.test.tsx diff --git a/.changeset/combined-flow-sso-callback-sign-in-steps.md b/.changeset/combined-flow-sso-callback-sign-in-steps.md index 2f7c77860f6..f46af364203 100644 --- a/.changeset/combined-flow-sso-callback-sign-in-steps.md +++ b/.changeset/combined-flow-sso-callback-sign-in-steps.md @@ -2,4 +2,4 @@ '@clerk/ui': patch --- -Fixed `` in the combined sign-in-or-up flow restarting the sign-in after an OAuth or SAML redirect when the sign-in needed a Clerk Protect check, a first or second factor, or a password reset. The callback now continues to that step instead of returning to the start of the flow, which previously caused a loop for users who were asked to complete a Protect check. +Fixed `` in the combined sign-in-or-up flow sending users back to the start after an OAuth or SAML redirect when the sign-in still needed a step, such as a Clerk Protect check, a second factor, or a password reset. Sign-ups created from an OAuth sign-in after a Protect check now also continue to their remaining steps. diff --git a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx index 03e080e4a70..39c57a50234 100644 --- a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx +++ b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx @@ -12,7 +12,7 @@ import { useNavigateToFlowStart } from '../../hooks/useNavigateToFlowStart'; import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner'; import { useRouter } from '../../router'; import { ProtectCheckCard } from '../ProtectCheck/ProtectCheckCard'; -import { buildSignInOAuthCallbackParams } from './buildOAuthCallbackParams'; +import { buildSignInProtectCheckResumeParams } from './buildOAuthCallbackParams'; import { isProtectCheckRequiredError, isSignInPendingOAuthTransfer, @@ -102,7 +102,7 @@ function SignInProtectCheckInternal(): JSX.Element | null { typeof __internal_resumeAfterProtectCheck === 'function' ? __internal_resumeAfterProtectCheck( { - ...buildSignInOAuthCallbackParams(ctx), + ...buildSignInProtectCheckResumeParams(ctx), continuation: 'transfer_to_sign_up', __internal_navigateOnSetActive: ctx.navigateOnSetActive, }, diff --git a/packages/ui/src/components/SignIn/__tests__/SignInCombinedFlowSSOCallback.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInCombinedFlowSSOCallback.test.tsx new file mode 100644 index 00000000000..b8432b38d2b --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInCombinedFlowSSOCallback.test.tsx @@ -0,0 +1,143 @@ +import type { HandleOAuthCallbackParams, SignInResource } from '@clerk/shared/types'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; + +import { PathRouter } from '../../../router'; +import { SignIn } from '../index'; + +vi.mock('@clerk/shared/internal/clerk-js/protectCheck', () => ({ + executeProtectCheck: vi.fn(), +})); + +import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +const mockExecute = executeProtectCheck as unknown as ReturnType; + +const signInProtectCheckFallbackUrl = `${window.location.origin}/sign-in#/protect-check`; + +type Fixtures = Awaited>['fixtures']; + +const setup = async (opts: { pendingOAuthTransfer?: boolean } = {}) => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withSocialProvider({ provider: 'google' }); + f.withPasskey(); + f.withPasskeySettings({ allow_autofill: true, show_sign_in_button: false }); + f.startSignInWithProtectCheck( + opts.pendingOAuthTransfer ? { pendingOAuthTransfer: true, status: 'needs_identifier' } : undefined, + ); + }); + props.setProps({ routing: 'path', path: '/sign-in', withSignUp: true } as any); + + // @ts-expect-error - This is not a public API + fixtures.clerk.__internal_isWebAuthnAutofillSupported = () => Promise.resolve(true); + fixtures.signIn.authenticateWithPasskey.mockReturnValue(new Promise(() => {})); + vi.mocked(fixtures.clerk.navigate).mockImplementation((to: string) => { + const url = new URL(to, window.location.href); + if (url.origin === window.location.origin) { + window.history.pushState({}, '', url.href); + } + return Promise.resolve(); + }); + vi.mocked(fixtures.clerk.handleRedirectCallback).mockImplementation( + async (params: HandleOAuthCallbackParams, navigate?: (to: string) => Promise) => + navigate!(params.signInProtectCheckUrl || signInProtectCheckFallbackUrl), + ); + + return { wrapper, fixtures }; +}; + +const renderAtCallback = (wrapper: React.FC<{ children: React.ReactNode }>) => { + window.history.replaceState({}, '', '/sign-in/create/sso-callback'); + return render( + + + + + , + { wrapper }, + ); +}; + +const expectNoReplacementSignIn = (fixtures: Fixtures) => { + expect(fixtures.signIn.authenticateWithPasskey).not.toHaveBeenCalled(); + expect(fixtures.signIn.create).not.toHaveBeenCalled(); +}; + +describe('SignIn combined-flow SSO callback gated by a Protect check', () => { + beforeEach(() => { + mockExecute.mockReset(); + mockExecute.mockResolvedValue('proof-abc'); + }); + + it('runs the challenge for the signed-in OAuth attempt and activates the session', async () => { + const { wrapper, fixtures } = await setup(); + fixtures.signIn.submitProtectCheck.mockResolvedValue({ + status: 'complete', + protectCheck: null, + createdSessionId: 'sess_1', + } as unknown as SignInResource); + + renderAtCallback(wrapper); + + await waitFor(() => { + expect(fixtures.signIn.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-abc' }); + }); + await waitFor(() => { + expect(fixtures.clerk.setActive).toHaveBeenCalledWith(expect.objectContaining({ session: 'sess_1' })); + }); + expect(window.location.pathname).toBe('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/sign-in/protect-check'); + expectNoReplacementSignIn(fixtures); + }); + + it('continues to the second factor after the challenge', async () => { + const { wrapper, fixtures } = await setup(); + fixtures.signIn.submitProtectCheck.mockResolvedValue({ + status: 'needs_second_factor', + protectCheck: null, + createdSessionId: null, + } as unknown as SignInResource); + + renderAtCallback(wrapper); + + await waitFor(() => { + expect(fixtures.clerk.navigate).toHaveBeenCalledWith( + expect.stringMatching(/^\/sign-in\/factor-two/), + expect.anything(), + ); + }); + expect(fixtures.signIn.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-abc' }); + expectNoReplacementSignIn(fixtures); + }); + + it('continues an incomplete OAuth transfer inside the embedded sign-up routes', async () => { + const { wrapper, fixtures } = await setup({ pendingOAuthTransfer: true }); + fixtures.signIn.submitProtectCheck.mockResolvedValue({ + status: 'needs_identifier', + protectCheck: null, + createdSessionId: null, + firstFactorVerification: { status: 'transferable' }, + } as unknown as SignInResource); + vi.mocked(fixtures.clerk.__internal_resumeAfterProtectCheck).mockImplementation( + async (params: HandleOAuthCallbackParams = {}, navigate?: (to: string) => Promise) => + navigate!(params.continueSignUpUrl!), + ); + + renderAtCallback(wrapper); + + await waitFor(() => { + expect(fixtures.clerk.navigate).toHaveBeenCalledWith( + expect.stringMatching(/^\/sign-in\/create\/continue/), + expect.anything(), + ); + }); + expect(fixtures.signIn.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-abc' }); + expectNoReplacementSignIn(fixtures); + }); +}); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx index ab310b8385b..1fb4d102374 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx @@ -740,6 +740,35 @@ describe('SignInProtectCheck', () => { expect(await findByText(/unable to complete action at this time/i)).toBeInTheDocument(); }); + it('resumes a combined-flow transfer into the embedded create routes', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.startSignInWithProtectCheck({ pendingOAuthTransfer: true, status: 'needs_identifier' }); + }); + props.setProps({ routing: 'path', path: '/sign-in', withSignUp: true } as any); + mockExecute.mockResolvedValue('proof-abc'); + fixtures.signIn.submitProtectCheck.mockResolvedValue({ + status: 'needs_identifier', + protectCheck: null, + createdSessionId: null, + firstFactorVerification: { status: 'transferable' }, + } as unknown as SignInResource); + + render(, { wrapper }); + + await waitFor(() => { + expect(fixtures.clerk.__internal_resumeAfterProtectCheck).toHaveBeenCalledWith( + expect.objectContaining({ + continuation: 'transfer_to_sign_up', + continueSignUpUrl: '../create/continue', + verifyEmailAddressUrl: '../create/verify-email-address', + verifyPhoneNumberUrl: '../create/verify-phone-number', + signUpProtectCheckUrl: '../create/protect-check', + }), + expect.any(Function), + ); + }); + }); + it('leaves an ordinary gated sign-in on the existing path', async () => { // The guard above must not divert every gated sign-in into the OAuth router. const { wrapper, fixtures } = await createFixtures(f => { diff --git a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts index 1a66046d18a..29491c0beef 100644 --- a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts +++ b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts @@ -4,6 +4,7 @@ import { buildCombinedFlowOAuthCallbackParams, buildSignInOAuthCallbackParams, buildSignInOAuthTransportCallbackParams, + buildSignInProtectCheckResumeParams, buildSignUpOAuthCallbackParams, buildSignUpOAuthTransportCallbackParams, } from '../buildOAuthCallbackParams'; @@ -43,6 +44,38 @@ describe('buildSignInOAuthCallbackParams', () => { }); }); +describe('buildSignInProtectCheckResumeParams', () => { + it('keeps the sign-in callback params outside the combined flow', () => { + const ctx = { + signUpUrl: '/sign-up', + signInUrl: '/sign-in', + signUpContinueUrl: '/sign-up#/continue', + signUpProtectCheckUrl: '/sign-up#/protect-check', + isCombinedFlow: false, + } as any; + + expect(buildSignInProtectCheckResumeParams(ctx)).toEqual(buildSignInOAuthCallbackParams(ctx)); + }); + + it('routes a combined-flow transfer to the embedded create routes', () => { + const ctx = { + signUpUrl: '/sign-in#/create', + signInUrl: '/sign-in', + signUpContinueUrl: '/sign-in#/create/continue', + signUpProtectCheckUrl: '/sign-in#/create/protect-check', + isCombinedFlow: true, + } as any; + + expect(buildSignInProtectCheckResumeParams(ctx)).toEqual({ + ...buildSignInOAuthCallbackParams(ctx), + continueSignUpUrl: '../create/continue', + verifyEmailAddressUrl: '../create/verify-email-address', + verifyPhoneNumberUrl: '../create/verify-phone-number', + signUpProtectCheckUrl: '../create/protect-check', + }); + }); +}); + describe('buildSignInOAuthTransportCallbackParams', () => { it('uses paths relative to the SignIn start route for transport callbacks', () => { const ctx = { diff --git a/packages/ui/src/components/SignIn/__tests__/combinedFlowSSOCallbackRouting.test.tsx b/packages/ui/src/components/SignIn/__tests__/combinedFlowSSOCallbackRouting.test.tsx index b72d621f201..5c21d8f2ae0 100644 --- a/packages/ui/src/components/SignIn/__tests__/combinedFlowSSOCallbackRouting.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/combinedFlowSSOCallbackRouting.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { PathRouter, Route, useRouter } from '../../../router'; +import { HashRouter, PathRouter, Route, useRouter } from '../../../router'; import { buildCombinedFlowOAuthCallbackParams } from '../buildOAuthCallbackParams'; const mockNavigate = vi.fn(); @@ -27,6 +27,14 @@ const params = buildCombinedFlowOAuthCallbackParams({ signInUrl: '/sign-in', } as any); +const destinations = [ + ['signInProtectCheckUrl', params.signInProtectCheckUrl!, 'protect-check'], + ['firstFactorUrl', params.firstFactorUrl!, 'factor-one'], + ['secondFactorUrl', params.secondFactorUrl!, 'factor-two'], + ['resetPasswordUrl', params.resetPasswordUrl!, 'reset-password'], + ['continueSignUpUrl', params.continueSignUpUrl!, 'create/continue'], +] as const; + const NavigateButton = ({ to }: { to: string }) => { const router = useRouter(); return ( @@ -39,39 +47,59 @@ const NavigateButton = ({ to }: { to: string }) => { ); }; -const CombinedFlowSSOCallback = () => ( - - - - - - - - - +const CallbackRoute = () => ( + + + {destinations.map(([name, to]) => ( + + ))} - + ); describe('combined-flow create/sso-callback route', () => { beforeEach(() => { mockNavigate.mockReset(); - window.history.replaceState({}, '', '/sign-in/create/sso-callback'); }); - it.each([ - [params.signInProtectCheckUrl!, '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/sign-in/protect-check'], - [params.firstFactorUrl!, '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/sign-in/factor-one'], - [params.secondFactorUrl!, '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/sign-in/factor-two'], - [params.resetPasswordUrl!, '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/sign-in/reset-password'], - [params.continueSignUpUrl!, '/sign-in/create/continue'], - ])('resolves %s to %s', async (to, expected) => { - render(); + describe.each([ + ['mounted at the root', '/sign-in', '/sign-in/create/sso-callback'], + ['mounted at a nested path', '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/auth/sign-in', '/auth/sign-in/create/sso-callback'], + ['reached with a trailing slash', '/sign-in', '/sign-in/create/sso-callback/'], + ])('with path routing %s', (_, basePath, callbackPath) => { + it.each(destinations)('resolves %s to the %s step', async (_name, to, step) => { + window.history.replaceState({}, '', callbackPath); + render( + + + , + ); + + await userEvent.click(screen.getByRole('button', { name: to })); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(`${basePath}/${step}`); + }); + }); + }); + + describe('with hash routing', () => { + it.each(destinations)('resolves %s to the %s step', async (_name, to, step) => { + window.history.replaceState({}, '', '/#/create/sso-callback'); + render( + + + , + ); - await userEvent.click(screen.getByRole('button', { name: to })); + await userEvent.click(screen.getByRole('button', { name: to })); - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith(expected); + await waitFor(() => { + expect(window.location.hash).toBe(`#/${step}`); + }); }); }); }); diff --git a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts index 22c062b0861..39c8a54ff56 100644 --- a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts +++ b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts @@ -23,6 +23,20 @@ export function buildSignInOAuthCallbackParams(ctx: SignInContextType): HandleOA }; } +export function buildSignInProtectCheckResumeParams(ctx: SignInContextType): HandleOAuthCallbackParams { + const params = buildSignInOAuthCallbackParams(ctx); + if (!ctx.isCombinedFlow) { + return params; + } + return { + ...params, + continueSignUpUrl: '../create/continue', + verifyEmailAddressUrl: '../create/verify-email-address', + verifyPhoneNumberUrl: '../create/verify-phone-number', + signUpProtectCheckUrl: '../create/protect-check', + }; +} + export function buildSignInOAuthTransportCallbackParams(ctx: SignInContextType): HandleOAuthCallbackParams { // Path form, not `#/step`: the in-place component router matches on pathname only and would drop the hash. const signUpStepUrl = (step: string): string => { From d3e0d06732f1c6531e52533a72c69d4087ce70dd Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Thu, 1 Oct 2026 15:22:12 -0400 Subject: [PATCH 3/4] refactor(ui): build combined-flow sign-up step URLs in one place --- ...ombined-flow-sso-callback-sign-in-steps.md | 2 +- .../components/SignIn/SignInProtectCheck.tsx | 4 +- .../__tests__/SignInProtectCheck.test.tsx | 29 --- .../buildOAuthCallbackParams.test.ts | 63 ----- .../combinedFlowSSOCallbackRouting.test.tsx | 227 +++++++++++++----- .../SignIn/buildOAuthCallbackParams.ts | 53 ++-- .../SignIn/handleSignUpIfMissingTransfer.ts | 6 +- packages/ui/src/contexts/components/SignIn.ts | 5 +- 8 files changed, 188 insertions(+), 201 deletions(-) diff --git a/.changeset/combined-flow-sso-callback-sign-in-steps.md b/.changeset/combined-flow-sso-callback-sign-in-steps.md index f46af364203..b28ee6f3d3d 100644 --- a/.changeset/combined-flow-sso-callback-sign-in-steps.md +++ b/.changeset/combined-flow-sso-callback-sign-in-steps.md @@ -2,4 +2,4 @@ '@clerk/ui': patch --- -Fixed `` in the combined sign-in-or-up flow sending users back to the start after an OAuth or SAML redirect when the sign-in still needed a step, such as a Clerk Protect check, a second factor, or a password reset. Sign-ups created from an OAuth sign-in after a Protect check now also continue to their remaining steps. +Fixed `` in the combined sign-in-or-up flow sending users back to the start after an OAuth or SAML redirect when the sign-in still needed a step, such as a Clerk Protect check, a second factor, or a password reset. Sign-ups created from an OAuth sign-in, including after a Protect check, now continue to their remaining steps inside the component, and in the modal (`openSignIn({ withSignUp: true })`) they no longer leave the modal for the sign-in page. diff --git a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx index 39c57a50234..03e080e4a70 100644 --- a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx +++ b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx @@ -12,7 +12,7 @@ import { useNavigateToFlowStart } from '../../hooks/useNavigateToFlowStart'; import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner'; import { useRouter } from '../../router'; import { ProtectCheckCard } from '../ProtectCheck/ProtectCheckCard'; -import { buildSignInProtectCheckResumeParams } from './buildOAuthCallbackParams'; +import { buildSignInOAuthCallbackParams } from './buildOAuthCallbackParams'; import { isProtectCheckRequiredError, isSignInPendingOAuthTransfer, @@ -102,7 +102,7 @@ function SignInProtectCheckInternal(): JSX.Element | null { typeof __internal_resumeAfterProtectCheck === 'function' ? __internal_resumeAfterProtectCheck( { - ...buildSignInProtectCheckResumeParams(ctx), + ...buildSignInOAuthCallbackParams(ctx), continuation: 'transfer_to_sign_up', __internal_navigateOnSetActive: ctx.navigateOnSetActive, }, diff --git a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx index 1fb4d102374..ab310b8385b 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx @@ -740,35 +740,6 @@ describe('SignInProtectCheck', () => { expect(await findByText(/unable to complete action at this time/i)).toBeInTheDocument(); }); - it('resumes a combined-flow transfer into the embedded create routes', async () => { - const { wrapper, fixtures, props } = await createFixtures(f => { - f.startSignInWithProtectCheck({ pendingOAuthTransfer: true, status: 'needs_identifier' }); - }); - props.setProps({ routing: 'path', path: '/sign-in', withSignUp: true } as any); - mockExecute.mockResolvedValue('proof-abc'); - fixtures.signIn.submitProtectCheck.mockResolvedValue({ - status: 'needs_identifier', - protectCheck: null, - createdSessionId: null, - firstFactorVerification: { status: 'transferable' }, - } as unknown as SignInResource); - - render(, { wrapper }); - - await waitFor(() => { - expect(fixtures.clerk.__internal_resumeAfterProtectCheck).toHaveBeenCalledWith( - expect.objectContaining({ - continuation: 'transfer_to_sign_up', - continueSignUpUrl: '../create/continue', - verifyEmailAddressUrl: '../create/verify-email-address', - verifyPhoneNumberUrl: '../create/verify-phone-number', - signUpProtectCheckUrl: '../create/protect-check', - }), - expect.any(Function), - ); - }); - }); - it('leaves an ordinary gated sign-in on the existing path', async () => { // The guard above must not divert every gated sign-in into the OAuth router. const { wrapper, fixtures } = await createFixtures(f => { diff --git a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts index 29491c0beef..1f960816b16 100644 --- a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts +++ b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from 'vitest'; import { - buildCombinedFlowOAuthCallbackParams, buildSignInOAuthCallbackParams, buildSignInOAuthTransportCallbackParams, - buildSignInProtectCheckResumeParams, buildSignUpOAuthCallbackParams, buildSignUpOAuthTransportCallbackParams, } from '../buildOAuthCallbackParams'; @@ -44,38 +42,6 @@ describe('buildSignInOAuthCallbackParams', () => { }); }); -describe('buildSignInProtectCheckResumeParams', () => { - it('keeps the sign-in callback params outside the combined flow', () => { - const ctx = { - signUpUrl: '/sign-up', - signInUrl: '/sign-in', - signUpContinueUrl: '/sign-up#/continue', - signUpProtectCheckUrl: '/sign-up#/protect-check', - isCombinedFlow: false, - } as any; - - expect(buildSignInProtectCheckResumeParams(ctx)).toEqual(buildSignInOAuthCallbackParams(ctx)); - }); - - it('routes a combined-flow transfer to the embedded create routes', () => { - const ctx = { - signUpUrl: '/sign-in#/create', - signInUrl: '/sign-in', - signUpContinueUrl: '/sign-in#/create/continue', - signUpProtectCheckUrl: '/sign-in#/create/protect-check', - isCombinedFlow: true, - } as any; - - expect(buildSignInProtectCheckResumeParams(ctx)).toEqual({ - ...buildSignInOAuthCallbackParams(ctx), - continueSignUpUrl: '../create/continue', - verifyEmailAddressUrl: '../create/verify-email-address', - verifyPhoneNumberUrl: '../create/verify-phone-number', - signUpProtectCheckUrl: '../create/protect-check', - }); - }); -}); - describe('buildSignInOAuthTransportCallbackParams', () => { it('uses paths relative to the SignIn start route for transport callbacks', () => { const ctx = { @@ -188,35 +154,6 @@ describe('buildSignUpOAuthCallbackParams', () => { }); }); -describe('buildCombinedFlowOAuthCallbackParams', () => { - it('routes sign-in steps from the combined-flow create/sso-callback route back to the SignIn routes', () => { - const ctx = { - signUpUrl: '/sign-in#/create', - signInUrl: '/sign-in', - afterSignUpUrl: '/after-up', - afterSignInUrl: '/after-in', - secondFactorUrl: '/sign-in#/factor-two', - unsafeMetadata: { b: 2 }, - } as any; - - expect(buildCombinedFlowOAuthCallbackParams(ctx)).toEqual({ - signUpUrl: '/sign-in#/create', - signInUrl: '/sign-in', - signUpForceRedirectUrl: '/after-up', - signInForceRedirectUrl: '/after-in', - firstFactorUrl: '../../factor-one', - secondFactorUrl: '../../factor-two', - resetPasswordUrl: '../../reset-password', - signInProtectCheckUrl: '../../protect-check', - continueSignUpUrl: '../continue', - verifyEmailAddressUrl: '../verify-email-address', - verifyPhoneNumberUrl: '../verify-phone-number', - signUpProtectCheckUrl: '../protect-check', - unsafeMetadata: { b: 2 }, - }); - }); -}); - describe('buildSignUpOAuthTransportCallbackParams', () => { it('uses paths relative to the SignUp start route for transport callbacks', () => { const ctx = { diff --git a/packages/ui/src/components/SignIn/__tests__/combinedFlowSSOCallbackRouting.test.tsx b/packages/ui/src/components/SignIn/__tests__/combinedFlowSSOCallbackRouting.test.tsx index 5c21d8f2ae0..d526cf7565d 100644 --- a/packages/ui/src/components/SignIn/__tests__/combinedFlowSSOCallbackRouting.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/combinedFlowSSOCallbackRouting.test.tsx @@ -1,39 +1,62 @@ -import type { Clerk } from '@clerk/shared/types'; -import { render, screen, waitFor } from '@testing-library/react'; +import type { Clerk, HandleOAuthCallbackParams } from '@clerk/shared/types'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { HashRouter, PathRouter, Route, useRouter } from '../../../router'; -import { buildCombinedFlowOAuthCallbackParams } from '../buildOAuthCallbackParams'; - -const mockNavigate = vi.fn(); +import { HashRouter, PathRouter, Route, useRouter, VirtualRouter } from '../../../router'; +import { buildCombinedFlowOAuthCallbackParams, buildSignInOAuthCallbackParams } from '../buildOAuthCallbackParams'; vi.mock('@clerk/shared/react', () => { return { useClerk: () => { return { - navigate: (to: string) => { - mockNavigate(to); - return Promise.resolve(); - }, - } as Clerk; + navigate: () => Promise.resolve(), + } as unknown as Clerk; }, }; }); -const params = buildCombinedFlowOAuthCallbackParams({ +const rootParams = buildSignInOAuthCallbackParams({ + signUpUrl: '/sign-in#/create', + signInUrl: '/sign-in', + signUpContinueUrl: '/sign-in#/create/continue', + signUpProtectCheckUrl: '/sign-in#/create/protect-check', + isCombinedFlow: true, +} as any); + +const createParams = buildCombinedFlowOAuthCallbackParams({ signUpUrl: '/sign-in#/create', signInUrl: '/sign-in', + secondFactorUrl: '/sign-in#/factor-two', } as any); -const destinations = [ - ['signInProtectCheckUrl', params.signInProtectCheckUrl!, 'protect-check'], - ['firstFactorUrl', params.firstFactorUrl!, 'factor-one'], - ['secondFactorUrl', params.secondFactorUrl!, 'factor-two'], - ['resetPasswordUrl', params.resetPasswordUrl!, 'reset-password'], - ['continueSignUpUrl', params.continueSignUpUrl!, 'create/continue'], -] as const; +const destinations = { + signInProtectCheckUrl: 'protect-check', + firstFactorUrl: 'factor-one', + secondFactorUrl: 'factor-two', + resetPasswordUrl: 'reset-password', + continueSignUpUrl: 'create/continue', + verifyEmailAddressUrl: 'create/verify-email-address', + verifyPhoneNumberUrl: 'create/verify-phone-number', + signUpProtectCheckUrl: 'create/protect-check', +} as const; + +type Destination = keyof typeof destinations; + +const starts: Array<[string, HandleOAuthCallbackParams]> = [ + ['create/sso-callback', createParams], + ['sso-callback', rootParams], + ['protect-check', rootParams], +]; + +const cases = starts.flatMap(([start, params]) => + (Object.keys(destinations) as Destination[]) + .filter(key => !(start === destinations[key])) + .map(key => [start, key, destinations[key], params[key] as string] as const), +); + +const Marker = ({ at }: { at: string }) =>

{`at:${at}`}

; const NavigateButton = ({ to }: { to: string }) => { const router = useRouter(); @@ -42,64 +65,136 @@ const NavigateButton = ({ to }: { to: string }) => { type='button' onClick={() => void router.navigate(to)} > - {to} + go ); }; -const CallbackRoute = () => ( - - - {destinations.map(([name, to]) => ( - - ))} - - -); - -describe('combined-flow create/sso-callback route', () => { - beforeEach(() => { - mockNavigate.mockReset(); - }); +const SignInRoutes = ({ start, to }: { start: string; to: string }) => { + const button = (at: string) => (start === at ? : null); + return ( + <> + + + + + + + + + + + + {button('protect-check')} + + {button('sso-callback')} + + + + + + + + + + + + + + {button('create/sso-callback')} + + + ); +}; - describe.each([ - ['mounted at the root', '/sign-in', '/sign-in/create/sso-callback'], - ['mounted at a nested path', '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/auth/sign-in', '/auth/sign-in/create/sso-callback'], - ['reached with a trailing slash', '/sign-in', '/sign-in/create/sso-callback/'], - ])('with path routing %s', (_, basePath, callbackPath) => { - it.each(destinations)('resolves %s to the %s step', async (_name, to, step) => { - window.history.replaceState({}, '', callbackPath); - render( - - - , +const routers: Array<[string, (start: string, to: string) => React.ReactElement]> = [ + [ + 'path routing mounted at the root', + (start, to) => { + window.history.replaceState({}, '', `/sign-in/${start}`); + return ( + + + ); + }, + ], + [ + 'path routing mounted at a nested path', + (start, to) => { + window.history.replaceState({}, '', `/auth/sign-in/${start}`); + return ( + + + + ); + }, + ], + [ + 'path routing reached with a trailing slash', + (start, to) => { + window.history.replaceState({}, '', `/sign-in/${start}/`); + return ( + + + + ); + }, + ], + [ + 'hash routing', + (start, to) => { + window.history.replaceState({}, '', `/#/${start}`); + return ( + + + + ); + }, + ], + [ + 'virtual routing', + (start, to) => { + window.history.replaceState({}, '', '/'); + return ( + + + + + + ); + }, + ], +]; - await userEvent.click(screen.getByRole('button', { name: to })); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith(`${basePath}/${step}`); - }); - }); +describe('combined-flow SSO callback and protect-check navigation', () => { + beforeEach(() => { + window.history.replaceState({}, '', '/'); }); - describe('with hash routing', () => { - it.each(destinations)('resolves %s to the %s step', async (_name, to, step) => { - window.history.replaceState({}, '', '/#/create/sso-callback'); - render( - - - , - ); + describe.each(routers)('with %s', (_, renderRouter) => { + it.each(cases)('from %s, %s reaches the %s step', async (start, _key, destination, to) => { + render(renderRouter(start, to)); + expect(screen.queryByText(`at:${destination}`)).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', { name: to })); + await userEvent.click(screen.getByRole('button', { name: 'go' })); - await waitFor(() => { - expect(window.location.hash).toBe(`#/${step}`); - }); + expect(await screen.findByText(`at:${destination}`)).toBeInTheDocument(); }); }); }); diff --git a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts index 39c8a54ff56..30810905250 100644 --- a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts +++ b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts @@ -4,45 +4,34 @@ import type { HandleOAuthCallbackParams } from '@clerk/shared/types'; import type { SignInContextType } from '../../contexts/components/SignIn'; import type { SignUpContextType } from '../../contexts/components/SignUp'; +export const signUpStepUrls = (prefix: string) => ({ + continueSignUpUrl: `${prefix}continue`, + verifyEmailAddressUrl: `${prefix}verify-email-address`, + verifyPhoneNumberUrl: `${prefix}verify-phone-number`, + signUpProtectCheckUrl: `${prefix}protect-check`, +}); + export function buildSignInOAuthCallbackParams(ctx: SignInContextType): HandleOAuthCallbackParams { return { signUpUrl: ctx.signUpUrl, signInUrl: ctx.signInUrl, signInForceRedirectUrl: ctx.afterSignInUrl, signUpForceRedirectUrl: ctx.afterSignUpUrl, - continueSignUpUrl: ctx.signUpContinueUrl, transferable: ctx.transferable, firstFactorUrl: '../factor-one', secondFactorUrl: '../factor-two', resetPasswordUrl: '../reset-password', signInProtectCheckUrl: '../protect-check', - // Absolute + combined-flow-aware (see SignIn context), so it stays correct regardless of the - // callback route's depth. - signUpProtectCheckUrl: ctx.signUpProtectCheckUrl, + ...(ctx.isCombinedFlow + ? signUpStepUrls('../create/') + : { continueSignUpUrl: ctx.signUpContinueUrl, signUpProtectCheckUrl: ctx.signUpProtectCheckUrl }), unsafeMetadata: ctx.unsafeMetadata, }; } -export function buildSignInProtectCheckResumeParams(ctx: SignInContextType): HandleOAuthCallbackParams { - const params = buildSignInOAuthCallbackParams(ctx); - if (!ctx.isCombinedFlow) { - return params; - } - return { - ...params, - continueSignUpUrl: '../create/continue', - verifyEmailAddressUrl: '../create/verify-email-address', - verifyPhoneNumberUrl: '../create/verify-phone-number', - signUpProtectCheckUrl: '../create/protect-check', - }; -} - export function buildSignInOAuthTransportCallbackParams(ctx: SignInContextType): HandleOAuthCallbackParams { // Path form, not `#/step`: the in-place component router matches on pathname only and would drop the hash. const signUpStepUrl = (step: string): string => { - if (ctx.isCombinedFlow) { - return `create/${step}`; - } const url = buildURL({ base: ctx.signUpUrl }, { stringify: false }); url.pathname = `${trimTrailingSlash(url.pathname)}/${step}`; url.hash = ''; @@ -55,10 +44,14 @@ export function buildSignInOAuthTransportCallbackParams(ctx: SignInContextType): secondFactorUrl: 'factor-two', resetPasswordUrl: 'reset-password', signInProtectCheckUrl: 'protect-check', - continueSignUpUrl: signUpStepUrl('continue'), - verifyEmailAddressUrl: signUpStepUrl('verify-email-address'), - verifyPhoneNumberUrl: signUpStepUrl('verify-phone-number'), - signUpProtectCheckUrl: signUpStepUrl('protect-check'), + ...(ctx.isCombinedFlow + ? signUpStepUrls('create/') + : { + continueSignUpUrl: signUpStepUrl('continue'), + verifyEmailAddressUrl: signUpStepUrl('verify-email-address'), + verifyPhoneNumberUrl: signUpStepUrl('verify-phone-number'), + signUpProtectCheckUrl: signUpStepUrl('protect-check'), + }), }; } @@ -69,10 +62,7 @@ export function buildSignUpOAuthCallbackParams(ctx: SignUpContextType): HandleOA signUpForceRedirectUrl: ctx.afterSignUpUrl, signInForceRedirectUrl: ctx.afterSignInUrl, secondFactorUrl: ctx.secondFactorUrl, - continueSignUpUrl: '../continue', - verifyEmailAddressUrl: '../verify-email-address', - verifyPhoneNumberUrl: '../verify-phone-number', - signUpProtectCheckUrl: '../protect-check', + ...signUpStepUrls('../'), unsafeMetadata: ctx.unsafeMetadata, }; } @@ -90,9 +80,6 @@ export function buildCombinedFlowOAuthCallbackParams(ctx: SignUpContextType): Ha export function buildSignUpOAuthTransportCallbackParams(ctx: SignUpContextType): HandleOAuthCallbackParams { return { ...buildSignUpOAuthCallbackParams(ctx), - continueSignUpUrl: 'continue', - verifyEmailAddressUrl: 'verify-email-address', - verifyPhoneNumberUrl: 'verify-phone-number', - signUpProtectCheckUrl: 'protect-check', + ...signUpStepUrls(''), }; } diff --git a/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts b/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts index 0643123c522..8a051bdff6d 100644 --- a/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts +++ b/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts @@ -5,6 +5,7 @@ import type { LoadedClerk } from '@clerk/shared/types'; import type { SignInContextType } from '../../contexts'; import type { RouteContextValue } from '../../router/RouteContext'; import { clerkWindowNavigate } from '../../utils/windowNavigate'; +import { signUpStepUrls } from './buildOAuthCallbackParams'; type HandleSignUpIfMissingTransferProps = { clerk: LoadedClerk; @@ -68,10 +69,7 @@ export async function handleSignUpIfMissingTransfer({ // email/phone identifications to their verify pages. return navigateToNextStepSignUp({ signUp: res, - continueSignUpUrl: '../create/continue', - verifyEmailAddressUrl: '../create/verify-email-address', - verifyPhoneNumberUrl: '../create/verify-phone-number', - signUpProtectCheckUrl: '../create/protect-check', + ...signUpStepUrls('../create/'), navigate, }); default: diff --git a/packages/ui/src/contexts/components/SignIn.ts b/packages/ui/src/contexts/components/SignIn.ts index 3be9d63fac6..e302e67f88d 100644 --- a/packages/ui/src/contexts/components/SignIn.ts +++ b/packages/ui/src/contexts/components/SignIn.ts @@ -149,9 +149,8 @@ export const useSignInContext = (): SignInContextType => { } const signUpContinueUrl = buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true }); - // Built off `signUpUrl`, which is rewritten to `#/create` in the combined flow, so this - // resolves to the embedded `…/create/protect-check` route there and the standalone sign-up route - // otherwise — keeping a Protect-gated sign-up inside whichever component is mounted. + // Built off `signUpUrl`, which is `#/create` in the combined flow. That hash form only reaches + // the embedded route on a fresh page load; navigation inside the mounted component uses relative `create/*` paths. const signUpProtectCheckUrl = buildURL({ base: signUpUrl, hashPath: '/protect-check' }, { stringify: true }); const navigateOnSetActive = async ({ From 0fdeb5bfc935d44f207e8393bf76e124a2c85aeb Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Thu, 1 Oct 2026 15:35:25 -0400 Subject: [PATCH 4/4] chore(ui): narrow the changeset to the callback continuation it fixes --- .changeset/combined-flow-sso-callback-sign-in-steps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/combined-flow-sso-callback-sign-in-steps.md b/.changeset/combined-flow-sso-callback-sign-in-steps.md index b28ee6f3d3d..62177f545ba 100644 --- a/.changeset/combined-flow-sso-callback-sign-in-steps.md +++ b/.changeset/combined-flow-sso-callback-sign-in-steps.md @@ -2,4 +2,4 @@ '@clerk/ui': patch --- -Fixed `` in the combined sign-in-or-up flow sending users back to the start after an OAuth or SAML redirect when the sign-in still needed a step, such as a Clerk Protect check, a second factor, or a password reset. Sign-ups created from an OAuth sign-in, including after a Protect check, now continue to their remaining steps inside the component, and in the modal (`openSignIn({ withSignUp: true })`) they no longer leave the modal for the sign-in page. +Fixed `` in the combined sign-in-or-up flow sending users back to the start after an OAuth or SAML redirect when the sign-in still needed a step, such as a Clerk Protect check, a second factor, or a password reset. Sign-ups created from an OAuth sign-in, including after a Protect check, now continue to their remaining steps inside the component.