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
178 changes: 178 additions & 0 deletions packages/react/src/components/HandleSSOCallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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
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]);
Comment on lines +78 to +165
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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the full HandleSSOCallback.tsx file
cat -n packages/react/src/components/HandleSSOCallback.tsx

Repository: clerk/javascript

Length of output: 7284


🏁 Script executed:

# Search for ESLint configuration files
find . -type f -name ".eslintrc*" -o -name "eslint.config.*" | head -20

Repository: clerk/javascript

Length of output: 144


🏁 Script executed:

# Check if eslintConfig is in package.json
find . -type f -name "package.json" | xargs grep -l "eslintConfig" 2>/dev/null | head -5

Repository: clerk/javascript

Length of output: 139


🏁 Script executed:

# Look for test files related to HandleSSOCallback
find . -type f \( -name "*HandleSSOCallback*test*" -o -name "*HandleSSOCallback*spec*" \) 2>/dev/null

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Check the main ESLint config for exhaustive-deps rule
cat -n eslint.config.mjs | head -100

Repository: clerk/javascript

Length of output: 4287


🏁 Script executed:

# Search for any test files related to HandleSSOCallback
find . -type f \( -name "*.test.*" -o -name "*.spec.*" \) | xargs grep -l "HandleSSOCallback" 2>/dev/null

Repository: clerk/javascript

Length of output: 44


🏁 Script executed:

# Check if there's a specific eslint config for the react package
find packages/react -type f -name "eslint.config.*" -o -name ".eslintrc*"

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Look for HandleSSOCallback in any test directory
find . -type f \( -name "*.test.ts*" -o -name "*.spec.ts*" \) | xargs grep -l "HandleSSO" 2>/dev/null | head -5

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Read more of the ESLint config to find the react-hooks configuration
cat eslint.config.mjs | grep -A 20 "react-hooks\|exhaustive-deps"

Repository: clerk/javascript

Length of output: 1543


🏁 Script executed:

# Check if this file is part of the PR changes (git status)
git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -i "handleSSO\|eslint"

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Look for any __tests__ or test directories in packages/react
find packages/react -type d \( -name "__tests__" -o -name "test" -o -name "tests" -o -name "__test__" \) 2>/dev/null

Repository: clerk/javascript

Length of output: 273


🏁 Script executed:

# Check if there are tests for HandleSSOCallback in the __tests__ directory
ls -la packages/react/src/components/__tests__/ | grep -i "handle\|sso\|callback"

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# List all files in the components __tests__ directory to see what tests exist
ls -1 packages/react/src/components/__tests__/

Repository: clerk/javascript

Length of output: 398


🏁 Script executed:

# Search the entire packages/react for any reference to HandleSSOCallback
grep -r "HandleSSOCallback" packages/react --include="*.ts" --include="*.tsx" --include="*.test.ts" --include="*.test.tsx" 2>/dev/null

Repository: clerk/javascript

Length of output: 562


🏁 Script executed:

# Check what the react-hooks recommended config includes for exhaustive-deps
# We need to check if exhaustive-deps is in the recommended rules and at what level
python3 << 'EOF'
# The react-hooks recommended config typically includes exhaustive-deps
# Let's check the default severity for recommended rules in React Hooks ESLint plugin
# According to react-hooks/eslint-plugin-react-hooks, the recommended config includes:
# - rules-of-hooks: 'error'
# - exhaustive-deps: 'warn'

# So exhaustive-deps should be 'warn' by default in the recommended config
print("React Hooks recommended config typically includes:")
print("  rules-of-hooks: 'error'")
print("  exhaustive-deps: 'warn'")
print("\nSince the config spreads ...pluginReactHooks.configs.recommended.rules,")
print("exhaustive-deps should be active at 'warn' level for this project.")
EOF

Repository: clerk/javascript

Length of output: 146


🏁 Script executed:

# Let's verify by checking if any files in the repo have exhaustive-deps configured
grep -r "exhaustive-deps" . --include="eslint*" --include="*.config.*" 2>/dev/null | head -10

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Final confirmation: check if any PR changes reference tests being added for HandleSSOCallback
git log --oneline -20 2>/dev/null | head -5

Repository: clerk/javascript

Length of output: 93


🏁 Script executed:

# Check the exact react-hooks version to confirm exhaustive-deps behavior
grep -A 2 "eslint-plugin-react-hooks" packages/react/package.json 2>/dev/null || grep -A 2 "eslint-plugin-react-hooks" package.json 2>/dev/null | head -10

Repository: clerk/javascript

Length of output: 187


Add tests to cover the new HandleSSOCallback component.

The component is new with no test coverage. Per project guidelines, tests must be added to cover changes.

The useEffect dependency array triggers an exhaustive-deps warning (warn-level) for the missing callback props, but the hasRun guard prevents re-execution regardless. If desired, add them to dependencies to silence the warning, but the pattern is valid as-is.

🤖 Prompt for AI Agents
In `@packages/react/src/components/HandleSSOCallback.tsx` around lines 77 - 164,
HandleSSOCallback has no tests — add unit tests for the component to cover the
various SSO flows handled in its useEffect: signIn.status === 'complete' path
(calls signIn.finalize -> navigateToApp), signUp.isTransferable -> signIn.create
then finalize or navigateToSignIn, signIn.isTransferable -> signUp.create then
finalize or navigateToSignUp,
needs_first_factor/needs_second_factor/needs_new_password -> navigateToSignIn,
and existingSession/session activation path (clerk.setActive -> navigateToApp);
in tests mock the clerk, signIn, signUp objects and assert the appropriate
methods (finalize, create, setActive) and navigation helpers (navigateToApp,
navigateToSignIn, navigateToSignUp) are called, and include at least one test
ensuring hasRun prevents re-running effect. Optionally, to silence
exhaustive-deps warnings in tests or code, either include the callback props
(navigateToApp/navigateToSignIn/navigateToSignUp) in the useEffect dependency
array or explicitly document/ignore the lint warning — but focus tests on
useEffect behaviour for functions useEffect, hasRun, clerk, signIn, signUp.


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 +173 to +174
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>
);
}
1 change: 1 addition & 0 deletions packages/react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ export { SignInButton } from './SignInButton';
export { SignInWithMetamaskButton } from './SignInWithMetamaskButton';
export { SignOutButton } from './SignOutButton';
export { SignUpButton } from './SignUpButton';
export { HandleSSOCallback } from './HandleSSOCallback';
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ exports[`root public exports > should not change unexpectedly 1`] = `
"ClerkProvider",
"CreateOrganization",
"GoogleOneTap",
"HandleSSOCallback",
"OrganizationList",
"OrganizationProfile",
"OrganizationSwitcher",
Expand Down