diff --git a/.changeset/resume-enterprise-sso-after-challenge.md b/.changeset/resume-enterprise-sso-after-challenge.md
new file mode 100644
index 00000000000..60f3a9be89e
--- /dev/null
+++ b/.changeset/resume-enterprise-sso-after-challenge.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': patch
+---
+
+Fix sign-ins that use an enterprise connection stranding on "Use another method" after a verification challenge, instead of continuing to the identity provider.
diff --git a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
index b71845be905..2c826535ddd 100644
--- a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
+++ b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
@@ -24,7 +24,11 @@ import { useNavigateToFlowStart } from '../../hooks/useNavigateToFlowStart';
import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner';
import { useRouter } from '../../router';
import { buildSignInOAuthCallbackParams } from './buildOAuthCallbackParams';
-import { isSignInPendingOAuthTransfer, resumeSignInAfterProtectCheck } from './handleProtectCheck';
+import {
+ isSignInPendingOAuthTransfer,
+ isSignInProtectGated,
+ resumeSignInAfterProtectCheck,
+} from './handleProtectCheck';
function SignInProtectCheckInternal(): JSX.Element | null {
const card = useCardState();
@@ -78,6 +82,21 @@ function SignInProtectCheckInternal(): JSX.Element | null {
}
await resumeSignInAfterProtectCheck(updatedSignIn, {
navigate,
+ resumeEnterpriseSSO: async () => {
+ await signIn.authenticateWithRedirect({
+ strategy: 'enterprise_sso',
+ redirectUrl: ctx.ssoCallbackUrl,
+ redirectUrlComplete: afterSignInUrl || '/',
+ oidcPrompt: ctx.oidcPrompt,
+ continueSignIn: true,
+ });
+
+ // Preparing the hand-off can raise a further challenge, in which case no redirect was
+ // issued: stay here and run it on the next render.
+ if (isSignInProtectGated(signIn)) {
+ await navigate('.');
+ }
+ },
startedAsOAuthTransfer: startedAsOAuthTransfer.current,
resumeOAuthContinuation: () =>
typeof __internal_resumeAfterProtectCheck === 'function'
diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx
index 81b34dcf9a0..059818c8b93 100644
--- a/packages/ui/src/components/SignIn/SignInStart.tsx
+++ b/packages/ui/src/components/SignIn/SignInStart.tsx
@@ -38,13 +38,10 @@ import { useLoadingStatus } from '../../hooks';
import { useSupportEmail } from '../../hooks/useSupportEmail';
import { useTotalEnabledAuthMethods } from '../../hooks/useTotalEnabledAuthMethods';
import { useRouter } from '../../router';
+import { hasOnlyEnterpriseSSOFirstFactors, shouldHandOffToEnterpriseConnection } from './enterpriseSSOFactors';
import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer';
import { navigateOnSignInProtectGate } from './handleProtectCheck';
-import {
- hasMultipleEnterpriseConnections,
- SIGN_IN_RESET_PASSWORD_INTENT_PARAM,
- useHandleAuthenticateWithPasskey,
-} from './shared';
+import { SIGN_IN_RESET_PASSWORD_INTENT_PARAM, useHandleAuthenticateWithPasskey } from './shared';
import { SignInAlternativePhoneCodePhoneNumberCard } from './SignInAlternativePhoneCodePhoneNumberCard';
import { SignInSocialButtons } from './SignInSocialButtons';
import {
@@ -241,7 +238,7 @@ function SignInStartInternal(): JSX.Element {
}
switch (res.status) {
case 'needs_first_factor': {
- if (!hasOnlyEnterpriseSSOFirstFactors(res) || hasMultipleEnterpriseConnections(res.supportedFirstFactors)) {
+ if (!shouldHandOffToEnterpriseConnection(res)) {
return navigate('factor-one');
}
@@ -418,7 +415,7 @@ function SignInStartInternal(): JSX.Element {
}
break;
case 'needs_first_factor': {
- if (!hasOnlyEnterpriseSSOFirstFactors(res) || hasMultipleEnterpriseConnections(res.supportedFirstFactors)) {
+ if (!shouldHandOffToEnterpriseConnection(res)) {
if (options?.resetPasswordIntent) {
return navigate('factor-one', {
searchParams: new URLSearchParams({ [SIGN_IN_RESET_PASSWORD_INTENT_PARAM]: 'true' }),
@@ -722,14 +719,6 @@ function SignInStartInternal(): JSX.Element {
);
}
-const hasOnlyEnterpriseSSOFirstFactors = (signIn: SignInResource): boolean => {
- if (!signIn.supportedFirstFactors?.length) {
- return false;
- }
-
- return signIn.supportedFirstFactors.every(ff => ff.strategy === 'enterprise_sso');
-};
-
const InstantPasswordRow = ({
field,
onForgotPasswordClick,
diff --git a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
index d067fbb7396..6367f77b1f2 100644
--- a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
+++ b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
@@ -23,6 +23,75 @@ beforeEach(() => {
});
describe('SignInProtectCheck', () => {
+ describe('enterprise SSO', () => {
+ const enterpriseSSOSignIn = (supportedFirstFactors: unknown[]) =>
+ ({
+ status: 'needs_first_factor',
+ protectCheck: null,
+ createdSessionId: null,
+ supportedFirstFactors,
+ }) as unknown as SignInResource;
+
+ it('hands off to the connection once the challenge resolves', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ mockExecute.mockResolvedValue('proof-abc');
+ fixtures.signIn.submitProtectCheck.mockResolvedValue(enterpriseSSOSignIn([{ strategy: 'enterprise_sso' }]));
+
+ render(, { wrapper });
+
+ await waitFor(() => {
+ expect(fixtures.signIn.authenticateWithRedirect).toHaveBeenCalledWith({
+ strategy: 'enterprise_sso',
+ redirectUrl: 'http://localhost:3000/#/sso-callback',
+ redirectUrlComplete: '/',
+ oidcPrompt: undefined,
+ continueSignIn: true,
+ });
+ });
+ expect(fixtures.router.navigate).not.toHaveBeenCalledWith('../factor-one');
+ });
+
+ it('stays on the challenge when preparing the hand-off raises another one', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ mockExecute.mockResolvedValue('proof-abc');
+ fixtures.signIn.submitProtectCheck.mockResolvedValue(enterpriseSSOSignIn([{ strategy: 'enterprise_sso' }]));
+ fixtures.signIn.authenticateWithRedirect.mockImplementationOnce(() => {
+ (fixtures.signIn as any).protectCheck = { status: 'pending', token: 'challenge-token-2' };
+ return Promise.resolve();
+ });
+
+ render(, { wrapper });
+
+ await waitFor(() => {
+ expect(fixtures.router.navigate).toHaveBeenCalledWith('.');
+ });
+ });
+
+ it('routes to factor one when there is more than one connection to choose from', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ mockExecute.mockResolvedValue('proof-abc');
+ fixtures.signIn.submitProtectCheck.mockResolvedValue(
+ enterpriseSSOSignIn([
+ { strategy: 'enterprise_sso', enterpriseConnectionId: 'ent_1', enterpriseConnectionName: 'Okta' },
+ { strategy: 'enterprise_sso', enterpriseConnectionId: 'ent_2', enterpriseConnectionName: 'Entra' },
+ ]),
+ );
+
+ render(, { wrapper });
+
+ await waitFor(() => {
+ expect(fixtures.router.navigate).toHaveBeenCalledWith('../factor-one');
+ });
+ expect(fixtures.signIn.authenticateWithRedirect).not.toHaveBeenCalled();
+ });
+ });
+
it('renders verification UI', async () => {
const { wrapper } = await createFixtures(f => {
f.startSignInWithProtectCheck();
diff --git a/packages/ui/src/components/SignIn/enterpriseSSOFactors.ts b/packages/ui/src/components/SignIn/enterpriseSSOFactors.ts
new file mode 100644
index 00000000000..b4df84e8d5f
--- /dev/null
+++ b/packages/ui/src/components/SignIn/enterpriseSSOFactors.ts
@@ -0,0 +1,54 @@
+import type { EnterpriseSSOFactor, SignInFirstFactor, SignInResource } from '@clerk/shared/types';
+
+/**
+ * Whether every supported first factor hands off to an enterprise connection, i.e. there is no
+ * factor the sign-in card could render instead.
+ */
+function hasOnlyEnterpriseSSOFirstFactors(signIn: SignInResource): boolean {
+ if (!signIn.supportedFirstFactors?.length) {
+ return false;
+ }
+
+ return signIn.supportedFirstFactors.every(ff => ff.strategy === 'enterprise_sso');
+}
+
+/**
+ * Type guard that checks if all factors in the array are enterprise SSO factors
+ * with both `enterpriseConnectionId` and `enterpriseConnectionName` properties.
+ * This is used to determine if the user should be presented with a choice
+ * between multiple enterprise connections.
+ * @experimental
+ */
+function hasMultipleEnterpriseConnections(
+ factors: SignInFirstFactor[] | null,
+): factors is Array {
+ if (!factors?.length) {
+ return false;
+ }
+
+ return (
+ factors.filter(
+ factor =>
+ factor.strategy === 'enterprise_sso' &&
+ 'enterpriseConnectionId' in factor &&
+ 'enterpriseConnectionName' in factor,
+ ).length > 1
+ );
+}
+
+/**
+ * Whether the sign-in should be handed straight to an enterprise connection rather than rendered
+ * as a first factor: SSO is the only way in, and there is a single connection to hand off to.
+ *
+ * Every place that continues a sign-in has to ask this — an SSO-only sign-in has no first factor
+ * to render, so routing it to the factor-one card leaves the user on alternative methods with no
+ * way to reach their identity provider. More than one connection is the exception: that is a
+ * choice, and the factor-one card presents it.
+ */
+function shouldHandOffToEnterpriseConnection(signIn: SignInResource): boolean {
+ return (
+ hasOnlyEnterpriseSSOFirstFactors(signIn) && !hasMultipleEnterpriseConnections(signIn.supportedFirstFactors ?? null)
+ );
+}
+
+export { hasMultipleEnterpriseConnections, hasOnlyEnterpriseSSOFirstFactors, shouldHandOffToEnterpriseConnection };
diff --git a/packages/ui/src/components/SignIn/handleProtectCheck.ts b/packages/ui/src/components/SignIn/handleProtectCheck.ts
index 71004dd29b5..00d10d1241e 100644
--- a/packages/ui/src/components/SignIn/handleProtectCheck.ts
+++ b/packages/ui/src/components/SignIn/handleProtectCheck.ts
@@ -1,5 +1,7 @@
import type { SignInResource } from '@clerk/shared/types';
+import { shouldHandOffToEnterpriseConnection } from './enterpriseSSOFactors';
+
/**
* Detects whether a sign-in response is gated by Clerk Protect.
*
@@ -48,10 +50,12 @@ export function resumeSignInAfterProtectCheck(
signIn: SignInResource,
{
navigate,
+ resumeEnterpriseSSO,
resumeOAuthContinuation,
startedAsOAuthTransfer,
}: {
navigate: (to: string) => Promise;
+ resumeEnterpriseSSO: () => Promise;
resumeOAuthContinuation: () => Promise;
startedAsOAuthTransfer: boolean;
},
@@ -63,6 +67,11 @@ export function resumeSignInAfterProtectCheck(
switch (signIn.status) {
case 'needs_first_factor':
+ // An SSO-only sign-in has no first factor to render — the hand-off to the identity
+ // provider is the next step, and it was interrupted before it could be issued.
+ if (shouldHandOffToEnterpriseConnection(signIn)) {
+ return resumeEnterpriseSSO();
+ }
return navigate('../factor-one');
case 'needs_second_factor':
return navigate('../factor-two');
diff --git a/packages/ui/src/components/SignIn/shared.ts b/packages/ui/src/components/SignIn/shared.ts
index 33cb2026be8..c418d82de6f 100644
--- a/packages/ui/src/components/SignIn/shared.ts
+++ b/packages/ui/src/components/SignIn/shared.ts
@@ -2,7 +2,7 @@ import { isClerkRuntimeError, isUserLockedError } from '@clerk/shared/error';
import { clerkInvalidFAPIResponse } from '@clerk/shared/internal/clerk-js/errors';
import { __internal_WebAuthnAbortService } from '@clerk/shared/internal/clerk-js/passkeys';
import { useClerk } from '@clerk/shared/react';
-import type { EnterpriseSSOFactor, SignInFirstFactor, SignInResource } from '@clerk/shared/types';
+import type { SignInResource } from '@clerk/shared/types';
import { useCallback, useEffect } from 'react';
import { useCardState } from '@/ui/elements/contexts';
@@ -84,28 +84,5 @@ function useHandleAuthenticateWithPasskey(
}, []);
}
-/**
- * Type guard that checks if all factors in the array are enterprise SSO factors
- * with both `enterpriseConnectionId` and `enterpriseConnectionName` properties.
- * This is used to determine if the user should be presented with a choice
- * between multiple enterprise connections.
- * @experimental
- */
-function hasMultipleEnterpriseConnections(
- factors: SignInFirstFactor[] | null,
-): factors is Array {
- if (!factors?.length) {
- return false;
- }
-
- return (
- factors.filter(
- factor =>
- factor.strategy === 'enterprise_sso' &&
- 'enterpriseConnectionId' in factor &&
- 'enterpriseConnectionName' in factor,
- ).length > 1
- );
-}
-
-export { hasMultipleEnterpriseConnections, useHandleAuthenticateWithPasskey };
+export { hasMultipleEnterpriseConnections } from './enterpriseSSOFactors';
+export { useHandleAuthenticateWithPasskey };