Repository: auth0/auth0.js
Pull request #1169 (2021-04-15 11:53:28)

Feature/recaptcha enterprise cauth 965
### Changes

Support enterprise recaptcha provider in auth0.js

Enterprise recaptcha requires identical configuration to recaptcha v2 with very minor changes on how it's loaded.
Rewrote the test suite for recaptcha to easier test both integrations

### References

https://auth0team.atlassian.net/browse/CAUTH-965

PRs to ULP and lockjs

https://github.com/auth0/lock/pull/1986
https://github.com/auth0/universal-login-prompts/pull/1080


### Testing

Login with recaptcha_v2 always on should still work.

- [x] This change adds unit test coverage
- [ ] 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)
- [ ] All tests and linters described in the [Develop section](https://github.com/auth0/auth0.js#develop) run without errors



Patch:
diff --git a/src/web-auth/captcha.js b/src/web-auth/captcha.js
index 732a10d..e0464db 100644
--- a/src/web-auth/captcha.js
+++ b/src/web-auth/captcha.js
@@ -2,56 +2,110 @@
 import Authentication from '../authentication';
 import object from '../helper/object';
 
-var noop = function () { };
+var noop = function() {};
+
+var RECAPTCHA_V2_PROVIDER = 'recaptcha_v2';
+var RECAPTCHA_ENTERPRISE_PROVIDER = 'recaptcha_enterprise';
+var AUTH0_PROVIDER = 'auth0';
 
 var defaults = {
   lang: 'en',
   templates: {
-    'auth0': function (challenge) {
-      var message = challenge.type === 'code' ?
-        'Enter the code shown above' :
-        'Solve the formula shown above';
-      return '<div class="captcha-challenge">\n' +
-        '  <img src="' + challenge.image + '" />\n' +
+    auth0: function(challenge) {
+      var message =
+        challenge.type === 'code'
+          ? 'Enter the code shown above'
+          : 'Solve the formula shown above';
+      return (
+        '<div class="captcha-challenge">\n' +
+        '  <img src="' +
+        challenge.image +
+        '" />\n' +
         '  <button type="button" class="captcha-reload">↺</button>\n' +
         '</div>\n' +
         '<input type="text" name="captcha"\n' +
         '  class="form-control captcha-control"\n' +
-        '  placeholder="' + message + '" />';
-    }
-    ,
-    'recaptcha_v2': function () {
+        '  placeholder="' +
+        message +
+        '" />'
+      );
+    },
+    recaptcha_v2: function() {
       return '<div class="recaptcha" ></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>'
+    },
+    recaptcha_enterprise: function() {
+      return '<div class="recaptcha" ></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>';
     }
   }
 };
 
 function handleAuth0Provider(element, options, challenge, load) {
   element.innerHTML = options.templates[challenge.provider](challenge);
-  element.querySelector('.captcha-reload').addEventListener('click', function (e) {
-    e.preventDefault();
-    load();
-  });
+  element
+    .querySelector('.captcha-reload')
+    .addEventListener('click', function(e) {
+      e.preventDefault();
+      load();
+    });
+}
+
+function globalForRecaptchaProvider(provider) {
+  switch (provider) {
+    case RECAPTCHA_V2_PROVIDER:
+      return window.grecaptcha;
+    case RECAPTCHA_ENTERPRISE_PROVIDER:
+      return window.grecaptcha.enterprise;
+    /* istanbul ignore next */
+
+    default:
+      throw new Error('Unknown captcha provider');
+  }
+}
+
+function scriptForRecaptchaProvider(provider, lang, callback) {
+  switch (provider) {
+    case RECAPTCHA_V2_PROVIDER:
+      return (
+        'https://www.google.com/recaptcha/api.js?hl=' +
+        lang +
+        '&onload=' +
+        callback
+      );
+    case RECAPTCHA_ENTERPRISE_PROVIDER:
+      return (
+        'https://www.google.com/recaptcha/enterprise.js?render=explicit&hl=' +
+        lang +
+        '&onload=' +
+        callback
+      );
+    /* istanbul ignore next */
+    default:
+      throw new Error('Unknown captcha provider');
+  }
 }
 
-function injectRecaptchaScript(element, lang, callback) {
+function injectRecaptchaScript(element, opts, callback) {
   var callbackName = 'recaptchaCallback_' + Math.floor(Math.random() * 1000001);
-  window[callbackName] = function () {
+  window[callbackName] = function() {
     delete window[callbackName];
     callback();
   };
   var script = window.document.createElement('script');
-  script.src = 'https://www.google.com/recaptcha/api.js?hl=' + lang + '&onload=' + callbackName;
+  script.src = scriptForRecaptchaProvider(
+    opts.provider,
+    opts.lang,
+    callbackName
+  );
   script.async = true;
   window.document.body.appendChild(script);
 }
 
 function handleRecaptchaProvider(element, options, challenge) {
-  var widgetId = element.hasAttribute('data-wid') && element.getAttribute('data-wid');
+  var widgetId =
+    element.hasAttribute('data-wid') && element.getAttribute('data-wid');
 
   function setValue(value) {
     var input = element.querySelector('input[name="captcha"]');
@@ -60,7 +114,7 @@ function handleRecaptchaProvider(element, options, challenge) {
 
   if (widgetId) {
     setValue();
-    window.grecaptcha.reset(widgetId);
+    globalForRecaptchaProvider(challenge.provider).reset(widgetId);
     return;
   }
 
@@ -68,18 +122,26 @@ function handleRecaptchaProvider(element, options, challenge) {
 
   var recaptchaDiv = element.querySelector('.recaptcha');
 
-  injectRecaptchaScript(element, options.lang, function () {
-    widgetId = window.grecaptcha.render(recaptchaDiv, {
-      callback: setValue,
-      'expired-callback': function () { setValue(); },
-      'error-callback': function () { setValue(); },
-      sitekey: challenge.siteKey
-    });
-    element.setAttribute('data-wid', widgetId)
-  });
+  injectRecaptchaScript(
+    element,
+    { lang: options.lang, provider: challenge.provider },
+    function() {
+      var global = globalForRecaptchaProvider(challenge.provider);
+      widgetId = global.render(recaptchaDiv, {
+        callback: setValue,
+        'expired-callback': function() {
+          setValue();
+        },
+        'error-callback': function() {
+          setValue();
+        },
+        sitekey: challenge.siteKey
+      });
+      element.setAttribute('data-wid', widgetId);
+    }
+  );
 }
 
-
 /**
  *
  * Renders the captcha challenge in the provided element.
@@ -98,7 +160,7 @@ function render(auth0Client, element, options, callback) {
 
   function load(done) {
     done = done || noop;
-    auth0Client.getChallenge(function (err, challenge) {
+    auth0Client.getChallenge(function(err, challenge) {
       if (err) {
         element.innerHTML = options.templates.error(err);
         return done(err);
@@ -109,9 +171,12 @@ function render(auth0Client, element, options, callback) {
         return;
       }
       element.style.display = '';
-      if (challenge.provider === 'auth0') {
+      if (challenge.provider === AUTH0_PROVIDER) {
         handleAuth0Provider(element, options, challenge, load);
-      } else if (challenge.provider === 'recaptcha_v2') {
+      } else if (
+        challenge.provider === RECAPTCHA_V2_PROVIDER ||
+        challenge.provider === RECAPTCHA_ENTERPRISE_PROVIDER
+      ) {
         handleRecaptchaProvider(element, options, challenge);
       }
       done();
@@ -120,7 +185,9 @@ function render(auth0Client, element, options, callback) {
 
   function getValue() {
     var captchaInput = element.querySelector('input[name="captcha"]');
-    if (!captchaInput) { return; }
+    if (!captchaInput) {
+      return;
+    }
     return captchaInput.value;
   }
 


Test patch:
diff --git a/test/web-auth/captcha.test.js b/test/web-auth/captcha.test.js
index 6a87523..04fef92 100644
--- a/test/web-auth/captcha.test.js
+++ b/test/web-auth/captcha.test.js
@@ -176,101 +176,123 @@ describe('captcha rendering', function () {
     });
   });
 
-  describe('when challenge is required and provider is recaptcha', function () {
-
-    const challenge = {
-      required: true,
-      provider: 'recaptcha_v2',
-      siteKey: 'blabla sitekey'
-    };
-
-    let c, recaptchaScript, scriptOnLoadCallback, element;
+  const RECAPTCHA_V2_PROVIDER = 'recaptcha_v2';
+  const RECAPTCHA_ENTERPRISE_PROVIDER = 'recaptcha_enterprise';
+
+  [RECAPTCHA_V2_PROVIDER,RECAPTCHA_ENTERPRISE_PROVIDER].forEach(provider => {
+    const getScript = () => {
+      switch(provider) {
+        case RECAPTCHA_V2_PROVIDER: return 'api.js';
+        case RECAPTCHA_ENTERPRISE_PROVIDER: return 'enterprise.js';
+      }
+    }
+    const setMockGlobal = (mock) => {
+      switch(provider) {
+        case RECAPTCHA_V2_PROVIDER: 
+          window.grecaptcha = mock;
+          break;
+        case RECAPTCHA_ENTERPRISE_PROVIDER:
+          window.grecaptcha = {enterprise:mock}
+      }
+    }
+    describe(`when challenge is required and provider is ${provider}`, function () {
 
-    beforeEach(() => {
-      const { window } = new JSDOM('<body><div class="captcha" /></body>');
-      element = window.document.querySelector('.captcha');
-      global.window = window;
-      const mockClient = {
-        getChallenge(cb) {
-          cb(null, challenge);
-        }
+      const challenge = {
+        required: true,
+        provider,
+        siteKey: 'blabla sitekey'
       };
-      c = captcha.render(mockClient, element);
-      recaptchaScript = [...window.document.querySelectorAll('script')].find(s => s.src.match('google\.com'));
-      scriptOnLoadCallback = window[url.parse(recaptchaScript.src, true).query.onload];
-    });
-
-    afterEach(function () {
-      delete global.window;
-    });
-
-    it('should inject the recaptcha script', function () {
-      expect(recaptchaScript.async).to.be.ok();
-      const scriptUrl = url.parse(recaptchaScript.src, true);
-      expect(scriptUrl.hostname).to.equal('www.google.com');
-      expect(scriptUrl.pathname).to.equal('/recaptcha/api.js');
-      expect(scriptUrl.query.hl).to.equal('en');
-      expect(scriptUrl.query).to.have.property('onload');
-    });
-
-    describe('after captcha is loaded', function () {
-      let renderOptions;
-      let renderElement;
-      let reseted = false;
-
-      beforeEach(function () {
-        reseted = false;
-        window.grecaptcha = {
-          render(element, options) {
-            renderElement = element;
-            renderOptions = options;
-            return 0;
-          },
-          reset() { reseted = true; }
+  
+      let c, recaptchaScript, scriptOnLoadCallback, element;
+  
+      beforeEach(() => {
+        const { window } = new JSDOM('<body><div class="captcha" /></body>');
+        element = window.document.querySelector('.captcha');
+        global.window = window;
+        const mockClient = {
+          getChallenge(cb) {
+            cb(null, challenge);
+          }
         };
-        scriptOnLoadCallback();
-      });
-
-      it('should render with the site key', function () {
-        expect(renderOptions.sitekey).to.equal(challenge.siteKey);
+        c = captcha.render(mockClient, element);
+        recaptchaScript = [...window.document.querySelectorAll('script')].find(s => s.src.match('google\.com'));
+        scriptOnLoadCallback = window[url.parse(recaptchaScript.src, true).query.onload];
       });
-
-      it('should set the value on the input when the user completes the captcha', function () {
-        const mockToken = 'token xxxxxx';
-        const input = element.querySelector('input[name="captcha"]');
-        renderOptions.callback(mockToken)
-        expect(input.value).to.equal(mockToken);
-      });
-
-
-      it('should return the value when calling getValue()', function () {
-        const mockToken = 'token xxxxxx';
-        renderOptions.callback(mockToken)
-        expect(c.getValue()).to.equal(mockToken);
+  
+      afterEach(function () {
+        delete global.window;
       });
-
-      it('should clean the value when the token expires', function () {
-        const input = element.querySelector('input[name="captcha"]');
-        input.value = 'expired token';
-        renderOptions['expired-callback']()
-        expect(input.value).to.equal('');
+  
+      it('should inject the recaptcha script', function () {
+        expect(recaptchaScript.async).to.be.ok();
+        const scriptUrl = url.parse(recaptchaScript.src, true);
+        expect(scriptUrl.hostname).to.equal('www.google.com');
+        expect(scriptUrl.pathname).to.equal(`/recaptcha/${getScript()}`);
+        expect(scriptUrl.query.hl).to.equal('en');
+        expect(scriptUrl.query).to.have.property('onload');
       });
-
-      it('should clean the value when there is an error', function () {
-        const input = element.querySelector('input[name="captcha"]');
-        input.value = 'expired token';
-        renderOptions['error-callback']()
-        expect(input.value).to.equal('');
+  
+      describe('after captcha is loaded', function () {
+        let renderOptions;
+        let renderElement;
+        let reseted = false;
+  
+        beforeEach(function () {
+          reseted = false;      
+          setMockGlobal({
+            render(element, options) {
+              renderElement = element;
+              renderOptions = options;
+              return 0;
+            },
+            reset() { reseted = true; }
+          });
+          scriptOnLoadCallback();
+        });
+  
+        it('should render with the site key', function () {
+          expect(renderOptions.sitekey).to.equal(challenge.siteKey);
+        });
+  
+        it('should set the value on the input when the user completes the captcha', function () {
+          const mockToken = 'token xxxxxx';
+          const input = element.querySelector('input[name="captcha"]');
+          renderOptions.callback(mockToken)
+          expect(input.value).to.equal(mockToken);
+        });
+  
+  
+        it('should return the value when calling getValue()', function () {
+          const mockToken = 'token xxxxxx';
+          renderOptions.callback(mockToken)
+          expect(c.getValue()).to.equal(mockToken);
+        });
+  
+        it('should clean the value when the token expires', function () {
+          const input = element.querySelector('input[name="captcha"]');
+          input.value = 'expired token';
+          renderOptions['expired-callback']()
+          expect(input.value).to.equal('');
+        });
+  
+        it('should clean the value when there is an error', function () {
+          const input = element.querySelector('input[name="captcha"]');
+          input.value = 'expired token';
+          renderOptions['error-callback']()
+          expect(input.value).to.equal('');
+        });
+  
+        it('should clean the value and reset when reloading', function () {
+          const input = element.querySelector('input[name="captcha"]');
+          input.value = 'old token';
+          c.reload();
+          expect(input.value).to.equal('');
+          expect(reseted).to.be.ok();
+        });
+  
       });
+    });
 
-      it('should clean the value and reset when reloading', function () {
-        const input = element.querySelector('input[name="captcha"]');
-        input.value = 'old token';
-        c.reload();
-        expect(input.value).to.equal('');
-        expect(reseted).to.be.ok();
-      });
+  })
 
-    });
-  });
 });