Repository: auth0/node-auth0
Pull request #556 (2020-11-27 20:26:02)

Add support for new passwordless endpoint
### Changes

Fixing `PasswordlessAuthenticator.signIn()`
    
 * Changed grant type to `http://auth0.com/oauth/grant-type/passwordless/otp`
 * Mapping `connection` to `realm` and `password` to `otp`
 * Changing default type from `ro` to `token` in `OAuthAuthenticator.signIn()`
 * Allowing type to be specified in options
    

### References

Passwordless signin process is attempting to use `oauth/ro` endpoint which no longer exists:

- https://support.auth0.com/tickets/00472740

### Testing

- [ ] 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

nbandarchi: Updated so that it's not defaulting to `token`.  if `passwordless.signIn()` is called with `otp` and optionally `realm` it will change the grant type to `http://auth0.com/oauth/grant-type/passwordless/otp` and pass in `token` as an option to `oauth.signIn()`.  Realm will default to `sms` the same way that connection currently does.
nbandarchi: No problem!  Added tests and documentation.  The tests are just a duplicated and updated copy of the current tests, but still should show that both paths can coexist.

Patch:
diff --git a/src/auth/OAuthAuthenticator.js b/src/auth/OAuthAuthenticator.js
index 02f813eb7..22a58a475 100644
--- a/src/auth/OAuthAuthenticator.js
+++ b/src/auth/OAuthAuthenticator.js
@@ -17,6 +17,9 @@ function getParamsFromOptions(options) {
       req.set('auth0-forwarded-for', options.forwardedFor);
     };
   }
+  if (options.type) {
+    params.type = options.type;
+  }
   return params;
 }
 
@@ -117,7 +120,10 @@ OAuthAuthenticator.prototype.signIn = function(userData, options, cb) {
     throw new ArgumentError('Missing user data object');
   }
 
