diff --git a/.changeset/dark-moons-sort.md b/.changeset/dark-moons-sort.md new file mode 100644 index 00000000000..13fdcff2638 --- /dev/null +++ b/.changeset/dark-moons-sort.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Trigger a new request to submit the captcha token on sign up when executing the `signUp.create` method. \ No newline at end of file diff --git a/packages/clerk-js/src/core/resources/DisplayConfig.ts b/packages/clerk-js/src/core/resources/DisplayConfig.ts index 453a2bf90de..8a1a27fbe77 100644 --- a/packages/clerk-js/src/core/resources/DisplayConfig.ts +++ b/packages/clerk-js/src/core/resources/DisplayConfig.ts @@ -26,6 +26,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource branded: boolean = false; captchaHeartbeat: boolean = false; captchaHeartbeatIntervalMs?: number; + twoStepSignUpCreateEnabled: boolean = false; captchaOauthBypass: OAuthStrategy[] = ['oauth_google', 'oauth_microsoft', 'oauth_apple']; captchaProvider: CaptchaProvider = 'turnstile'; captchaPublicKey: string | null = null; @@ -80,6 +81,10 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource this.applicationName = this.withDefault(data.application_name, this.applicationName); this.branded = this.withDefault(data.branded, this.branded); this.captchaHeartbeat = this.withDefault(data.captcha_heartbeat, this.captchaHeartbeat); + this.twoStepSignUpCreateEnabled = this.withDefault( + data.two_step_sign_up_create_enabled, + this.twoStepSignUpCreateEnabled, + ); this.captchaHeartbeatIntervalMs = this.withDefault( data.captcha_heartbeat_interval_ms, this.captchaHeartbeatIntervalMs, @@ -130,6 +135,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource branded: this.branded, captcha_heartbeat_interval_ms: this.captchaHeartbeatIntervalMs, captcha_heartbeat: this.captchaHeartbeat, + two_step_sign_up_create_enabled: this.twoStepSignUpCreateEnabled, captcha_oauth_bypass: this.captchaOauthBypass, captcha_provider: this.captchaProvider, captcha_public_key_invisible: this.captchaPublicKeyInvisible, diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index db934a95930..8d9aea7362d 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -14,6 +14,7 @@ import type { PrepareVerificationParams, PrepareWeb3WalletVerificationParams, SignUpAuthenticateWithWeb3Params, + SignUpCreateOptions, SignUpCreateParams, SignUpField, SignUpIdentificationField, @@ -21,6 +22,7 @@ import type { SignUpJSONSnapshot, SignUpResource, SignUpStatus, + SignUpUpdateOptions, SignUpUpdateParams, StartEmailLinkFlowParams, Web3Provider, @@ -45,7 +47,7 @@ import { clerkVerifyEmailAddressCalledBeforeCreate, clerkVerifyWeb3WalletCalledBeforeCreate, } from '../errors'; -import { BaseResource, ClerkRuntimeError, SignUpVerifications } from './internal'; +import { BaseResource, SignUpVerifications } from './internal'; declare global { interface Window { @@ -82,16 +84,23 @@ export class SignUp extends BaseResource implements SignUpResource { this.fromJSON(data); } - create = async (_params: SignUpCreateParams): Promise => { + create = async (_params: SignUpCreateParams, options?: SignUpCreateOptions): Promise => { + if (SignUp.clerk.__unstable__environment?.displayConfig?.twoStepSignUpCreateEnabled) { + return this.twoStepCreate(_params, options); + } + + // This is the old flow and will be completely replaced by the two step flow when it's rolled out to everyone + return this.legacyCreate(_params); + }; + + private legacyCreate = async (_params: SignUpCreateParams): Promise => { let params: Record = _params; - if (!__BUILD_DISABLE_RHC__ && !this.clientBypass() && !this.shouldBypassCaptchaForAttempt(params)) { - const captchaChallenge = new CaptchaChallenge(SignUp.clerk); - const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signup' }); - if (!captchaParams) { - throw new ClerkRuntimeError('', { code: 'captcha_unavailable' }); + if (!this.shouldBypassCaptchaForAttempt(params)) { + const captchaParams = await this.solveCaptchaChallenge(); + if (captchaParams) { + params = { ...params, ...captchaParams }; } - params = { ...params, ...captchaParams }; } if (params.transfer && this.shouldBypassCaptchaForAttempt(params)) { @@ -104,6 +113,33 @@ export class SignUp extends BaseResource implements SignUpResource { }); }; + private twoStepCreate = async ( + _params: SignUpCreateParams, + options?: SignUpCreateOptions, + ): Promise => { + const params: Record = _params; + + // This is a legacy flow, where we allowed specific OAuth providers to bypass the captcha + // This is no longer supported, but we need to keep it for backwards compatibility + if (params.transfer && this.shouldBypassCaptchaForAttempt(params)) { + params.strategy = SignUp.clerk.client?.signIn.firstFactorVerification.strategy; + } + + await this._basePost({ + path: this.pathRoot, + body: normalizeUnsafeMetadata(params), + }); + + if (!this.shouldBypassCaptchaForAttempt(params) && !options?.skipCaptchaChallenge) { + return this.update( + { strategy: params.strategy as SignUpUpdateParams['strategy'] }, + { triggerCaptchaChallenge: true }, + ); + } + + return this; + }; + prepareVerification = (params: PrepareVerificationParams): Promise => { return this._basePost({ body: params, @@ -346,7 +382,18 @@ export class SignUp extends BaseResource implements SignUpResource { }); }; - update = (params: SignUpUpdateParams): Promise => { + update = async (params: SignUpUpdateParams, options?: SignUpUpdateOptions): Promise => { + const isCaptchaChallengeMissingAndRequired = + this.missingFields.some(field => field === 'captcha_challenge') && + this.requiredFields.some(field => field === 'captcha_challenge'); + + if (options?.triggerCaptchaChallenge && isCaptchaChallengeMissingAndRequired) { + const captchaParams = await this.solveCaptchaChallenge(); + if (captchaParams) { + params = { ...params, ...captchaParams }; + } + } + return this._basePatch({ body: normalizeUnsafeMetadata(params), }); @@ -417,12 +464,24 @@ export class SignUp extends BaseResource implements SignUpResource { }; } + private solveCaptchaChallenge = async (): Promise | undefined> => { + let params: Record | undefined; + + if (!__BUILD_DISABLE_RHC__ && !this.clientBypass()) { + const captchaChallenge = new CaptchaChallenge(SignUp.clerk); + params = await captchaChallenge.managedOrInvisible({ action: 'signup' }); + } + + return params; + }; + private clientBypass() { return SignUp.clerk.client?.captchaBypass; } /** * We delegate bot detection to the following providers, instead of relying on turnstile exclusively + * This is a legacy flow, where we allowed specific OAuth providers to bypass the captcha */ protected shouldBypassCaptchaForAttempt(params: SignUpCreateParams) { if (!params.strategy) { diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx index 516bd81abba..399c6f52434 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx @@ -170,7 +170,7 @@ function SignUpContinueInternal() { card.setError(undefined); return signUp - .update(buildRequest(fieldsToSubmit)) + .update(buildRequest(fieldsToSubmit), { triggerCaptchaChallenge: true }) .then(res => completeSignUpFlow({ signUp: res, diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx index 4cc39f16efe..bfb2590ed3c 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx @@ -220,8 +220,8 @@ function SignUpStartInternal(): JSX.Element { // TODO: This is a hack to reset the sign in attempt so that the oauth error // does not persist on full page reloads. - // We will revise this strategy as part of the Clerk DX epic. - void (await signUp.create({})); + // This will be handled by the backend (FAPI) in the future. + void (await signUp.create({}, { skipCaptchaChallenge: true })); } } diff --git a/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts b/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts index 57459dc9aa8..aeb5c2d9d48 100644 --- a/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts +++ b/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts @@ -56,15 +56,14 @@ export class CaptchaChallenge { if (e.captchaError) { return { captchaError: e.captchaError }; } - // if captcha action is signup, we return undefined, because we don't want to make the call to FAPI - return opts?.action === 'verify' ? { captchaError: e?.message || e || 'unexpected_captcha_error' } : undefined; + return { captchaError: e?.message || e || 'unexpected_captcha_error' }; }); - return opts?.action === 'verify' ? { ...captchaResult, captchaAction: 'verify' } : captchaResult; + return { ...captchaResult, captchaAction: opts?.action }; } - // if captcha action is signup, we return an empty object, because it means that the bot protection is disabled + // if captcha action is signup, we return undefined, because it means that the bot protection is disabled // and the user should be able to sign up without solving a captcha - return opts?.action === 'verify' ? { captchaError: 'captcha_unavailable', captchaAction: opts?.action } : {}; + return opts?.action === 'verify' ? { captchaError: 'captcha_unavailable', captchaAction: opts?.action } : undefined; } /** diff --git a/packages/types/src/attributes.ts b/packages/types/src/attributes.ts index 7cc23cbcc46..db0c66e6d88 100644 --- a/packages/types/src/attributes.ts +++ b/packages/types/src/attributes.ts @@ -2,3 +2,4 @@ export type FirstNameAttribute = 'first_name'; export type LastNameAttribute = 'last_name'; export type PasswordAttribute = 'password'; export type LegalAcceptedAttribute = 'legal_accepted'; +export type CaptchaChallengeAttribute = 'captcha_challenge'; diff --git a/packages/types/src/displayConfig.ts b/packages/types/src/displayConfig.ts index 9f7c5b07ea5..c80d2b19482 100644 --- a/packages/types/src/displayConfig.ts +++ b/packages/types/src/displayConfig.ts @@ -24,6 +24,7 @@ export interface DisplayConfigJSON { captcha_oauth_bypass: OAuthStrategy[] | null; captcha_heartbeat?: boolean; captcha_heartbeat_interval_ms?: number; + two_step_sign_up_create_enabled?: boolean; home_url: string; instance_environment_type: string; logo_image_url: string; @@ -69,6 +70,7 @@ export interface DisplayConfigResource extends ClerkResource { captchaOauthBypass: OAuthStrategy[]; captchaHeartbeat: boolean; captchaHeartbeatIntervalMs?: number; + twoStepSignUpCreateEnabled?: boolean; homeUrl: string; instanceEnvironmentType: string; logoImageUrl: string; diff --git a/packages/types/src/signUp.ts b/packages/types/src/signUp.ts index d9485906aa3..1cb5edfcf0f 100644 --- a/packages/types/src/signUp.ts +++ b/packages/types/src/signUp.ts @@ -1,6 +1,12 @@ import type { PhoneCodeChannel } from 'phoneCodeChannel'; -import type { FirstNameAttribute, LastNameAttribute, LegalAcceptedAttribute, PasswordAttribute } from './attributes'; +import type { + CaptchaChallengeAttribute, + FirstNameAttribute, + LastNameAttribute, + LegalAcceptedAttribute, + PasswordAttribute, +} from './attributes'; import type { AttemptEmailAddressVerificationParams, PrepareEmailAddressVerificationParams } from './emailAddress'; import type { EmailAddressIdentifier, @@ -71,9 +77,9 @@ export interface SignUpResource extends ClerkResource { abandonAt: number | null; legalAcceptedAt: number | null; - create: (params: SignUpCreateParams) => Promise; + create: (params: SignUpCreateParams, options?: SignUpCreateOptions) => Promise; - update: (params: SignUpUpdateParams) => Promise; + update: (params: SignUpUpdateParams, options?: SignUpUpdateOptions) => Promise; upsert: (params: SignUpCreateParams | SignUpUpdateParams) => Promise; @@ -160,7 +166,12 @@ export type AttemptVerificationParams = signature: string; }; -export type SignUpAttributeField = FirstNameAttribute | LastNameAttribute | PasswordAttribute | LegalAcceptedAttribute; +export type SignUpAttributeField = + | FirstNameAttribute + | LastNameAttribute + | PasswordAttribute + | LegalAcceptedAttribute + | CaptchaChallengeAttribute; // TODO: SignUpVerifiableField or SignUpIdentifier? export type SignUpVerifiableField = @@ -199,6 +210,14 @@ export type SignUpCreateParams = Partial< } & Omit>, 'legalAccepted'> >; +export type SignUpCreateOptions = Partial<{ + skipCaptchaChallenge: boolean; +}>; + +export type SignUpUpdateOptions = Partial<{ + triggerCaptchaChallenge: boolean; +}>; + export type SignUpUpdateParams = SignUpCreateParams; /**