Repository: auth0/node-auth0
Pull request #676 (2021-11-01 21:12:11)

[CAUTH-1274]: feat(attack protection): add breached password detection endpoints
### Changes

Adding 2 new endpoints: 
- GET /api/v2/attack-protection/breached-password-detection
  - `mng.attackProtection.getBreachedPasswordDetectionConfig(params, callback)`
  - params is always an empty {} for now, since this endpoint does not support query parameters
 
- PATCH /api/v2/attack-protection/breached-password-detection
  - `mng.attackProtection.updateBreachedPasswordDetectionConfig(params, data, callback)`
  - params is always an empty {} for now, since this endpoint does not support query parameters

These endpoint are not yet public, but stable and merged into a iam risk feature branch of api2. 

### References

<img width="626" alt="Screen Shot 2021-11-01 at 5 13 48 PM" src="https://user-images.githubusercontent.com/8777372/139742975-65a60f37-70d8-44b5-a0b0-577a6cd9de70.png">

- https://github.com/auth0/api2/pull/5467
- https://auth0team.atlassian.net/browse/CAUTH-1274
- https://auth0team.atlassian.net/servicedesk/customer/portal/34/ESD-16552?created=true

### Testing

- [x] This change was manually tested
- [x] This change adds unit 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 existing and new tests complete without errors


sdwvit: @adamjmcgrath please feel free to merge

Patch:
diff --git a/src/management/AttackProtectionManager.js b/src/management/AttackProtectionManager.js
index bf372a9a5..7443e8e93 100644
--- a/src/management/AttackProtectionManager.js
+++ b/src/management/AttackProtectionManager.js
@@ -53,10 +53,17 @@ var AttackProtectionManager = function(options) {
     options.tokenProvider
   );
   this.suspiciousIpThrottling = new RetryRestClient(suspiciousIpThrottling, options.retry);
+
+  var breachedPasswordDetection = new Auth0RestClient(
+    options.baseUrl + '/attack-protection/breached-password-detection',
+    clientOptions,
+    options.tokenProvider
+  );
+  this.breachedPasswordDetection = new RetryRestClient(breachedPasswordDetection, options.retry);
 };
 
 /**
- * Get the brute force configuration.
+ * Get the Brute Force Protection configuration.
  *
  * @method    getBruteForceConfig
  * @memberOf  module:management.AttackProtectionManager.prototype
@@ -83,7 +90,7 @@ utils.wrapPropertyMethod(
 );
 
 /**
- * Update the brute force configuration.
+ * Update the Brute Force Protection configuration.
  *
  * @method    updateBruteForceConfig
  * @memberOf module:management.AttackProtectionManager.prototype
@@ -111,7 +118,7 @@ utils.wrapPropertyMethod(
 );
 
 /**
- * Get the suspicious IP throttling configuration.
+ * Get the Suspicious IP Throttling configuration.
  *
  * @method    getSuspiciousIpThrottlingConfig
  * @memberOf  module:management.AttackProtectionManager.prototype
@@ -138,7 +145,7 @@ utils.wrapPropertyMethod(
 );
 
 /**
- * Update the suspicious IP throttling configuration.
+ * Update the Suspicious IP Throttling configuration.
  *
  * @method    updateSuspiciousIpThrottlingConfig
  * @memberOf  module:management.AttackProtectionManager.prototype
@@ -165,4 +172,59 @@ utils.wrapPropertyMethod(
   'suspiciousIpThrottling.patch'
 );
 
+/**
+ * Get the Breached Password Detection configuration.
+ *
+ * @method    getBreachedPasswordDetectionConfig
+ * @memberOf  module:management.AttackProtectionManager.prototype
+ *
+ * @example
+ * management.attackProtection.getBreachedPasswordDetectionConfig(params, function (err, breachedPasswordDetectionConfig) {
+ *   if (err) {
+ *     // Handle error.
+ *   }
+ *
+ *   // Access breached password detection configuration
+ *   console.log(breachedPasswordDetectionConfig);
+ * });
+ *
+ * @param   {Object}    params            Breached password detection parameters (leave empty).
+ * @param   {Function}  [cb]              Callback function.
+ *
+ * @return    {Promise|undefined}
+ */
+utils.wrapPropertyMethod(
+  AttackProtectionManager,
+  'getBreachedPasswordDetectionConfig',
+  'breachedPasswordDetection.get'
+);
+
+/**
+ * Update the breached password detection configuration.
+ *
+ * @method    updateBreachedPasswordDetectionConfig
+ * @memberOf  module:management.AttackProtectionManager.prototype
+ *
+ * @example
+ * management.attackProtection.updateBreachedPasswordDetectionConfig(params, data, function (err, breachedPasswordDetectionConfig) {
+ *   if (err) {
+ *     // Handle error.
+ *   }
+ *
+ *   // Access breached password detection configuration
+ *   console.log(breachedPasswordDetectionConfig);
+ * });
+ *
+ * @param   {Object}    params            Breached password detection parameters (leave empty).
+ * @param   {Object}    data              Updated breached password detection configuration.
+ * @param   {Function}  [cb]              Callback function.
+ *
+ * @return    {Promise|undefined}
+ */
+utils.wrapPropertyMethod(
+  AttackProtectionManager,
+  'updateBreachedPasswordDetectionConfig',
+  'breachedPasswordDetection.patch'
+);
+
 module.exports = AttackProtectionManager;


