Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/strict-needles-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Support `sign_up_if_missing` on SignIn.create, including captcha
140 changes: 135 additions & 5 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
AuthenticateWithPopupParams,
AuthenticateWithRedirectParams,
AuthenticateWithWeb3Params,
CaptchaWidgetType,
ClientTrustState,
CreateEmailLinkFlowReturn,
EmailCodeConfig,
Expand Down Expand Up @@ -82,6 +83,7 @@ import {
_futureAuthenticateWithPopup,
wrapWithPopupRoutes,
} from '../../utils/authenticateWithPopup';
import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge';
import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask';
import { loadZxcvbn } from '../../utils/zxcvbn';
import {
Expand Down Expand Up @@ -164,12 +166,34 @@ export class SignIn extends BaseResource implements SignInResource {
this.fromJSON(data);
}

create = (params: SignInCreateParams): Promise<SignInResource> => {
create = async (params: SignInCreateParams): Promise<SignInResource> => {
debugLogger.debug('SignIn.create', { id: this.id, strategy: 'strategy' in params ? params.strategy : undefined });
const locale = getBrowserLocale();

let body: Record<string, unknown> = { ...params };

// Inject browser locale
const browserLocale = getBrowserLocale();
if (browserLocale) {
body.locale = browserLocale;
}

if (
this.shouldRequireCaptcha(params) &&
!__BUILD_DISABLE_RHC__ &&
!this.clientBypass() &&
!this.shouldBypassCaptchaForAttempt(params)
) {
const captchaChallenge = new CaptchaChallenge(SignIn.clerk);
const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signin' });
if (!captchaParams) {
throw new ClerkRuntimeError('', { code: 'captcha_unavailable' });
}
body = { ...body, ...captchaParams };
}

return this._basePost({
path: this.pathRoot,
body: locale ? { locale, ...params } : params,
body: body,
});
};

Expand Down Expand Up @@ -576,6 +600,43 @@ export class SignIn extends BaseResource implements SignInResource {
return this;
}

private clientBypass() {
return SignIn.clerk.client?.captchaBypass;
}

/**
* Determines whether captcha is required for sign in based on the provided params.
* Add new conditions here as captcha requirements evolve.
*/
private shouldRequireCaptcha(params: SignInCreateParams): boolean {
if ('signUpIfMissing' in params && params.signUpIfMissing) {
return true;
}

return false;
}

/**
* We delegate bot detection to the following providers, instead of relying on turnstile exclusively
*/
protected shouldBypassCaptchaForAttempt(params: SignInCreateParams) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const captchaOauthBypass = SignIn.clerk.__internal_environment!.displayConfig.captchaOauthBypass;

// Check transfer strategy against bypass list
if (params.transfer && SignIn.clerk.client?.signUp?.verifications?.externalAccount?.status === 'transferable') {
const signUpStrategy = SignIn.clerk.client.signUp.verifications.externalAccount.strategy;
return signUpStrategy ? captchaOauthBypass.some(strategy => strategy === signUpStrategy) : false;
}

// Check direct strategy against bypass list
if ('strategy' in params && params.strategy) {
return captchaOauthBypass.some(strategy => strategy === params.strategy);
}

return false;
}

public __internal_updateFromJSON(data: SignInJSON | SignInJSONSnapshot | null): this {
return this.fromJSON(data);
}
Expand Down Expand Up @@ -814,11 +875,80 @@ class SignInFuture implements SignInFutureResource {
});
}

/**
* Determines whether captcha is required for sign in based on the provided params.
* Add new conditions here as captcha requirements evolve.
*/
private shouldRequireCaptcha(params: { signUpIfMissing?: boolean }): boolean {
if (params.signUpIfMissing) {
return true;
}

return false;
}

private shouldBypassCaptchaForAttempt(params: { strategy?: string; transfer?: boolean }) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const captchaOauthBypass = SignIn.clerk.__internal_environment!.displayConfig.captchaOauthBypass;

// Check transfer strategy against bypass list
if (params.transfer && SignIn.clerk.client?.signUp?.verifications?.externalAccount?.status === 'transferable') {
const signUpStrategy = SignIn.clerk.client.signUp.verifications.externalAccount.strategy;
return signUpStrategy ? captchaOauthBypass.some(strategy => strategy === signUpStrategy) : false;
}

// Check direct strategy against bypass list
if (params.strategy) {
return captchaOauthBypass.some(strategy => strategy === params.strategy);
}

return false;
}

private async getCaptchaToken(
params: { strategy?: string; transfer?: boolean; signUpIfMissing?: boolean } = {},
): Promise<{
captchaToken?: string;
captchaWidgetType?: CaptchaWidgetType;
captchaError?: unknown;
}> {
if (
!this.shouldRequireCaptcha(params) ||
__BUILD_DISABLE_RHC__ ||
SignIn.clerk.client?.captchaBypass ||
this.shouldBypassCaptchaForAttempt(params)
) {
return {
captchaToken: undefined,
captchaWidgetType: undefined,
captchaError: undefined,
};
}

const captchaChallenge = new CaptchaChallenge(SignIn.clerk);
const response = await captchaChallenge.managedOrInvisible({ action: 'signin' });
if (!response) {
throw new Error('Captcha challenge failed');
}

const { captchaError, captchaToken, captchaWidgetType } = response;
return { captchaToken, captchaWidgetType, captchaError };
}

