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/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} + )} timeout: 30_000, }); +const blockProtectChallengeScript = async (page: Page) => { + const scripts = new Set(); + const key = (url: URL) => url.origin + url.pathname; + await page.route(/\/v1\/client/, async route => { + const response = await route.fetch(); + const body = await response.text(); + for (const [, sdkUrl] of body.matchAll(/"sdk_url":("[^"]+")/g)) { + scripts.add(key(new URL(JSON.parse(sdkUrl)))); + } + await route.fulfill({ response, body }); + }); + await page.route( + url => scripts.has(key(url)), + route => route.abort(), + ); +}; + test.describe('protect check @generic', () => { test.describe.configure({ mode: 'serial' }); @@ -184,4 +201,20 @@ test.describe('protect check in custom flows @custom', () => { expect(createStatus).toBe('needs_protect_check'); expect(protectCheckRequests).toEqual([]); }); + + test('returns to sign-up with the error when the challenge fails on the SSO callback', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + fakeUser = u.services.users.createFakeUser(test); + await blockProtectChallengeScript(page); + + await u.page.goToRelative('/sign-up'); + await expect(u.page.getByText('Sign up', { exact: true })).toBeVisible(); + await u.po.signUp.signUp({ email: fakeUser.email!, password: fakeUser.password }); + await page.waitForFunction(() => !!window.Clerk?.client?.signUp.protectCheck); + + await u.page.goToRelative('/sso-callback'); + + await u.page.waitForAppUrl('/sign-up'); + await expect(u.page.getByText(/protect_check_script_load_failed/)).toBeVisible(); + }); }); 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/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. 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' };
{errors.global[0].longMessage ?? errors.global[0].message}