Repository: auth0/auth0.js
Pull request #1368 (2023-12-11 14:57:44)

[IAMRISK-2916] Added support for Auth0 v2 captcha provider
### Changes
Added support for Auth0 v2 (Cloudflare turnstile) captcha provider.

### References
https://auth0team.atlassian.net/browse/IAMRISK-2916

### Testing
tested locally with server:
![Screenshot 2023-12-11 at 1 07 25 PM](https://github.com/auth0/auth0.js/assets/117461691/6e1ca715-71da-4b8d-8c8c-23ab96e0f59e)
![Screenshot 2023-12-11 at 1 07 09 PM](https://github.com/auth0/auth0.js/assets/117461691/77d53627-030e-49f8-ad20-53ea836b6a12)

- [x] This change adds unit test coverage
- [x] This change adds integration test coverage

### Checklist

- [x] I have read the [Auth0 general contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md)
- [x] I have read the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md)
- [x] All tests and linters described in the [Develop section](https://github.com/auth0/auth0.js#develop) run without errors


alexkoumarianos-okta: > Is there a reason we have named all captcha's based on the implementation (hcaptcha, arkose, friendly), while now we are using turnstile and name it auth0 v2?

Yes, we are white-labeling this implementation so product wants us to use the naming `auth0_v2` everywhere we can.

Patch:
diff --git a/src/web-auth/captcha.js b/src/web-auth/captcha.js
index de46558..c23537a 100644
--- a/src/web-auth/captcha.js
+++ b/src/web-auth/captcha.js
@@ -11,6 +11,7 @@ var HCAPTCHA_PROVIDER = 'hcaptcha';
 var FRIENDLY_CAPTCHA_PROVIDER = 'friendly_captcha';
 var ARKOSE_PROVIDER = 'arkose';
 var AUTH0_PROVIDER = 'auth0';
+var AUTH0_V2_CAPTCHA_PROVIDER = 'auth0_v2';
 var MAX_RETRY = 3;
 
 var defaults = {
@@ -50,6 +51,9 @@ var defaults = {
     arkose: function () {
       return '<div class="arkose" ></div><input type="hidden" name="captcha" />';
     },
+    auth0_v2: function () {
+      return '<div class="auth0_v2" ></div><input type="hidden" name="captcha" />';
+    },
     error: function () {
       return '<div class="error" style="color: red;">Error getting the bot detection challenge. Please contact the system administrator.</div>';
     }
@@ -78,6 +82,8 @@ function globalForCaptchaProvider(provider) {
       return window.friendlyChallenge;
     case ARKOSE_PROVIDER:
       return window.arkose;
+    case AUTH0_V2_CAPTCHA_PROVIDER:
+      return window.turnstile;
     /* istanbul ignore next */
     default:
       throw new Error('Unknown captcha provider');
@@ -120,6 +126,11 @@ function scriptForCaptchaProvider(
         siteKey +
         '/api.js'
       );
+    case AUTH0_V2_CAPTCHA_PROVIDER:
+      return (
+        'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=' +
+        callback
+      );
     /* istanbul ignore next */
     default:
       throw new Error('Unknown captcha provider');
@@ -238,6 +249,9 @@ function handleCaptchaProvider(element, options, challenge) {
     case ARKOSE_PROVIDER:
       captchaClass = '.arkose';
       break;
+    case AUTH0_V2_CAPTCHA_PROVIDER:
+      captchaClass = '.auth0_v2';
+      break;
   }
   var captchaDiv = element.querySelector(captchaClass);
 
@@ -285,7 +299,7 @@ function handleCaptchaProvider(element, options, challenge) {
           }
         });
       } else {
-        widgetId = global.render(captchaDiv, {
+        var renderParams = {
           callback: setValue,
           'expired-callback': function () {
             setValue();
@@ -294,7 +308,12 @@ function handleCaptchaProvider(element, options, challenge) {
             setValue();
           },
           sitekey: challenge.siteKey
-        });
+        };
+        if (challenge.provider === AUTH0_V2_CAPTCHA_PROVIDER) {
+          renderParams.language = options.lang;
+          renderParams.theme = 'light';
+        }
+        widgetId = global.render(captchaDiv, renderParams);
         element.setAttribute('data-wid', widgetId);
       }
     },
@@ -316,6 +335,7 @@ function handleCaptchaProvider(element, options, challenge) {
  * @param {Function} [options.templates.hcaptcha] template function receiving the challenge and returning a string
  * @param {Function} [options.templates.friendly_captcha] template function receiving the challenge and returning a string
  * @param {Function} [options.templates.arkose] template function receiving the challenge and returning a string
+ * @param {Function} [options.templates.auth0_v2] template function receiving the challenge and returning a string
  * @param {Function} [options.templates.error] template function returning a custom error message when the challenge could not be fetched, receives the error as first argument
  * @param {String} [options.lang=en] the ISO code of the language for recaptcha
  * @param {Function} [callback] An optional callback called after captcha is loaded
@@ -343,7 +363,8 @@ function render(auth0Client, element, options, callback) {
         challenge.provider === RECAPTCHA_ENTERPRISE_PROVIDER ||
         challenge.provider === HCAPTCHA_PROVIDER ||
         challenge.provider === FRIENDLY_CAPTCHA_PROVIDER ||
-        challenge.provider === ARKOSE_PROVIDER
+        challenge.provider === ARKOSE_PROVIDER ||
+        challenge.provider === AUTH0_V2_CAPTCHA_PROVIDER
       ) {
         handleCaptchaProvider(element, options, challenge);
       }
@@ -390,6 +411,7 @@ function render(auth0Client, element, options, callback) {
  * @param {Function} [options.templates.hcaptcha] template function receiving the challenge and returning a string
  * @param {Function} [options.templates.friendly_captcha] template function receiving the challenge and returning a string
  * @param {Function} [options.templates.arkose] template function receiving the challenge and returning a string
+ * @param {Function} [options.templates.auth0_v2] template function receiving the challenge and returning a string
  * @param {Function} [options.templates.error] template function returning a custom error message when the challenge could not be fetched, receives the error as first argument
  * @param {String} [options.lang=en] the ISO code of the language for recaptcha
  * @param {Function} [callback] An optional callback called after captcha is loaded
@@ -417,7 +439,8 @@ function renderPasswordless(auth0Client, element, options, callback) {
         challenge.provider === RECAPTCHA_ENTERPRISE_PROVIDER ||
         challenge.provider === HCAPTCHA_PROVIDER ||
         challenge.provider === FRIENDLY_CAPTCHA_PROVIDER ||
-        challenge.provider === ARKOSE_PROVIDER
+        challenge.provider === ARKOSE_PROVIDER ||
+        challenge.provider === AUTH0_V2_CAPTCHA_PROVIDER
       ) {
         handleCaptchaProvider(element, options, challenge);
       }
diff --git a/src/web-auth/index.js b/src/web-auth/index.js
index 44c4350..4733bdd 100644
--- a/src/web-auth/index.js
+++ b/src/web-auth/index.js
@@ -1151,6 +1151,7 @@ WebAuth.prototype.passwordlessVerify = function (options, cb) {
  * @param {Function} [options.templates.hcaptcha] template function receiving the challenge and returning a string
  * @param {Function} [options.templates.friendly_captcha] template function receiving the challenge and returning a string
  * @param {Function} [options.templates.arkose] template function receiving the challenge and returning a string
+ * @param {Function} [options.templates.auth0_v2] template function receiving the challenge and returning a string
  * @param {Function} [options.templates.error] template function returning a custom error message when the challenge could not be fetched, receives the error as first argument
  * @param {String} [options.lang=en] the ISO code of the language for the captcha provider
  * @param {captchaLoadedCallback} [callback] An optional callback called after captcha is loaded
@@ -1175,6 +1176,7 @@ WebAuth.prototype.renderCaptcha = function (element, options, callback) {
  * @param {Function} [options.templates.hcaptcha] template function receiving the challenge and returning a string
  * @param {Function} [options.templates.friendly_captcha] template function receiving the challenge and returning a string
  * @param {Function} [options.templates.arkose] template function receiving the challenge and returning a string
+ * @param {Function} [options.templates.auth0_v2] template function receiving the challenge and returning a string
  * @param {Function} [options.templates.error] template function returning a custom error message when the challenge could not be fetched, receives the error as first argument
  * @param {String} [options.lang=en] the ISO code of the language for the captcha provider
  * @param {captchaLoadedCallback} [callback] An optional callback called after captcha is loaded


Test patch:
diff --git a/test/web-auth/captcha.test.js b/test/web-auth/captcha.test.js
index ddcb9fd..78b1942 100644
--- a/test/web-auth/captcha.test.js
+++ b/test/web-auth/captcha.test.js
@@ -192,12 +192,14 @@ describe('captcha rendering', function () {
   const HCAPTCHA_PROVIDER = 'hcaptcha';
   const FRIENDLY_CAPTCHA_PROVIDER = 'friendly_captcha';
   const ARKOSE_PROVIDER = 'arkose';
+  const AUTH0_V2_CAPTCHA_PROVIDER = 'auth0_v2';
 
   [
     RECAPTCHA_V2_PROVIDER,
     RECAPTCHA_ENTERPRISE_PROVIDER,
     HCAPTCHA_PROVIDER,
-    FRIENDLY_CAPTCHA_PROVIDER
+    FRIENDLY_CAPTCHA_PROVIDER,
+    AUTH0_V2_CAPTCHA_PROVIDER
   ].forEach(provider => {
     const getScript = () => {
       switch (provider) {
@@ -209,19 +211,19 @@ describe('captcha rendering', function () {
           return 'enterprise.js';
         case FRIENDLY_CAPTCHA_PROVIDER:
           return 'widget.min.js';
+        case AUTH0_V2_CAPTCHA_PROVIDER:
+          return 'api.js';
       }
     };
     const getHostname = () => {
-      switch (provider) {
-        case RECAPTCHA_V2_PROVIDER:
-          return 'recaptcha.net';
-        case RECAPTCHA_ENTERPRISE_PROVIDER:
-          return 'recaptcha.net';
-        case HCAPTCHA_PROVIDER:
-          return 'hcaptcha.com';
-        case FRIENDLY_CAPTCHA_PROVIDER:
-          return 'jsdelivr.net';
+      const hosts = {
+        [RECAPTCHA_V2_PROVIDER]: 'recaptcha.net',
+        [RECAPTCHA_ENTERPRISE_PROVIDER]: 'recaptcha.net',
+        [HCAPTCHA_PROVIDER]: 'hcaptcha.com',
+        [FRIENDLY_CAPTCHA_PROVIDER]: 'jsdelivr.net',
+        [AUTH0_V2_CAPTCHA_PROVIDER]: 'cloudflare.com'
       }
+      return hosts[provider];
     };
     const getSubdomain = () => {
       switch (provider) {
@@ -233,6 +235,8 @@ describe('captcha rendering', function () {
           return 'js';
         case FRIENDLY_CAPTCHA_PROVIDER:
           return 'cdn';
+        case AUTH0_V2_CAPTCHA_PROVIDER:
+          return 'challenges';
       }
     };
     const getPath = () => {
@@ -245,6 +249,8 @@ describe('captcha rendering', function () {
           return '1';
         case FRIENDLY_CAPTCHA_PROVIDER:
           return 'npm/friendly-challenge@0.9.12';
+        case AUTH0_V2_CAPTCHA_PROVIDER:
+          return 'turnstile/v0';
       }
     };
     const setMockGlobal = mock => {
@@ -261,6 +267,9 @@ describe('captcha rendering', function () {
         case FRIENDLY_CAPTCHA_PROVIDER:
           window.friendlyChallenge = mock;
           break;
+        case AUTH0_V2_CAPTCHA_PROVIDER:
+          window.turnstile = mock;
+          break;
       }
     };
     describe(`when challenge is required and provider is ${provider}`, function () {
@@ -303,8 +312,13 @@ describe('captcha rendering', function () {
           `${getSubdomain()}.${getHostname()}`
         );
         expect(scriptUrl.pathname).to.equal(`/${getPath()}/${getScript()}`);
-        if (provider !== FRIENDLY_CAPTCHA_PROVIDER) {
+        if (
+          provider !== FRIENDLY_CAPTCHA_PROVIDER &&
+          provider !== AUTH0_V2_CAPTCHA_PROVIDER
+        ) {
           expect(scriptUrl.query.hl).to.equal('en');
+        }
+        if (provider !== FRIENDLY_CAPTCHA_PROVIDER) {
           expect(scriptUrl.query).to.have.property('onload');
         }
       });
@@ -748,12 +762,14 @@ describe('passwordless captcha rendering', function () {
   const HCAPTCHA_PROVIDER = 'hcaptcha';
   const FRIENDLY_CAPTCHA_PROVIDER = 'friendly_captcha';
   const ARKOSE_PROVIDER = 'arkose';
+  const AUTH0_V2_CAPTCHA_PROVIDER = 'auth0_v2';
 
   [
     RECAPTCHA_V2_PROVIDER,
     RECAPTCHA_ENTERPRISE_PROVIDER,
     HCAPTCHA_PROVIDER,
-    FRIENDLY_CAPTCHA_PROVIDER
+    FRIENDLY_CAPTCHA_PROVIDER,
+    AUTH0_V2_CAPTCHA_PROVIDER
   ].forEach(provider => {
     const getScript = () => {
       switch (provider) {
@@ -765,19 +781,19 @@ describe('passwordless captcha rendering', function () {
           return 'enterprise.js';
         case FRIENDLY_CAPTCHA_PROVIDER:
           return 'widget.min.js';
+        case AUTH0_V2_CAPTCHA_PROVIDER:
+          return 'api.js';
       }
     };
     const getHostname = () => {
-      switch (provider) {
-        case RECAPTCHA_V2_PROVIDER:
-          return 'recaptcha.net';
-        case RECAPTCHA_ENTERPRISE_PROVIDER:
-          return 'recaptcha.net';
-        case HCAPTCHA_PROVIDER:
-          return 'hcaptcha.com';
-        case FRIENDLY_CAPTCHA_PROVIDER:
-          return 'jsdelivr.net';
+      const hosts = {
+        [RECAPTCHA_V2_PROVIDER]: 'recaptcha.net',
+        [RECAPTCHA_ENTERPRISE_PROVIDER]: 'recaptcha.net',
+        [HCAPTCHA_PROVIDER]: 'hcaptcha.com',
+        [FRIENDLY_CAPTCHA_PROVIDER]: 'jsdelivr.net',
+        [AUTH0_V2_CAPTCHA_PROVIDER]: 'cloudflare.com'
       }
+      return hosts[provider];
     };
     const getSubdomain = () => {
       switch (provider) {
@@ -789,6 +805,8 @@ describe('passwordless captcha rendering', function () {
           return 'js';
         case FRIENDLY_CAPTCHA_PROVIDER:
           return 'cdn';
+        case AUTH0_V2_CAPTCHA_PROVIDER:
+          return 'challenges';
       }
     };
     const getPath = () => {
@@ -801,6 +819,8 @@ describe('passwordless captcha rendering', function () {
           return '1';
         case FRIENDLY_CAPTCHA_PROVIDER:
           return 'npm/friendly-challenge@0.9.12';
+        case AUTH0_V2_CAPTCHA_PROVIDER:
+          return 'turnstile/v0';
       }
     };
     const setMockGlobal = mock => {
@@ -817,6 +837,9 @@ describe('passwordless captcha rendering', function () {
         case FRIENDLY_CAPTCHA_PROVIDER:
           window.friendlyChallenge = mock;
           break;
+        case AUTH0_V2_CAPTCHA_PROVIDER:
+          window.turnstile = mock;
+          break;
       }
     };
     describe(`when challenge is required and provider is ${provider}`, function () {
@@ -861,8 +884,13 @@ describe('passwordless captcha rendering', function () {
           `${getSubdomain()}.${getHostname()}`
         );
         expect(scriptUrl.pathname).to.equal(`/${getPath()}/${getScript()}`);
-        if (provider !== FRIENDLY_CAPTCHA_PROVIDER) {
+        if (
+          provider !== FRIENDLY_CAPTCHA_PROVIDER &&
+          provider !== AUTH0_V2_CAPTCHA_PROVIDER
+        ) {
           expect(scriptUrl.query.hl).to.equal('en');
+        }
+        if (provider !== FRIENDLY_CAPTCHA_PROVIDER) {
           expect(scriptUrl.query).to.have.property('onload');
         }
       });