diff --git a/.changeset/vast-loops-open.md b/.changeset/vast-loops-open.md new file mode 100644 index 00000000000..baf4dc4e371 --- /dev/null +++ b/.changeset/vast-loops-open.md @@ -0,0 +1,7 @@ +--- +'@clerk/chrome-extension': minor +'@clerk/nextjs': minor +'@clerk/react': minor +--- + +Add `HandleSSOCallback` component which handles the SSO callback during custom flows, including support for sign-in-or-up. diff --git a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap index a3dc11760d2..dcf9f52e07a 100644 --- a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap @@ -11,6 +11,7 @@ exports[`public exports > should not include a breaking change 1`] = ` "ClerkProvider", "CreateOrganization", "GoogleOneTap", + "HandleSSOCallback", "OrganizationList", "OrganizationProfile", "OrganizationSwitcher", diff --git a/packages/chrome-extension/src/react/re-exports.ts b/packages/chrome-extension/src/react/re-exports.ts index 0c4b6c83fdf..2ef5056cb2e 100644 --- a/packages/chrome-extension/src/react/re-exports.ts +++ b/packages/chrome-extension/src/react/re-exports.ts @@ -6,6 +6,7 @@ export { ClerkLoaded, ClerkLoading, CreateOrganization, + HandleSSOCallback, OrganizationList, OrganizationProfile, OrganizationSwitcher, diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx index 6d032dc45c5..eef6fe05152 100644 --- a/packages/nextjs/src/client-boundary/uiComponents.tsx +++ b/packages/nextjs/src/client-boundary/uiComponents.tsx @@ -27,6 +27,7 @@ export { UserAvatar, UserButton, Waitlist, + HandleSSOCallback, } from '@clerk/react'; // The assignment of UserProfile with BaseUserProfile props is used diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 87dc4845653..aaf05912db6 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -25,6 +25,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "ClerkProvider", "CreateOrganization", "GoogleOneTap", + "HandleSSOCallback", "OrganizationList", "OrganizationProfile", "OrganizationSwitcher", diff --git a/packages/react/src/components/HandleSSOCallback.tsx b/packages/react/src/components/HandleSSOCallback.tsx new file mode 100644 index 00000000000..88fd7348cd9 --- /dev/null +++ b/packages/react/src/components/HandleSSOCallback.tsx @@ -0,0 +1,179 @@ +import type { SetActiveNavigate } from '@clerk/shared/types'; +import React, { type ReactNode, useEffect, useRef } from 'react'; + +import { useClerk, useSignIn, useSignUp } from '../hooks'; + +export interface HandleSSOCallbackProps { + /** + * Called when the SSO callback is complete and a session has been created. + */ + 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. + */ + navigateToSignIn: () => void; + /** + * Called when a sign-in is transfered to a sign-up that requires additional verification. + */ + navigateToSignUp: () => void; + /** + * Can be provided to render a custom component while the SSO callback is being processed. This component should, at + * a minimum, render a `
` element to handle captchas. + */ + render?: () => ReactNode; +} + +/** + * Use this component when building custom UI to handle the SSO callback and navigate to the appropriate page based on + * the status of the sign-in or sign-up. By default, this component might render a captcha element to handle captchas + * when required by the Clerk API. + * + * @example + * ```tsx + * import { HandleSSOCallback } from '@clerk/react'; + * import { useNavigate } from 'react-router'; + * + * export default function Page() { + * const navigate = useNavigate(); + * + * return ( + * { + * if (session?.currentTask) { + * const destination = decorateUrl(`/onboarding/${session?.currentTask.key}`); + * if (destination.startsWith('http')) { + * window.location.href = destination; + * return; + * } + * navigate(destination); + * return; + * } + * + * const destination = decorateUrl('/dashboard'); + * if (destination.startsWith('http')) { + * window.location.href = destination; + * return; + * } + * navigate(destination); + * }} + * navigateToSignIn={() => { + * navigate('/sign-in'); + * }} + * navigateToSignUp={() => { + * navigate('/sign-up'); + * }} + * /> + * ); + * } + * ``` + */ +export function HandleSSOCallback(props: HandleSSOCallbackProps): ReactNode { + const { navigateToApp, navigateToSignIn, navigateToSignUp, render } = props; + const clerk = useClerk(); + const { signIn } = useSignIn(); + const { signUp } = useSignUp(); + const hasRun = useRef(false); + + useEffect(() => { + (async () => { + if (!clerk.loaded || hasRun.current) { + return; + } + // Prevent re-running this effect if the page is re-rendered during session activation (such as on Next.js). + hasRun.current = true; + + // If this was a sign-in, and it's complete, there's nothing else to do. + // Note: We perform a cast here to prevent TypeScript from narrowing the type of signIn.status. TypeScript + // doesn't understand that the status can be mutated during the execution of this function. + if ((signIn.status as string) === 'complete') { + await signIn.finalize({ + navigate: async (...params) => { + navigateToApp(...params); + }, + }); + return; + } + + // If the sign-up used an existing account, transfer it to a sign-in. + if (signUp.isTransferable) { + await signIn.create({ transfer: true }); + if (signIn.status === 'complete') { + await signIn.finalize({ + navigate: async (...params) => { + navigateToApp(...params); + }, + }); + return; + } + // The sign-in requires additional verification, so we need to navigate to the sign-in page. + return navigateToSignIn(); + } + + if ( + signIn.status === 'needs_first_factor' && + !signIn.supportedFirstFactors?.every(f => f.strategy === 'enterprise_sso') + ) { + // The sign-in requires the use of a configured first factor, so navigate to the sign-in page. + return navigateToSignIn(); + } + + // If the sign-in used an external account not associated with an existing user, create a sign-up. + if (signIn.isTransferable) { + await signUp.create({ transfer: true }); + if (signUp.status === 'complete') { + await signUp.finalize({ + navigate: async (...params) => { + navigateToApp(...params); + }, + }); + return; + } + return navigateToSignUp(); + } + + if (signUp.status === 'complete') { + await signUp.finalize({ + navigate: async (...params) => { + navigateToApp(...params); + }, + }); + return; + } + + if (signIn.status === 'needs_second_factor' || signIn.status === 'needs_new_password') { + // The sign-in requires a MFA token or a new password, so navigate to the sign-in page. + return navigateToSignIn(); + } + + // The external account used to sign-in or sign-up was already associated with an existing user and active + // session on this client, so activate the session and navigate to the application. + if (signIn.existingSession || signUp.existingSession) { + const sessionId = signIn.existingSession?.sessionId || signUp.existingSession?.sessionId; + if (sessionId) { + // Because we're activating a session that's not the result of a sign-in or sign-up, we need to use the + // Clerk `setActive` API instead of the `finalize` API. + await clerk.setActive({ + session: sessionId, + navigate: async (...params) => { + return navigateToApp(...params); + }, + }); + return; + } + } + })(); + }, [clerk, signIn, signUp]); + + if (render) { + return render(); + } + + return ( +
+ {/* Because a sign-in transferred to a sign-up might require captcha verification, make sure to render the + captcha element. */} +
+
+ ); +} diff --git a/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx b/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx new file mode 100644 index 00000000000..1f800b257f3 --- /dev/null +++ b/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx @@ -0,0 +1,393 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { HandleSSOCallback } from '../HandleSSOCallback'; + +const mockNavigateToApp = vi.fn(); +const mockNavigateToSignIn = vi.fn(); +const mockNavigateToSignUp = vi.fn(); + +const mockSignInFinalize = vi.fn().mockImplementation(async ({ navigate }) => { + await navigate({ session: { id: 'sess_sign_in' }, decorateUrl: (url: string) => url }); + return { error: null }; +}); +const mockSignInCreate = vi.fn().mockResolvedValue({ error: null }); +const mockSignUpFinalize = vi.fn().mockImplementation(async ({ navigate }) => { + await navigate({ session: { id: 'sess_sign_up' }, decorateUrl: (url: string) => url }); + return { error: null }; +}); +const mockSignUpCreate = vi.fn().mockResolvedValue({ error: null }); +const mockSetActive = vi.fn().mockImplementation(async ({ navigate }) => { + await navigate({ session: { id: 'sess_existing' }, decorateUrl: (url: string) => url }); +}); + +let mockClerkLoaded = true; +let mockSignIn: Record = {}; +let mockSignUp: Record = {}; + +vi.mock('../../../src/hooks', () => ({ + useClerk: () => ({ + loaded: mockClerkLoaded, + setActive: mockSetActive, + }), + useSignIn: () => ({ + signIn: { + finalize: mockSignInFinalize, + create: mockSignInCreate, + get status() { + return mockSignIn.status; + }, + get isTransferable() { + return mockSignIn.isTransferable; + }, + get supportedFirstFactors() { + return mockSignIn.supportedFirstFactors; + }, + get existingSession() { + return mockSignIn.existingSession; + }, + }, + }), + useSignUp: () => ({ + signUp: { + finalize: mockSignUpFinalize, + create: mockSignUpCreate, + get status() { + return mockSignUp.status; + }, + get isTransferable() { + return mockSignUp.isTransferable; + }, + get existingSession() { + return mockSignUp.existingSession; + }, + }, + }), +})); + +describe('', () => { + let consoleErrorSpy: ReturnType; + + beforeAll(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterAll(() => { + consoleErrorSpy.mockRestore(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockClerkLoaded = true; + mockSignIn = {}; + mockSignUp = {}; + }); + + it('renders captcha element by default', () => { + mockClerkLoaded = false; + render( + , + ); + + expect(document.getElementById('clerk-captcha')).not.toBeNull(); + }); + + it('renders custom component when render prop is provided', async () => { + mockClerkLoaded = false; + render( +
Loading...
} + />, + ); + + await screen.findByTestId('custom-render'); + await screen.findByText('Loading...'); + }); + + it('does nothing when clerk is not loaded', async () => { + mockClerkLoaded = false; + render( + , + ); + + await waitFor(() => { + expect(mockSignInFinalize).not.toHaveBeenCalled(); + expect(mockSignUpFinalize).not.toHaveBeenCalled(); + expect(mockNavigateToApp).not.toHaveBeenCalled(); + expect(mockNavigateToSignIn).not.toHaveBeenCalled(); + expect(mockNavigateToSignUp).not.toHaveBeenCalled(); + }); + }); + + it('finalizes sign-in and navigates to app when signIn.status is complete', async () => { + mockSignIn = { status: 'complete' }; + + render( + , + ); + + await waitFor(() => { + expect(mockSignInFinalize).toHaveBeenCalled(); + expect(mockNavigateToApp).toHaveBeenCalled(); + }); + }); + + it('transfers sign-up to sign-in when signUp.isTransferable is true and sign-in completes', async () => { + mockSignUp = { isTransferable: true }; + mockSignIn = { status: 'needs_identifier' }; + + mockSignInCreate.mockImplementation(async () => { + mockSignIn.status = 'complete'; + return { error: null }; + }); + + render( + , + ); + + await waitFor(() => { + expect(mockSignInCreate).toHaveBeenCalledWith({ transfer: true }); + expect(mockSignInFinalize).toHaveBeenCalled(); + expect(mockNavigateToApp).toHaveBeenCalled(); + }); + }); + + it('navigates to sign-in when signUp.isTransferable is true but sign-in needs verification', async () => { + mockSignUp = { isTransferable: true }; + mockSignIn = { status: 'needs_identifier' }; + + mockSignInCreate.mockImplementation(async () => { + mockSignIn.status = 'needs_first_factor'; + return { error: null }; + }); + + render( + , + ); + + await waitFor(() => { + expect(mockSignInCreate).toHaveBeenCalledWith({ transfer: true }); + expect(mockNavigateToSignIn).toHaveBeenCalled(); + }); + }); + + it('navigates to sign-in when signIn.status is needs_first_factor with non-enterprise SSO factors', async () => { + mockSignIn = { + status: 'needs_first_factor', + supportedFirstFactors: [{ strategy: 'password' }, { strategy: 'email_code' }], + }; + + render( + , + ); + + await waitFor(() => { + expect(mockNavigateToSignIn).toHaveBeenCalled(); + }); + }); + + it('transfers sign-in to sign-up when signIn.isTransferable is true and sign-up completes', async () => { + mockSignIn = { status: 'needs_identifier', isTransferable: true }; + mockSignUp = { status: 'missing_requirements' }; + + mockSignUpCreate.mockImplementation(async () => { + mockSignUp.status = 'complete'; + return { error: null }; + }); + + render( + , + ); + + await waitFor(() => { + expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true }); + expect(mockSignUpFinalize).toHaveBeenCalled(); + expect(mockNavigateToApp).toHaveBeenCalled(); + }); + }); + + it('navigates to sign-up when signIn.isTransferable is true but sign-up needs verification', async () => { + mockSignIn = { status: 'needs_identifier', isTransferable: true }; + mockSignUp = { status: 'missing_requirements' }; + + mockSignUpCreate.mockImplementation(async () => { + mockSignUp.status = 'missing_requirements'; + return { error: null }; + }); + + render( + , + ); + + await waitFor(() => { + expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true }); + expect(mockNavigateToSignUp).toHaveBeenCalled(); + }); + }); + + it('finalizes sign-up and navigates to app when signUp.status is complete', async () => { + mockSignIn = { status: 'needs_identifier', isTransferable: false }; + mockSignUp = { status: 'complete', isTransferable: false }; + + render( + , + ); + + await waitFor(() => { + expect(mockSignUpFinalize).toHaveBeenCalled(); + expect(mockNavigateToApp).toHaveBeenCalled(); + }); + }); + + it('navigates to sign-in when signIn.status is needs_second_factor', async () => { + mockSignIn = { status: 'needs_second_factor', isTransferable: false }; + mockSignUp = { status: 'missing_requirements', isTransferable: false }; + + render( + , + ); + + await waitFor(() => { + expect(mockNavigateToSignIn).toHaveBeenCalled(); + }); + }); + + it('navigates to sign-in when signIn.status is needs_new_password', async () => { + mockSignIn = { status: 'needs_new_password', isTransferable: false }; + mockSignUp = { status: 'missing_requirements', isTransferable: false }; + + render( + , + ); + + await waitFor(() => { + expect(mockNavigateToSignIn).toHaveBeenCalled(); + }); + }); + + it('activates existing session from signIn.existingSession and navigates to app', async () => { + mockSignIn = { + status: 'needs_identifier', + isTransferable: false, + existingSession: { sessionId: 'sess_existing_1' }, + }; + mockSignUp = { status: 'missing_requirements', isTransferable: false }; + + render( + , + ); + + await waitFor(() => { + expect(mockSetActive).toHaveBeenCalledWith({ + session: 'sess_existing_1', + navigate: expect.any(Function), + }); + expect(mockNavigateToApp).toHaveBeenCalled(); + }); + }); + + it('activates existing session from signUp.existingSession and navigates to app', async () => { + mockSignIn = { status: 'needs_identifier', isTransferable: false }; + mockSignUp = { + status: 'missing_requirements', + isTransferable: false, + existingSession: { sessionId: 'sess_existing_2' }, + }; + + render( + , + ); + + await waitFor(() => { + expect(mockSetActive).toHaveBeenCalledWith({ + session: 'sess_existing_2', + navigate: expect.any(Function), + }); + expect(mockNavigateToApp).toHaveBeenCalled(); + }); + }); + + it('does not run effect twice due to hasRun ref', async () => { + mockSignIn = { status: 'complete' }; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mockSignInFinalize).toHaveBeenCalledTimes(1); + }); + + rerender( + , + ); + + await waitFor(() => { + expect(mockSignInFinalize).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index c200f386236..8daec8bb784 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -37,3 +37,4 @@ export { SignInButton } from './SignInButton'; export { SignInWithMetamaskButton } from './SignInWithMetamaskButton'; export { SignOutButton } from './SignOutButton'; export { SignUpButton } from './SignUpButton'; +export { HandleSSOCallback } from './HandleSSOCallback'; diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 6e0d04985b8..903c5107080 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -30,6 +30,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "ClerkProvider", "CreateOrganization", "GoogleOneTap", + "HandleSSOCallback", "OrganizationList", "OrganizationProfile", "OrganizationSwitcher",