-  if (typeof data.connection !== 'string' || data.connection.split().length === 0) {
+  if (
+    params.type === 'ro' &&
+    (typeof data.connection !== 'string' || data.connection.split().length === 0)
+  ) {
     throw new ArgumentError('connection field is required');
   }
 
diff --git a/src/auth/PasswordlessAuthenticator.js b/src/auth/PasswordlessAuthenticator.js
index 794649604..38e1122ad 100644
--- a/src/auth/PasswordlessAuthenticator.js
+++ b/src/auth/PasswordlessAuthenticator.js
@@ -48,7 +48,7 @@ var PasswordlessAuthenticator = function(options, oauth) {
  * @example <caption>
  *   Given the user credentials (`phone_number` and `code`), it will do the
  *   authentication on the provider and return a JSON with the `access_token`
- *   and `id_token`.
+ *   and `id_token` using `/oauth/ro` endpoint.
  * </caption>
  *
  * var data = {
@@ -63,6 +63,21 @@ var PasswordlessAuthenticator = function(options, oauth) {
  * });
  *
  * @example <caption>
+ * To use `/oauth/token` endpoint, use `otp` and `realm` instead
+ * </caption>
+ *
+ * var data = {
+ *   username: '{PHONE_NUMBER}',
+ *   otp: '{VERIFICATION_CODE}'
+ * };
+ *
+ * auth0.passwordless.signIn(data, function (err) {
+ *   if (err) {
+ *     // Handle error.
+ *   }
+ * });
+ *
+ * @example <caption>
  *   The user data object has the following structure.
  * </caption>
  *
@@ -73,9 +88,11 @@ var PasswordlessAuthenticator = function(options, oauth) {
  * }
  *
  * @param   {Object}    userData              User credentials object.
- * @param   {String}    userData.username     Username.
- * @param   {String}    userData.password     Password.
- * @param   {String}    [userData.connection=sms]  Connection string: "sms" or "email".
+ * @param   {String}    userData.otp          The user's verification code.
+ * @param   {String}    [userData.realm=sms]  Realm string: "sms" or "email".
+ * @param   {String}    userData.username     The user's phone number if realm=sms, or the user's email if realm=email
+ * @param   {String}    userData.password     [DEPRECATED] Password.
+ * @param   {String}    [userData.connection=sms] [DEPRECATED] Connection string: "sms" or "email".
  * @param   {Function}  [cb]                  Method callback.
  *
  * @return  {Promise|undefined}
@@ -87,12 +104,6 @@ PasswordlessAuthenticator.prototype.signIn = function(userData, cb) {
   };
   var data = extend(defaultFields, userData);
 
-  // Don't let the user override the connection nor the grant type.
-  if (!data.connection || (data.connection !== 'email' && data.connection !== 'sms')) {
-    data.connection = 'sms';
-  }
-  data.grant_type = 'password';
-
   if (!userData || typeof userData !== 'object') {
     throw new ArgumentError('Missing user data object');
   }
@@ -101,10 +112,29 @@ PasswordlessAuthenticator.prototype.signIn = function(userData, cb) {
     throw new ArgumentError('username field (phone number) is required');
   }
 
+  // If otp is provided, attempt to sign in using otp grant
+  if (typeof data.otp === 'string' && data.otp.trim().length > 0) {
+    if (!data.realm || (data.realm !== 'email' && data.realm !== 'sms')) {
+      data.realm = 'sms';
+    }
+    data.grant_type = 'http://auth0.com/oauth/grant-type/passwordless/otp';
+    return this.oauth.signIn(data, { type: 'token' }, cb);
+  }
+
+  // Don't let the user override the connection nor the grant type.
+  if (!data.connection || (data.connection !== 'email' && data.connection !== 'sms')) {
+    data.connection = 'sms';
+  }
+  data.grant_type = 'password';
+
   if (typeof data.password !== 'string' || data.password.trim().length === 0) {
     throw new ArgumentError('password field (verification code) is required');
   }
 
+  console.warn(
+    'The oauth/ro endpoint has been deprecated. Please use the realm and otp parameters in this function.'
+  );
+
   return this.oauth.signIn(data, cb);
 };
 


Test patch:
diff --git a/test/auth/passwordless.tests.js b/test/auth/passwordless.tests.js
index 67271376d..c694ad545 100644
--- a/test/auth/passwordless.tests.js
+++ b/test/auth/passwordless.tests.js
@@ -48,202 +48,404 @@ describe('PasswordlessAuthenticator', function() {
   });
 
   describe('#signIn', function() {
-    var path = '/oauth/ro';
-    var userData = {
-      username: 'username',
-      password: 'pwd'
-    };
-
-    beforeEach(function() {
-      var oauth = new OAuth(validOptions);
-      this.authenticator = new Authenticator(validOptions, oauth);
-      this.request = nock(API_URL)
-        .post(path)
-        .reply(200);
-    });
+    describe('/oauth/ro', function() {
+      var path = '/oauth/ro';
+      var userData = {
+        username: 'username',
+        password: 'pwd'
+      };
+
+      beforeEach(function() {
+        var oauth = new OAuth(validOptions);
+        this.authenticator = new Authenticator(validOptions, oauth);
+        this.request = nock(API_URL)
+          .post(path)
+          .reply(200);
+      });
 
-    it('should require an object as first argument', function() {
-      expect(this.authenticator.signIn).to.throw(ArgumentError, 'Missing user data object');
-    });
+      it('should require an object as first argument', function() {
+        expect(this.authenticator.signIn).to.throw(ArgumentError, 'Missing user data object');
+      });
 
-    it('should require a phone number', function() {
-      var auth = this.authenticator;
-      var userData = { password: 'password' };
-      var signIn = auth.signIn.bind(auth, userData);
+      it('should require a phone number', function() {
+        var auth = this.authenticator;
+        var userData = { password: 'password' };
+        var signIn = auth.signIn.bind(auth, userData);
 
-      expect(signIn).to.throw(ArgumentError, 'username field (phone number) is required');
-    });
+        expect(signIn).to.throw(ArgumentError, 'username field (phone number) is required');
+      });
 
-    it('should require a verification code', function() {
-      var auth = this.authenticator;
-      var userData = { username: 'username' };
-      var signIn = auth.signIn.bind(auth, userData);
+      it('should require a verification code', function() {
+        var auth = this.authenticator;
+        var userData = { username: 'username' };
+        var signIn = auth.signIn.bind(auth, userData);
 
-      expect(signIn).to.throw(ArgumentError, 'password field (verification code) is required');
-    });
+        expect(signIn).to.throw(ArgumentError, 'password field (verification code) is required');
+      });
 
-    it('should accept a callback', function(done) {
-      this.authenticator.signIn(userData, done.bind(null, null));
-    });
+      it('should accept a callback', function(done) {
+        this.authenticator.signIn(userData, done.bind(null, null));
+      });
 
-    it('should return a promise when no callback is provided', function(done) {
-      this.authenticator
-        .signIn(userData)
-        .then(done.bind(null, null))
-        .catch(done.bind(null, null));
-    });
+      it('should return a promise when no callback is provided', function(done) {
+        this.authenticator
+          .signIn(userData)
+          .then(done.bind(null, null))
+          .catch(done.bind(null, null));
+      });
 
-    it('should perform a POST request to ' + path, function(done) {
-      var request = this.request;
+      it('should perform a POST request to ' + path, function(done) {
+        var request = this.request;
 
-      this.authenticator
-        .signIn(userData)
-        .then(function() {
-          expect(request.isDone()).to.be.true;
+        this.authenticator
+          .signIn(userData)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
 
-          done();
-        })
-        .catch(done);
-    });
+            done();
+          })
+          .catch(done);
+      });
 
-    it('should include the user data in the request', function(done) {
-      nock.cleanAll();
+      it('should include the user data in the request', function(done) {
+        nock.cleanAll();
 
-      var request = nock(API_URL)
-        .post(path, function(body) {
-          for (var property in userData) {
-            if (userData[property] !== body[property]) {
-              return false;
+        var request = nock(API_URL)
+          .post(path, function(body) {
+            for (var property in userData) {
+              if (userData[property] !== body[property]) {
+                return false;
+              }
             }
-          }
 
-          return true;
-        })
-        .reply(200);
+            return true;
+          })
+          .reply(200);
 
-      this.authenticator
-        .signIn(userData)
-        .then(function() {
-          expect(request.isDone()).to.be.true;
+        this.authenticator
+          .signIn(userData)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
 
-          done();
-        })
-        .catch(done);
-    });
+            done();
+          })
+          .catch(done);
+      });
 
-    it('should include the Auth0 client ID in the request', function(done) {
-      nock.cleanAll();
+      it('should include the Auth0 client ID in the request', function(done) {
+        nock.cleanAll();
 
-      var request = nock(API_URL)
-        .post(path, function(body) {
-          return body.client_id === CLIENT_ID;
-        })
-        .reply(200);
+        var request = nock(API_URL)
+          .post(path, function(body) {
+            return body.client_id === CLIENT_ID;
+          })
+          .reply(200);
 
-      this.authenticator
-        .signIn(userData)
-        .then(function() {
-          expect(request.isDone()).to.be.true;
+        this.authenticator
+          .signIn(userData)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
 
-          done();
-        })
-        .catch(done);
-    });
+            done();
+          })
+          .catch(done);
+      });
 
