Repository: auth0/node-auth0
Pull request #579 (2021-01-25 15:20:10)

feat: add revokeRefreshToken
### Changes

I've added to the `TokensManager` the revokeRefreshToken method based on the [Revoke Refresh Token endpoint](https://auth0.com/docs/api/authentication?javascript#revoke-refresh-token).

### References

In [Finleap Connect](https://connect.finleap.com/) we're designing a stateless authentication and authorization layer over the auth0 endpoints using node-auth0.

In order to achieve it we believe that being able to proactively revoke a refresh token is a must.

### Testing

Please describe how this can be tested by reviewers. Be specific about anything not tested and reasons why. If this library has unit and/or integration testing, tests should be added for new functionality and existing tests should complete without errors. 

- [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)
- [x] All existing and new tests complete without errors


andreafspeziale: Hello guys, I hope to find you well. Any reviewer or advise about any missing tests in order to be able to merge the PR.

Patch:
diff --git a/docs/auth_TokensManager.js.html b/docs/auth_TokensManager.js.html
index a78ed591d..9a4020ec5 100644
--- a/docs/auth_TokensManager.js.html
+++ b/docs/auth_TokensManager.js.html
@@ -50,10 +50,11 @@ var ArgumentError = require('rest-facade').ArgumentError;
  * @constructor
  * @memberOf module:auth
  *
- * @param  {Object}   options               Manager options.
- * @param  {String}   options.baseUrl       The auth0 account URL.
- * @param  {String}   [options.headers]     Default request headers.
- * @param  {String}   [options.clientId]    Default client ID.
+ * @param  {Object}   options                 Manager options.
+ * @param  {String}   options.baseUrl         The auth0 account URL.
+ * @param  {String}   [options.headers]       Default request headers.
+ * @param  {String}   [options.clientId]      Default client ID.
+ * @param  {String}   [options.clientSecret]  Default client Secret.
  */
 var TokensManager = function(options) {
   if (typeof options !== 'object') {
@@ -67,6 +68,7 @@ var TokensManager = function(options) {
   this.baseUrl = options.baseUrl;
   this.headers = options.headers || {};
   this.clientId = options.clientId || '';
+  this.clientSecret = options.clientSecret || '';
 };
 
 /**
@@ -213,6 +215,86 @@ TokensManager.prototype.getDelegationToken = function(data, cb) {
   return promise;
 };
 
+/**
+ * Proactively revoke an issued refresh token.
+ *
+ * @method
+ * @memberOf module:auth.TokensManager.prototype
+ *
+ * @example &lt;caption>
+ *   Given an existing refresh token, this endpoint will revoke it in order
+ *   to prevent unauthorized silently user authentication tokens refresh.
+ *   Find more information in the &lt;a href="https://auth0.com/docs/api/authentication#revoke-refresh-token">API Docs&lt;/a>.
+ * &lt;/caption>
+ *
+ *  * var data = {
+ *   token: '{REFRESH_TOKEN}'
+ * };
+ *
+ * auth0.tokens.revokeRefreshToken(data, function (err, _) {
+ *   if (err) {
+ *     // Handle error.
+ *   }
+ *
+ *   // Do stuff.
+ * });
+ *
+ * @param   {Object}    data                  Token data object.
+ * @param   {String}    data.token            User refresh token.
+ * @param   {String}    [data.client_id]      Target client ID.
+ * @param   {String}    [data.client_secret]  Target client secret.
+ * @param   {Function}  [cb]                  Callback function.
+ *
+ * @return  {Promise|undefined}
+ */
+TokensManager.prototype.revokeRefreshToken = function(data, cb) {
+  if (!data) {
+    throw new ArgumentError('Missing token data object');
+  }
+
+  var hasToken = typeof data.token === 'string' &amp;&amp; data.token.trim().length !== 0;
+
+  if (!hasToken) {
+    throw new ArgumentError('token property is required');
+  }
+
+  var hasClientId =
+    (data.client_id &amp;&amp; typeof data.client_id === 'string' &amp;&amp; data.client_id.trim().length !== 0) ||
+    this.clientId !== '';
+
+  if (!hasClientId) {
+    throw new ArgumentError(
+      'Neither token data client_id property or constructor clientId property has been set'
+    );
+  }
+
+  var body = extend(
+    {
+      client_id: this.clientId,
+      client_secret: this.clientSecret
+    },
+    data
+  );
+
+  var headers = this.headers;
+
+  // Perform the request.
+  var promise = axios({
+    method: 'POST',
+    url: this.baseUrl + '/oauth/revoke',
+    data: body,
+    headers: headers
+  }).then(({ data }) => data);
+
+  // Use callback if given.
+  if (cb instanceof Function) {
+    promise.then(cb.bind(null, null)).catch(cb);
+    return;
+  }
+
+  return promise;
+};
+
 module.exports = TokensManager;
 </code></pre>
         </article>
@@ -226,7 +308,7 @@ module.exports = TokensManager;
 <br class="clear">
 
 <footer>
-    Generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 3.6.6</a> on Thu Jan 21 2021 12:34:05 GMT-0800 (Pacific Standard Time) using the Minami theme.
+    Generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 3.6.6</a> on Mon Feb 01 2021 15:09:19 GMT+0100 (Ora standard dell’Europa centrale) using the Minami theme.
 </footer>
 
 <script>prettyPrint();</script>
diff --git a/src/auth/TokensManager.js b/src/auth/TokensManager.js
index 04b08b10e..112f18b62 100644
--- a/src/auth/TokensManager.js
+++ b/src/auth/TokensManager.js
@@ -9,10 +9,11 @@ var ArgumentError = require('rest-facade').ArgumentError;
  * @constructor
  * @memberOf module:auth
  *
- * @param  {Object}   options               Manager options.
- * @param  {String}   options.baseUrl       The auth0 account URL.
- * @param  {String}   [options.headers]     Default request headers.
- * @param  {String}   [options.clientId]    Default client ID.
+ * @param  {Object}   options                 Manager options.
+ * @param  {String}   options.baseUrl         The auth0 account URL.
+ * @param  {String}   [options.headers]       Default request headers.
+ * @param  {String}   [options.clientId]      Default client ID.
+ * @param  {String}   [options.clientSecret]  Default client Secret.
  */
 var TokensManager = function(options) {
   if (typeof options !== 'object') {
@@ -26,6 +27,7 @@ var TokensManager = function(options) {
   this.baseUrl = options.baseUrl;
   this.headers = options.headers || {};
   this.clientId = options.clientId || '';
+  this.clientSecret = options.clientSecret || '';
 };
 
 /**
@@ -172,4 +174,84 @@ TokensManager.prototype.getDelegationToken = function(data, cb) {
   return promise;
 };
 
+/**
+ * Proactively revoke an issued refresh token.
+ *
+ * @method
+ * @memberOf module:auth.TokensManager.prototype
+ *
+ * @example <caption>
+ *   Given an existing refresh token, this endpoint will revoke it in order
+ *   to prevent unauthorized silently user authentication tokens refresh.
+ *   Find more information in the <a href="https://auth0.com/docs/api/authentication#revoke-refresh-token">API Docs</a>.
+ * </caption>
+ *
+ *  * var data = {
+ *   token: '{REFRESH_TOKEN}'
+ * };
+ *
+ * auth0.tokens.revokeRefreshToken(data, function (err, _) {
+ *   if (err) {
+ *     // Handle error.
+ *   }
+ *
+ *   // Do stuff.
+ * });
+ *
+ * @param   {Object}    data                  Token data object.
+ * @param   {String}    data.token            User refresh token.
+ * @param   {String}    [data.client_id]      Target client ID.
+ * @param   {String}    [data.client_secret]  Target client secret.
+ * @param   {Function}  [cb]                  Callback function.
+ *
+ * @return  {Promise|undefined}
+ */
+TokensManager.prototype.revokeRefreshToken = function(data, cb) {
+  if (!data) {
+    throw new ArgumentError('Missing token data object');
+  }
+
+  var hasToken = typeof data.token === 'string' && data.token.trim().length !== 0;
+
+  if (!hasToken) {
+    throw new ArgumentError('token property is required');
+  }
+
+  var hasClientId =
+    (data.client_id && typeof data.client_id === 'string' && data.client_id.trim().length !== 0) ||
+    this.clientId !== '';
+
+  if (!hasClientId) {
+    throw new ArgumentError(
+      'Neither token data client_id property or constructor clientId property has been set'
+    );
+  }
+
+  var body = extend(
+    {
+      client_id: this.clientId,
+      client_secret: this.clientSecret
+    },
+    data
+  );
+
+  var headers = this.headers;
+
+  // Perform the request.
+  var promise = axios({
+    method: 'POST',
+    url: this.baseUrl + '/oauth/revoke',
+    data: body,
+    headers: headers
+  }).then(({ data }) => data);
+
+  // Use callback if given.
+  if (cb instanceof Function) {
+    promise.then(cb.bind(null, null)).catch(cb);
+    return;
+  }
+
+  return promise;
+};
+
 module.exports = TokensManager;


Test patch:
diff --git a/test/auth/tokens-manager.tests.js b/test/auth/tokens-manager.tests.js
index a9a4c9c9b..f77838a51 100644
--- a/test/auth/tokens-manager.tests.js
+++ b/test/auth/tokens-manager.tests.js
@@ -14,7 +14,8 @@ describe('TokensManager', function() {
       'Content-Type': 'application/json',
       'Test-Header': 'TEST'
     },
-    clientId: 'CLIENT_ID'
+    clientId: 'CLIENT_ID',
+    clientSecret: 'CLIENT_SECRET'
   };
 
   afterEach(function() {
@@ -34,7 +35,7 @@ describe('TokensManager', function() {
   });
 
   describe('instance', function() {
-    var methods = ['getInfo', 'getDelegationToken'];
+    var methods = ['getInfo', 'getDelegationToken', 'revokeRefreshToken'];
     var manager = new TokensManager(validOptions);
 
     methods.forEach(function(methodName) {
@@ -327,4 +328,203 @@ describe('TokensManager', function() {
       });
     });
   });
+
+  describe('#revokeRefreshToken', function() {
+    var path = '/oauth/revoke';
+    var manager = new TokensManager(validOptions);
+
+    beforeEach(function() {
+      this.request = nock(BASE_URL)
+        .post(path)
+        .reply(200);
+    });
+
+    it('should require a token data object', function() {
+      var revokeRefreshToken = manager.revokeRefreshToken.bind(manager);
+
+      expect(revokeRefreshToken).to.throw(ArgumentError, 'Missing token data object');
+    });
+
+    it('should require a token property in the token data object', function() {
+      var data = {};
+      var revokeRefreshToken = manager.revokeRefreshToken.bind(manager, data);
+
+      expect(revokeRefreshToken).to.throw(ArgumentError, 'token property is required');
+    });
+
+    it('should require at least a target client ID', function() {
+      var manager = new TokensManager({
+        baseUrl: BASE_URL,
+        headers: {
+          'Content-Type': 'application/json',
+          'Test-Header': 'TEST'
+        }
+      });
+
+      var data = {
+        token: 'TEST_REFRESH_TOKEN'
+      };
+
+      var revokeRefreshToken = manager.revokeRefreshToken.bind(manager, data);
+
+      expect(revokeRefreshToken).to.throw(
+        ArgumentError,
+        'Neither token data client_id property or constructor clientId property has been set'
+      );
+    });
+
+    it('should accept a callback', function(done) {
+      var data = {
+        token: 'TEST_REFRESH_TOKEN'
+      };
+
+      manager.revokeRefreshToken(data, done.bind(null, null));
+    });
+
+    it('should return a promise when no callback is given', function() {
+      var data = {
+        token: 'TEST_REFRESH_TOKEN'
+      };
+      var returnValue = manager.revokeRefreshToken(data);
+      expect(utilTypes.isPromise(returnValue)).ok;
+    });
+
+    it('should not return a promise when a callback is given', function() {
+      var data = {
+        token: 'TEST_REFRESH_TOKEN'
+      };
+      var returnValue = manager.revokeRefreshToken(data, function() {});
+
+      expect(returnValue).to.equal(undefined);
+    });
+
+    it('should perform a POST request to ' + path, function() {});
+
+    it('should include the data in the body of the request', function(done) {
+      nock.cleanAll();
+
+      var data = {
+        token: 'TEST_REFRESH_TOKEN'
+      };
+
+      var request = nock(BASE_URL)
+        .post(path, function(body) {
+          for (var property in data) {
+            if (body[property] !== data[property]) {
+              return false;
+            }
+          }
+
+          return true;
+        })
+        .reply();
+
+      manager.revokeRefreshToken(data).then(function() {
+        expect(request.isDone()).to.be.true;
+
+        done();
+      });
+    });
+
+    it('should use the TokensManager instance client ID if none specified', function(done) {
+      nock.cleanAll();
+
+      var data = {
+        token: 'TEST_REFRESH_TOKEN'
+      };
+
+      var request = nock(BASE_URL)
+        .post(path, function(body) {
+          return body.client_id === validOptions.clientId;
+        })
+        .reply();
+
+      manager.revokeRefreshToken(data).then(function() {
+        expect(request.isDone()).to.be.true;
+
+        done();
+      });
+    });
+
+    it('should let the user override the default client ID', function(done) {
+      nock.cleanAll();
+
+      var data = {
+        token: 'TEST_REFRESH_TOKEN',
+        client_id: 'OVERRIDEN_CLIENT_ID'
+      };
+
+      var request = nock(BASE_URL)
+        .post(path, function(body) {
+          return body.client_id === data.client_id;
+        })
+        .reply();
+
+      manager.revokeRefreshToken(data).then(function() {
+        expect(request.isDone()).to.be.true;
+
+        done();
+      });
+    });
+
+    it('should use the TokensManager instance client secret if none specified', function(done) {
+      nock.cleanAll();
+
+      var data = {
+        token: 'TEST_REFRESH_TOKEN'
+      };
+
+      var request = nock(BASE_URL)
+        .post(path, function(body) {
+          return body.client_secret === validOptions.clientSecret;
+        })
+        .reply();
+
+      manager.revokeRefreshToken(data).then(function() {
+        expect(request.isDone()).to.be.true;
+
+        done();
+      });
+    });
+
+    it('should let the user override the default client secret', function(done) {
+      nock.cleanAll();
+
+      var data = {
+        token: 'TEST_REFRESH_TOKEN',
+        client_secret: 'OVERRIDEN_CLIENT_SECRET'
+      };
+
+      var request = nock(BASE_URL)
+        .post(path, function(body) {
+          return body.client_secret === data.client_secret;
+        })
+        .reply();
+
+      manager.revokeRefreshToken(data).then(function() {
+        expect(request.isDone()).to.be.true;
+
+        done();
+      });
+    });
+
+    it('should include the headers specified in the instance options', function(done) {
+      nock.cleanAll();
+
+      var data = {
+        token: 'TEST_REFRESH_TOKEN'
+      };
+
+      var request = nock(BASE_URL)
+        .post(path)
+        .matchHeader('Test-Header', validOptions.headers['Test-Header'])
+        .reply(200);
+
+      manager.revokeRefreshToken(data).then(function() {
+        expect(request.isDone()).to.be.true;
+
+        done();
+      });
+    });
+  });
 });