From 6531a8134e6a49ea1cf8d742a281225363e42695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maur=C3=ADcio=20Antunes?= Date: Tue, 8 Sep 2026 17:11:18 -0300 Subject: [PATCH 01/14] feat(ui): add SSO fallback sign-in flow to `` Enterprise-routed sign-ins on instances that allow a fallback now stop at a screen offering SSO plus a "Can't use SSO?" link to an email code step, instead of redirecting straight to the identity provider. --- .changeset/sso-fallback-sign-in-flow.md | 12 ++ .../clerk-js/src/core/resources/SignIn.ts | 9 ++ .../core/resources/__tests__/SignIn.test.ts | 44 ++++++ packages/localizations/src/en-US.ts | 12 ++ packages/shared/src/types/elementIds.ts | 3 +- packages/shared/src/types/localization.ts | 12 ++ packages/shared/src/types/signIn.ts | 15 ++ packages/shared/src/types/signInFuture.ts | 5 + .../common/ChooseEnterpriseConnectionCard.tsx | 6 +- .../src/components/SignIn/SignInFactorOne.tsx | 16 +- .../SignIn/SignInFactorOneCodeForm.tsx | 2 + .../SignIn/SignInFactorOneSSOFallback.tsx | 144 ++++++++++++++++++ .../ui/src/components/SignIn/SignInStart.tsx | 15 +- .../SignInFactorOneSSOFallback.test.tsx | 123 +++++++++++++++ .../SignIn/__tests__/SignInStart.test.tsx | 20 +++ packages/ui/src/components/SignIn/shared.ts | 20 ++- packages/ui/src/elements/contexts/index.tsx | 1 + packages/ui/src/test/fixture-helpers.ts | 29 ++++ .../__tests__/formatSafeIdentifier.test.ts | 17 ++- packages/ui/src/utils/formatSafeIdentifier.ts | 15 ++ 20 files changed, 512 insertions(+), 8 deletions(-) create mode 100644 .changeset/sso-fallback-sign-in-flow.md create mode 100644 packages/ui/src/components/SignIn/SignInFactorOneSSOFallback.tsx create mode 100644 packages/ui/src/components/SignIn/__tests__/SignInFactorOneSSOFallback.test.tsx diff --git a/.changeset/sso-fallback-sign-in-flow.md b/.changeset/sso-fallback-sign-in-flow.md new file mode 100644 index 00000000000..17ce9b7fa2b --- /dev/null +++ b/.changeset/sso-fallback-sign-in-flow.md @@ -0,0 +1,12 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Add the SSO fallback sign-in flow to ``, for instances that let enterprise users sign in with an email code when they cannot reach their identity provider. + +On such an instance the sign-in no longer redirects straight to the identity provider. It shows the SSO action — or the connection picker, when several connections serve the address — alongside a "Can't use SSO?" link leading to the standard email code step, which carries a notice that the organization requires single sign-on and that the attempt is recorded. The link is driven purely by instance configuration and looks identical for every user; the API never discloses who is actually allowed to use the fallback. Instances without the feature are unaffected. + +Custom flows can read the same factor from the new `ssoFallbackFirstFactors` property on the sign-in resource. The flow adds the `signIn.enterpriseSSO` and `signIn.ssoFallback` localization keys and the `ssoFallback` card action element id. diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index c2de93a5030..0af675f8ea2 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -118,6 +118,7 @@ export class SignIn extends BaseResource implements SignInResource { supportedIdentifiers: SignInIdentifier[] = []; supportedFirstFactors: SignInFirstFactor[] | null = []; supportedSecondFactors: SignInSecondFactor[] | null = null; + ssoFallbackFirstFactors: SignInFirstFactor[] | null = null; firstFactorVerification: VerificationResource = new Verification(null); secondFactorVerification: VerificationResource = new Verification(null); identifier: string | null = null; @@ -639,6 +640,9 @@ export class SignIn extends BaseResource implements SignInResource { this.identifier = data.identifier; this.supportedFirstFactors = deepSnakeToCamel(data.supported_first_factors) as SignInFirstFactor[] | null; this.supportedSecondFactors = deepSnakeToCamel(data.supported_second_factors) as SignInSecondFactor[] | null; + this.ssoFallbackFirstFactors = deepSnakeToCamel(data.sso_fallback_first_factors ?? null) as + | SignInFirstFactor[] + | null; this.firstFactorVerification = new Verification(data.first_factor_verification); this.secondFactorVerification = new Verification(data.second_factor_verification); this.createdSessionId = data.created_session_id; @@ -708,6 +712,7 @@ export class SignIn extends BaseResource implements SignInResource { supported_identifiers: this.supportedIdentifiers, supported_first_factors: deepCamelToSnake(this.supportedFirstFactors), supported_second_factors: deepCamelToSnake(this.supportedSecondFactors), + sso_fallback_first_factors: deepCamelToSnake(this.ssoFallbackFirstFactors) ?? undefined, first_factor_verification: this.firstFactorVerification.__internal_toSnapshot(), second_factor_verification: this.secondFactorVerification.__internal_toSnapshot(), identifier: this.identifier, @@ -827,6 +832,10 @@ class SignInFuture implements SignInFutureResource { return this.#resource.supportedSecondFactors ?? []; } + get ssoFallbackFirstFactors() { + return this.#resource.ssoFallbackFirstFactors ?? []; + } + get isTransferable() { return this.#resource.firstFactorVerification.status === 'transferable'; } diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index df1d5a34891..7d721a3bbc6 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -3291,4 +3291,48 @@ describe('SignIn', () => { expect(result.protectCheck).toBeNull(); }); }); + + describe('ssoFallbackFirstFactors', () => { + const baseJSON = { + id: 'signin_123', + object: 'sign_in', + status: 'needs_first_factor', + supported_identifiers: [], + identifier: 'user@corp.com', + user_data: {} as any, + supported_first_factors: [{ strategy: 'enterprise_sso' }], + supported_second_factors: [], + first_factor_verification: null, + second_factor_verification: null, + created_session_id: null, + } as any; + + it('defaults to null when the field is absent', () => { + const signIn = new SignIn(baseJSON); + + expect(signIn.ssoFallbackFirstFactors).toBeNull(); + expect(signIn.__internal_future.ssoFallbackFirstFactors).toEqual([]); + expect(signIn.__internal_toSnapshot().sso_fallback_first_factors).toBeUndefined(); + }); + + it('round-trips the field through the snapshot', () => { + const signIn = new SignIn({ + ...baseJSON, + sso_fallback_first_factors: [ + { strategy: 'email_code', safe_identifier: 'user@corp.com', email_address_id: 'idn_hmac' }, + ], + }); + + expect(signIn.ssoFallbackFirstFactors).toEqual([ + { strategy: 'email_code', safeIdentifier: 'user@corp.com', emailAddressId: 'idn_hmac' }, + ]); + expect(signIn.__internal_future.ssoFallbackFirstFactors).toEqual(signIn.ssoFallbackFirstFactors); + + const snapshot = signIn.__internal_toSnapshot(); + expect(snapshot.sso_fallback_first_factors).toEqual([ + { strategy: 'email_code', safe_identifier: 'user@corp.com', email_address_id: 'idn_hmac' }, + ]); + expect(new SignIn(snapshot).ssoFallbackFirstFactors).toEqual(signIn.ssoFallbackFirstFactors); + }); + }); }); diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index f13d1f147e6..3f703b22211 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1485,6 +1485,9 @@ export const enUS: LocalizationResource = { subtitle: 'Select the enterprise account with which you wish to continue.', title: 'Choose your enterprise account', }, + enterpriseSSO: { + formButtonPrimary: 'Continue with SSO', + }, forgotPassword: { formTitle: 'Reset password code', resendButton: "Didn't receive a code? Resend", @@ -1571,6 +1574,15 @@ export const enUS: LocalizationResource = { title: 'Sign in to {{applicationName}}', titleCombined: 'Continue to {{applicationName}}', }, + ssoFallback: { + actionLink: "Can't use SSO?", + code: { + resendButton: "Didn't receive a code? Resend", + subtitle: 'to continue to {{applicationName}}', + title: 'Check your email', + }, + notice: 'Your organization requires single sign-on. Continuing without it is recorded.', + }, totpMfa: { formTitle: 'Verification code', subtitle: 'To continue, please enter the verification code generated by your authenticator app', diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index 03a51460dc5..32deb108f1b 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -95,7 +95,8 @@ export type CardActionId = | 'signIn' | 'usePasskey' | 'waitlist' - | 'signOut'; + | 'signOut' + | 'ssoFallback'; export type MenuId = 'invitation' | 'member' | ProfileSectionId; export type SelectId = 'countryCode' | 'role' | 'paymentMethod' | 'apiKeyExpiration'; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index f1f6e9dd7bf..68480473c3e 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -619,6 +619,18 @@ export type __internal_LocalizationResource = { title: LocalizationValue; subtitle: LocalizationValue; }; + enterpriseSSO: { + formButtonPrimary: LocalizationValue; + }; + ssoFallback: { + actionLink: LocalizationValue; + notice: LocalizationValue; + code: { + title: LocalizationValue; + subtitle: LocalizationValue; + resendButton: LocalizationValue; + }; + }; web3Solana: { title: LocalizationValue; subtitle: LocalizationValue; diff --git a/packages/shared/src/types/signIn.ts b/packages/shared/src/types/signIn.ts index 9078b4accdb..6bf024d3db5 100644 --- a/packages/shared/src/types/signIn.ts +++ b/packages/shared/src/types/signIn.ts @@ -51,6 +51,16 @@ export interface SignInResource extends ClerkResource { supportedIdentifiers: SignInIdentifier[]; supportedFirstFactors: SignInFirstFactor[] | null; supportedSecondFactors: SignInSecondFactor[] | null; + /** + * Factors an enterprise-routed user can fall back to when they cannot reach their + * identity provider. Kept out of `supportedFirstFactors` so consumers that assert + * that list is purely enterprise keep working. + * + * Its presence says the instance allows a fallback, never that this particular + * user may use one — the API does not disclose that, and the UI must not branch + * on it. + */ + ssoFallbackFirstFactors: SignInFirstFactor[] | null; clientTrustState?: ClientTrustState; firstFactorVerification: VerificationResource; secondFactorVerification: VerificationResource; @@ -130,6 +140,11 @@ export interface SignInJSON extends ClerkResourceJSON { user_data: UserDataJSON; supported_first_factors: SignInFirstFactorJSON[]; supported_second_factors: SignInSecondFactorJSON[]; + /** + * Present only when the instance has opted into SSO fallback and the sign-in is + * still owed its first factor. Absent everywhere else. + */ + sso_fallback_first_factors?: SignInFirstFactorJSON[]; first_factor_verification: VerificationJSON | null; second_factor_verification: VerificationJSON | null; created_session_id: string | null; diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index 59941ee856a..b9c4390f5fb 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -338,6 +338,11 @@ export interface SignInFutureResource { */ readonly supportedSecondFactors: SignInSecondFactor[]; + /** + * Factors an enterprise-routed user can fall back to when they cannot reach their identity provider. Empty unless the instance allows a fallback; its contents never indicate whether this particular user is permitted to use one. + */ + readonly ssoFallbackFirstFactors: SignInFirstFactor[]; + /** * The current status of the sign-in. *
    diff --git a/packages/ui/src/common/ChooseEnterpriseConnectionCard.tsx b/packages/ui/src/common/ChooseEnterpriseConnectionCard.tsx index 73f3efb7b3c..0c865053090 100644 --- a/packages/ui/src/common/ChooseEnterpriseConnectionCard.tsx +++ b/packages/ui/src/common/ChooseEnterpriseConnectionCard.tsx @@ -1,3 +1,4 @@ +import type { PropsWithChildren } from 'react'; import { useState } from 'react'; import type { LocalizationKey } from '@/ui/customizables'; @@ -22,7 +23,8 @@ export const ChooseEnterpriseConnectionCard = ({ subtitle, onClick, enterpriseConnections, -}: ChooseEnterpriseConnectionCardProps) => { + children, +}: PropsWithChildren) => { const card = useCardState(); return ( @@ -47,6 +49,8 @@ export const ChooseEnterpriseConnectionCard = ({ /> ))} + + {children} diff --git a/packages/ui/src/components/SignIn/SignInFactorOne.tsx b/packages/ui/src/components/SignIn/SignInFactorOne.tsx index fbf417e2800..63f5f76006b 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOne.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOne.tsx @@ -13,7 +13,7 @@ import { localizationKeys } from '../../localization'; import { useRouter } from '../../router'; import type { AlternativeMethodsMode } from './AlternativeMethods'; import { AlternativeMethods } from './AlternativeMethods'; -import { hasMultipleEnterpriseConnections, SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from './shared'; +import { getSSOFallbackFactor, hasMultipleEnterpriseConnections, SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from './shared'; import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard'; import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard'; import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard'; @@ -23,6 +23,7 @@ import { SignInFactorOnePasskey } from './SignInFactorOnePasskey'; import type { PasswordErrorCode } from './SignInFactorOnePasswordCard'; import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard'; import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard'; +import { SignInFactorOneSSOFallback } from './SignInFactorOneSSOFallback'; import { useResetPasswordFactor } from './useResetPasswordFactor'; import { determineStartingSignInFactor, factorHasLocalStrategy } from './utils'; @@ -109,6 +110,10 @@ function SignInFactorOneInternal(): JSX.Element { supportedFirstFactors, }); + // Frozen on mount: a later response may drop the field, which would otherwise unmount the + // fallback screens mid-flow. + const ssoFallbackFactor = React.useRef(getSSOFallbackFactor(signIn)).current; + const resetPasswordFactor = useResetPasswordFactor(); const resetPasswordIntent = router.queryParams[SIGN_IN_RESET_PASSWORD_INTENT_PARAM] === 'true'; @@ -162,6 +167,15 @@ function SignInFactorOneInternal(): JSX.Element { })); }; + /** + * An enterprise-routed sign-in on an instance that allows a fallback owns its own screens, + * including the enterprise connection choice, so that the fallback stays reachable from them. + * @experimental + */ + if (ssoFallbackFactor) { + return ; + } + /** * Prompt to choose between a list of enterprise connections as supported first factors * @experimental diff --git a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx index e18f44c00e9..09bfc8e4df0 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx @@ -29,6 +29,7 @@ export type SignInFactorOneCodeCard = Pick< export type SignInFactorOneCodeFormProps = SignInFactorOneCodeCard & { cardTitle: LocalizationKey; cardSubtitle: LocalizationKey; + cardNotice?: LocalizationKey; inputLabel: LocalizationKey; resendButton: LocalizationKey; identityPreviewEditButtonAriaLabel: LocalizationKey; @@ -169,6 +170,7 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => { + const { fallbackFactor } = props; + const card = useCardState(); + const ctx = useSignInContext(); + const signIn = useCoreSignIn(); + const [step, setStep] = React.useState('sso'); + const [isRedirecting, setIsRedirecting] = React.useState(false); + + // `safe_identifier` is the address the user typed, unmasked; the design shows it obfuscated. + const maskedFallbackFactor = React.useMemo( + () => ({ ...fallbackFactor, safeIdentifier: maskEmailAddress(fallbackFactor.safeIdentifier) as string }), + [fallbackFactor], + ); + + const goToStep = (next: Step) => { + card.setError(undefined); + setStep(next); + }; + + const authenticateWithEnterpriseSSO = async (enterpriseConnectionId?: string) => { + await signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: ctx.ssoCallbackUrl, + redirectUrlComplete: ctx.afterSignInUrl || '/', + oidcPrompt: ctx.oidcPrompt, + continueSignIn: true, + ...(enterpriseConnectionId && { enterpriseConnectionId }), + }); + }; + + const handleContinueWithSSO = () => { + setIsRedirecting(true); + authenticateWithEnterpriseSSO().catch(err => { + setIsRedirecting(false); + handleError(err, [], card.setError); + }); + }; + + if (step === 'code') { + return ( + + {}} + cardTitle={localizationKeys('signIn.ssoFallback.code.title')} + cardSubtitle={localizationKeys('signIn.ssoFallback.code.subtitle')} + cardNotice={localizationKeys('signIn.ssoFallback.notice')} + inputLabel={localizationKeys('signIn.emailCode.formTitle')} + resendButton={localizationKeys('signIn.ssoFallback.code.resendButton')} + identityPreviewEditButtonAriaLabel={localizationKeys('identityPreviewEditButton__emailAddress')} + onShowAlternativeMethodsClicked={() => goToStep('sso')} + /> + + ); + } + + const fallbackAction = ( + + goToStep('code')} + /> + + ); + + if (hasMultipleEnterpriseConnections(signIn.supportedFirstFactors)) { + const enterpriseConnections = signIn.supportedFirstFactors.map(factor => ({ + id: factor.enterpriseConnectionId, + name: factor.enterpriseConnectionName, + })); + + return ( + + + {fallbackAction} + + + ); + } + + return ( + + + + + + + + {card.error} + +