-    it('should use SMS connection', function(done) {
-      nock.cleanAll();
+      it('should use SMS connection', function(done) {
+        nock.cleanAll();
 
-      var request = nock(API_URL)
-        .post(path, function(body) {
-          return body.connection === 'sms';
-        })
-        .reply(200);
+        var request = nock(API_URL)
+          .post(path, function(body) {
+            return body.connection === 'sms';
+          })
+          .reply(200);
 
-      this.authenticator
-        .signIn(userData)
-        .then(function() {
-          expect(request.isDone()).to.be.true;
+        this.authenticator
+          .signIn(userData)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
 
-          done();
-        })
-        .catch(done);
-    });
+            done();
+          })
+          .catch(done);
+      });
 
-    it('should use email connection', function(done) {
-      nock.cleanAll();
-      var data = extend({ connection: 'email' }, userData);
-      var request = nock(API_URL)
-        .post(path, function(body) {
-          return body.connection === 'email';
-        })
-        .reply(200);
+      it('should use email connection', function(done) {
+        nock.cleanAll();
+        var data = extend({ connection: 'email' }, userData);
+        var request = nock(API_URL)
+          .post(path, function(body) {
+            return body.connection === 'email';
+          })
+          .reply(200);
+
+        this.authenticator
+          .signIn(data)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
+
+            done();
+          })
+          .catch(done);
+      });
 
