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
5 changes: 5 additions & 0 deletions .changeset/resume-enterprise-sso-after-challenge.md
Original file line numberDiff line numberDiff line change
@@ -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.
21 changes: 20 additions & 1 deletion packages/ui/src/components/SignIn/SignInProtectCheck.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
Expand DownExpand Up@@ -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'
Expand Down
19 changes: 4 additions & 15 deletions packages/ui/src/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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' }),
Expand DownExpand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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();
Expand Down
54 changes: 54 additions & 0 deletions packages/ui/src/components/SignIn/enterpriseSSOFactors.ts
Original file line numberDiff line numberDiff line change
@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
9 changes: 9 additions & 0 deletions packages/ui/src/components/SignIn/handleProtectCheck.ts
Original file line numberDiff line numberDiff line change
@@ -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.
*
Expand DownExpand Up@@ -48,10 +50,12 @@ export function resumeSignInAfterProtectCheck(
signIn: SignInResource,
{
navigate,
resumeEnterpriseSSO,
resumeOAuthContinuation,
startedAsOAuthTransfer,
}: {
navigate: (to: string) => Promise<unknown>;
resumeEnterpriseSSO: () => Promise<unknown>;
resumeOAuthContinuation: () => Promise<unknown>;
startedAsOAuthTransfer: boolean;
},
Expand All@@ -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');
Expand Down
29 changes: 3 additions & 26 deletions packages/ui/src/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
5 changes: 5 additions & 0 deletions .changeset/resume-enterprise-sso-after-challenge.md
Original file line numberDiff line numberDiff line change
@@ -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.
21 changes: 20 additions & 1 deletion packages/ui/src/components/SignIn/SignInProtectCheck.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
Expand DownExpand Up@@ -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'
Expand Down
19 changes: 4 additions & 15 deletions packages/ui/src/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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' }),
Expand DownExpand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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();
Expand Down
54 changes: 54 additions & 0 deletions packages/ui/src/components/SignIn/enterpriseSSOFactors.ts
Original file line numberDiff line numberDiff line change
@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
9 changes: 9 additions & 0 deletions packages/ui/src/components/SignIn/handleProtectCheck.ts
Original file line numberDiff line numberDiff line change
@@ -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.
*
Expand DownExpand Up@@ -48,10 +50,12 @@ export function resumeSignInAfterProtectCheck(
signIn: SignInResource,
{
navigate,
resumeEnterpriseSSO,
resumeOAuthContinuation,
startedAsOAuthTransfer,
}: {
navigate: (to: string) => Promise<unknown>;
resumeEnterpriseSSO: () => Promise<unknown>;
resumeOAuthContinuation: () => Promise<unknown>;
startedAsOAuthTransfer: boolean;
},
Expand All@@ -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');
Expand Down
29 changes: 3 additions & 26 deletions packages/ui/src/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
5 changes: 5 additions & 0 deletions .changeset/resume-enterprise-sso-after-challenge.md
Original file line numberDiff line numberDiff line change
@@ -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.
21 changes: 20 additions & 1 deletion packages/ui/src/components/SignIn/SignInProtectCheck.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
Expand DownExpand Up@@ -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'
Expand Down
19 changes: 4 additions & 15 deletions packages/ui/src/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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' }),
Expand DownExpand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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();
Expand Down
54 changes: 54 additions & 0 deletions packages/ui/src/components/SignIn/enterpriseSSOFactors.ts
Original file line numberDiff line numberDiff line change
@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
9 changes: 9 additions & 0 deletions packages/ui/src/components/SignIn/handleProtectCheck.ts
Original file line numberDiff line numberDiff line change
@@ -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.
*
Expand DownExpand Up@@ -48,10 +50,12 @@ export function resumeSignInAfterProtectCheck(
signIn: SignInResource,
{
navigate,
resumeEnterpriseSSO,
resumeOAuthContinuation,
startedAsOAuthTransfer,
}: {
navigate: (to: string) => Promise<unknown>;
resumeEnterpriseSSO: () => Promise<unknown>;
resumeOAuthContinuation: () => Promise<unknown>;
startedAsOAuthTransfer: boolean;
},
Expand All@@ -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');
Expand Down
29 changes: 3 additions & 26 deletions packages/ui/src/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
5 changes: 5 additions & 0 deletions .changeset/resume-enterprise-sso-after-challenge.md
Original file line numberDiff line numberDiff line change
@@ -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.
21 changes: 20 additions & 1 deletion packages/ui/src/components/SignIn/SignInProtectCheck.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
Expand DownExpand Up@@ -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'
Expand Down
19 changes: 4 additions & 15 deletions packages/ui/src/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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' }),
Expand DownExpand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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();
Expand Down
54 changes: 54 additions & 0 deletions packages/ui/src/components/SignIn/enterpriseSSOFactors.ts
Original file line numberDiff line numberDiff line change
@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
9 changes: 9 additions & 0 deletions packages/ui/src/components/SignIn/handleProtectCheck.ts
Original file line numberDiff line numberDiff line change
@@ -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.
*
Expand DownExpand Up@@ -48,10 +50,12 @@ export function resumeSignInAfterProtectCheck(
signIn: SignInResource,
{
navigate,
resumeEnterpriseSSO,
resumeOAuthContinuation,
startedAsOAuthTransfer,
}: {
navigate: (to: string) => Promise<unknown>;
resumeEnterpriseSSO: () => Promise<unknown>;
resumeOAuthContinuation: () => Promise<unknown>;
startedAsOAuthTransfer: boolean;
},
Expand All@@ -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');
Expand Down
29 changes: 3 additions & 26 deletions packages/ui/src/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
5 changes: 5 additions & 0 deletions .changeset/resume-enterprise-sso-after-challenge.md
Original file line numberDiff line numberDiff line change
@@ -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.
21 changes: 20 additions & 1 deletion packages/ui/src/components/SignIn/SignInProtectCheck.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
Expand DownExpand Up@@ -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'
Expand Down
19 changes: 4 additions & 15 deletions packages/ui/src/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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' }),
Expand DownExpand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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();
Expand Down
54 changes: 54 additions & 0 deletions packages/ui/src/components/SignIn/enterpriseSSOFactors.ts
Original file line numberDiff line numberDiff line change
@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
9 changes: 9 additions & 0 deletions packages/ui/src/components/SignIn/handleProtectCheck.ts
Original file line numberDiff line numberDiff line change
@@ -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.
*
Expand DownExpand Up@@ -48,10 +50,12 @@ export function resumeSignInAfterProtectCheck(
signIn: SignInResource,
{
navigate,
resumeEnterpriseSSO,
resumeOAuthContinuation,
startedAsOAuthTransfer,
}: {
navigate: (to: string) => Promise<unknown>;
resumeEnterpriseSSO: () => Promise<unknown>;
resumeOAuthContinuation: () => Promise<unknown>;
startedAsOAuthTransfer: boolean;
},
Expand All@@ -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');
Expand Down
29 changes: 3 additions & 26 deletions packages/ui/src/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
5 changes: 5 additions & 0 deletions .changeset/resume-enterprise-sso-after-challenge.md
Original file line numberDiff line numberDiff line change
@@ -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.
21 changes: 20 additions & 1 deletion packages/ui/src/components/SignIn/SignInProtectCheck.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
Expand DownExpand Up@@ -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'
Expand Down
19 changes: 4 additions & 15 deletions packages/ui/src/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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' }),
Expand DownExpand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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();
Expand Down
54 changes: 54 additions & 0 deletions packages/ui/src/components/SignIn/enterpriseSSOFactors.ts
Original file line numberDiff line numberDiff line change
@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
9 changes: 9 additions & 0 deletions packages/ui/src/components/SignIn/handleProtectCheck.ts
Original file line numberDiff line numberDiff line change
@@ -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.
*
Expand DownExpand Up@@ -48,10 +50,12 @@ export function resumeSignInAfterProtectCheck(
signIn: SignInResource,
{
navigate,
resumeEnterpriseSSO,
resumeOAuthContinuation,
startedAsOAuthTransfer,
}: {
navigate: (to: string) => Promise<unknown>;
resumeEnterpriseSSO: () => Promise<unknown>;
resumeOAuthContinuation: () => Promise<unknown>;
startedAsOAuthTransfer: boolean;
},
Expand All@@ -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');
Expand Down
29 changes: 3 additions & 26 deletions packages/ui/src/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
5 changes: 5 additions & 0 deletions .changeset/resume-enterprise-sso-after-challenge.md
Original file line numberDiff line numberDiff line change
@@ -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.
21 changes: 20 additions & 1 deletion packages/ui/src/components/SignIn/SignInProtectCheck.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
Expand DownExpand Up@@ -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'
Expand Down
19 changes: 4 additions & 15 deletions packages/ui/src/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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' }),
Expand DownExpand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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();
Expand Down
54 changes: 54 additions & 0 deletions packages/ui/src/components/SignIn/enterpriseSSOFactors.ts
Original file line numberDiff line numberDiff line change
@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
9 changes: 9 additions & 0 deletions packages/ui/src/components/SignIn/handleProtectCheck.ts
Original file line numberDiff line numberDiff line change
@@ -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.
*
Expand DownExpand Up@@ -48,10 +50,12 @@ export function resumeSignInAfterProtectCheck(
signIn: SignInResource,
{
navigate,
resumeEnterpriseSSO,
resumeOAuthContinuation,
startedAsOAuthTransfer,
}: {
navigate: (to: string) => Promise<unknown>;
resumeEnterpriseSSO: () => Promise<unknown>;
resumeOAuthContinuation: () => Promise<unknown>;
startedAsOAuthTransfer: boolean;
},
Expand All@@ -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');
Expand Down
29 changes: 3 additions & 26 deletions packages/ui/src/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
5 changes: 5 additions & 0 deletions .changeset/resume-enterprise-sso-after-challenge.md
Original file line numberDiff line numberDiff line change
@@ -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.
21 changes: 20 additions & 1 deletion packages/ui/src/components/SignIn/SignInProtectCheck.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
Expand DownExpand Up@@ -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'
Expand Down
19 changes: 4 additions & 15 deletions packages/ui/src/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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' }),
Expand DownExpand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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(<SignInProtectCheck />, { 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();
Expand Down
54 changes: 54 additions & 0 deletions packages/ui/src/components/SignIn/enterpriseSSOFactors.ts
Original file line numberDiff line numberDiff line change
@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
9 changes: 9 additions & 0 deletions packages/ui/src/components/SignIn/handleProtectCheck.ts
Original file line numberDiff line numberDiff line change
@@ -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.
*
Expand DownExpand Up@@ -48,10 +50,12 @@ export function resumeSignInAfterProtectCheck(
signIn: SignInResource,
{
navigate,
resumeEnterpriseSSO,
resumeOAuthContinuation,
startedAsOAuthTransfer,
}: {
navigate: (to: string) => Promise<unknown>;
resumeEnterpriseSSO: () => Promise<unknown>;
resumeOAuthContinuation: () => Promise<unknown>;
startedAsOAuthTransfer: boolean;
},
Expand All@@ -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');
Expand Down
29 changes: 3 additions & 26 deletions packages/ui/src/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }> {
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 };
Loading