Test patch:
diff --git a/test/management/attack-protection.tests.js b/test/management/attack-protection.tests.js
index 337ee4430..73020428f 100644
--- a/test/management/attack-protection.tests.js
+++ b/test/management/attack-protection.tests.js
@@ -10,6 +10,7 @@ var ArgumentError = require('rest-facade').ArgumentError;
 describe('AttackProtectionManager', function() {
   var bruteForcePath = '/attack-protection/brute-force-protection';
   var suspiciousIpPath = '/attack-protection/suspicious-ip-throttling';
+  var breachedPasswordDetectionPath = '/attack-protection/breached-password-detection';
 
   before(function() {
     this.token = 'TOKEN';
@@ -48,15 +49,15 @@ describe('AttackProtectionManager', function() {
   });
 
   describe('Brute Force Protection', function() {
-    describe('#getBruteForceConfig', function() {
-      var data = {
-        enabled: true,
-        shields: ['user_notification', 'block'],
-        mode: 'count_per_identifier_and_ip',
-        allowlist: ['1.1.2.2'],
-        max_attempts: 100
-      };
+    var data = {
+      enabled: true,
+      shields: ['user_notification', 'block'],
+      mode: 'count_per_identifier_and_ip',
+      allowlist: ['1.1.2.2'],
+      max_attempts: 100
+    };
 
+    describe('#getBruteForceConfig', function() {
       beforeEach(function() {
         this.request = nock(API_URL)
           .get(bruteForcePath)
@@ -125,14 +126,6 @@ describe('AttackProtectionManager', function() {
     });
 
     describe('#updateBruteForceConfig', function() {
-      var data = {
-        enabled: true,
-        shields: ['user_notification', 'block'],
-        mode: 'count_per_identifier_and_ip',
-        allowlist: ['1.1.2.2'],
-        max_attempts: 100
-      };
-
       beforeEach(function() {
         this.request = nock(API_URL)
           .patch(bruteForcePath)
@@ -166,7 +159,7 @@ describe('AttackProtectionManager', function() {
         });
       });
 
-      it('should perform a PATCH request to /api/v2/attack-protection/brute-force-protection', function(done) {
+      it('should perform a PATCH request to /api/v2' + bruteForcePath, function(done) {
         var request = this.request;
 
         this.attackProtection.updateBruteForceConfig({}, {}).then(function() {
@@ -212,23 +205,23 @@ describe('AttackProtectionManager', function() {
   });
 
   describe('Suspicious IP Throttling', function() {
-    describe('#getSuspiciousIpThrottlingConfig', function() {
-      var data = {
-        enabled: true,
-        shields: ['admin_notification', 'block'],
-        allowlist: ['1.1.1.0'],
-        stage: {
-          'pre-login': {
-            max_attempts: 1,
-            rate: 864000
-          },
-          'pre-user-registration': {
-            max_attempts: 1,
-            rate: 864000
-          }
+    var data = {
+      enabled: true,
+      shields: ['admin_notification', 'block'],
+      allowlist: ['1.1.1.0'],
+      stage: {
+        'pre-login': {
+          max_attempts: 1,
+          rate: 864000
+        },
+        'pre-user-registration': {
+          max_attempts: 1,
+          rate: 864000
         }
-      };
+      }
+    };
 
+    describe('#getSuspiciousIpThrottlingConfig', function() {
       beforeEach(function() {
         this.request = nock(API_URL)
           .get(suspiciousIpPath)
@@ -272,7 +265,7 @@ describe('AttackProtectionManager', function() {
           });
       });
 
-      it('should perform a GET request to /api/v2/attack-protection/brute-force-protection', function(done) {
+      it('should perform a GET request to /api/v2' + suspiciousIpPath, function(done) {
         var request = this.request;
 
         this.attackProtection.getSuspiciousIpThrottlingConfig().then(function() {
@@ -299,22 +292,6 @@ describe('AttackProtectionManager', function() {
     });
 
     describe('#updateSuspiciousIpThrottlingConfig', function() {
-      var data = {
-        enabled: true,
-        shields: ['admin_notification', 'block'],
-        allowlist: ['1.1.1.0'],
-        stage: {
-          'pre-login': {
-            max_attempts: 1,
-            rate: 864000
-          },
-          'pre-user-registration': {
-            max_attempts: 1,
-            rate: 864000
-          }
-        }
-      };
-
       beforeEach(function() {
         this.request = nock(API_URL)
           .patch(suspiciousIpPath)
@@ -394,4 +371,164 @@ describe('AttackProtectionManager', function() {
       });
     });
   });
+
+  describe('Breached Password Detection', function() {
+    var data = {
+      enabled: true,
+      shields: ['block', 'user_notification', 'admin_notification'],
+      admin_notification_frequency: ['immediately']
+    };
+
+    describe('#getBreachedPasswordDetectionConfig', function() {
+      beforeEach(function() {
+        this.request = nock(API_URL)
+          .get(breachedPasswordDetectionPath)
+          .reply(200, data);
+      });
+
+      it('should accept a callback', function(done) {
+        this.attackProtection.getBreachedPasswordDetectionConfig({}, function() {
+          done();
+        });
+      });
+
+      it('should return a promise if no callback is given', function(done) {
+        this.attackProtection
+          .getBreachedPasswordDetectionConfig()
+          .then(done.bind(null, null))
+          .catch(done.bind(null, null));
+      });
+
+      it('should pass any errors to the promise catch handler', function(done) {
+        nock.cleanAll();
+
+        var request = nock(API_URL)
+          .get(breachedPasswordDetectionPath)
+          .reply(500);
+
+        this.attackProtection.getBreachedPasswordDetectionConfig().catch(function(err) {
+          expect(err).to.exist;
+
+          done();
+        });
+      });
+
+      it('should pass the body of the response to the "then" handler', function(done) {
+        this.attackProtection
+          .getBreachedPasswordDetectionConfig()
+          .then(function(breachedPasswordDetectionConfig) {
+            expect(breachedPasswordDetectionConfig).to.deep.equal(data);
+
+            done();
+          });
+      });
+
+      it('should perform a GET request to /api/v2' + breachedPasswordDetectionPath, function(done) {
+        var request = this.request;
+
+        this.attackProtection.getBreachedPasswordDetectionConfig().then(function() {
+          expect(request.isDone()).to.be.true;
+
+          done();
+        });
+      });
+
+      it('should include the token in the Authorization header', function(done) {
+        nock.cleanAll();
+
+        var request = nock(API_URL)
+          .get(breachedPasswordDetectionPath)
+          .matchHeader('Authorization', 'Bearer ' + this.token)
+          .reply(200);
+
+        this.attackProtection.getBreachedPasswordDetectionConfig().then(function() {
+          expect(request.isDone()).to.be.true;
+
+          done();
+        });
+      });
+    });
+
+    describe('#updateBreachedPasswordDetectionConfig', function() {
+      beforeEach(function() {
+        this.request = nock(API_URL)
+          .patch(breachedPasswordDetectionPath)
+          .reply(200, data);
+      });
+
+      it('should accept a callback', function(done) {
+        this.attackProtection.updateBreachedPasswordDetectionConfig({}, data, function() {
+          done();
+        });
+      });
+
+      it('should return a promise if no callback is given', function(done) {
+        this.attackProtection
+          .updateBreachedPasswordDetectionConfig({}, data)
+          .then(done.bind(null, null))
+          .catch(done.bind(null, null));
+      });
+
+      it('should pass any errors to the promise catch handler', function(done) {
+        nock.cleanAll();
+
+        var request = nock(API_URL)
+          .patch(breachedPasswordDetectionPath)
+          .reply(500);
+
+        this.attackProtection.updateBreachedPasswordDetectionConfig({}, data).catch(function(err) {
+          expect(err).to.exist.to.be.an.instanceOf(Error);
+
+          done();
+        });
+      });
+
+      it('should perform a PATCH request to /api/v2' + breachedPasswordDetectionPath, function(
+        done
+      ) {
+        var request = this.request;
+
+        this.attackProtection.updateBreachedPasswordDetectionConfig({}, {}).then(function() {
+          expect(request.isDone()).to.be.true;
+
+          done();
+        });
+      });
+
+      it('should pass the data in the body of the request', function(done) {
+        var request = this.request;
+
+        this.attackProtection.updateBreachedPasswordDetectionConfig({}, data).then(function() {
+          expect(request.isDone()).to.be.true;
+
+          done();
+        });
+      });
+
+      it('should pass the body of the response to the "then" handler', function(done) {
+        this.attackProtection
+          .updateBreachedPasswordDetectionConfig({}, data)
+          .then(function(breachedPasswordDetectionConfig) {
+            expect(breachedPasswordDetectionConfig).to.deep.equal(data);
+
+            done();
+          });
+      });
+
+      it('should include the token in the Authorization header', function(done) {
+        nock.cleanAll();
+
+        var request = nock(API_URL)
+          .patch(breachedPasswordDetectionPath)
+          .matchHeader('Authorization', 'Bearer ' + this.token)
+          .reply(200);
+
+        this.attackProtection.updateBreachedPasswordDetectionConfig({}, data).then(function() {
+          expect(request.isDone()).to.be.true;
+
+          done();
+        });
+      });
+    });
+  });
 });