-      this.authenticator
-        .signIn(data)
-        .then(function() {
-          expect(request.isDone()).to.be.true;
+      it('should allow the user to specify the connection as sms or email', function(done) {
+        nock.cleanAll();
 
-          done();
-        })
-        .catch(done);
-    });
+        var data = extend({ connection: 'TEST_CONNECTION' }, userData);
+        var request = nock(API_URL)
+          .post(path, function(body) {
+            return body.connection === 'sms' || body.connection === 'email';
+          })
+          .reply(200);
 
-    it('should allow the user to specify the connection as sms or email', function(done) {
-      nock.cleanAll();
+        this.authenticator
+          .signIn(data)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
 
-      var data = extend({ connection: 'TEST_CONNECTION' }, userData);
-      var request = nock(API_URL)
-        .post(path, function(body) {
-          return body.connection === 'sms' || body.connection === 'email';
-        })
-        .reply(200);
+            done();
+          })
+          .catch(done);
+      });
 
-      this.authenticator
-        .signIn(data)
-        .then(function() {
-          expect(request.isDone()).to.be.true;
+      it('should use password as grant type', function(done) {
+        nock.cleanAll();
 
-          done();
-        })
-        .catch(done);
-    });
+        var request = nock(API_URL)
+          .post(path, function(body) {
+            return body.grant_type === 'password';
+          })
+          .reply(200);
 
-    it('should use password as grant type', function(done) {
-      nock.cleanAll();
+        this.authenticator
+          .signIn(userData)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
 
-      var request = nock(API_URL)
-        .post(path, function(body) {
-          return body.grant_type === 'password';
-        })
-        .reply(200);
+            done();
+          })
+          .catch(done);
+      });
 
-      this.authenticator
-        .signIn(userData)
-        .then(function() {
-          expect(request.isDone()).to.be.true;
+      it('should use the openid scope', function(done) {
+        nock.cleanAll();
 
-          done();
-        })
-        .catch(done);
+        var request = nock(API_URL)
+          .post(path, function(body) {
+            return body.scope === 'openid';
+          })
+          .reply(200);
+
+        this.authenticator
+          .signIn(userData)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
+
+            done();
+          })
+          .catch(done);
+      });
     });
 
