Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{ "path": "./dist/clerk.js", "maxSize": "539KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "66KB" },
{ "path": "./dist/clerk.chips.browser.js", "maxSize": "66KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "106KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "107KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "307KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "65KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
Expand Down
143 changes: 139 additions & 4 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,85 @@ 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 body: Record<string, unknown> = { ...params };

const locale = getBrowserLocale();
if (locale) {
body.locale = locale;
}

const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken(params);

if (captchaToken !== undefined) {
body.captchaToken = captchaToken;
body.captchaWidgetType = captchaWidgetType;
body.captchaError = captchaError;
}

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

Expand Down
51 changes: 24 additions & 27 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)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I cleaned up this code path, which is pointless: On a transfer, the backend already pulls the strategy from the transfer and ignores any strategy included in the query params. We don't need the SDK to also pull the strategy from the transfer and include it in the query params.

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,24 @@ 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) {
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)) {
// For transfers, inspect the SignIn strategy to determine bypass logic
if (params.transfer && SignUp.clerk.client?.signIn?.firstFactorVerification?.status === 'transferable') {
const signInStrategy = SignUp.clerk.client.signIn.firstFactorVerification.strategy;

// OAuth transfer: Check if strategy is in bypass list
if (signInStrategy?.startsWith('oauth_')) {
return captchaOauthBypass.some(strategy => strategy === signInStrategy);
}

// Non-OAuth transfer (signUpIfMissing): Captcha already validated during SignIn
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)
) {
// For direct SignUp (not transfer), check OAuth bypass
if (params.strategy && captchaOauthBypass.some(strategy => strategy === params.strategy)) {
return true;
}

Expand Down Expand Up @@ -787,22 +782,24 @@ 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)) {
// For transfers, inspect the SignIn strategy to determine bypass logic
if (params.transfer && SignUp.clerk.client?.signIn?.firstFactorVerification?.status === 'transferable') {
const signInStrategy = SignUp.clerk.client.signIn.firstFactorVerification.strategy;

// OAuth transfer: Check if strategy is in bypass list
if (signInStrategy?.startsWith('oauth_')) {
return captchaOauthBypass.some(strategy => strategy === signInStrategy);
}

// Non-OAuth transfer (signUpIfMissing): Captcha already validated during SignIn
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)
) {
// For direct SignUp (not transfer), check OAuth bypass
if (params.strategy && captchaOauthBypass.some(strategy => strategy === params.strategy)) {
return true;
}

Expand Down
Loading
Loading