From a34ab49341a3dd8543904f4bed4ab37eb2aac95f Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Mon, 31 Aug 2026 05:45:37 -0800 Subject: [PATCH 1/8] 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 d6369a138a8..bb0f09ff27e 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -389,6 +389,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, @@ -396,6 +402,10 @@ export class SignIn extends BaseResource implements SignInResource { redirectUrl, actionCompleteRedirectUrl, }); + + if (isChallengePending()) { + return; + } } if (strategy === 'enterprise_sso') { @@ -406,6 +416,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 120aafdc753..05543455710 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 5e55b7223c6..8ae2d9af1d2 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -455,13 +455,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 7c26db967fb..4b5adad11df 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -496,6 +496,28 @@ describe('SignInStart', () => { expect(fixtures.signIn.authenticateWithRedirect).not.toHaveBeenCalled(); expect(fixtures.router.navigate).toHaveBeenCalledWith('factor-one'); }); + + 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 e4de079f66b765d603657c0c7597a22891726c90 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Mon, 31 Aug 2026 16:52:44 -0800 Subject: [PATCH 2/8] 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 bb0f09ff27e..ec054991533 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -389,10 +389,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) { From 61ada103b6724f662b968c6a3273603afd609d68 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Tue, 22 Sep 2026 22:37:17 -0800 Subject: [PATCH 3/8] fix(clerk-js,ui): throw a coded error when a challenge stops authenticateWithRedirect When Protect raises a challenge before the redirect, `authenticateWithRedirect` returned without navigating, and every caller had to know to inspect the sign-in afterwards. Callers that didn't were stuck or misled: the social buttons sat on the card, custom flows saw a silent success, and the OAuth transport failed with `oauth_transport_missing_verification_url`, which hid the real reason. It now throws a `ClerkRuntimeError` with code `protect_check_required`. That keeps the method's contract of navigating or throwing, so a caller that doesn't handle challenges gets an error it can recognise rather than a silent success. Whether the user sees it depends on the caller: UI that drops errors still needs to handle it. A caller that does handle it finds `protectCheck` already set, routes to the challenge, and calls back in with `continueSignIn`. The start page catches it itself, because its recovery path drops errors that didn't come from the API. SignUp is unchanged. It can legitimately navigate with a challenge still outstanding, so throwing there would change the order the sign-up runs in. --- .../enterprise-sso-hand-off-challenge.md | 3 ++ .../clerk-js/src/core/resources/SignIn.ts | 23 ++++++++------ .../core/resources/__tests__/SignIn.test.ts | 29 +++++++++++++++--- .../shared/src/internal/clerk-js/constants.ts | 1 + .../ui/src/components/SignIn/SignInStart.tsx | 30 +++++++++++-------- .../SignIn/__tests__/SignInStart.test.tsx | 29 +++++++++++++++--- .../__tests__/handleProtectCheck.test.ts | 19 +++++++++++- .../components/SignIn/handleProtectCheck.ts | 15 ++++++++++ 8 files changed, 119 insertions(+), 30 deletions(-) diff --git a/.changeset/enterprise-sso-hand-off-challenge.md b/.changeset/enterprise-sso-hand-off-challenge.md index dcef54d036e..3d7fb629d4e 100644 --- a/.changeset/enterprise-sso-hand-off-challenge.md +++ b/.changeset/enterprise-sso-hand-off-challenge.md @@ -1,6 +1,9 @@ --- '@clerk/clerk-js': patch +'@clerk/shared': patch '@clerk/ui': patch --- Fix enterprise SSO sign-ins erroring instead of showing a verification challenge raised while handing off to the identity provider. + +`signIn.authenticateWithRedirect()` now throws a `ClerkRuntimeError` with code `protect_check_required` when a verification challenge has to be completed first, instead of a generic "not supported" error. `signIn.protectCheck` is set when this happens, so custom flows can run the challenge and then call `authenticateWithRedirect()` again with `continueSignIn: true`. diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index ec054991533..f01003eec97 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -1,5 +1,6 @@ import { inBrowser } from '@clerk/shared/browser'; import { type ClerkError, ClerkRuntimeError, ClerkWebAuthnError } from '@clerk/shared/error'; +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import { convertJSONToPublicKeyRequestOptions, serializePublicKeyCredentialAssertion, @@ -389,9 +390,17 @@ export class SignIn extends BaseResource implements SignInResource { const redirectUrl = SignIn.clerk.buildUrlWithAuth(params.redirectUrl); - // 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'; + // A pending challenge stops the flow before there is anywhere to navigate to. Throw rather than + // return: a caller that doesn't handle challenges then fails visibly instead of stalling. A + // caller that does runs the challenge (`protectCheck` is set) and calls back in with + // `continueSignIn`. + const throwIfChallengePending = () => { + if (this.protectCheck || this.status === 'needs_protect_check') { + throw new ClerkRuntimeError('A verification challenge must be completed before this sign-in can continue.', { + code: ERROR_CODES.PROTECT_CHECK_REQUIRED, + }); + } + }; if (!this.id || !continueSignIn) { await this.create({ @@ -401,9 +410,7 @@ export class SignIn extends BaseResource implements SignInResource { actionCompleteRedirectUrl, }); - if (isChallengePending()) { - return; - } + throwIfChallengePending(); } if (strategy === 'enterprise_sso') { @@ -415,9 +422,7 @@ export class SignIn extends BaseResource implements SignInResource { enterpriseConnectionId, }); - if (isChallengePending()) { - return; - } + throwIfChallengePending(); } 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 05543455710..408b02e2423 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -344,7 +344,7 @@ describe('SignIn', () => { return windowNavigate; }; - it('stops after create instead of preparing a hand-off it cannot follow', async () => { + it('throws protect_check_required after create instead of preparing a hand-off it cannot follow', async () => { const windowNavigate = setupClerk(); const mockFetch = vi.fn().mockResolvedValue(gatedResponse); BaseResource._fetch = mockFetch; @@ -356,7 +356,7 @@ describe('SignIn', () => { redirectUrl: '/sso-callback', redirectUrlComplete: '/', }), - ).resolves.toBeUndefined(); + ).rejects.toMatchObject({ code: 'protect_check_required' }); // Only the create call — the prepare is not attempted while the challenge is pending. expect(mockFetch).toHaveBeenCalledTimes(1); @@ -364,7 +364,7 @@ describe('SignIn', () => { expect(signIn.protectCheck?.status).toBe('pending'); }); - it('stops when preparing the enterprise SSO hand-off returns a challenge', async () => { + it('throws protect_check_required when preparing the enterprise SSO hand-off returns a challenge', async () => { const windowNavigate = setupClerk(); const mockFetch = vi.fn().mockResolvedValue(gatedResponse); BaseResource._fetch = mockFetch; @@ -377,12 +377,33 @@ describe('SignIn', () => { redirectUrlComplete: '/', continueSignIn: true, }), - ).resolves.toBeUndefined(); + ).rejects.toMatchObject({ code: 'protect_check_required' }); expect(windowNavigate).not.toHaveBeenCalled(); expect(signIn.protectCheck?.status).toBe('pending'); }); + it('surfaces protect_check_required through an OAuth transport instead of opening it', async () => { + // The transport expects a URL back. Returning without one used to surface as + // `oauth_transport_missing_verification_url`, which hid the real reason. + setupClerk(); + const transport = { getRedirectUrl: vi.fn().mockResolvedValue('app://callback'), open: vi.fn() }; + (SignIn.clerk as any).__internal_oauthTransport = transport; + BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse); + + const signIn = new SignIn({ id: 'signin_123' } as any); + await expect( + signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + continueSignIn: true, + }), + ).rejects.toMatchObject({ code: 'protect_check_required' }); + + expect(transport.open).not.toHaveBeenCalled(); + }); + it('follows the hand-off once no challenge is pending', async () => { const windowNavigate = setupClerk(); BaseResource._fetch = vi.fn().mockResolvedValue({ diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts index c11db68f590..b87fbaa578f 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -47,6 +47,7 @@ export const ERROR_CODES = { FRAUD_DEVICE_BLOCKED: 'device_blocked', FRAUD_ACTION_BLOCKED: 'action_blocked', PROTECT_CHECK_ALREADY_RESOLVED: 'protect_check_already_resolved', + PROTECT_CHECK_REQUIRED: 'protect_check_required', PROTECT_CHECK_TIMED_OUT: 'protect_check_timed_out', PROTECT_CHECK_UNSUPPORTED_ENVIRONMENT: 'protect_check_unsupported_environment', SIGNUP_RATE_LIMIT_EXCEEDED: 'signup_rate_limit_exceeded', diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 8ae2d9af1d2..5f28d42f8f0 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -39,7 +39,7 @@ import { useSupportEmail } from '../../hooks/useSupportEmail'; import { useTotalEnabledAuthMethods } from '../../hooks/useTotalEnabledAuthMethods'; import { useRouter } from '../../router'; import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer'; -import { navigateOnSignInProtectGate } from './handleProtectCheck'; +import { isProtectCheckRequiredError, navigateOnSignInProtectGate } from './handleProtectCheck'; import { getSSOBypassFactor, hasMultipleEnterpriseConnections, @@ -455,17 +455,23 @@ function SignInStartInternal(): JSX.Element { const redirectUrl = ctx.ssoCallbackUrl; const redirectUrlComplete = ctx.afterSignInUrl || '/'; - 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'); + try { + await signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl, + redirectUrlComplete, + oidcPrompt: ctx.oidcPrompt, + continueSignIn: true, + }); + } catch (err) { + // Preparing the hand-off can itself raise a challenge. No redirect was issued and the sign-in + // is sitting on the gate instead. Handled here because the callers' recovery path drops + // errors that didn't come from the API. + if (isProtectCheckRequiredError(err) && navigateOnSignInProtectGate(signIn, navigate, 'protect-check')) { + return; + } + throw err; + } }; 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 4b5adad11df..56edf97c330 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -1,4 +1,4 @@ -import { ClerkAPIResponseError, ClerkWebAuthnError } from '@clerk/shared/error'; +import { ClerkAPIResponseError, ClerkRuntimeError, ClerkWebAuthnError } from '@clerk/shared/error'; import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; import type { SignInResource } from '@clerk/shared/types'; @@ -507,10 +507,10 @@ describe('SignInStart', () => { 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(() => { + // No redirect is issued: the sign-in comes back sitting on the challenge and the call throws. + fixtures.signIn.authenticateWithRedirect.mockImplementationOnce(async () => { (fixtures.signIn as any).protectCheck = { status: 'pending', token: 'challenge-token-abc' }; - return Promise.resolve(); + throw new ClerkRuntimeError('challenge required', { code: 'protect_check_required' }); }); const { userEvent } = render(, { wrapper }); await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); @@ -518,6 +518,27 @@ describe('SignInStart', () => { expect(fixtures.signIn.authenticateWithRedirect).toHaveBeenCalled(); expect(fixtures.router.navigate).toHaveBeenCalledWith('protect-check'); }); + + it('does not route to the challenge for other hand-off errors', 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), + ); + fixtures.signIn.authenticateWithRedirect.mockImplementationOnce(async () => { + (fixtures.signIn as any).protectCheck = { status: 'pending', token: 'challenge-token-abc' }; + throw new ClerkRuntimeError('something else', { code: 'captcha_unavailable' }); + }); + 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).not.toHaveBeenCalledWith('protect-check'); + }); }); describe('Identifier switching', () => { diff --git a/packages/ui/src/components/SignIn/__tests__/handleProtectCheck.test.ts b/packages/ui/src/components/SignIn/__tests__/handleProtectCheck.test.ts index 154bd70731d..63d6e306358 100644 --- a/packages/ui/src/components/SignIn/__tests__/handleProtectCheck.test.ts +++ b/packages/ui/src/components/SignIn/__tests__/handleProtectCheck.test.ts @@ -1,7 +1,8 @@ +import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error'; import type { ProtectCheckResource, SignInResource } from '@clerk/shared/types'; import { describe, expect, it, vi } from 'vitest'; -import { isSignInProtectGated, navigateOnSignInProtectGate } from '../handleProtectCheck'; +import { isProtectCheckRequiredError, isSignInProtectGated, navigateOnSignInProtectGate } from '../handleProtectCheck'; const PENDING_CHECK: ProtectCheckResource = { status: 'pending', @@ -25,6 +26,22 @@ describe('isSignInProtectGated', () => { }); }); +describe('isProtectCheckRequiredError', () => { + it('is true for the runtime error authenticateWithRedirect throws on a pending challenge', () => { + expect(isProtectCheckRequiredError(new ClerkRuntimeError('x', { code: 'protect_check_required' }))).toBe(true); + }); + + it('is false for other runtime errors, API errors and non-errors', () => { + expect(isProtectCheckRequiredError(new ClerkRuntimeError('x', { code: 'captcha_unavailable' }))).toBe(false); + expect( + isProtectCheckRequiredError( + new ClerkAPIResponseError('x', { data: [{ code: 'protect_check_required', message: 'x' }], status: 400 }), + ), + ).toBe(false); + expect(isProtectCheckRequiredError(undefined)).toBe(false); + }); +}); + describe('navigateOnSignInProtectGate', () => { it('navigates to the provided path and returns true when gated by the protectCheck field', () => { const navigate = vi.fn().mockResolvedValue(undefined); diff --git a/packages/ui/src/components/SignIn/handleProtectCheck.ts b/packages/ui/src/components/SignIn/handleProtectCheck.ts index 71004dd29b5..3936927b235 100644 --- a/packages/ui/src/components/SignIn/handleProtectCheck.ts +++ b/packages/ui/src/components/SignIn/handleProtectCheck.ts @@ -1,3 +1,5 @@ +import { isClerkRuntimeError } from '@clerk/shared/error'; +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import type { SignInResource } from '@clerk/shared/types'; /** @@ -37,6 +39,19 @@ export function navigateOnSignInProtectGate( return false; } +/** + * Whether `err` is the error `authenticateWithRedirect` throws when a challenge stopped it before it + * could redirect. The sign-in has already been updated and is sitting on the gate, so the caller + * routes to the challenge rather than showing the error. + */ +export function isProtectCheckRequiredError(err: unknown): boolean { + // The type guard throws on a non-object, and a catch block can receive anything. + if (typeof err !== 'object' || err === null) { + return false; + } + return isClerkRuntimeError(err) && err.code === ERROR_CODES.PROTECT_CHECK_REQUIRED; +} + /** * Whether this sign-in is waiting to become a sign-up. */ From 8ebff5cdb69faae20d55d069007cfd2f882c8cec Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Tue, 22 Sep 2026 22:37:30 -0800 Subject: [PATCH 4/8] fix(clerk-js): follow the hand-off a challenged create already built The server builds the verification before it decides, so a sign-in create that Protect challenges can come back with a usable identity-provider redirect as well. Main follows it and runs the challenge on the way back, where the OAuth/SSO callback routes to it. Stopping instead left OAuth and social sign-ins with nowhere to go on any instance that challenges sign-in creation. Now: - A challenged create with a redirect follows it. - A challenged create without one throws `protect_check_required`. - A challenged enterprise SSO prepare follows only the redirect this call's create built. The prepare builds no verification of its own, so anything else on the sign-in is from an earlier attempt and may be for another connection. Otherwise it throws. The changeset scopes the recovery advice to enterprise SSO and mentions `authenticateWithPopup`, which throws the same error. --- .../enterprise-sso-hand-off-challenge.md | 2 +- .../clerk-js/src/core/resources/SignIn.ts | 47 +++++++++---- .../core/resources/__tests__/SignIn.test.ts | 66 ++++++++++++++++++- 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/.changeset/enterprise-sso-hand-off-challenge.md b/.changeset/enterprise-sso-hand-off-challenge.md index 3d7fb629d4e..9aecf02bcbd 100644 --- a/.changeset/enterprise-sso-hand-off-challenge.md +++ b/.changeset/enterprise-sso-hand-off-challenge.md @@ -6,4 +6,4 @@ Fix enterprise SSO sign-ins erroring instead of showing a verification challenge raised while handing off to the identity provider. -`signIn.authenticateWithRedirect()` now throws a `ClerkRuntimeError` with code `protect_check_required` when a verification challenge has to be completed first, instead of a generic "not supported" error. `signIn.protectCheck` is set when this happens, so custom flows can run the challenge and then call `authenticateWithRedirect()` again with `continueSignIn: true`. +When a verification challenge has to be completed before `signIn.authenticateWithRedirect()` or `signIn.authenticateWithPopup()` can redirect, they now throw a `ClerkRuntimeError` with code `protect_check_required` instead of a generic "not supported" error. The sign-in is gated when this happens (`signIn.protectCheck` is set, or its status is `needs_protect_check`). For enterprise SSO, run the challenge and call `authenticateWithRedirect()` again with `continueSignIn: true`. If the server has already prepared the redirect, the sign-in continues to the identity provider and the challenge runs when it returns. diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index f01003eec97..8f3e8ab0414 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -390,18 +390,27 @@ export class SignIn extends BaseResource implements SignInResource { const redirectUrl = SignIn.clerk.buildUrlWithAuth(params.redirectUrl); - // A pending challenge stops the flow before there is anywhere to navigate to. Throw rather than - // return: a caller that doesn't handle challenges then fails visibly instead of stalling. A - // caller that does runs the challenge (`protectCheck` is set) and calls back in with - // `continueSignIn`. - const throwIfChallengePending = () => { - if (this.protectCheck || this.status === 'needs_protect_check') { - throw new ClerkRuntimeError('A verification challenge must be completed before this sign-in can continue.', { - code: ERROR_CODES.PROTECT_CHECK_REQUIRED, - }); - } + const isChallengePending = () => !!this.protectCheck || this.status === 'needs_protect_check'; + const pendingHandOff = () => { + const { status, externalVerificationRedirectURL } = this.firstFactorVerification; + return status === 'unverified' ? externalVerificationRedirectURL : null; + }; + + // A pending challenge with nowhere to navigate to. Throw rather than return, so the method still + // either navigates or throws: a caller that doesn't handle challenges gets an error it can + // recognise instead of a silent success. A caller that does runs the challenge and calls back + // in with `continueSignIn`. + const throwChallengeRequired = (): never => { + throw new ClerkRuntimeError('A verification challenge must be completed before this sign-in can continue.', { + code: ERROR_CODES.PROTECT_CHECK_REQUIRED, + }); }; + // The hand-off a challenged create built, if any. The server builds it before deciding, so a + // challenge on create can arrive with a usable redirect: that means "go to the identity + // provider first" and the challenge runs on the way back, where the callback routes to it. + let challengedCreateHandOff: URL | null = null; + if (!this.id || !continueSignIn) { await this.create({ strategy, @@ -410,7 +419,12 @@ export class SignIn extends BaseResource implements SignInResource { actionCompleteRedirectUrl, }); - throwIfChallengePending(); + if (isChallengePending()) { + challengedCreateHandOff = pendingHandOff(); + if (!challengedCreateHandOff) { + throwChallengeRequired(); + } + } } if (strategy === 'enterprise_sso') { @@ -422,7 +436,16 @@ export class SignIn extends BaseResource implements SignInResource { enterpriseConnectionId, }); - throwIfChallengePending(); + // A challenged prepare builds no verification, so any redirect left on the sign-in is from an + // earlier attempt and may be for another connection. Only this call's create hand-off is safe + // to follow. + if (isChallengePending()) { + if (challengedCreateHandOff) { + navigateCallback(challengedCreateHandOff); + return; + } + throwChallengeRequired(); + } } 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 408b02e2423..374b11163a1 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -344,7 +344,71 @@ describe('SignIn', () => { return windowNavigate; }; - it('throws protect_check_required after create instead of preparing a hand-off it cannot follow', async () => { + // The server builds the hand-off before it decides, so a challenged create can also carry a + // usable redirect. + const gatedWithHandOff = (url: string) => ({ + client: null, + response: { + ...gatedResponse.response, + first_factor_verification: { status: 'unverified', external_verification_redirect_url: url }, + }, + }); + + it('follows the hand-off a challenged OAuth create built, leaving the challenge for the way back', async () => { + const windowNavigate = setupClerk(); + const mockFetch = vi.fn().mockResolvedValue(gatedWithHandOff('https://accounts.google.example/auth')); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + await signIn.authenticateWithRedirect({ + strategy: 'oauth_google', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(windowNavigate).toHaveBeenCalledWith(new URL('https://accounts.google.example/auth')); + }); + + it('follows the create hand-off when the enterprise SSO prepare after it hits the same pending challenge', async () => { + const windowNavigate = setupClerk(); + // Create builds the hand-off and is challenged; the prepare that follows lands on the same + // pending gate and builds nothing new. + const mockFetch = vi.fn().mockResolvedValue(gatedWithHandOff('https://idp.example/from-create')); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + await signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(windowNavigate).toHaveBeenCalledWith(new URL('https://idp.example/from-create')); + }); + + it('does not follow a hand-off left from an earlier attempt when the prepare is challenged', async () => { + const windowNavigate = setupClerk(); + // The challenged prepare builds no verification, so the redirect on the sign-in is stale and + // may be for a different connection. + BaseResource._fetch = vi.fn().mockResolvedValue(gatedWithHandOff('https://idp.example/earlier-attempt')); + + const signIn = new SignIn({ id: 'signin_123' } as any); + await expect( + signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + continueSignIn: true, + enterpriseConnectionId: 'ent_other', + }), + ).rejects.toMatchObject({ code: 'protect_check_required' }); + + expect(windowNavigate).not.toHaveBeenCalled(); + }); + + it('throws protect_check_required when a challenged create built no hand-off', async () => { const windowNavigate = setupClerk(); const mockFetch = vi.fn().mockResolvedValue(gatedResponse); BaseResource._fetch = mockFetch; From 5f138919e9c5f323995c6f63265e44f8af9c68a3 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Tue, 22 Sep 2026 22:36:57 -0800 Subject: [PATCH 5/8] fix(localizations,ui): explain protect_check_required instead of showing the raw error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A caller that can't run the challenge shows the error through the card. With no localization for the code, that fell back to the formatted runtime error message: `Clerk: A verification challenge… (code="protect_check_required")`. Add the key, with an English message that tells the user what to do, and let the generator fill it in as untranslated for the other locales. The social buttons are the remaining place this can show, now that a challenged OAuth create follows its redirect. --- .changeset/enterprise-sso-hand-off-challenge.md | 1 + packages/localizations/src/ar-SA.ts | 1 + packages/localizations/src/be-BY.ts | 1 + packages/localizations/src/bg-BG.ts | 1 + packages/localizations/src/bn-IN.ts | 1 + packages/localizations/src/ca-ES.ts | 1 + packages/localizations/src/cs-CZ.ts | 1 + packages/localizations/src/da-DK.ts | 1 + packages/localizations/src/de-DE.ts | 1 + packages/localizations/src/el-GR.ts | 1 + packages/localizations/src/en-GB.ts | 1 + packages/localizations/src/en-US.ts | 2 ++ packages/localizations/src/es-CR.ts | 1 + packages/localizations/src/es-ES.ts | 1 + packages/localizations/src/es-MX.ts | 1 + packages/localizations/src/es-UY.ts | 1 + packages/localizations/src/fa-IR.ts | 1 + packages/localizations/src/fi-FI.ts | 1 + packages/localizations/src/fr-FR.ts | 1 + packages/localizations/src/he-IL.ts | 1 + packages/localizations/src/hi-IN.ts | 1 + packages/localizations/src/hr-HR.ts | 1 + packages/localizations/src/hu-HU.ts | 1 + packages/localizations/src/id-ID.ts | 1 + packages/localizations/src/is-IS.ts | 1 + packages/localizations/src/it-IT.ts | 1 + packages/localizations/src/ja-JP.ts | 1 + packages/localizations/src/kk-KZ.ts | 1 + packages/localizations/src/ko-KR.ts | 1 + packages/localizations/src/mn-MN.ts | 1 + packages/localizations/src/ms-MY.ts | 1 + packages/localizations/src/nb-NO.ts | 1 + packages/localizations/src/nl-BE.ts | 1 + packages/localizations/src/nl-NL.ts | 1 + packages/localizations/src/pl-PL.ts | 1 + packages/localizations/src/pt-BR.ts | 1 + packages/localizations/src/pt-PT.ts | 1 + packages/localizations/src/ro-RO.ts | 1 + packages/localizations/src/ru-RU.ts | 1 + packages/localizations/src/sk-SK.ts | 1 + packages/localizations/src/sr-RS.ts | 1 + packages/localizations/src/sv-SE.ts | 1 + packages/localizations/src/ta-IN.ts | 1 + packages/localizations/src/te-IN.ts | 1 + packages/localizations/src/th-TH.ts | 1 + packages/localizations/src/tr-TR.ts | 1 + packages/localizations/src/uk-UA.ts | 1 + packages/localizations/src/vi-VN.ts | 1 + packages/localizations/src/zh-CN.ts | 1 + packages/localizations/src/zh-TW.ts | 1 + packages/shared/src/types/localization.ts | 1 + .../SignIn/__tests__/SignInStart.test.tsx | 17 +++++++++++++++++ 52 files changed, 69 insertions(+) diff --git a/.changeset/enterprise-sso-hand-off-challenge.md b/.changeset/enterprise-sso-hand-off-challenge.md index 9aecf02bcbd..581a16fd7c1 100644 --- a/.changeset/enterprise-sso-hand-off-challenge.md +++ b/.changeset/enterprise-sso-hand-off-challenge.md @@ -1,5 +1,6 @@ --- '@clerk/clerk-js': patch +'@clerk/localizations': patch '@clerk/shared': patch '@clerk/ui': patch --- diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts index b170384b18e..79464cff73d 100644 --- a/packages/localizations/src/ar-SA.ts +++ b/packages/localizations/src/ar-SA.ts @@ -2061,6 +2061,7 @@ export const arSA: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/be-BY.ts b/packages/localizations/src/be-BY.ts index a487e4c1a91..0499ceeee98 100644 --- a/packages/localizations/src/be-BY.ts +++ b/packages/localizations/src/be-BY.ts @@ -2072,6 +2072,7 @@ export const beBY: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/bg-BG.ts b/packages/localizations/src/bg-BG.ts index 5667ae5a7b5..c3202e61755 100644 --- a/packages/localizations/src/bg-BG.ts +++ b/packages/localizations/src/bg-BG.ts @@ -2065,6 +2065,7 @@ export const bgBG: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/bn-IN.ts b/packages/localizations/src/bn-IN.ts index 709c93aaebe..a5e177ae7dd 100644 --- a/packages/localizations/src/bn-IN.ts +++ b/packages/localizations/src/bn-IN.ts @@ -2090,6 +2090,7 @@ export const bnIN: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/ca-ES.ts b/packages/localizations/src/ca-ES.ts index cb05017c14f..be31010d7ed 100644 --- a/packages/localizations/src/ca-ES.ts +++ b/packages/localizations/src/ca-ES.ts @@ -2078,6 +2078,7 @@ export const caES: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/cs-CZ.ts b/packages/localizations/src/cs-CZ.ts index b996c0ce0fc..018f7b4a436 100644 --- a/packages/localizations/src/cs-CZ.ts +++ b/packages/localizations/src/cs-CZ.ts @@ -2077,6 +2077,7 @@ export const csCZ: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/da-DK.ts b/packages/localizations/src/da-DK.ts index 29e042758f5..a34d0a620d6 100644 --- a/packages/localizations/src/da-DK.ts +++ b/packages/localizations/src/da-DK.ts @@ -2062,6 +2062,7 @@ export const daDK: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/de-DE.ts b/packages/localizations/src/de-DE.ts index b44f723fb29..f9b838ef47e 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -2095,6 +2095,7 @@ export const deDE: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/el-GR.ts b/packages/localizations/src/el-GR.ts index 3c2456d3edd..50f0672c06e 100644 --- a/packages/localizations/src/el-GR.ts +++ b/packages/localizations/src/el-GR.ts @@ -2086,6 +2086,7 @@ export const elGR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/en-GB.ts b/packages/localizations/src/en-GB.ts index c35c73fb1fd..f41dfdd3265 100644 --- a/packages/localizations/src/en-GB.ts +++ b/packages/localizations/src/en-GB.ts @@ -2069,6 +2069,7 @@ export const enGB: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 367ccc7fd4b..0ec04871220 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -2118,6 +2118,8 @@ export const enUS: LocalizationResource = { protect_check_execution_failed: "Verification didn't complete. Please try again.", protect_check_invalid_script: "Couldn't load verification. Please contact support if this persists.", protect_check_invalid_sdk_url: "Verification couldn't start. Please contact support.", + protect_check_required: + "This sign-in needs an extra verification step that can't be shown here. Please try again or use a different sign-in method.", protect_check_script_load_failed: "Couldn't load verification. This may be caused by a network issue or a Content Security Policy that blocks the verification script. Please try again or contact support.", protect_check_timed_out: "Verification didn't complete in time. Please try again.", diff --git a/packages/localizations/src/es-CR.ts b/packages/localizations/src/es-CR.ts index 206c578c1d8..0d8ea7143cf 100644 --- a/packages/localizations/src/es-CR.ts +++ b/packages/localizations/src/es-CR.ts @@ -2077,6 +2077,7 @@ export const esCR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/es-ES.ts b/packages/localizations/src/es-ES.ts index 4cf19bd234f..4ef9b4bd31b 100644 --- a/packages/localizations/src/es-ES.ts +++ b/packages/localizations/src/es-ES.ts @@ -2078,6 +2078,7 @@ export const esES: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/es-MX.ts b/packages/localizations/src/es-MX.ts index f70472db2a7..0f71e3df409 100644 --- a/packages/localizations/src/es-MX.ts +++ b/packages/localizations/src/es-MX.ts @@ -2078,6 +2078,7 @@ export const esMX: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/es-UY.ts b/packages/localizations/src/es-UY.ts index f9dd35007b5..398fe3a806c 100644 --- a/packages/localizations/src/es-UY.ts +++ b/packages/localizations/src/es-UY.ts @@ -2079,6 +2079,7 @@ export const esUY: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/fa-IR.ts b/packages/localizations/src/fa-IR.ts index 7922218161c..69f31d17907 100644 --- a/packages/localizations/src/fa-IR.ts +++ b/packages/localizations/src/fa-IR.ts @@ -2076,6 +2076,7 @@ export const faIR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/fi-FI.ts b/packages/localizations/src/fi-FI.ts index 4cf9bc33f64..92673e1d8fd 100644 --- a/packages/localizations/src/fi-FI.ts +++ b/packages/localizations/src/fi-FI.ts @@ -2092,6 +2092,7 @@ export const fiFI: LocalizationResource = { protect_check_execution_failed: 'Tarkistus ei valmistunut. Yritä uudelleen.', protect_check_invalid_script: 'Tarkistusta ei voitu ladata. Ota yhteyttä tukeen, jos ongelma jatkuu.', protect_check_invalid_sdk_url: 'Tarkistusta ei voitu käynnistää. Ota yhteyttä tukeen.', + protect_check_required: undefined, protect_check_script_load_failed: 'Tarkistusta ei voitu ladata. Syynä voi olla verkkoyhteys tai sisällön suojauskäytäntö, joka estää tarkistuksen skriptin. Yritä uudelleen tai ota yhteyttä tukeen.', protect_check_timed_out: 'Tarkistus ei valmistunut ajoissa. Yritä uudelleen.', diff --git a/packages/localizations/src/fr-FR.ts b/packages/localizations/src/fr-FR.ts index da5192566a5..d0da663e7f1 100644 --- a/packages/localizations/src/fr-FR.ts +++ b/packages/localizations/src/fr-FR.ts @@ -2087,6 +2087,7 @@ export const frFR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/he-IL.ts b/packages/localizations/src/he-IL.ts index 93e2b829ee1..620cf66b1e7 100644 --- a/packages/localizations/src/he-IL.ts +++ b/packages/localizations/src/he-IL.ts @@ -2053,6 +2053,7 @@ export const heIL: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/hi-IN.ts b/packages/localizations/src/hi-IN.ts index b916ac42fcd..36678670448 100644 --- a/packages/localizations/src/hi-IN.ts +++ b/packages/localizations/src/hi-IN.ts @@ -2091,6 +2091,7 @@ export const hiIN: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/hr-HR.ts b/packages/localizations/src/hr-HR.ts index 724ffa36afd..0dc97826c30 100644 --- a/packages/localizations/src/hr-HR.ts +++ b/packages/localizations/src/hr-HR.ts @@ -2096,6 +2096,7 @@ export const hrHR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/hu-HU.ts b/packages/localizations/src/hu-HU.ts index 1a706959464..68784c5f74b 100644 --- a/packages/localizations/src/hu-HU.ts +++ b/packages/localizations/src/hu-HU.ts @@ -2095,6 +2095,7 @@ export const huHU: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/id-ID.ts b/packages/localizations/src/id-ID.ts index 95e2115583f..0d7501697e3 100644 --- a/packages/localizations/src/id-ID.ts +++ b/packages/localizations/src/id-ID.ts @@ -2075,6 +2075,7 @@ export const idID: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/is-IS.ts b/packages/localizations/src/is-IS.ts index acdb6e234ec..447a608a5ae 100644 --- a/packages/localizations/src/is-IS.ts +++ b/packages/localizations/src/is-IS.ts @@ -2093,6 +2093,7 @@ export const isIS: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/it-IT.ts b/packages/localizations/src/it-IT.ts index 93fb06cc64a..85c063ec9e5 100644 --- a/packages/localizations/src/it-IT.ts +++ b/packages/localizations/src/it-IT.ts @@ -2074,6 +2074,7 @@ export const itIT: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/ja-JP.ts b/packages/localizations/src/ja-JP.ts index c9064dccb59..4f35db7734a 100644 --- a/packages/localizations/src/ja-JP.ts +++ b/packages/localizations/src/ja-JP.ts @@ -2089,6 +2089,7 @@ export const jaJP: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/kk-KZ.ts b/packages/localizations/src/kk-KZ.ts index f6bfd105eff..46121895fee 100644 --- a/packages/localizations/src/kk-KZ.ts +++ b/packages/localizations/src/kk-KZ.ts @@ -2055,6 +2055,7 @@ export const kkKZ: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/ko-KR.ts b/packages/localizations/src/ko-KR.ts index 8f897540d61..03da92dd60f 100644 --- a/packages/localizations/src/ko-KR.ts +++ b/packages/localizations/src/ko-KR.ts @@ -2062,6 +2062,7 @@ export const koKR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/mn-MN.ts b/packages/localizations/src/mn-MN.ts index 22628699d22..ff13835bc24 100644 --- a/packages/localizations/src/mn-MN.ts +++ b/packages/localizations/src/mn-MN.ts @@ -2067,6 +2067,7 @@ export const mnMN: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/ms-MY.ts b/packages/localizations/src/ms-MY.ts index 9e6f2196d2c..ec8f6d5ebba 100644 --- a/packages/localizations/src/ms-MY.ts +++ b/packages/localizations/src/ms-MY.ts @@ -2100,6 +2100,7 @@ export const msMY: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/nb-NO.ts b/packages/localizations/src/nb-NO.ts index 656d86c9abd..cedb85df992 100644 --- a/packages/localizations/src/nb-NO.ts +++ b/packages/localizations/src/nb-NO.ts @@ -2093,6 +2093,7 @@ export const nbNO: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/nl-BE.ts b/packages/localizations/src/nl-BE.ts index 058d0566e3f..cbe40324bdf 100644 --- a/packages/localizations/src/nl-BE.ts +++ b/packages/localizations/src/nl-BE.ts @@ -2066,6 +2066,7 @@ export const nlBE: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/nl-NL.ts b/packages/localizations/src/nl-NL.ts index a51c1995653..81c6dc0e1f0 100644 --- a/packages/localizations/src/nl-NL.ts +++ b/packages/localizations/src/nl-NL.ts @@ -2066,6 +2066,7 @@ export const nlNL: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/pl-PL.ts b/packages/localizations/src/pl-PL.ts index 24c93f5909b..104d1190f30 100644 --- a/packages/localizations/src/pl-PL.ts +++ b/packages/localizations/src/pl-PL.ts @@ -2074,6 +2074,7 @@ export const plPL: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/pt-BR.ts b/packages/localizations/src/pt-BR.ts index ff44fd059d6..4e3f15b9ea2 100644 --- a/packages/localizations/src/pt-BR.ts +++ b/packages/localizations/src/pt-BR.ts @@ -2084,6 +2084,7 @@ export const ptBR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/pt-PT.ts b/packages/localizations/src/pt-PT.ts index d9b75a842ac..6d6200408f7 100644 --- a/packages/localizations/src/pt-PT.ts +++ b/packages/localizations/src/pt-PT.ts @@ -2090,6 +2090,7 @@ export const ptPT: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/ro-RO.ts b/packages/localizations/src/ro-RO.ts index 3af154f9b57..d8f2a12cfe0 100644 --- a/packages/localizations/src/ro-RO.ts +++ b/packages/localizations/src/ro-RO.ts @@ -2085,6 +2085,7 @@ export const roRO: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/ru-RU.ts b/packages/localizations/src/ru-RU.ts index 2e8589012b4..7a2630db832 100644 --- a/packages/localizations/src/ru-RU.ts +++ b/packages/localizations/src/ru-RU.ts @@ -2082,6 +2082,7 @@ export const ruRU: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/sk-SK.ts b/packages/localizations/src/sk-SK.ts index 8a7fa661be9..f17f98c54a1 100644 --- a/packages/localizations/src/sk-SK.ts +++ b/packages/localizations/src/sk-SK.ts @@ -2073,6 +2073,7 @@ export const skSK: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/sr-RS.ts b/packages/localizations/src/sr-RS.ts index 3cb05958a6f..27654d0c457 100644 --- a/packages/localizations/src/sr-RS.ts +++ b/packages/localizations/src/sr-RS.ts @@ -2065,6 +2065,7 @@ export const srRS: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/sv-SE.ts b/packages/localizations/src/sv-SE.ts index d30a15de531..a0805ff84ae 100644 --- a/packages/localizations/src/sv-SE.ts +++ b/packages/localizations/src/sv-SE.ts @@ -2068,6 +2068,7 @@ export const svSE: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/ta-IN.ts b/packages/localizations/src/ta-IN.ts index b8c369c0280..bb91d5af794 100644 --- a/packages/localizations/src/ta-IN.ts +++ b/packages/localizations/src/ta-IN.ts @@ -2101,6 +2101,7 @@ export const taIN: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/te-IN.ts b/packages/localizations/src/te-IN.ts index cf3e3b183ec..2cbe9dd2e0d 100644 --- a/packages/localizations/src/te-IN.ts +++ b/packages/localizations/src/te-IN.ts @@ -2095,6 +2095,7 @@ export const teIN: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/th-TH.ts b/packages/localizations/src/th-TH.ts index 83b15833bb5..28bd03c214e 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -2063,6 +2063,7 @@ export const thTH: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/tr-TR.ts b/packages/localizations/src/tr-TR.ts index a09e323e061..73d3d25c826 100644 --- a/packages/localizations/src/tr-TR.ts +++ b/packages/localizations/src/tr-TR.ts @@ -2069,6 +2069,7 @@ export const trTR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/uk-UA.ts b/packages/localizations/src/uk-UA.ts index 5b5283e1b6c..f590cf5557d 100644 --- a/packages/localizations/src/uk-UA.ts +++ b/packages/localizations/src/uk-UA.ts @@ -2063,6 +2063,7 @@ export const ukUA: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/vi-VN.ts b/packages/localizations/src/vi-VN.ts index 028d41cf23d..5cb8bc9b8dd 100644 --- a/packages/localizations/src/vi-VN.ts +++ b/packages/localizations/src/vi-VN.ts @@ -2086,6 +2086,7 @@ export const viVN: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/zh-CN.ts b/packages/localizations/src/zh-CN.ts index 2c3ee135aa3..bed7dffb089 100644 --- a/packages/localizations/src/zh-CN.ts +++ b/packages/localizations/src/zh-CN.ts @@ -2046,6 +2046,7 @@ export const zhCN: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/zh-TW.ts b/packages/localizations/src/zh-TW.ts index 7cbca618cb1..e5275233143 100644 --- a/packages/localizations/src/zh-TW.ts +++ b/packages/localizations/src/zh-TW.ts @@ -2051,6 +2051,7 @@ export const zhTW: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 16c2296d41f..15eaa32b173 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -2410,6 +2410,7 @@ type UnstableErrors = WithParamName<{ protect_check_execution_failed: LocalizationValue; protect_check_invalid_script: LocalizationValue; protect_check_invalid_sdk_url: LocalizationValue; + protect_check_required: LocalizationValue; protect_check_script_load_failed: LocalizationValue; protect_check_timed_out: LocalizationValue; protect_check_unsupported_environment: LocalizationValue; diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 56edf97c330..1da263fbd58 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -394,6 +394,23 @@ describe('SignInStart', () => { }); }); }); + + it('explains a challenge the social button cannot run, instead of showing the raw error', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + fixtures.signIn.authenticateWithRedirect.mockRejectedValueOnce( + new ClerkRuntimeError('A verification challenge must be completed before this sign-in can continue.', { + code: 'protect_check_required', + }), + ); + + const { userEvent } = render(, { wrapper }); + await userEvent.click(screen.getByText('Continue with Google')); + + expect(await screen.findByText(/needs an extra verification step/i)).toBeInTheDocument(); + expect(screen.queryByText(/code="protect_check_required"/)).not.toBeInTheDocument(); + }); }); describe('navigation', () => { From 9d1e7cbd6f6046fa8dc9a4f726d0a39509257c01 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Tue, 22 Sep 2026 22:58:04 -0800 Subject: [PATCH 6/8] fix(ui): route the challenge raised from the SSO bypass card The SSO bypass card (from main) starts the enterprise hand-off itself, both from its SSO action and from its connection list. A challenge raised while preparing that hand-off now surfaces as `protect_check_required`. Without handling, the card showed it as an error instead of taking the user to the challenge. Route it, as the start page does. --- .../SignIn/SignInFactorOneSSOBypass.tsx | 28 +++++++++++++------ .../SignInFactorOneSSOBypass.test.tsx | 19 +++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/SignIn/SignInFactorOneSSOBypass.tsx b/packages/ui/src/components/SignIn/SignInFactorOneSSOBypass.tsx index 770f60ce70f..5f9072e411e 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneSSOBypass.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneSSOBypass.tsx @@ -9,6 +9,8 @@ import { handleError } from '@/ui/utils/errorHandler'; import { useCoreSignIn, useSignInContext } from '../../contexts'; import { Button, Col, descriptors, Flow, localizationKeys } from '../../customizables'; +import { useRouter } from '../../router'; +import { isProtectCheckRequiredError, navigateOnSignInProtectGate } from './handleProtectCheck'; import { hasMultipleEnterpriseConnections } from './shared'; import { SignInFactorOneCodeForm } from './SignInFactorOneCodeForm'; @@ -30,6 +32,7 @@ export const SignInFactorOneSSOBypass = (props: SignInFactorOneSSOBypassProps) = const card = useCardState(); const ctx = useSignInContext(); const signIn = useCoreSignIn(); + const { navigate } = useRouter(); const [step, setStep] = React.useState('sso'); const [isRedirecting, setIsRedirecting] = React.useState(false); @@ -39,14 +42,23 @@ export const SignInFactorOneSSOBypass = (props: SignInFactorOneSSOBypassProps) = }; const authenticateWithEnterpriseSSO = async (enterpriseConnectionId?: string) => { - await signIn.authenticateWithRedirect({ - strategy: 'enterprise_sso', - redirectUrl: ctx.ssoCallbackUrl, - redirectUrlComplete: ctx.afterSignInUrl || '/', - oidcPrompt: ctx.oidcPrompt, - continueSignIn: true, - ...(enterpriseConnectionId && { enterpriseConnectionId }), - }); + try { + await signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: ctx.ssoCallbackUrl, + redirectUrlComplete: ctx.afterSignInUrl || '/', + oidcPrompt: ctx.oidcPrompt, + continueSignIn: true, + ...(enterpriseConnectionId && { enterpriseConnectionId }), + }); + } catch (err) { + // Preparing the hand-off can itself raise a challenge. No redirect was issued and the sign-in + // is sitting on the gate instead: run the challenge rather than showing it as an error. + if (isProtectCheckRequiredError(err) && navigateOnSignInProtectGate(signIn, navigate, '../protect-check')) { + return; + } + throw err; + } }; const handleSSOError = (err: Error) => handleError(err, [], card.setError); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSSOBypass.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSSOBypass.test.tsx index ce14de6e081..10bceebcec0 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSSOBypass.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSSOBypass.test.tsx @@ -1,3 +1,4 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; import type { SignInResource } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; @@ -66,6 +67,24 @@ describe('SignInFactorOne SSO bypass', () => { ); }); + it('routes to the challenge when preparing the hand-off raises one', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.startSignInWithEnterpriseSSO({ supportSSOBypass: true }); + }); + // No redirect is issued: the sign-in comes back sitting on the challenge and the call throws. + fixtures.signIn.authenticateWithRedirect.mockImplementationOnce(() => { + (fixtures.signIn as any).protectCheck = { status: 'pending', token: 'challenge-token-abc' }; + throw new ClerkRuntimeError('challenge required', { code: 'protect_check_required' }); + }); + + const { userEvent } = render(, { wrapper }); + await userEvent.click(await screen.findByText('Continue with SSO')); + + expect(fixtures.router.navigate).toHaveBeenCalledWith('../protect-check'); + expect(screen.queryByText(/needs an extra verification step/i)).not.toBeInTheDocument(); + }); + it('prepares the email code with the handle and warns on the code screen', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withEmailAddress(); From a46ed0a167c7d2d43fdf531d4ef3a8a16de5addb Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Wed, 23 Sep 2026 13:23:36 -0800 Subject: [PATCH 7/8] fix(ui): keep a failed enterprise hand-off inside the start page's error handling `signInWithFields` returned the hand-off promise from inside its `try` without awaiting it, so a rejected hand-off skipped the `catch` and escaped as an unhandled rejection. The other two hand-off call sites already route failures through the recovery path; this one now does too. This was already true on main. The test for hand-off errors other than a challenge surfaced it: the run reported every test passing but exited 1 on the unhandled rejection. --- packages/ui/src/components/SignIn/SignInStart.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 5f28d42f8f0..3349ae5d541 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -428,7 +428,8 @@ function SignInStartInternal(): JSX.Element { return navigate('factor-one'); } - return authenticateWithEnterpriseSSO(); + // Awaited so a failed hand-off reaches the catch below instead of escaping this try. + return await authenticateWithEnterpriseSSO(); } case 'needs_second_factor': return navigate('factor-two'); From 1d27dcca7b649cf03412945253cd82a8b7f082f2 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Thu, 24 Sep 2026 13:12:42 -0800 Subject: [PATCH 8/8] docs(changeset): lead with what a reader of the changelog has to do Most readers use the prebuilt component and have nothing to do, so say that first. Then say what a custom flow with Protect enabled should catch, and only after that the details of the error. --- .changeset/enterprise-sso-hand-off-challenge.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/enterprise-sso-hand-off-challenge.md b/.changeset/enterprise-sso-hand-off-challenge.md index 581a16fd7c1..6625e315976 100644 --- a/.changeset/enterprise-sso-hand-off-challenge.md +++ b/.changeset/enterprise-sso-hand-off-challenge.md @@ -7,4 +7,6 @@ Fix enterprise SSO sign-ins erroring instead of showing a verification challenge raised while handing off to the identity provider. -When a verification challenge has to be completed before `signIn.authenticateWithRedirect()` or `signIn.authenticateWithPopup()` can redirect, they now throw a `ClerkRuntimeError` with code `protect_check_required` instead of a generic "not supported" error. The sign-in is gated when this happens (`signIn.protectCheck` is set, or its status is `needs_protect_check`). For enterprise SSO, run the challenge and call `authenticateWithRedirect()` again with `continueSignIn: true`. If the server has already prepared the redirect, the sign-in continues to the identity provider and the challenge runs when it returns. +If you use the prebuilt `` component, there is nothing to do. If you have Clerk Protect enabled and call `signIn.authenticateWithRedirect()` or `signIn.authenticateWithPopup()` from a custom sign-in flow, catch a `ClerkRuntimeError` with code `protect_check_required` and show the verification challenge, to avoid a stalled sign-in. + +That error means a verification challenge has to be completed before the sign-in can redirect. It replaces the generic "not supported" error these methods threw before. When it is thrown, the sign-in is gated: `signIn.protectCheck` is set, or its status is `needs_protect_check`. For enterprise SSO, run the challenge and then call `authenticateWithRedirect()` again with `continueSignIn: true`. If the server has already prepared the redirect, the sign-in continues to the identity provider and the challenge runs when it returns, so no error is thrown.