Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/vast-loops-open.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ exports[`public exports > should not include a breaking change 1`] = `
"ClerkProvider",
"CreateOrganization",
"GoogleOneTap",
"HandleSSOCallback",
"OrganizationList",
"OrganizationProfile",
"OrganizationSwitcher",
Expand Down
1 change: 1 addition & 0 deletions packages/chrome-extension/src/react/re-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
ClerkLoaded,
ClerkLoading,
CreateOrganization,
HandleSSOCallback,
OrganizationList,
OrganizationProfile,
OrganizationSwitcher,
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/src/client-boundary/uiComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export {
UserAvatar,
UserButton,
Waitlist,
HandleSSOCallback,
} from '@clerk/react';

// The assignment of UserProfile with BaseUserProfile props is used
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ exports[`root public exports > should not change unexpectedly 1`] = `
"ClerkProvider",
"CreateOrganization",
"GoogleOneTap",
"HandleSSOCallback",
"OrganizationList",
"OrganizationProfile",
"OrganizationSwitcher",
Expand Down
179 changes: 179 additions & 0 deletions packages/react/src/components/HandleSSOCallback.tsx
Original file line number Diff line number Diff line change
@@ -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<SetActiveNavigate>) => 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 `<div id='clerk-captcha'></div>` 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 (
* <HandleSSOCallback
* navigateToApp={({ session, decorateUrl }) => {
* 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') {
Copy link
Member

@nikosdouvlis nikosdouvlis Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Note: We perform a cast - comment is cut off (i think?), should it explain why?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whoops 😬

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 (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just double checking - this navigates away unless ALL factors are enterprise_sso. Is that the intent?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is directly copy and pasted from our existing handleRedirectCallback implementation. It only navigates to sign-in if the status is needs_first_factor and there is at least one supported first-factor that's NOT enterprise_sso.

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]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Navigate callbacks missing from deps array - intentional?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes they're omitted since we're trying to avoid re-running this effect. We want to run this effect once once Clerk is loaded.

Comment on lines +78 to +166
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the hook implementations to check if they return stable references
find packages/react/src/hooks -type f \( -name "*.ts" -o -name "*.tsx" \) | head -20

Repository: clerk/javascript

Length of output: 609


🏁 Script executed:

#!/bin/bash
# Check the implementation of useClerk hook
rg -n "export.*useClerk\s*=" packages/react/src -A 10 -B 2

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

#!/bin/bash
# Look for test files for HandleSSOCallback
find . -type f \( -name "*HandleSSOCallback*" -o -path "*/test/*" -o -path "*/tests/*" \) | grep -i "sso\|callback"

Repository: clerk/javascript

Length of output: 113


🏁 Script executed:

#!/bin/bash
# Check if there are any test files in the same directory as HandleSSOCallback
ls -la packages/react/src/components/ | grep -i test

Repository: clerk/javascript

Length of output: 119


🏁 Script executed:

#!/bin/bash
# Search for jest or test config to understand testing setup
find packages/react -type f \( -name "*.test.ts" -o -name "*.test.tsx" -o -name "*.spec.ts" -o -name "*.spec.tsx" \) | head -10

Repository: clerk/javascript

Length of output: 700


🏁 Script executed:

#!/bin/bash
# Search for where useClerk is defined or exported
rg "useClerk" packages/react/src/hooks --max-count 5

Repository: clerk/javascript

Length of output: 692


🏁 Script executed:

#!/bin/bash
# Check the hooks index to see what's exported
cat packages/react/src/hooks/index.ts

Repository: clerk/javascript

Length of output: 545


🏁 Script executed:

#!/bin/bash
# Read the full HandleSSOCallback component to understand context
cat packages/react/src/components/HandleSSOCallback.tsx

Repository: clerk/javascript

Length of output: 6220


🏁 Script executed:

#!/bin/bash
# Check if there's a test file specifically for HandleSSOCallback
ls -la packages/react/src/components/__tests__/ | grep -i "sso\|callback"

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

#!/bin/bash
# Search for how useClerk is typically used in other components to understand stability
rg "useClerk\(\)" packages/react/src/components -A 2 | head -30

Repository: clerk/javascript

Length of output: 311


🏁 Script executed:

#!/bin/bash
# Find the shared package location
find . -type d -name shared | grep -E "packages/shared|@clerk/shared" | head -5

Repository: clerk/javascript

Length of output: 77


🏁 Script executed:

#!/bin/bash
# Look for useClerk implementation in the codebase
rg "export.*function useClerk|export const useClerk" -g "*.ts" -g "*.tsx" -A 10

Repository: clerk/javascript

Length of output: 10049


🏁 Script executed:

#!/bin/bash
# Check for memoization in hook implementations - look for useMemo wrapping
rg "useMemo.*useClerk|useClerk.*useMemo" packages/ -g "*.ts" -g "*.tsx"

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

#!/bin/bash
# Check the clerk object structure to understand what clerk.loaded is
rg "loaded.*:" packages/shared -g "*.ts" -g "*.tsx" -B 2 -A 2 | head -50

Repository: clerk/javascript

Length of output: 4252


🏁 Script executed:

#!/bin/bash
# Check how useClerk is typically memoized or cached in tests
find packages -name "*.test.tsx" -o -name "*.test.ts" | xargs grep -l "useClerk" | head -1 | xargs cat | head -100

Repository: clerk/javascript

Length of output: 2170


🏁 Script executed:

#!/bin/bash
# Find the useClerkInstanceContext implementation
rg "useClerkInstanceContext" packages/shared/src -A 10 -B 2

Repository: clerk/javascript

Length of output: 49661


🏁 Script executed:

#!/bin/bash
# Check if there's a ClerkProvider that wraps the context
rg "ClerkProvider|ClerkInstanceContext" packages/shared/src -A 5 -B 2 | head -80

Repository: clerk/javascript

Length of output: 5548


🏁 Script executed:

#!/bin/bash
# Check how the clerk context value is created to verify stability
rg "Provider.*value.*clerk|clerk.*Provider.*value" packages/ -g "*.tsx" -g "*.ts" -A 3

Repository: clerk/javascript

Length of output: 1226


Add clerk.loaded to the useEffect dependency array to ensure the effect reruns when Clerk finishes loading.

The effect currently guards on !clerk.loaded but doesn't include it in dependencies [clerk, signIn, signUp]. If the clerk object reference remains stable, the effect will skip on first render when clerk.loaded is false and never rerun when it becomes true, causing the entire SSO flow to be skipped.

Additionally, tests should be added to cover this component's behavior.

🤖 Prompt for AI Agents
In `@packages/react/src/components/HandleSSOCallback.tsx` around lines 78 - 166,
The useEffect in HandleSSOCallback.tsx currently checks clerk.loaded but doesn't
include it in the dependency array, so update the dependency array on the
useEffect that contains the async IIFE (the effect referencing signIn, signUp
and clerk) to include clerk.loaded (e.g., change [clerk, signIn, signUp] to
include clerk.loaded) so the effect re-runs when Clerk finishes loading; also
add unit/interaction tests for HandleSSOCallback to cover the flow when
clerk.loaded transitions from false to true to ensure the SSO logic runs as
expected.


if (render) {
return render();
}

return (
<div>
{/* Because a sign-in transferred to a sign-up might require captcha verification, make sure to render the
captcha element. */}
Comment on lines +174 to +175
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we allow children for them to render a spinner here possibly?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went with the render prop for that, but I'll defer to what you suggest here since I know the thinking has evolved recently!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to date we've used children, so I might lean towards children for now. We've yet to introduce a render prop but possibly in the future.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stepping back do we even need either? This renders a div and the captcha (possibly). wouldn't folks probably write code that looks like this?

<Card>
  <CardBody>
    <Spinner />
    <HandleSSOCallback />
  </CardBody>
</Card>

In this situation I don't think they'd want to put their Spinner as a child of HandleSSOCallback. If it's a render prop it would make more sense for it to be:

<HandleSSOCallback render={() => {
  return (
    <Card><CardBody><Spinner /></CardBody></Card>  
  )
}} />

<div id='clerk-captcha' />
</div>
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeRabbit flagged this too - no test coverage for this component

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something e2e tests are better suited for?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

given the number of permutations I actually think a unit test with mocks might be better. I'll add that!

}
Loading
Loading