From 84d79f69fa426c7cc5bee88f8eb78a7d85272bff Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Wed, 25 Feb 2026 13:12:53 -0500 Subject: [PATCH 1/2] feat(ui): Support signUpIfMissing with Clerk component The `` component can already be used in a sign-in-or-sign-up flow (`CombinedFlow`) under certain conditions. When strict enumeration protection is enabled, make that combined flow pass the `signUpIfMissing` parameter to the backend to allow an enumeration-safe combined flow. Previously, attempting to use a combined flow with strict enumeration protection enabled was silently broken. Under the hood, the backend treats sign up if missing as an account transfer. We therefore add support for this account transfer logic when handling first factor verification in the combined sign in flow when strict enumeration protection is enabled. --- .changeset/fancy-candies-slide.md | 7 + packages/clerk-js/src/core/clerk.ts | 8 ++ .../clerk-js/src/core/resources/SignIn.ts | 2 +- .../src/core/resources/UserSettings.ts | 4 + packages/shared/src/errors/emailLinkError.ts | 1 + packages/shared/src/types/userSettings.ts | 8 ++ .../SignIn/SignInFactorOneCodeForm.tsx | 21 ++- .../SignIn/SignInFactorOneEmailLinkCard.tsx | 19 ++- .../ui/src/components/SignIn/SignInStart.tsx | 14 +- .../SignInFactorOneTransfer.test.tsx | 130 ++++++++++++++++++ .../SignIn/__tests__/SignInStart.test.tsx | 75 ++++++++++ .../handleSignUpIfMissingTransfer.test.ts | 103 ++++++++++++++ .../SignIn/handleSignUpIfMissingTransfer.ts | 47 +++++++ packages/ui/src/test/fixture-helpers.ts | 9 ++ packages/ui/src/test/fixtures.ts | 5 + 15 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 .changeset/fancy-candies-slide.md create mode 100644 packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx create mode 100644 packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts create mode 100644 packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts diff --git a/.changeset/fancy-candies-slide.md b/.changeset/fancy-candies-slide.md new file mode 100644 index 00000000000..852bf39a8a8 --- /dev/null +++ b/.changeset/fancy-candies-slide.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Support signUpIfMissing with strict enumeration protection and Clerk component diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 1423f8bf1a2..8b854c5dbd0 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2116,6 +2116,14 @@ export class Clerk implements ClerkInterface { throw new EmailLinkError(EmailLinkErrorCodeStatus.Expired); } else if (verificationStatus === 'client_mismatch') { throw new EmailLinkError(EmailLinkErrorCodeStatus.ClientMismatch); + } else if (verificationStatus === 'transferable') { + // signUpIfMissing flow: the email was verified but the user doesn't exist. + // The polling tab handles the actual sign-up transfer, so treat this + // the same as verified-on-other-device for the link-click tab. + if (typeof params.onVerifiedOnOtherDevice === 'function') { + params.onVerifiedOnOtherDevice(); + } + return; } else if (verificationStatus !== 'verified') { throw new EmailLinkError(EmailLinkErrorCodeStatus.Failed); } diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 487ccb12f76..285e4697a8a 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -308,7 +308,7 @@ export class SignIn extends BaseResource implements SignInResource { return this.reload() .then(res => { const status = res[verificationKey].status; - if (status === 'verified' || status === 'expired') { + if (status === 'verified' || status === 'expired' || status === 'transferable') { stop(); resolve(res); } diff --git a/packages/clerk-js/src/core/resources/UserSettings.ts b/packages/clerk-js/src/core/resources/UserSettings.ts index 93078997ebc..b70fd0b139a 100644 --- a/packages/clerk-js/src/core/resources/UserSettings.ts +++ b/packages/clerk-js/src/core/resources/UserSettings.ts @@ -1,4 +1,5 @@ import type { + AttackProtectionData, Attributes, EnterpriseSSOSettings, OAuthProviders, @@ -103,6 +104,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource { name: 'passkey', }, }; + attackProtection: AttackProtectionData = { enumeration_protection: { enabled: false } }; enterpriseSSO: EnterpriseSSOSettings = { enabled: false, }; @@ -213,6 +215,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource { this.attributes, ); this.actions = this.withDefault(data.actions, this.actions); + this.attackProtection = this.withDefault(data.attack_protection, this.attackProtection); this.enterpriseSSO = this.withDefault(data.enterprise_sso, this.enterpriseSSO); this.passkeySettings = this.withDefault(data.passkey_settings, this.passkeySettings); this.passwordSettings = data.password_settings @@ -251,6 +254,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource { public __internal_toSnapshot(): UserSettingsJSONSnapshot { return { actions: this.actions, + attack_protection: this.attackProtection, attributes: this.attributes, passkey_settings: this.passkeySettings, password_settings: this.passwordSettings, diff --git a/packages/shared/src/errors/emailLinkError.ts b/packages/shared/src/errors/emailLinkError.ts index 8c1055ea4a5..a353b4da6fb 100644 --- a/packages/shared/src/errors/emailLinkError.ts +++ b/packages/shared/src/errors/emailLinkError.ts @@ -24,4 +24,5 @@ export const EmailLinkErrorCodeStatus = { Expired: 'expired', Failed: 'failed', ClientMismatch: 'client_mismatch', + Transferable: 'transferable', } as const; diff --git a/packages/shared/src/types/userSettings.ts b/packages/shared/src/types/userSettings.ts index 149db66220f..a5bee6c462a 100644 --- a/packages/shared/src/types/userSettings.ts +++ b/packages/shared/src/types/userSettings.ts @@ -81,6 +81,12 @@ export type UsernameSettingsData = { max_length: number; }; +export type AttackProtectionData = { + enumeration_protection: { + enabled: boolean; + }; +}; + export type PasskeySettingsData = { allow_autofill: boolean; show_sign_in_button: boolean; @@ -120,6 +126,7 @@ export interface UserSettingsJSON extends ClerkResourceJSON { password_settings: PasswordSettingsData; passkey_settings: PasskeySettingsData; username_settings: UsernameSettingsData; + attack_protection: AttackProtectionData; } export interface UserSettingsResource extends ClerkResource { @@ -134,6 +141,7 @@ export interface UserSettingsResource extends ClerkResource { signUp: SignUpData; passwordSettings: PasswordSettingsData; usernameSettings: UsernameSettingsData; + attackProtection: AttackProtectionData; passkeySettings: PasskeySettingsData; socialProviderStrategies: OAuthStrategy[]; authenticatableSocialStrategies: OAuthStrategy[]; diff --git a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx index da2863fd3d9..a8fd22e4a3d 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx @@ -9,11 +9,12 @@ import type { VerificationCodeCardProps } from '@/ui/elements/VerificationCodeCa import { VerificationCodeCard } from '@/ui/elements/VerificationCodeCard'; import { handleError } from '@/ui/utils/errorHandler'; -import { useCoreSignIn, useSignInContext } from '../../contexts'; +import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; import { useFetch } from '../../hooks'; import { useSupportEmail } from '../../hooks/useSupportEmail'; import { type LocalizationKey } from '../../localization'; import { useRouter } from '../../router'; +import { handleSignUpIfMissingTransfer } from './handleSignUpIfMissingTransfer'; export type SignInFactorOneCodeCard = Pick< VerificationCodeCardProps, @@ -35,8 +36,10 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => const signIn = useCoreSignIn(); const card = useCardState(); const { navigate } = useRouter(); - const { afterSignInUrl, navigateOnSetActive } = useSignInContext(); + const ctx = useSignInContext(); + const { afterSignInUrl, afterSignUpUrl, navigateOnSetActive, isCombinedFlow } = ctx; const { setActive } = useClerk(); + const { userSettings } = useEnvironment(); const supportEmail = useSupportEmail(); const clerk = useClerk(); @@ -116,6 +119,20 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => return clerk.__internal_navigateWithError('..', err.errors[0]); } + if ( + isCombinedFlow && + userSettings.attackProtection.enumeration_protection.enabled && + signIn.firstFactorVerification.status === 'transferable' + ) { + return handleSignUpIfMissingTransfer({ + clerk, + navigate, + afterSignUpUrl, + navigateOnSetActive, + unsafeMetadata: ctx.unsafeMetadata, + }); + } + return reject(err); }); }; diff --git a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx index 25a158a5044..54fc14e7726 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx @@ -9,11 +9,12 @@ import { handleError } from '@/ui/utils/errorHandler'; import { EmailLinkStatusCard } from '../../common'; import { buildVerificationRedirectUrl } from '../../common/redirects'; -import { useCoreSignIn, useSignInContext } from '../../contexts'; +import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; import { Flow, localizationKeys, useLocalizations } from '../../customizables'; import { useCardState } from '../../elements/contexts'; import { useEmailLink } from '../../hooks/useEmailLink'; import { useRouter } from '../../router/RouteContext'; +import { handleSignUpIfMissingTransfer } from './handleSignUpIfMissingTransfer'; type SignInFactorOneEmailLinkCardProps = Pick & { factor: EmailLinkFactor; @@ -26,10 +27,10 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard const card = useCardState(); const signIn = useCoreSignIn(); const signInContext = useSignInContext(); - const { signInUrl } = signInContext; + const { signInUrl, afterSignInUrl, afterSignUpUrl, isCombinedFlow, navigateOnSetActive } = signInContext; const { navigate } = useRouter(); - const { afterSignInUrl } = useSignInContext(); const { setActive } = useClerk(); + const { userSettings } = useEnvironment(); const { startEmailLinkFlow, cancelEmailLinkFlow } = useEmailLink(signIn); const [showVerifyModal, setShowVerifyModal] = React.useState(false); const clerk = useClerk(); @@ -63,6 +64,18 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard const ver = si.firstFactorVerification; if (ver.status === 'expired') { card.setError(t(localizationKeys('formFieldError__verificationLinkExpired'))); + } else if ( + isCombinedFlow && + userSettings.attackProtection.enumeration_protection.enabled && + ver.status === 'transferable' + ) { + return handleSignUpIfMissingTransfer({ + clerk, + navigate, + afterSignUpUrl, + navigateOnSetActive, + unsafeMetadata: signInContext.unsafeMetadata, + }); } else if (ver.verifiedFromTheSameClient()) { setShowVerifyModal(true); } else { diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 73cb247af7e..e03f0c0c55b 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -375,7 +375,19 @@ function SignInStartInternal(): JSX.Element { } as any); } try { - const res = await safePasswordSignInForEnterpriseSSOInstance(signIn.create(buildSignInParams(fields)), fields); + // Sign up if missing sign-in-or-sign-up flows do not currently support password + // sign in, since this is not enumeration-safe. + const hasPassword = fields.some(f => f.name === 'password' && !!f.value); + const shouldSignUpIfMissing = + isCombinedFlow && userSettings.attackProtection.enumeration_protection.enabled && !hasPassword; + + const res = await safePasswordSignInForEnterpriseSSOInstance( + signIn.create({ + ...buildSignInParams(fields), + ...(shouldSignUpIfMissing && { signUpIfMissing: true }), + }), + fields, + ); switch (res.status) { case 'needs_identifier': diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx new file mode 100644 index 00000000000..a3b143b2ffb --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx @@ -0,0 +1,130 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { SignInResource } from '@clerk/shared/types'; +import { waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { SignInFactorOne } from '../SignInFactorOne'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +describe('SignInFactorOne sign-up-if-missing transfer', () => { + it('triggers sign-up transfer when attemptFirstFactor fails with transferable status', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + // Simulate SDK updating the resource before throwing (backend returns 404 with transferable in meta.client) + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + return Promise.reject( + new ClerkAPIResponseError('Error', { + data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }], + status: 404, + }), + ); + }); + fixtures.signUp.create.mockResolvedValueOnce({ status: 'complete', createdSessionId: 'sess_123' } as any); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + await waitFor(() => { + expect(fixtures.signUp.create).toHaveBeenCalledWith( + expect.objectContaining({ + transfer: true, + }), + ); + }); + }); + + it('navigates to create/continue when transfer results in missing_requirements', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + return Promise.reject( + new ClerkAPIResponseError('Error', { + data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }], + status: 404, + }), + ); + }); + fixtures.signUp.create.mockResolvedValueOnce({ status: 'missing_requirements' } as any); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + await waitFor(() => { + expect(fixtures.router.navigate).toHaveBeenCalledWith('../create/continue'); + }); + }); + + it('does not trigger transfer when enumeration protection is disabled', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + return Promise.reject( + new ClerkAPIResponseError('Error', { + data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }], + status: 404, + }), + ); + }); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + await waitFor(() => { + expect(fixtures.signUp.create).not.toHaveBeenCalled(); + }); + }); + + it('does not trigger transfer when not in combined flow', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + return Promise.reject( + new ClerkAPIResponseError('Error', { + data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }], + status: 404, + }), + ); + }); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + await waitFor(() => { + expect(fixtures.signUp.create).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 7b14917cbe5..f6990fc37d8 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -606,6 +606,81 @@ describe('SignInStart', () => { }); }); + describe('signUpIfMissing', () => { + it('passes signUpIfMissing: true when combined flow and enumeration protection are enabled', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withEnumerationProtection(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + + it('does not pass signUpIfMissing when enumeration protection is disabled', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + + it('does not pass signUpIfMissing when not in combined flow', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withEnumerationProtection(); + }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + + it('does not pass signUpIfMissing when password is present', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword({ required: true }); + f.withEnumerationProtection(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { container, userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + const passwordField = container.querySelector('#password-field') as Element; + expect(passwordField).not.toBeNull(); + fireEvent.change(passwordField, { target: { value: 'some-password' } }); + const form = container.querySelector('form') as Element; + fireEvent.submit(form); + await waitFor(() => { + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + }); + }); + describe('ticket flow', () => { it('calls the appropriate resource function upon detecting the ticket', async () => { const { wrapper, fixtures } = await createFixtures(f => { diff --git a/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts b/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts new file mode 100644 index 00000000000..13fde8cc756 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts @@ -0,0 +1,103 @@ +import type { LoadedClerk } from '@clerk/shared/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { handleSignUpIfMissingTransfer } from '../handleSignUpIfMissingTransfer'; + +const mockNavigate = vi.fn(); +const mockNavigateOnSetActive = vi.fn(); + +const createMockClerk = (signUpCreateResult: unknown = {}) => { + return { + client: { + signUp: { + create: vi.fn().mockResolvedValue(signUpCreateResult), + }, + }, + setActive: vi.fn(), + } as unknown as LoadedClerk; +}; + +describe('handleSignUpIfMissingTransfer', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should call signUp.create with transfer: true', async () => { + const clerk = createMockClerk({ status: 'complete', createdSessionId: 'sess_123' }); + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + navigateOnSetActive: mockNavigateOnSetActive, + }); + + expect(clerk.client.signUp.create).toHaveBeenCalledWith({ + transfer: true, + unsafeMetadata: undefined, + }); + }); + + it('should pass unsafeMetadata to signUp.create', async () => { + const clerk = createMockClerk({ status: 'complete', createdSessionId: 'sess_123' }); + const unsafeMetadata = { foo: 'bar' }; + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + navigateOnSetActive: mockNavigateOnSetActive, + unsafeMetadata, + }); + + expect(clerk.client.signUp.create).toHaveBeenCalledWith({ + transfer: true, + unsafeMetadata, + }); + }); + + it('should call setActive when sign-up status is complete', async () => { + const clerk = createMockClerk({ status: 'complete', createdSessionId: 'sess_123' }); + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + navigateOnSetActive: mockNavigateOnSetActive, + }); + + expect(clerk.setActive).toHaveBeenCalledWith( + expect.objectContaining({ + session: 'sess_123', + }), + ); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('should navigate to create/continue when sign-up status is missing_requirements', async () => { + const clerk = createMockClerk({ status: 'missing_requirements' }); + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + navigateOnSetActive: mockNavigateOnSetActive, + }); + + expect(mockNavigate).toHaveBeenCalledWith('../create/continue'); + expect(clerk.setActive).not.toHaveBeenCalled(); + }); + + it('should throw on unexpected sign-up status', async () => { + const clerk = createMockClerk({ status: 'abandoned' }); + + await expect( + handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + navigateOnSetActive: mockNavigateOnSetActive, + }), + ).rejects.toThrow('Unexpected sign-up status after transfer: abandoned'); + }); +}); diff --git a/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts b/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts new file mode 100644 index 00000000000..370906548a9 --- /dev/null +++ b/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts @@ -0,0 +1,47 @@ +import type { DecorateUrl, LoadedClerk, SessionResource } from '@clerk/shared/types'; + +import type { RouteContextValue } from '../../router/RouteContext'; + +type HandleSignUpIfMissingTransferProps = { + clerk: LoadedClerk; + navigate: RouteContextValue['navigate']; + afterSignUpUrl: string; + navigateOnSetActive: (opts: { + session: SessionResource; + redirectUrl: string; + decorateUrl: DecorateUrl; + }) => Promise; + unsafeMetadata?: SignUpUnsafeMetadata; +}; + +/** + * Handles transferring from sign-in to sign-up when the backend returns + * `firstFactorVerification.status === 'transferable'` (i.e. the user does not + * exist and `signUpIfMissing` was used). + */ +export async function handleSignUpIfMissingTransfer({ + clerk, + navigate, + afterSignUpUrl, + navigateOnSetActive, + unsafeMetadata, +}: HandleSignUpIfMissingTransferProps): Promise { + const res = await clerk.client.signUp.create({ + transfer: true, + unsafeMetadata, + }); + + switch (res.status) { + case 'complete': + return clerk.setActive({ + session: res.createdSessionId, + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl }); + }, + }); + case 'missing_requirements': + return navigate(`../create/continue`); + default: + throw new Error(`Unexpected sign-up status after transfer: ${res.status}`); + } +} diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts index 45525aef82d..8a866a4469d 100644 --- a/packages/ui/src/test/fixture-helpers.ts +++ b/packages/ui/src/test/fixture-helpers.ts @@ -588,6 +588,14 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { us.sign_up.mfa = { required }; }; + const withEnumerationProtection = () => { + us.attack_protection = { + enumeration_protection: { + enabled: true, + }, + }; + }; + // TODO: Add the rest, consult pkg/generate/auth_config.go return { @@ -609,5 +617,6 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { withLegalConsent, withWaitlistMode, withMfaRequired, + withEnumerationProtection, }; }; diff --git a/packages/ui/src/test/fixtures.ts b/packages/ui/src/test/fixtures.ts index 8591a0df459..f20c77adaa3 100644 --- a/packages/ui/src/test/fixtures.ts +++ b/packages/ui/src/test/fixtures.ts @@ -235,6 +235,11 @@ const createBaseUserSettings = (): UserSettingsJSON => { }, password_settings: passwordSettingsConfig, passkey_settings: passkeySettingsConfig, + attack_protection: { + enumeration_protection: { + enabled: false, + }, + }, }; }; From 40d8ceb126c410aa36bbeb39ab7f8d4eacaea062 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Mon, 2 Mar 2026 16:54:54 -0500 Subject: [PATCH 2/2] fix: Also add transferable status to SignInFuture email link This is connected to custom flows and was missed in the previous PRs supporting custom flows. Let's add it now while we are here. --- .../clerk-js/src/core/resources/SignIn.ts | 2 +- .../core/resources/__tests__/SignIn.test.ts | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 285e4697a8a..8c84bfbf4e4 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -1070,7 +1070,7 @@ class SignInFuture implements SignInFutureResource { try { const res = await this.#resource.__internal_baseGet(); const status = res.firstFactorVerification.status; - if (status === 'verified' || status === 'expired') { + if (status === 'verified' || status === 'expired' || status === 'transferable') { stop(); resolve(res); } 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 76d82b08de8..095c2c2a0f6 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -951,6 +951,37 @@ describe('SignIn', () => { expect.anything(), ); }); + + it('polls until firstFactorVerification status is transferable', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_123', + first_factor_verification: { status: 'unverified' }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_123', + first_factor_verification: { status: 'transferable' }, + }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + await signIn.__internal_future.emailLink.waitForVerification(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/client/sign_ins/signin_123', + }), + expect.anything(), + ); + }); }); describe('sendPhoneCode', () => {