diff --git a/.changeset/itchy-birds-juggle.md b/.changeset/itchy-birds-juggle.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/itchy-birds-juggle.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/nasty-melons-cross.md b/.changeset/nasty-melons-cross.md new file mode 100644 index 00000000000..e0c69c28bc3 --- /dev/null +++ b/.changeset/nasty-melons-cross.md @@ -0,0 +1,6 @@ +--- +"@clerk/clerk-js": patch +"@clerk/shared": patch +--- + +Only retry the OAuth flow if the captcha check failed. diff --git a/.changeset/odd-squids-dress.md b/.changeset/odd-squids-dress.md new file mode 100644 index 00000000000..04e8745bdee --- /dev/null +++ b/.changeset/odd-squids-dress.md @@ -0,0 +1,7 @@ +--- +"@clerk/clerk-js": patch +--- + +Improve bot detection by loading the Turnstile SDK directly from CloudFlare. + +If loading fails due to CSP rules, load it through FAPI instead. diff --git a/.changeset/quick-months-rest.md b/.changeset/quick-months-rest.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/quick-months-rest.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/shy-peaches-grow.md b/.changeset/shy-peaches-grow.md new file mode 100644 index 00000000000..923fd722f0b --- /dev/null +++ b/.changeset/shy-peaches-grow.md @@ -0,0 +1,6 @@ +--- +"@clerk/clerk-js": patch +"@clerk/types": patch +--- + +Bypass captcha for providers dynamically provided in environment diff --git a/packages/clerk-js/src/core/resources/DisplayConfig.ts b/packages/clerk-js/src/core/resources/DisplayConfig.ts index d98f5bf9a83..53e22beb391 100644 --- a/packages/clerk-js/src/core/resources/DisplayConfig.ts +++ b/packages/clerk-js/src/core/resources/DisplayConfig.ts @@ -5,6 +5,7 @@ import type { DisplayConfigJSON, DisplayConfigResource, DisplayThemeJSON, + OAuthStrategy, PreferredSignInStrategy, } from '@clerk/types'; @@ -25,6 +26,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource captchaWidgetType: CaptchaWidgetType = null; captchaProvider: CaptchaProvider = 'turnstile'; captchaPublicKeyInvisible: string | null = null; + captchaOauthBypass: OAuthStrategy[] = []; homeUrl!: string; instanceEnvironmentType!: string; faviconImageUrl!: string; @@ -84,6 +86,9 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource this.captchaWidgetType = data.captcha_widget_type; this.captchaProvider = data.captcha_provider; this.captchaPublicKeyInvisible = data.captcha_public_key_invisible; + // These are the OAuth strategies we used to bypass the captcha for by default + // before the introduction of the captcha_oauth_bypass field + this.captchaOauthBypass = data.captcha_oauth_bypass || ['oauth_google', 'oauth_microsoft', 'oauth_apple']; this.supportEmail = data.support_email || ''; this.clerkJSVersion = data.clerk_js_version; this.organizationProfileUrl = data.organization_profile_url; diff --git a/packages/clerk-js/src/core/resources/Environment.ts b/packages/clerk-js/src/core/resources/Environment.ts index 9ccee532d76..b8594336679 100644 --- a/packages/clerk-js/src/core/resources/Environment.ts +++ b/packages/clerk-js/src/core/resources/Environment.ts @@ -33,7 +33,7 @@ export class Environment extends BaseResource implements EnvironmentResource { this.fromJSON(data); } - fetch({ touch = false }: { touch: boolean }): Promise { + fetch({ touch }: { touch: boolean } = { touch: false }): Promise { if (touch) { return this._basePatch({}); } diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index f7902bedb47..52b2983f707 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -1,4 +1,5 @@ import { deprecated, Poller } from '@clerk/shared'; +import { isCaptchaError, isClerkAPIResponseError } from '@clerk/shared/error'; import type { AttemptEmailAddressVerificationParams, AttemptPhoneNumberVerificationParams, @@ -275,15 +276,26 @@ export class SignUp extends BaseResource implements SignUpResource { }: AuthenticateWithRedirectParams & { unsafeMetadata?: SignUpUnsafeMetadata; }): Promise => { - const authenticateFn = (args: SignUpCreateParams | SignUpUpdateParams) => - continueSignUp && this.id ? this.update(args) : this.create(args); - - const { verifications } = await authenticateFn({ - strategy, - redirectUrl: SignUp.clerk.buildUrlWithAuth(redirectUrl), - actionCompleteRedirectUrl: redirectUrlComplete, - unsafeMetadata, - emailAddress, + const authenticateFn = () => { + const params = { + strategy, + redirectUrl: SignUp.clerk.buildUrlWithAuth(redirectUrl), + actionCompleteRedirectUrl: redirectUrlComplete, + unsafeMetadata, + emailAddress, + }; + return continueSignUp && this.id ? this.update(params) : this.create(params); + }; + + const { verifications } = await authenticateFn().catch(async e => { + // If captcha verification failed because the environment has changed, we need + // to reload the environment and try again one more time with the new environment. + // If this fails again, we will let the caller handle the error accordingly. + if (isClerkAPIResponseError(e) && isCaptchaError(e)) { + await SignUp.clerk.__unstable__environment!.reload(); + return authenticateFn(); + } + throw e; }); const { externalAccount } = verifications; @@ -339,18 +351,19 @@ 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 === 'oauth_google' || - params.strategy === 'oauth_microsoft' || - params.strategy === 'oauth_apple' - ) { + if (!params.strategy) { + return false; + } + + const captchaOauthBypass = SignUp.clerk.__unstable__environment!.displayConfig.captchaOauthBypass; + + if (captchaOauthBypass.some(strategy => strategy === params.strategy)) { return true; } + if ( params.transfer && - (SignUp.clerk.client?.signIn.firstFactorVerification.strategy === 'oauth_google' || - SignUp.clerk.client?.signIn.firstFactorVerification.strategy === 'oauth_microsoft' || - SignUp.clerk.client?.signIn.firstFactorVerification.strategy === 'oauth_apple') + captchaOauthBypass.some(strategy => strategy === SignUp.clerk.client!.signIn.firstFactorVerification.strategy) ) { return true; } diff --git a/packages/clerk-js/src/utils/captcha/turnstile.ts b/packages/clerk-js/src/utils/captcha/turnstile.ts index ce63d283a4a..33a72a570a1 100644 --- a/packages/clerk-js/src/utils/captcha/turnstile.ts +++ b/packages/clerk-js/src/utils/captcha/turnstile.ts @@ -3,6 +3,8 @@ import type { CaptchaWidgetType } from '@clerk/types'; import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from './constants'; +const CLOUDFLARE_TURNSTILE_ORIGINAL_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; + interface RenderOptions { /** * Every widget has a sitekey. This sitekey is associated with the corresponding widget configuration and is created upon the widget creation. @@ -69,21 +71,38 @@ export const shouldRetryTurnstileErrorCode = (errorCode: string) => { return !!codesWithRetries.find(w => errorCode.startsWith(w)); }; -async function loadCaptcha(url: string) { +async function loadCaptcha(fallbackUrl: string) { if (!window.turnstile) { - try { - await loadScript(url, { defer: true }); - } catch { - // Rethrow with specific message - console.error('Clerk: Failed to load the CAPTCHA script from the URL: ', url); - throw { - captchaError: 'captcha_script_failed_to_load', - }; - } + await loadCaptchaFromCloudflareURL() + .catch(() => loadCaptchaFromFAPIProxiedURL(fallbackUrl)) + .catch(() => { + throw { captchaError: 'captcha_script_failed_to_load' }; + }); } return window.turnstile; } +async function loadCaptchaFromCloudflareURL() { + try { + return await loadScript(CLOUDFLARE_TURNSTILE_ORIGINAL_URL, { defer: true }); + } catch (err) { + console.warn( + 'Clerk: Failed to load the CAPTCHA script from Cloudflare. If you see a CSP error in your browser, please add the necessary CSP rules to your app. Visit https://clerk.com/docs/security/clerk-csp for more information.', + ); + throw err; + } +} + +async function loadCaptchaFromFAPIProxiedURL(fallbackUrl: string) { + try { + return await loadScript(fallbackUrl, { defer: true }); + } catch (err) { + // Rethrow with specific message + console.error('Clerk: Failed to load the CAPTCHA script from the URL: ', fallbackUrl); + throw err; + } +} + /* * How this function works: * The widgetType is either 'invisible' or 'smart'. diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 4c8e574bad6..6ed04152bce 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -8,6 +8,10 @@ export function isUnauthorizedError(e: any): boolean { return code === 'authentication_invalid' && status === 401; } +export function isCaptchaError(e: ClerkAPIResponseError): boolean { + return ['captcha_invalid', 'captcha_not_enabled', 'captcha_missing_token'].includes(e.errors[0].code); +} + export function is4xxError(e: any): boolean { const status = e?.status; return !!status && status >= 400 && status < 500; diff --git a/packages/types/src/displayConfig.ts b/packages/types/src/displayConfig.ts index d6fed83e52f..8c495f9dd83 100644 --- a/packages/types/src/displayConfig.ts +++ b/packages/types/src/displayConfig.ts @@ -1,5 +1,6 @@ import type { DisplayThemeJSON } from './json'; import type { ClerkResource } from './resource'; +import type { OAuthStrategy } from './strategies'; export type PreferredSignInStrategy = 'password' | 'otp'; export type CaptchaWidgetType = 'smart' | 'invisible' | null; @@ -19,6 +20,7 @@ export interface DisplayConfigJSON { captcha_widget_type: CaptchaWidgetType; captcha_public_key_invisible: string | null; captcha_provider: CaptchaProvider; + captcha_oauth_bypass: OAuthStrategy[] | null; home_url: string; instance_environment_type: string; /* @deprecated */ @@ -55,6 +57,12 @@ export interface DisplayConfigResource extends ClerkResource { captchaWidgetType: CaptchaWidgetType; captchaProvider: CaptchaProvider; captchaPublicKeyInvisible: string | null; + /** + * An array of OAuth strategies for which we will bypass the captcha. + * We trust that the provider will verify that the user is not a bot on their end. + * This can also be used to bypass the captcha for a specific OAuth provider on a per-instance basis. + */ + captchaOauthBypass: OAuthStrategy[]; homeUrl: string; instanceEnvironmentType: string; logoImageUrl: string;