Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fancy-candies-slide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/clerk-js': minor
'@clerk/shared': minor
'@clerk/ui': minor
---

Support signUpIfMissing with strict enumeration protection and Clerk <SignIn> component
8 changes: 8 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
resolve(res);
}
Expand Down
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/UserSettings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
AttackProtectionData,
Attributes,
EnterpriseSSOSettings,
OAuthProviders,
Expand Down Expand Up @@ -103,6 +104,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource {
name: 'passkey',
},
};
attackProtection: AttackProtectionData = { enumeration_protection: { enabled: false } };
enterpriseSSO: EnterpriseSSOSettings = {
enabled: false,
};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/errors/emailLinkError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ export const EmailLinkErrorCodeStatus = {
Expired: 'expired',
Failed: 'failed',
ClientMismatch: 'client_mismatch',
Transferable: 'transferable',
} as const;
8 changes: 8 additions & 0 deletions packages/shared/src/types/userSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -134,6 +141,7 @@ export interface UserSettingsResource extends ClerkResource {
signUp: SignUpData;
passwordSettings: PasswordSettingsData;
usernameSettings: UsernameSettingsData;
attackProtection: AttackProtectionData;
passkeySettings: PasskeySettingsData;
socialProviderStrategies: OAuthStrategy[];
authenticatableSocialStrategies: OAuthStrategy[];
Expand Down
21 changes: 19 additions & 2 deletions packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();

Expand Down Expand Up @@ -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);
});
};
Expand Down
19 changes: 16 additions & 3 deletions packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<VerificationCodeCardProps, 'onShowAlternativeMethodsClicked'> & {
factor: EmailLinkFactor;
Expand All @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 13 additions & 1 deletion packages/ui/src/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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(<SignInFactorOne />, { wrapper });

await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
await waitFor(() => {
expect(fixtures.signUp.create).not.toHaveBeenCalled();
});
});
});
Loading