private async _create(params: SignInFutureCreateParams): Promise<void> {
const locale = getBrowserLocale();
const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken(params);

const body: Record<string, unknown> = {
...params,
captchaToken,
captchaWidgetType,
captchaError,
locale: getBrowserLocale() || undefined,
};

await this.#resource.__internal_basePost({
path: this.#resource.pathRoot,
body: locale ? { locale, ...params } : params,
body,
});
}

Expand Down
83 changes: 54 additions & 29 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,13 +173,6 @@ export class SignUp extends BaseResource implements SignUpResource {
finalParams = { ...finalParams, ...captchaParams };
}

if (finalParams.transfer && this.shouldBypassCaptchaForAttempt(finalParams)) {
const strategy = SignUp.clerk.client?.signIn.firstFactorVerification.strategy;
if (strategy) {
finalParams = { ...finalParams, strategy: strategy as SignUpCreateParams['strategy'] };
}
}

return this._basePost({
path: this.pathRoot,
body: normalizeUnsafeMetadata(finalParams),
Expand Down Expand Up @@ -561,22 +554,30 @@ export class SignUp extends BaseResource implements SignUpResource {
* We delegate bot detection to the following providers, instead of relying on turnstile exclusively
*/
protected shouldBypassCaptchaForAttempt(params: SignUpCreateParams) {
if (!params.strategy) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the previous logic, which unconditionally showed captchas on EnterpriseSSO and OAuth transfers, since transfers do not have a strategy. The former behavior is correct, the latter was a bug.

return false;
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const captchaOauthBypass = SignUp.clerk.__internal_environment!.displayConfig.captchaOauthBypass;

if (captchaOauthBypass.some(strategy => strategy === params.strategy)) {
return true;
// Check for transfer captcha bypass.
if (params.transfer) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new logic explicitly checks transfer first and checks both paths that can lead to a bypass: OAuth strategy, and sign up if missing. We fall through on EnterpriseSSO transfers, which then ends up returning false, i.e., we do show captchas on Enterprise SSO.

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const signInVerificationStrategy = SignUp.clerk.client!.signIn.firstFactorVerification.strategy;

// OAuth transfers: If we delegate captcha detection to OAuth provider,
// do not show another captcha on sign up.
if (captchaOauthBypass.some(strategy => strategy === signInVerificationStrategy)) {
return true;
}

// Sign up if missing transfers: We let sign in handle the captcha,
// do not show another captcha on sign up.
if (isSignUpIfMissingCaptchaBypassStrategy(signInVerificationStrategy)) {
return true;
}
}

if (
params.transfer &&
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
captchaOauthBypass.some(strategy => strategy === SignUp.clerk.client!.signIn.firstFactorVerification.strategy)
) {
// OAuth sign ups: If we delegate captcha detection to OAuth provider,
// do not show another captcha on sign up.
if (params.strategy && captchaOauthBypass.some(strategy => strategy === params.strategy)) {
return true;
}

Expand All @@ -595,6 +596,22 @@ export class SignUp extends BaseResource implements SignUpResource {
};
}

/**
* Returns true if the given strategy is one where captcha is already handled
* by the sign-in attempt with sign up if missing, so a subsequent sign-up should
* not show another captcha. Matches email_link, email_code, phone_code, and any
* web3 wallet strategy. This should be kept in sync with `validateSignUpIfMissing`
* in the backend.
*/
const SIGN_UP_IF_MISSING_CAPTCHA_BYPASS_STRATEGIES = new Set(['email_link', 'email_code', 'phone_code']);

export function isSignUpIfMissingCaptchaBypassStrategy(strategy: string | null): boolean {
if (!strategy) {
return false;
}
return SIGN_UP_IF_MISSING_CAPTCHA_BYPASS_STRATEGIES.has(strategy) || strategy.startsWith('web3_');
}

type SignUpFutureVerificationsMethods = Pick<
SignUpFutureVerifications,
| 'sendEmailCode'
Expand Down Expand Up @@ -787,22 +804,30 @@ class SignUpFuture implements SignUpFutureResource {
}

private shouldBypassCaptchaForAttempt(params: { strategy?: string; transfer?: boolean }) {
if (!params.strategy) {
return false;
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const captchaOauthBypass = SignUp.clerk.__internal_environment!.displayConfig.captchaOauthBypass;

if (captchaOauthBypass.some(strategy => strategy === params.strategy)) {
return true;
// Check for transfer captcha bypass.
if (params.transfer) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's exactly the same change for sign up future.

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const signInVerificationStrategy = SignUp.clerk.client!.signIn.firstFactorVerification.strategy;

// OAuth transfers: If we delegate captcha detection to OAuth provider,
// do not show another captcha on sign up.
if (captchaOauthBypass.some(strategy => strategy === signInVerificationStrategy)) {
return true;
}

// Sign up if missing transfers: We let sign in handle the captcha,
// do not show another captcha on sign up.
if (isSignUpIfMissingCaptchaBypassStrategy(signInVerificationStrategy)) {
return true;
}
}

if (
params.transfer &&
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
captchaOauthBypass.some(strategy => strategy === SignUp.clerk.client!.signIn.firstFactorVerification.strategy)
) {
// OAuth sign ups: If we delegate captcha detection to OAuth provider,
// do not show another captcha on sign up.
if (params.strategy && captchaOauthBypass.some(strategy => strategy === params.strategy)) {
return true;
}

Expand Down
Loading
Loading