Passwordless authentication in Meteor


#1

I want to implement passwordless authentication similar to WhatsApp’s for a mobile app. I have found two useful sources for this. First one is done via Auth0 here. The second one is even closer solution for my need as it is an Atmosphere package called accounts-passwordless but it is two years old. I don’t know if is still reliable/useful. I would like to know your experience and advices about implementing passwordless auth. on Meteor (also with React).


#2

Hey @gkaradag !
I just recently started the same journey as yours, apparently. Only a few hours clocked in, so far, so I am wondering if you made any progress ?

On my end, I tried the package you mentioned, but as fas as I can tell, it relies on having a user already created, which is not what I am looking for. It does work for login and token generation though, and will probably be a helpful ressource if a new solution has to be coded.
From what I have gathered, it seems like one would have to create some kind of package like accounts-oauth - which is passwordless but relies on a traditional oauth mechanism happening somewhere, which isn’t our goal - and have some custom logic to handle creating/sending/checking a hash for emails and/or code for SMS.

I will look deeper into it tomorrow, it may not be that hard, considering the API of the account package. I will keep you updated, if you want.


#3

Sure! I did not prefer using a package either. There is another passwordless package acemtp/meteor-accounts-passwordless which has a better code but I decided to go without a package. I perform user creation on the server side via Accounts.createUser(). Now, I’m trying to figure out how to set and manage tokens and automatically sign the user in when he enters the verification code, correctly.


#4

Oh, it does have way better code, indeed !
This looks like the proper way to handle it, to me. I had gathered that using Accounts.registerLoginHandler() was the way to go, this should allow us to hook into the actual token system Meteor uses for keeping a user connected through different sessions, and thus not having to worry about it.
There also is Accounts.updateOrCreateUserFromExternalService() that accounts-oauth uses, I suspect it would be a cleaner way of creating a new user, considering Accounts.createUser() expects a password, as far as I know.

Thanks for pointing out the other package, I assumed poetic’s was directly copied from it, and its code is… hacky, at best, so didn’t make me want to check acempt’s sources. Glad I was wrong, with all this combined, I’m confident I will make good progress tomorrow :slight_smile:

Let’s do this !


#5

Done ! Still have to iron out the kinks and some corner cases, but like I thought, using Accounts.registerLoginHandler() and Accounts.updateOrCreateUserFromExternalService() does most of the job for you, including logging in automatically after a successful attempt, generating tokens to keep the session alive, etc. Basically all the goodies you’d expect, and all I had to code was the actual logic of generating and checking the temporary token sent through email.

I’ll gladly share more details later, right now, I need a nap :smiley:
Cheers !


#6

Do you add the package and use it or implement your own code based on the code of the package?
I don’t know what Accounts.updateOrCreateUserFromExternalService() is used for?


#7

Here’s the whole thing. I am in the process of making it work for returning users. It should, at this point, but it doesn’t matter, the basics are here.
The only packages I have added are “accounts-base”, “base64” (optional), and “email” (optional), all of which are from MDG.

As its name suggests, this is where I store pending credentials, be it for new users or returning ones.

const pendingCredentials = new Meteor.Collection('passwordless_pending_credentials');

Basic logic to send the login email. I choose to build the url with a base64
encoded string of the user email, so that I don’t have to rely on localStorage, but that part is
up to you. You have to send the token though :slight_smile: I store the pending credentials with relevant
informations on the aforementioned collection so that I can verify it once the user clicks the
link we sent him

Passwordless.sendLoginEmail = (email) => {
  const token = Random.secret();
  const encodedEmail = Base64.encode(email);
  const loginUrl = Meteor.absoluteUrl(`login/${encodedEmail}:${token}`);
  const pendingCredential = pendingCredentials.findOne({ email });
  const now = Date.now();

  if (pendingCredential) {
    /**
      In case of existing pending credentials for this email, we check its
      expiry date, and only proceed - by updating the existing one with the new
      token - if it is expired.
    **/
    if (pendingCredential.when > (now + 3600)) {
      pendingCredentials.update(pendingCredential._id, {
        token,
        when: now
      });
    } else {
      throw new Meteor.Error('token-already-sent',
        "You already requested access recently, please check your inbox"
      );
    }
  } else {
    pendingCredentials.insert({
      email,
      encodedEmail,
      token,
      when: Date.now()
    });
  }

  Email.send({
    to: email,
    from: Meteor.settings.email.from,
    subject: Passwordless.emailTemplates.sendLoginEmail.subject,
    text: Passwordless.emailTemplates.sendLoginEmail.text(loginUrl),
  });

};

Basic verification. If there is a document in the pendingCredentials collection that matches the encodedEmail and token, and its expiry date isn’t passed, we proceed with
Accounts.updateOrCreateUserFromExternalService(), passing it the name of the service
registered a bit further down, and some service data containing a unique id, most likely for
returning users. Not quite sure about that part, but it works… I’ll look into it later.
A third argument can be passed to populate the user document that will be created in case the user doesn’t yet exists.
This function call takes care of token creation to keep the user connected, etc.

Passwordless.verifyToken = ({ encodedEmail, token }) => {
  const pendingCredential = pendingCredentials.findOne({ encodedEmail, token });
  const now = Date.now();

  if (!pendingCredential) {
    throw new Meteor.Error('no-pending-credential',
      "No pending credential was found for this email"
    );
  }

  if (pendingCredential.when > (now + 3600)) {
    throw new Meteor.Error('token-expired',
      "You waited too long before using this token"
    );
  }
  const serviceData = {
    id: encodedEmail
  };

  pendingCredentials.remove({ encodedEmail, token });

  return Accounts.updateOrCreateUserFromExternalService('passwordless', serviceData);
}

