diff --git a/.gitignore b/.gitignore index 7bb4edd405..c210b96ee1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /dev_bundle /dev_bundle*.tar.gz /dist +\#*\# +.\#* \ No newline at end of file diff --git a/packages/accounts/accounts_client.js b/packages/accounts/accounts_client.js new file mode 100644 index 0000000000..4921426850 --- /dev/null +++ b/packages/accounts/accounts_client.js @@ -0,0 +1,43 @@ +Meteor.loginWithFacebook = function () { + var openCenteredPopup = function(url, width, height) { + var screenX = typeof window.screenX !== 'undefined' + ? window.screenX : window.screenLeft; + var screenY = typeof window.screenY !== 'undefined' + ? window.screenY : window.screenTop; + var outerWidth = typeof window.outerWidth !== 'undefined' + ? window.outerWidth : document.body.clientWidth; + var outerHeight = typeof window.outerHeight !== 'undefined' + ? window.outerHeight : (document.body.clientHeight - 22); + + // Use `outerWidth - width` and `outerHeight - height` for help in + // positioning the popup centered relative to the current window + var left = screenX + (outerWidth - width) / 2; + var top = screenY + (outerHeight - height) / 2; + var features = ('width=' + width + ',height=' + height + + ',left=' + left + ',top=' + top); + + var newwindow = window.open(url, 'Login', features); + if (newwindow.focus) + newwindow.focus(); + }; + + if (!Meteor.accounts.facebook._appId || !Meteor.accounts.facebook._appUrl) + throw new Error("Need to call Meteor.accounts.facebook.setup first"); + + var oauthState = Meteor.uuid(); + + openCenteredPopup( + 'https://www.facebook.com/dialog/oauth?client_id=' + Meteor.accounts.facebook._appId + + '&redirect_uri=' + Meteor.accounts.facebook._appUrl + '/_oauth/facebook' + + '&scope=email&state=' + oauthState, + 1000, 600); // XXX should we use different dimensions, e.g. on mobile? + + Meteor.apply('login', [ + {oauth: {version: 2, provider: 'facebook', state: oauthState}} + ], {wait: true}, function(error, result) { + Meteor.default_connection.setUserId(result.id); + Meteor.default_connection.onReconnect = function() { + Meteor.apply('login', [{resume: result.token}], {wait: true}); + }; + }); +}; diff --git a/packages/accounts/accounts_common.js b/packages/accounts/accounts_common.js new file mode 100644 index 0000000000..2a2b6dbf95 --- /dev/null +++ b/packages/accounts/accounts_common.js @@ -0,0 +1,16 @@ +Meteor.users = new Meteor.Collection("users"); + +if (!Meteor.accounts) { + Meteor.accounts = {}; +} + +if (!Meteor.accounts.facebook) { + Meteor.accounts.facebook = {}; +} + +Meteor.accounts._loginTokens = new Meteor.Collection("accounts._loginTokens"); + +Meteor.accounts.facebook.setup = function(appId, appUrl) { + Meteor.accounts.facebook._appId = appId; + Meteor.accounts.facebook._appUrl = appUrl; +}; diff --git a/packages/accounts/accounts_server.js b/packages/accounts/accounts_server.js new file mode 100644 index 0000000000..2618a382b9 --- /dev/null +++ b/packages/accounts/accounts_server.js @@ -0,0 +1,170 @@ +(function() { + + var connect = __meteor_bootstrap__.require("connect"); + + // A map from oauth "state"s to `Future`s on which calling `return` + // will unblock the corresponding outstanding call to `login` + var oauthFutures = {}; + + // A map from oauth "state"s to incoming requests that, when processed, + // had no matching future (presumably because the login popup window + // finished its work before the server executed the call to `login`) + var unmatchedOauthRequests = {}; + + // XXX add test for supporting both: first receiving the oauth request + // and then executing call to `login`; and vice versa + + Meteor.accounts.facebook.setSecret = function(secret) { + Meteor.accounts.facebook._secret = secret; + }; + + // Listen on /_oauth/* + __meteor_bootstrap__.app + .use(connect.query()) + .use(function (req, res, next) { + Fiber(function() { + // Any non-oauth request will continue down the default middlewares + if (req.url.split('/')[1] !== '_oauth') + next(); + + if (!Meteor.accounts.facebook._appId || !Meteor.accounts.facebook._appUrl) + throw new Error("Need to call Meteor.accounts.facebook.setup first"); + if (!Meteor.accounts.facebook._secret) + throw new Error("Need to call Meteor.accounts.facebook.setSecret first"); + + // Close the popup window + res.writeHead(200, { 'Content-Type': 'text/html' }); + var content = + ''; + res.end(content, 'utf-8'); + + // Try to unblock the appropriate call to `login` + var future = oauthFutures[req.query.state]; + if (future) { + // Unblock the `login` call + future.return(handleOauthRequest(req)); + } else { + // Store this request. We expect to soon get a call to `login` + unmatchedOauthRequests[req.query.state] = req; + } + }).run(); + }); + + Meteor.methods({ + login: function(options) { + // XXX write test for updateOrCreateUser + var updateOrCreateUser = function(email, fbId, fbAccessToken) { + var userByEmail = Meteor.users.findOne({emails: email}); + if (userByEmail) { + var user = userByEmail; + if (!user.services || !user.services.facebook) + Meteor.users.update(user, {$set: {"services.facebook": { + id: fbId, + accessToken: fbAccessToken + }}}); + return user._id; + } else { + var userByFacebookId = Meteor.users.findOne({"services.facebook.id": fbId}); + if (userByFacebookId) { + var user = userByFacebookId; + if (user.emails.indexOf(email) === -1) { + // The user may have changed the email address associated with + // their facebook account. + Meteor.users.update(user, {$push: {emails: email}}); + } + return user._id; + } else { + return Meteor.users.insert({ + emails: [email], + services: { + facebook: {id: fbId, accessToken: fbAccessToken} + } + }); + } + } + }; + + if (options.oauth) { + if (options.oauth.version !== 2 || options.oauth.provider !== 'facebook') + throw new Error("We only support facebook login for now. More soon!"); + + var fbAccessToken; + if (unmatchedOauthRequests[options.oauth.state]) { + // We had previously received the HTTP request with the OAuth code + fbAccessToken = handleOauthRequest( + unmatchedOauthRequests[options.oauth.state]); + delete unmatchedOauthRequests[options.oauth.state]; + } else { + if (oauthFutures[options.oauth.state]) + throw new Error("STRANGE! We are trying to set up a future for this OAuth state twice " + + "(this could happen if one calls login twice without waiting). " + + options.oauth.state); + + // Prepare Future that will be `return`ed when we get an incoming + // HTTP request with the OAuth code + oauthFutures[options.oauth.state] = new Future; + fbAccessToken = oauthFutures[options.oauth.state].wait(); + delete oauthFutures[options.oauth.state]; + } + + // Fetch user's facebook identity + var identity = Meteor.http.get( + "https://graph.facebook.com/me?access_token=" + fbAccessToken).data; + this.setUserId(updateOrCreateUser(identity.email, identity.id, fbAccessToken)); + + // Generate and store a login token for reconnect + var loginToken = Meteor.accounts._loginTokens.insert({ + userId: this.userId() + }); + + return { + token: loginToken, + id: this.userId() + }; + } else if (options.resume) { + var loginToken = Meteor.accounts._loginTokens.findOne({_id: options.resume}); + if (!loginToken) + throw new Meteor.Error("Couldn't find login token"); + this.setUserId(loginToken.userId); + + // XXX do we need to actually return this here? + return { + token: loginToken, + id: this.userId() + }; + } else { + throw new Error("Unrecognized options for login request"); + } + } + }); + + // @returns {String} Facebook access token + var handleOauthRequest = function(req) { + var bareUrl = req.url.substring(0, req.url.indexOf('?')); + var provider = bareUrl.split('/')[2]; + if (provider === 'facebook') { + // Request an access token + var response = Meteor.http.get( + "https://graph.facebook.com/oauth/access_token?" + + "client_id=" + Meteor.accounts.facebook._appId + + // XXX what does this redirect_uri even mean? + "&redirect_uri=" + Meteor.accounts.facebook._appUrl + "/_oauth/facebook" + + "&client_secret=" + Meteor.accounts.facebook._secret + + "&code=" + req.query.code).content; + + // Extract the facebook access token from the response + var fbAccessToken; + _.each(response.split('&'), function(kvString) { + var kvArray = kvString.split('='); + if (kvArray[0] === 'access_token') + fbAccessToken = kvArray[1]; + // XXX also parse the "expires" argument? + }); + + return fbAccessToken; + } else { + throw new Error("Unknown OAuth provider: " + provider); + } + }; +})(); + diff --git a/packages/accounts/package.js b/packages/accounts/package.js new file mode 100644 index 0000000000..07e5564cf8 --- /dev/null +++ b/packages/accounts/package.js @@ -0,0 +1,9 @@ +Package.describe({ + summary: "A user account system", +}); + +Package.on_use(function(api) { + api.add_files('accounts_common.js', ['client', 'server']); + api.add_files('accounts_server.js', 'server'); + api.add_files('accounts_client.js', 'client'); +}); \ No newline at end of file