From a91f81b72f8de8f1c1a7f043a269088f0a3f2fb7 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 6 Oct 2026 16:07:56 -0700 Subject: [PATCH 1/3] test(clerk-js,react): cover a failed Protect challenge on the SSO callback --- .../clerk-js/src/core/__tests__/clerk.test.ts | 17 +++++ .../__tests__/HandleSSOCallback.test.tsx | 68 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 0a2a7f40aaa..de137e89533 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -4105,6 +4105,23 @@ describe('Clerk singleton', () => { expect(resolve).toHaveBeenCalledWith(sut, sut.client?.signUp); resolve.mockRestore(); }); + + it('reports a failed gate on the resource that carries it and rejects', async () => { + const blocked = new Error('blocked'); + const resolve = vi + .spyOn(ProtectCheckGate.prototype, 'resolve') + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(blocked); + const sut = await loadWithClient({ signIn: gatedSignIn(), signUp: gatedSignIn() }); + const emit = vi.spyOn(eventBus, 'emit'); + + await expect(sut.__internal_resolvePendingProtectCheck()).rejects.toBe(blocked); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith(events.ResourceError, { resource: sut.client?.signUp, error: blocked }); + emit.mockRestore(); + resolve.mockRestore(); + }); }); describe('ui.ClerkUI option', () => { diff --git a/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx b/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx index 1a29782611c..4e631ad3266 100644 --- a/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx +++ b/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx @@ -49,6 +49,9 @@ vi.mock('../../../src/hooks', () => ({ get existingSession() { return mockSignIn.existingSession; }, + get protectCheck() { + return mockSignIn.protectCheck; + }, }, }), useSignUp: () => ({ @@ -64,6 +67,9 @@ vi.mock('../../../src/hooks', () => ({ get existingSession() { return mockSignUp.existingSession; }, + get protectCheck() { + return mockSignUp.protectCheck; + }, }, }), })); @@ -182,6 +188,68 @@ describe('', () => { expect(mockNavigateToApp).not.toHaveBeenCalled(); }); + it('navigates to sign-up when Protect blocks a sign-up', async () => { + mockSignUp = { status: 'missing_requirements', protectCheck: { token: 'tok' } }; + mockResolvePendingProtectCheck.mockRejectedValue(new Error('blocked')); + + render( + , + ); + + await waitFor(() => { + expect(mockNavigateToSignUp).toHaveBeenCalled(); + }); + expect(mockNavigateToSignIn).not.toHaveBeenCalled(); + }); + + it('navigates to sign-in when Protect blocks a sign-in and a sign-up gate is also pending', async () => { + mockSignIn = { status: 'needs_protect_check', protectCheck: { token: 'tok' } }; + mockSignUp = { status: 'missing_requirements', protectCheck: { token: 'tok' } }; + mockResolvePendingProtectCheck.mockRejectedValue(new Error('blocked')); + + render( + , + ); + + await waitFor(() => { + expect(mockNavigateToSignIn).toHaveBeenCalled(); + }); + expect(mockNavigateToSignUp).not.toHaveBeenCalled(); + }); + + it('navigates to sign-up when Protect blocks a callback scoped to sign-up', async () => { + const href = window.location.href; + window.history.replaceState(null, '', '/sso-callback?intent=signUp'); + mockSignIn = { status: 'needs_protect_check', protectCheck: { token: 'tok' } }; + mockSignUp = { status: 'missing_requirements', protectCheck: { token: 'tok' } }; + mockResolvePendingProtectCheck.mockRejectedValue(new Error('blocked')); + + try { + render( + , + ); + + await waitFor(() => { + expect(mockNavigateToSignUp).toHaveBeenCalled(); + }); + expect(mockNavigateToSignIn).not.toHaveBeenCalled(); + } finally { + window.history.replaceState(null, '', href); + } + }); + it('finalizes sign-in and navigates to app when signIn.status is complete', async () => { mockSignIn = { status: 'complete' }; From da46628119cb56e907067ec4444bd6e1333c5ec3 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 6 Oct 2026 16:10:29 -0700 Subject: [PATCH 2/3] fix(clerk-js,react): report a failed Protect challenge from HandleSSOCallback A challenge that failed on the SSO callback rejected without reaching the resource error state, so the errors from useSignIn() and useSignUp() stayed empty and the app had nothing to show. The resolver now emits the failure on the resource that carried the challenge, the same event the custom flow methods use. HandleSSOCallback also sent every failure to navigateToSignIn. It now calls navigateToSignUp when the failed challenge belonged to the sign-up. --- .changeset/handle-sso-callback-protect-error.md | 6 ++++++ packages/clerk-js/src/core/clerk.ts | 13 +++++++++++-- packages/react/src/components/HandleSSOCallback.tsx | 9 ++++++--- 3 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 .changeset/handle-sso-callback-protect-error.md diff --git a/.changeset/handle-sso-callback-protect-error.md b/.changeset/handle-sso-callback-protect-error.md new file mode 100644 index 00000000000..02802ea34e2 --- /dev/null +++ b/.changeset/handle-sso-callback-protect-error.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/react': patch +--- + +Fix `` dropping the error when a Clerk Protect challenge fails on the SSO callback. The error is now available on `errors` from `useSignIn()` or `useSignUp()`, and a failed sign-up challenge calls `navigateToSignUp` instead of `navigateToSignIn`. diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index f5eaa05aa92..e66740ffdfd 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -198,6 +198,7 @@ import { OAuthApplication } from './modules/oauthApplication'; import { Protect } from './protect'; import { protectAssertionParams } from './protectAssertion'; import { ProtectCheckGate } from './protectCheckGate'; +import type { SignIn, SignUp } from './resources/internal'; import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal'; import { State } from './state'; @@ -1000,11 +1001,19 @@ export class Clerk implements ClerkInterface { return; } const gate = ProtectCheckGate.getInstance(); + const resolve = async (resource: SignInResource | SignUpResource) => { + try { + await gate.resolve(this, resource); + } catch (error) { + eventBus.emit(events.ResourceError, { resource: resource as SignIn | SignUp, error }); + throw error; + } + }; if (flow !== 'signUp') { - await gate.resolve(this, client.signIn); + await resolve(client.signIn); } if (flow !== 'signIn') { - await gate.resolve(this, client.signUp); + await resolve(client.signUp); } }; diff --git a/packages/react/src/components/HandleSSOCallback.tsx b/packages/react/src/components/HandleSSOCallback.tsx index 261c94fed8e..e08b1f3384d 100644 --- a/packages/react/src/components/HandleSSOCallback.tsx +++ b/packages/react/src/components/HandleSSOCallback.tsx @@ -10,11 +10,13 @@ export interface HandleSSOCallbackProps { navigateToApp: (...params: Parameters) => void; /** * Called when a sign-in requires additional verification, or a sign-up is transfered to a sign-in that requires - * additional verification. + * additional verification. Also called when a Clerk Protect challenge fails during a sign-in, with the error + * available on `errors` from `useSignIn()`. */ navigateToSignIn: () => void; /** - * Called when a sign-in is transfered to a sign-up that requires additional verification. + * Called when a sign-in is transfered to a sign-up that requires additional verification. Also called when a Clerk + * Protect challenge fails during a sign-up, with the error available on `errors` from `useSignUp()`. */ navigateToSignUp: () => void; } @@ -83,7 +85,8 @@ export function HandleSSOCallback(props: HandleSSOCallbackProps): ReactNode { try { await clerk.__internal_resolvePendingProtectCheck?.(flow); } catch { - return navigateToSignIn(); + const failedSignUp = flow ? flow === 'signUp' : !signIn.protectCheck && !!signUp.protectCheck; + return failedSignUp ? navigateToSignUp() : navigateToSignIn(); } // If this was a sign-in, and it's complete, there's nothing else to do. From 8cfcbf2400d200b4902601ef05962d4b8ef7d13d Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 6 Oct 2026 16:30:14 -0700 Subject: [PATCH 3/3] test(e2e): cover a failed Protect challenge on the custom flow SSO callback The custom-flows-react-vite template now handles its SSO callback with HandleSSOCallback, which is the callback for the sso() call its sign-in page already makes. Its sign-up page shows the global error on the verification step so the test can assert on it. --- .../custom-flows-react-vite/src/main.tsx | 10 ++---- .../src/routes/SSOCallback.tsx | 21 ++++++++++++ .../src/routes/SignUp.tsx | 3 ++ integration/tests/protect-check.test.ts | 33 +++++++++++++++++++ 4 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 integration/templates/custom-flows-react-vite/src/routes/SSOCallback.tsx diff --git a/integration/templates/custom-flows-react-vite/src/main.tsx b/integration/templates/custom-flows-react-vite/src/main.tsx index b133724280a..91268401ddc 100644 --- a/integration/templates/custom-flows-react-vite/src/main.tsx +++ b/integration/templates/custom-flows-react-vite/src/main.tsx @@ -2,11 +2,12 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter, Route, Routes } from 'react-router'; import './index.css'; -import { AuthenticateWithRedirectCallback, ClerkProvider } from '@clerk/react'; +import { ClerkProvider } from '@clerk/react'; import { Home } from './routes/Home'; import { SignIn } from './routes/SignIn'; import { SignUp } from './routes/SignUp'; import { Protected } from './routes/Protected'; +import { SSOCallback } from './routes/SSOCallback'; import { Waitlist } from './routes/Waitlist'; createRoot(document.getElementById('root')!).render( @@ -46,12 +47,7 @@ createRoot(document.getElementById('root')!).render( /> - } + element={} /> diff --git a/integration/templates/custom-flows-react-vite/src/routes/SSOCallback.tsx b/integration/templates/custom-flows-react-vite/src/routes/SSOCallback.tsx new file mode 100644 index 00000000000..3bca319b1f4 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/routes/SSOCallback.tsx @@ -0,0 +1,21 @@ +import { HandleSSOCallback } from '@clerk/react'; +import { useNavigate } from 'react-router'; + +export function SSOCallback() { + const navigate = useNavigate(); + + return ( + { + const destination = decorateUrl('/protected'); + if (destination.startsWith('http')) { + window.location.href = destination; + return; + } + navigate(destination); + }} + navigateToSignIn={() => navigate('/sign-in')} + navigateToSignUp={() => navigate('/sign-up')} + /> + ); +} diff --git a/integration/templates/custom-flows-react-vite/src/routes/SignUp.tsx b/integration/templates/custom-flows-react-vite/src/routes/SignUp.tsx index b506c46ecb0..d1d513d9b64 100644 --- a/integration/templates/custom-flows-react-vite/src/routes/SignUp.tsx +++ b/integration/templates/custom-flows-react-vite/src/routes/SignUp.tsx @@ -86,6 +86,9 @@ export function SignUp({ className, ...props }: React.ComponentProps<'div'>) { /> {errors.fields.code &&
{errors.fields.code.message}
} + {errors.global && ( +

{errors.global[0].longMessage ?? errors.global[0].message}

+ )}