There we register our service, here called “passwordless”, so that we can add it
as a proper login method. If you’ve used any oauth package, it works the same. Once you’ve
set that up, you can call it from the client like you would with any other auth package,
using Accounts.callLoginMethod(). This should be documented on the online meteor doc.

Accounts.registerLoginHandler('passwordless', (options) => {
  if (!options.passwordless)
    return undefined;

  check(options.passwordless, {
    encodedEmail: String,
    token: String
  });

  return Passwordless.verifyToken(options.passwordless);
});

From there, it’s all up to you to implement it client side. I have a method to send to initial email, and I plan on having a route that parses the encodedEmail and token out of the url and pass them to Accounts.callLoginMethod(). I’m doing it with the browser console so far, works like a charm :slight_smile:

Let me know if you have any questions !

Edited for readability. Also, I have tested the logic for returning users, it does work. All that is missing is the third argument of Accounts.updateOrCreateUserFromExternalService(), which I can’t seem to make work. I’ll look into it later this week.
I also switched from the base64 package of MDG, to this one, which has the upside of doing what one would expect when decoding, which is returning a decoded string. I’m guessing mdg has its reason for returning an array of int instead, but whatever…


#8

Or just read that link

Wish I found that before this weekend haha.


#9

I was just reading the same article :slight_smile: Thank you for your long answer!


#10

I finally make it work smoothly. I think this article is more helpful than others.


#11

Cool !
I can’t access the link you provided, but managed to find this version
I assume it’s the same ?
It’s fine as well, but I dislike using private APIs (the ones starting with _), as they can be changed without notice. Although technically, Accounts.updateOrCreateUserFromExternalService() is private as well, but as it is shared across different packages, it’s less likely to change at all.

What’s good about the article you shared though, is that it shows the bare minimum necessary to achieve our goal. That’s always good to know :slight_smile:


#12

I fixed the link above bu,t yes they are the same.


#13

Hi @pdecrat,

Nice work on passwordless logins. Just wondering, have you got an full updated example? Are you planning on making this a package?

Thanks,
Chat


#14

Thanks !
Well, I still haven’t used it in production, but I can share the current code, it’s still working with recent meteor updates.

import { Meteor } from 'meteor/meteor';
import { Random } from 'meteor/random';
import { Base64 } from 'meteor/ostrio:base64';
import { Email } from 'meteor/email';
import { check } from 'meteor/check';

const {
  login,
  key,
  hostname
} = Meteor.settings.email;
process.env.MAIL_URL = `smtp://${login}:${key}@${hostname}`;
const appName = Meteor.settings.appName;

const pendingCredentials = new Meteor.Collection('passwordless_pending_credentials');

Passwordless = {};

Passwordless.emailTemplates = {
  sendLoginEmail: {
    subject: `Your login link for ${appName}`,
    text: function (url) {
      return `You requested access to ${appName}.
Please click on the following link to proceed
${url}
Thank you !`;
    }
  }
};

Passwordless.sendLoginEmail = (email, url) => {
  const token = Random.secret();
  const loginUrl = Meteor.absoluteUrl(`${url}?token=${token}`);
  const pendingCredential = pendingCredentials.findOne({ email });
  const now = Date.now();

  if (pendingCredential) {
    /**
      In case of existing pending credentials for this email, we check its
      expiry date, and only proceed - by updating the existing one with the new
      token - if it is expired.
    **/
    if (pendingCredential.when < (now - 3600000)) {
      pendingCredentials.update(pendingCredential._id, {
        $set: {
          token,
          when: now
        }
      });
    } else {
      throw new Meteor.Error('token-already-sent',
        "You already requested access recently, please check your inbox"
      );
    }
  } else {
    pendingCredentials.insert({
      email,
      token,
      when: now
    });
  }

  Email.send({
    to: email,
    from: Meteor.settings.email.from,
    subject: Passwordless.emailTemplates.sendLoginEmail.subject,
    text: Passwordless.emailTemplates.sendLoginEmail.text(loginUrl),
  });

};

Passwordless.verifyToken = ({ token }) => {
  const pendingCredential = pendingCredentials.findOne({ token });
  const now = Date.now();

  if (!pendingCredential) {
    throw new Meteor.Error('no-pending-credential',
      "No pending credential was found for this email"
    );
  }

  if (pendingCredential.when < (now - 3600000)) {
    pendingCredentials.remove({ token });
    throw new Meteor.Error('token-expired',
      "You waited too long before using this token"
    );
  }
  const serviceData = {
    id: pendingCredential.email
  };

  pendingCredentials.remove({ token });

  const { userId } = Accounts.updateOrCreateUserFromExternalService('passwordless', serviceData);

  return {
    userId,
    email: pendingCredential.email
  };
}

Accounts.registerLoginHandler('passwordless', (options) => {
  if (!options.passwordless)
    return undefined;

  check(options.passwordless, {
    token: String,
  });

  return Passwordless.verifyToken(options.passwordless);
});

export default Passwordless;

From there all is missing is creating the method which receives the email and calls sendLoginEmail, and something to interpret the link you send and call the usual Accounts.callLoginMethod for passwordless.
I can help with those parts if you need, but my implementations of both routing and methods are pretty weird in my case, it wouldn’t help much…


#15

Thanks @pdecrat, Where should include the resgisterLoginHandler? In start up?


#16

Not in a Meteor.startup(), no. It is simply imported as is server side where the Meteor method calls Passwordless.sendLoginEmail(). The registerLoginHandler just has to be somewhere server side and it should work.
By the way, with the code I gave, it should be noted that the user schema won’t match the usual one of Meteor. No profile, no multiple emails, basically just en email and _id field and the bits where Meteor manages user sessions. To change that, I think you just have to change the return value of verifyToken().