Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using Meteor Accounts package to link multiple services

Tags:

oauth

meteor

So Meteor has this great Accounts package which allows for easy login via password or other services. However, I'm currently creating a web service with multiple services required (facebook / twitter / etc). The link here: How to add External Service logins to an already existing account in Meteor? suggests a "hack" by creating duplicate accounts and just merging the data, but it seems very unsatisfactory to me.

So my questions are:

1) Is there a more elegant way to use the Accounts-xxx packages to create one user but with multiple services attached?

2) If not, can I use the now separated oauth packages to just add tokens onto one user. If for example, the github token was "attached" manually at a later point, would Accounts.loginWithGithub still find that manually merged account at a later point?

Thanks for your help.

like image 687
Xiv Avatar asked Aug 21 '13 12:08

Xiv


2 Answers

Solved!

I've solved the problem by reverse engineering the accounts-oauth-X packages! The optimal solution that handles all the edge cases is to have one story for logins, and another for explicit associations. Here are the solutions written up in literate coffeescript.

Before:

Make sure you have the required packages:

meteor add google
meteor add facebook
meteor add oauth

Method for Having Multi-Service Login:

This autojoins accounts if they have the same email. Best used for login because it is robust. However, isn't good for explicit associations because if the email addresses are different, then it won't work. (And a manual merge will log the user out and force you to log back in). For explicitly associating an account, even if it has a different email, see the solution underneath.

Server-side code:

Accounts.onCreateUser (options, user) ->
  user.city = null
  user.networks = []
  user.attending =[]
  user.profile ?= {}

We need to act extra logic if the user registered via an oauth service, so that we can standardize the location of name and email.

  if user.services?
    service = _.keys(user.services)[0]

Check if any existing account already has the email associated with the service. If so, just incorporate the user information in.

    email = user.services[service].email
    if email?
      oldUser = Meteor.users.findOne({"emails.address": email})

Make sure the old account has the required services object.

      if oldUser?
        oldUser.services ?= {}
        if service == "google" or service == "facebook"

Merge the new service key into our old user. We also check if there are new emails to add to our user. Then delete the old user from the DB, and just return it again to create it again directly. The supposed new user that was to be created is discarded.

          oldUser.services[service] = user.services[service]
          Meteor.users.remove(oldUser._id)
          user = oldUser

Otherwise just create the user as normal, standardizing the email and name fields.

      else
        if service == "google" or service == "facebook"
          if user.services[service].email?
            user.emails = [{address: user.services[service].email, verified: true}]
          else
            throw new Meteor.Error(500, "#{service} account has no email attached")
          user.profile.name = user.services[service].name

  return user

Method for Explicit Associations:

This adds services to the services hash on the User record, identical to as if it were created by the built in hash system. The code needs to be client-side (to grab the service oauth token), and part server-side (to make further API calls to grab user data and associate it with the user record)

Client-side code:

This function is the entrypoint to adding functions onto our account.

addUserService = (service) ->

We need to use Meteor's built in email verification system if they choose email.

  if service == "email"

  else
    switch service

For standard oauth services, we request the credentials on the client side, and then pass them off to the server to do the further API calls to gather the requisite information and to add that information to our account.

      when "facebook"
        Facebook.requestCredential(
          requestPermissions: ["email", "user_friends", "manage_notifications"],
        , (token) ->
          Meteor.call "userAddOauthCredentials", token, Meteor.userId(), service, (err, resp) ->
            if err?
              Meteor.userError.throwError(err.reason)
        )
      when "google"
        Google.requestCredential
          requestPermissions: ["email", "https://www.googleapis.com/auth/calendar"]
          requestOfflineToken: true,
        , (token) ->
          Meteor.call "userAddOauthCredentials", token, Meteor.userId(), service, (err, resp) ->
            if err?
              Meteor.userError.throwError(err.reason)

we just need to set up a simple binding to glue it together.

Template.userAddServices.events
  "click button": (e) ->
    e.preventDefault()
    service = $(event.target).data("service")
    addUserService(service)

Server-side code:

Here we define server side methods related to the user model.

Meteor.methods

This gets passed the data from the clientside, which grabbed the access token already. Uses the token to discover the rest of the information about the user on that service. It then checks to see if that account is already registered, and if not - adds it to the current account.

  userAddOauthCredentials: (token, userId, service) ->
    switch service
      when "facebook"
        data = Facebook.retrieveCredential(token).serviceData
      when "google"
        data = Google.retrieveCredential(token).serviceData

    selector = "services.#{service}.id"
    oldUser = Meteor.users.findOne({selector: data.id})
    if oldUser?
      throw new Meteor.Error(500, "This #{service} account has already" +
      "been assigned to another user.")

    updateSelector = "services.#{service}"
    Meteor.users.update(userId, {$set: {updateSelector: data }})

We can also gleam at the email from this account. If it isn't already on the current user, we can grab it and add it.

    if not _.contains(Meteor.user().emails, data.email)
      Meteor.users.update(userId, {$push: {"emails": {address: data.email, verified: true} }})
like image 62
Xiv Avatar answered Oct 08 '22 05:10

Xiv


Removing existing user may create issues in some senarios. In my use case I need to merge google account while log in in to the system with another oauth account. This can be achieved with following modifications.

Modify Accounts.onCreateUser

Accounts.onCreateUser(function(options, user) {

if ( user.services )
    service = _.keys( user.services )[0]; // get service type

var email = user.services[service].email ;
var oldUser = Meteor.users.findOne({ 'emails.address': email });

if ( oldUser ){
    oldUser.services = oldUser.services ? oldUser.services : {};

    oldUser.services[service] = user.services[ service ];     
    return oldUser;     

}

return user;

});

Modify accounts-base package

userId = Meteor.users.insert(fullUser); 

line in Accounts.insertUserDoc function of packages/accounts-base/ accounts_server.js should be replaced with following code. Hope Meteor will fix this. until that have to use a custom package.

if(Meteor.users.findOne(fullUser._id)){
  userId = fullUser._id;
  delete fullUser._id;
  Meteor.users.update(userId, {$set: fullUser});
} else
  userId = Meteor.users.insert(fullUser);
like image 29
Prasad19sara Avatar answered Oct 08 '22 06:10

Prasad19sara