From 822801affca3805d027633944b1c25fdf22d0304 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Mon, 31 Aug 2026 05:45:37 -0800 Subject: [PATCH 1/2] fix(clerk-js,ui): show the challenge raised while handing off to an enterprise connection Preparing an enterprise SSO hand-off can return a pending verification challenge, in which case the server returns before it builds a verification and there is no external URL to follow. That response was reported as invalid and the sign-in dead-ended with an error. Return from the hand-off instead, and route to the challenge so it can be resolved and the hand-off retried. Co-authored-by: Claude Opus 5 (1M context) --- .../enterprise-sso-hand-off-challenge.md | 6 ++ .../clerk-js/src/core/resources/SignIn.ts | 14 +++ .../core/resources/__tests__/SignIn.test.ts | 98 +++++++++++++++++++ .../ui/src/components/SignIn/SignInStart.tsx | 6 +- .../SignIn/__tests__/SignInStart.test.tsx | 22 +++++ 5 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 .changeset/enterprise-sso-hand-off-challenge.md diff --git a/.changeset/enterprise-sso-hand-off-challenge.md b/.changeset/enterprise-sso-hand-off-challenge.md new file mode 100644 index 00000000000..dcef54d036e --- /dev/null +++ b/.changeset/enterprise-sso-hand-off-challenge.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/ui': patch +--- + +Fix enterprise SSO sign-ins erroring instead of showing a verification challenge raised while handing off to the identity provider. diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index c2de93a5030..fd9e9234b61 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -388,6 +388,12 @@ export class SignIn extends BaseResource implements SignInResource { const redirectUrl = SignIn.clerk.buildUrlWithAuth(params.redirectUrl); + // A pending `protect_check` leaves the hand-off unprepared: the server returns before it + // builds a verification, so there is no external URL to navigate to. Stop rather than + // reporting the response as invalid — the caller runs the challenge and calls back in with + // `continueSignIn`, at which point the hand-off is prepared for real. + const isChallengePending = () => !!this.protectCheck || this.status === 'needs_protect_check'; + if (!this.id || !continueSignIn) { await this.create({ strategy, @@ -395,6 +401,10 @@ export class SignIn extends BaseResource implements SignInResource { redirectUrl, actionCompleteRedirectUrl, }); + + if (isChallengePending()) { + return; + } } if (strategy === 'enterprise_sso') { @@ -405,6 +415,10 @@ export class SignIn extends BaseResource implements SignInResource { oidcPrompt, enterpriseConnectionId, }); + + if (isChallengePending()) { + return; + } } const { status, externalVerificationRedirectURL } = this.firstFactorVerification; diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index df1d5a34891..2bb3f1ae614 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -311,6 +311,104 @@ describe('SignIn', () => { }); }); + describe('authenticateWithRedirect with a pending challenge', () => { + const originalFetch = BaseResource._fetch; + + afterEach(() => { + BaseResource._fetch = originalFetch; + vi.clearAllMocks(); + SignIn.clerk = {} as any; + }); + + const gatedResponse = { + client: null, + response: { + id: 'signin_123', + status: 'needs_protect_check', + first_factor_verification: null, + protect_check: { + status: 'pending', + token: 'challenge-token-abc', + sdk_url: 'https://sdk.example.com/challenge.js', + }, + }, + }; + + const setupClerk = () => { + const windowNavigate = vi.fn(); + SignIn.clerk = { + buildUrlWithAuth: vi.fn(u => u), + __internal_windowNavigate: windowNavigate, + __internal_environment: { displayConfig: { captchaOauthBypass: [] } }, + } as any; + return windowNavigate; + }; + + it('stops after create instead of preparing a hand-off it cannot follow', async () => { + const windowNavigate = setupClerk(); + const mockFetch = vi.fn().mockResolvedValue(gatedResponse); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + await expect( + signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + }), + ).resolves.toBeUndefined(); + + // Only the create call — the prepare is not attempted while the challenge is pending. + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(windowNavigate).not.toHaveBeenCalled(); + expect(signIn.protectCheck?.status).toBe('pending'); + }); + + it('stops when preparing the enterprise SSO hand-off returns a challenge', async () => { + const windowNavigate = setupClerk(); + const mockFetch = vi.fn().mockResolvedValue(gatedResponse); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + await expect( + signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + continueSignIn: true, + }), + ).resolves.toBeUndefined(); + + expect(windowNavigate).not.toHaveBeenCalled(); + expect(signIn.protectCheck?.status).toBe('pending'); + }); + + it('follows the hand-off once no challenge is pending', async () => { + const windowNavigate = setupClerk(); + BaseResource._fetch = vi.fn().mockResolvedValue({ + client: null, + response: { + id: 'signin_123', + status: 'needs_first_factor', + first_factor_verification: { + status: 'unverified', + external_verification_redirect_url: 'https://idp.example/auth', + }, + }, + }); + + const signIn = new SignIn({ id: 'signin_123' } as any); + await signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + continueSignIn: true, + }); + + expect(windowNavigate).toHaveBeenCalledWith(new URL('https://idp.example/auth')); + }); + }); + describe('signIn.create', () => { afterEach(() => { vi.clearAllMocks(); diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 658643d83bb..81b34dcf9a0 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -454,13 +454,17 @@ function SignInStartInternal(): JSX.Element { const redirectUrl = ctx.ssoCallbackUrl; const redirectUrlComplete = ctx.afterSignInUrl || '/'; - return signIn.authenticateWithRedirect({ + await signIn.authenticateWithRedirect({ strategy: 'enterprise_sso', redirectUrl, redirectUrlComplete, oidcPrompt: ctx.oidcPrompt, continueSignIn: true, }); + + // Preparing the hand-off can itself raise a challenge, in which case no redirect was issued + // and the sign-in is sitting on the gate instead. + navigateOnSignInProtectGate(signIn, navigate, 'protect-check'); }; const attemptToRecoverFromSignInError = async (e: any) => { diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 36a0b24858b..3a1958e4333 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -450,6 +450,28 @@ describe('SignInStart', () => { continueSignIn: true, }); }); + + it('routes to the challenge when preparing the hand-off raises one', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + }); + fixtures.signIn.create.mockReturnValueOnce( + Promise.resolve({ + status: 'needs_first_factor', + supportedFirstFactors: [{ strategy: 'enterprise_sso' }], + } as unknown as SignInResource), + ); + // No redirect is issued: the sign-in comes back sitting on the challenge instead. + fixtures.signIn.authenticateWithRedirect.mockImplementationOnce(() => { + (fixtures.signIn as any).protectCheck = { status: 'pending', token: 'challenge-token-abc' }; + return Promise.resolve(); + }); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.authenticateWithRedirect).toHaveBeenCalled(); + expect(fixtures.router.navigate).toHaveBeenCalledWith('protect-check'); + }); }); describe('Identifier switching', () => { From e371555936444ee991e1079b71e5c4a4baeda2e3 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Mon, 31 Aug 2026 16:52:44 -0800 Subject: [PATCH 2/2] fix(clerk-js): correct the pending-challenge comment A challenge can coexist with an external redirect URL, so the comment's claim that none exists was only true of the enterprise hand-off. Co-authored-by: Claude Opus 5 (1M context) --- packages/clerk-js/src/core/resources/SignIn.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index fd9e9234b61..955c0df5f0d 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -388,10 +388,8 @@ export class SignIn extends BaseResource implements SignInResource { const redirectUrl = SignIn.clerk.buildUrlWithAuth(params.redirectUrl); - // A pending `protect_check` leaves the hand-off unprepared: the server returns before it - // builds a verification, so there is no external URL to navigate to. Stop rather than - // reporting the response as invalid — the caller runs the challenge and calls back in with - // `continueSignIn`, at which point the hand-off is prepared for real. + // Defer external navigation while a challenge is pending: the caller resolves it and calls + // back in with `continueSignIn`. const isChallengePending = () => !!this.protectCheck || this.status === 'needs_protect_check'; if (!this.id || !continueSignIn) {