-    it('should use the openid scope', function(done) {
-      nock.cleanAll();
+    describe('/oauth/token', function() {
+      var path = '/oauth/token';
+      var userData = {
+        username: 'username',
+        otp: '000000'
+      };
 
-      var request = nock(API_URL)
-        .post(path, function(body) {
-          return body.scope === 'openid';
-        })
-        .reply(200);
+      beforeEach(function() {
+        var oauth = new OAuth(validOptions);
+        this.authenticator = new Authenticator(validOptions, oauth);
+        this.request = nock(API_URL)
+          .post(path)
+          .reply(200);
+      });
 
-      this.authenticator
-        .signIn(userData)
-        .then(function() {
-          expect(request.isDone()).to.be.true;
+      it('should require an object as first argument', function() {
+        expect(this.authenticator.signIn).to.throw(ArgumentError, 'Missing user data object');
+      });
 
-          done();
-        })
-        .catch(done);
+      it('should require a phone number', function() {
+        var auth = this.authenticator;
+        var userData = { otp: '000000' };
+        var signIn = auth.signIn.bind(auth, userData);
+
+        expect(signIn).to.throw(ArgumentError, 'username field (phone number) is required');
+      });
+
+      it('should require a verification code', function() {
+        var auth = this.authenticator;
+        var userData = { username: 'username' };
+        var signIn = auth.signIn.bind(auth, userData);
+
+        expect(signIn).to.throw(ArgumentError, 'password field (verification code) is required');
+      });
+
+      it('should accept a callback', function(done) {
+        this.authenticator.signIn(userData, done.bind(null, null));
+      });
+
+      it('should return a promise when no callback is provided', function(done) {
+        this.authenticator
+          .signIn(userData)
+          .then(done.bind(null, null))
+          .catch(done.bind(null, null));
+      });
+
+      it('should perform a POST request to ' + path, function(done) {
+        var request = this.request;
+
+        this.authenticator
+          .signIn(userData)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
+
+            done();
+          })
+          .catch(done);
+      });
+
+      it('should include the user data in the request', function(done) {
+        nock.cleanAll();
+
+        var request = nock(API_URL)
+          .post(path, function(body) {
+            for (var property in userData) {
+              if (userData[property] !== body[property]) {
+                return false;
+              }
+            }
+
+            return true;
+          })
+          .reply(200);
+
+        this.authenticator
+          .signIn(userData)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
+
+            done();
+          })
+          .catch(done);
+      });
+
+      it('should include the Auth0 client ID in the request', function(done) {
+        nock.cleanAll();
+
+        var request = nock(API_URL)
+          .post(path, function(body) {
+            return body.client_id === CLIENT_ID;
+          })
+          .reply(200);
+
+        this.authenticator
+          .signIn(userData)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
+
+            done();
+          })
+          .catch(done);
+      });
+
+      it('should use SMS realm', function(done) {
+        nock.cleanAll();
+
+        var request = nock(API_URL)
+          .post(path, function(body) {
+            return body.realm === 'sms';
+          })
+          .reply(200);
+
+        this.authenticator
+          .signIn(userData)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
+
+            done();
+          })
+          .catch(done);
+      });
+
+      it('should use email realm', function(done) {
+        nock.cleanAll();
+        var data = extend({ realm: 'email' }, userData);
+        var request = nock(API_URL)
+          .post(path, function(body) {
+            return body.realm === 'email';
+          })
+          .reply(200);
+
+        this.authenticator
+          .signIn(data)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
+
+            done();
+          })
+          .catch(done);
+      });
+
+      it('should allow the user to specify the realm as sms or email', function(done) {
+        nock.cleanAll();
+
+        var data = extend({ realm: 'TEST_CONNECTION' }, userData);
+        var request = nock(API_URL)
+          .post(path, function(body) {
+            return body.realm === 'sms' || body.realm === 'email';
+          })
+          .reply(200);
+
+        this.authenticator
+          .signIn(data)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
+
+            done();
+          })
+          .catch(done);
+      });
+
+      it('should use otp as grant type', function(done) {
+        nock.cleanAll();
+
+        var request = nock(API_URL)
+          .post(path, function(body) {
+            return body.grant_type === 'http://auth0.com/oauth/grant-type/passwordless/otp';
+          })
+          .reply(200);
+
+        this.authenticator
+          .signIn(userData)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
+
+            done();
+          })
+          .catch(done);
+      });
+
+      it('should use the openid scope', function(done) {
+        nock.cleanAll();
+
+        var request = nock(API_URL)
+          .post(path, function(body) {
+            return body.scope === 'openid';
+          })
+          .reply(200);
+
+        this.authenticator
+          .signIn(userData)
+          .then(function() {
+            expect(request.isDone()).to.be.true;
+
+            done();
+          })
+          .catch(done);
+      });
     });
   });