diff --git a/examples/todos/.meteor/packages b/examples/todos/.meteor/packages index df9943c56c..d35f3979f0 100644 --- a/examples/todos/.meteor/packages +++ b/examples/todos/.meteor/packages @@ -6,3 +6,5 @@ underscore backbone accounts +accounts-facebook +accounts-google diff --git a/examples/todos/client/todos.html b/examples/todos/client/todos.html index 3b7972a485..d5456fcd88 100644 --- a/examples/todos/client/todos.html +++ b/examples/todos/client/todos.html @@ -121,7 +121,8 @@ {{#if user}}
logout
{{else}} -
login using facebook
+
login using facebook
+
login using google
{{/if}} diff --git a/examples/todos/client/todos.js b/examples/todos/client/todos.js index cc0e2c731b..6f45cb1f71 100644 --- a/examples/todos/client/todos.js +++ b/examples/todos/client/todos.js @@ -307,12 +307,23 @@ Template.login.events = { Meteor.loginWithFacebook(); } catch (e) { if (e instanceof Meteor.accounts.facebook.SetupError) - alert("You haven't set up your facebook app details. See fb-app.js and server/fb-secret.js"); + alert("You haven't set up your Facebook app details. See fb-app.js and server/fb-secret.js"); else throw e; } }, + 'click #google-login': function () { + try { + Meteor.loginWithGoogle(); + } catch (e) { + if (e instanceof Meteor.accounts.google.SetupError) + alert("You haven't set up your Google API details. See google-api.js and server/google-secret.js"); + else + throw e; + }; + }, + 'click #logout': function() { Meteor.logout(); } diff --git a/examples/todos/google-api.js b/examples/todos/google-api.js new file mode 100644 index 0000000000..7639342f02 --- /dev/null +++ b/examples/todos/google-api.js @@ -0,0 +1,4 @@ +// Uncomment and correct following line for integration with Google accounts. +// Also see server/google-secret.js + +// Meteor.accounts.google.setup('987846107089.apps.googleusercontent.com', 'http://auth-todos.meteor.com'); diff --git a/examples/todos/server/google-secret.js b/examples/todos/server/google-secret.js new file mode 100644 index 0000000000..822ca36683 --- /dev/null +++ b/examples/todos/server/google-secret.js @@ -0,0 +1,4 @@ +// Uncomment and correct following line for integration with Google accounts. +// Also see ../google-api.js + +// Meteor.accounts.google.setSecret('SECRET'); \ No newline at end of file diff --git a/packages/accounts-facebook/facebook_client.js b/packages/accounts-facebook/facebook_client.js new file mode 100644 index 0000000000..60cca7f60f --- /dev/null +++ b/packages/accounts-facebook/facebook_client.js @@ -0,0 +1,21 @@ +(function () { + Meteor.loginWithFacebook = function () { + if (!Meteor.accounts.facebook._appId || !Meteor.accounts.facebook._appUrl) + throw new Meteor.accounts.facebook.SetupError("Need to call Meteor.accounts.facebook.setup first"); + + var state = Meteor.uuid(); + // XXX I think there's a smaller popup. Replace with appropriate URL. + // XXX need to support configuring scope + var loginUrl = + 'https://www.facebook.com/dialog/oauth?client_id=' + Meteor.accounts.facebook._appId + + '&redirect_uri=' + Meteor.accounts.facebook._appUrl + '/_oauth/facebook?close' + + '&scope=email&state=' + state; + + Meteor.accounts.oauth2.initiateLogin(state, loginUrl); + }; + +})(); + + + + diff --git a/packages/accounts-facebook/facebook_common.js b/packages/accounts-facebook/facebook_common.js new file mode 100644 index 0000000000..f272f8a89c --- /dev/null +++ b/packages/accounts-facebook/facebook_common.js @@ -0,0 +1,12 @@ +if (!Meteor.accounts.facebook) { + Meteor.accounts.facebook = {}; +} + +Meteor.accounts.facebook.setup = function(appId, appUrl) { + Meteor.accounts.facebook._appId = appId; + Meteor.accounts.facebook._appUrl = appUrl; +}; + +Meteor.accounts.facebook.SetupError = function(description) { + this.message = description; +}; diff --git a/packages/accounts-facebook/facebook_server.js b/packages/accounts-facebook/facebook_server.js new file mode 100644 index 0000000000..51efee1cb8 --- /dev/null +++ b/packages/accounts-facebook/facebook_server.js @@ -0,0 +1,79 @@ +(function () { + + Meteor.accounts.facebook.setSecret = function (secret) { + Meteor.accounts.facebook._secret = secret; + }; + + // register the facebook identity provider + Meteor.accounts.oauth2.providers.facebook = { + userIdForOauthReq: function(req) { + if (!Meteor.accounts.facebook._appId || !Meteor.accounts.facebook._appUrl) + throw new Meteor.accounts.facebook.SetupError("Need to call Meteor.accounts.facebook.setup first"); + if (!Meteor.accounts.facebook._secret) + throw new Meteor.accounts.facebook.SetupError("Need to call Meteor.accounts.facebook.setSecret first"); + + var accessToken = getAccessToken(req); + // If the user didn't authorize the login, either explicitly + // or by closing the popup window, return null + if (!accessToken) + return null; + + // Fetch user's facebook identity + var identity = Meteor.http.get("https://graph.facebook.com/me", { + params: {access_token: accessToken}}).data; + + return Meteor.accounts.updateOrCreateUser( + identity.email, 'facebook', identity.id, + {accessToken: accessToken}); + } + }; + + // @returns {String} Facebook access token + var getAccessToken = function (req) { + if (req.query.error) { + // The user didn't authorize access + return null; + } + + // Request an access token + var response = Meteor.http.get( + "https://graph.facebook.com/oauth/access_token", { + params: { + client_id: Meteor.accounts.facebook._appId, + redirect_uri: Meteor.accounts.facebook._appUrl + "/_oauth/facebook?close", + client_secret: Meteor.accounts.facebook._secret, + code: req.query.code + } + }).content; + + // Errors come back as JSON but success looks like a query encoded in a url + var error_response; + try { + // Just try to parse so that we know if we failed or not, + // while storing the parsed results + error_response = JSON.parse(response); + } catch (e) { + error_response = null; + } + + if (error_response) { + if (error_response.error) { + throw new Meteor.Error("Error trying to get access token from Facebook", error_response); + } else { + throw new Meteor.Error("Unexpected response when trying to get access token from Facebook", error_response); + } + } else { + // Success! 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; + } + }; +}) (); \ No newline at end of file diff --git a/packages/accounts-facebook/package.js b/packages/accounts-facebook/package.js new file mode 100644 index 0000000000..49ce3d95bd --- /dev/null +++ b/packages/accounts-facebook/package.js @@ -0,0 +1,13 @@ +Package.describe({ + summary: "Integration with facebook accounts", +}); + +Package.on_use(function(api) { + api.use('accounts', ['client', 'server']); + api.use('oauth2', ['client', 'server']); + api.use('http', ['client', 'server']); + + api.add_files('facebook_common.js', ['client', 'server']); + api.add_files('facebook_server.js', 'server'); + api.add_files('facebook_client.js', 'client'); +}); diff --git a/packages/accounts-google/google_client.js b/packages/accounts-google/google_client.js new file mode 100644 index 0000000000..7bb0ed49fa --- /dev/null +++ b/packages/accounts-google/google_client.js @@ -0,0 +1,19 @@ +(function () { + Meteor.loginWithGoogle = function () { + if (!Meteor.accounts.google._clientId || !Meteor.accounts.google._appUrl) + throw new Meteor.accounts.google.SetupError("Need to call Meteor.accounts.google.setup first"); + + var state = Meteor.uuid(); + // XXX need to support configuring access_type and scopy + var loginUrl = + 'https://accounts.google.com/o/oauth2/auth' + + '?response_type=code' + + '&client_id=' + Meteor.accounts.google._clientId + + '&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile' + + '&redirect_uri=' + Meteor.accounts.google._appUrl + '/_oauth/google?close' + + '&state=' + state; + + Meteor.accounts.oauth2.initiateLogin(state, loginUrl); + }; + +}) (); diff --git a/packages/accounts-google/google_common.js b/packages/accounts-google/google_common.js new file mode 100644 index 0000000000..18ce2c56fc --- /dev/null +++ b/packages/accounts-google/google_common.js @@ -0,0 +1,12 @@ +if (!Meteor.accounts.google) { + Meteor.accounts.google = {}; +} + +Meteor.accounts.google.setup = function(clientId, appUrl) { + Meteor.accounts.google._clientId = clientId; + Meteor.accounts.google._appUrl = appUrl; +}; + +Meteor.accounts.google.SetupError = function(description) { + this.message = description; +}; diff --git a/packages/accounts-google/google_server.js b/packages/accounts-google/google_server.js new file mode 100644 index 0000000000..af40eb392c --- /dev/null +++ b/packages/accounts-google/google_server.js @@ -0,0 +1,45 @@ +(function () { + Meteor.accounts.google.setSecret = function (secret) { + Meteor.accounts.google._secret = secret; + }; + + Meteor.accounts.oauth2.providers.google = { + userIdForOauthReq: function(req) { + var accessToken = getAccessToken(req); + + // XXX can we generalize this flow into the oauth abstraction? + if (!accessToken) + return null; + + var identity = Meteor.http.get( + "https://www.googleapis.com/oauth2/v1/userinfo", + {params: {access_token: accessToken}}).data; + + return Meteor.accounts.updateOrCreateUser( + identity.email, 'google', identity.id, + {accessToken: accessToken}); + } + }; + + var getAccessToken = function (req) { + if (req.query.error) { + // The user didn't authorize access + // XXX can we generalize this into the oauth abstration? + return null; + } + + var response = Meteor.http.post( + "https://accounts.google.com/o/oauth2/token", {params: { + code: req.query.code, + client_id: Meteor.accounts.google._clientId, + client_secret: Meteor.accounts.google._secret, + redirect_uri: Meteor.accounts.google._appUrl + "/_oauth/google?close", + grant_type: 'authorization_code' + }}).data; + + if (response.error) + throw response; + + return response.access_token; + }; +})(); \ No newline at end of file diff --git a/packages/accounts-google/package.js b/packages/accounts-google/package.js new file mode 100644 index 0000000000..ca4119491d --- /dev/null +++ b/packages/accounts-google/package.js @@ -0,0 +1,13 @@ +Package.describe({ + summary: "Integration with google accounts", +}); + +Package.on_use(function(api) { + api.use('accounts', ['client', 'server']); + api.use('oauth2', ['client', 'server']); + api.use('http', ['client', 'server']); + + api.add_files('google_common.js', ['client', 'server']); + api.add_files('google_server.js', 'server'); + api.add_files('google_client.js', 'client'); +}); diff --git a/packages/accounts/accounts_client.js b/packages/accounts/accounts_client.js index ecb3644b9d..1f93cfa5ac 100644 --- a/packages/accounts/accounts_client.js +++ b/packages/accounts/accounts_client.js @@ -1,4 +1,4 @@ -(function() { +(function () { Meteor.user = function () { if (Meteor.default_connection.userId()) { // XXX full identity? @@ -14,74 +14,12 @@ }); } - 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(); - return newwindow; - }; - - if (!Meteor.accounts.facebook._appId || !Meteor.accounts.facebook._appUrl) - throw new Meteor.accounts.facebook.SetupError("Need to call Meteor.accounts.facebook.setup first"); - - var oauthState = Meteor.uuid(); - - var popup = openCenteredPopup( - 'https://www.facebook.com/dialog/oauth?client_id=' + Meteor.accounts.facebook._appId + - '&redirect_uri=' + Meteor.accounts.facebook._appUrl + '/_oauth/facebook?close' + - '&scope=email&state=' + oauthState, - 1000, 600); // XXX should we use different dimensions, e.g. on mobile? - - var checkPopupOpen = setInterval(function() { - if (popup.closed) { - clearInterval(checkPopupOpen); - tryLoginAfterPopupClosed(oauthState); - } - }, 100); - }; - - // Send an OAuth login method to the server. If the user authorized - // access in the popup this should log the user in, otherwise - // nothing should happen. - var tryLoginAfterPopupClosed = function(oauthState) { - Meteor.apply('login', [ - {oauth: {version: 2, provider: 'facebook', state: oauthState}} - ], {wait: true}, function(error, result) { - if (error) { - Meteor._debug("Server error on login", error); - return; - } - - Meteor.accounts.loginAndStoreToken(result.token); - callback && callback(); - }); - }; - Meteor.logout = function () { Meteor.apply('logout', [], {wait: true}, function(error, result) { - if (error) { - Meteor._debug("Server error on logout", error); - return; - } else { + if (error) + throw error; + else Meteor.accounts.forceClientLoggedOut(); - } }); }; })(); diff --git a/packages/accounts/accounts_common.js b/packages/accounts/accounts_common.js index 9a15b34269..297949567b 100644 --- a/packages/accounts/accounts_common.js +++ b/packages/accounts/accounts_common.js @@ -4,21 +4,8 @@ if (!Meteor.accounts) { Meteor.accounts = {}; } -if (!Meteor.accounts.facebook) { - Meteor.accounts.facebook = {}; -} - Meteor.accounts._loginTokens = new Meteor.Collection( "accounts._loginTokens", null /*manager*/, null /*driver*/, true /*preventAutopublish*/); - -Meteor.accounts.facebook.setup = function(appId, appUrl) { - Meteor.accounts.facebook._appId = appId; - Meteor.accounts.facebook._appUrl = appUrl; -}; - -Meteor.accounts.facebook.SetupError = function(description) { - this.message = description; -}; diff --git a/packages/accounts/accounts_server.js b/packages/accounts/accounts_server.js index 81571f4b7e..a4fc96acfb 100644 --- a/packages/accounts/accounts_server.js +++ b/packages/accounts/accounts_server.js @@ -1,53 +1,65 @@ -(function() { +(function () { + // Updates or creates a user after we authenticate with a 3rd party + // @param serviceName {String} e.g. 'facebook' or 'google' + // @param serviceUserId {?} user id in 3rd party service + // @param more {Object} additional attributes to store on the user record + // @returns {String} userId + Meteor.accounts.updateOrCreateUser = function(email, + serviceName, + serviceUserId, + more) { - var connect = __meteor_bootstrap__.require("connect"); + var userByEmail = Meteor.users.findOne({emails: email}); + if (userByEmail) { - // Incoming OAuth http requests are recorded here when the OAuth - // process is completed inside a popup window. Afterwards, these are - // read by the OAuth login method to complete the process. - // - // @type {Object} maps from Oauth "state" to request - Meteor.accounts._unmatchedOauthRequests = {}; + // If we know about this email address that is our user. + // Update the information from this service. + var user = userByEmail; + if (!user.services || !user.services[serviceName]) { + var attrs = {}; + attrs["services." + serviceName] = _.extend( + {id: serviceUserId}, more); + Meteor.users.update(user, {$set: attrs}); + } + return user._id; + } else { - Meteor.accounts.facebook.setSecret = function(secret) { - Meteor.accounts.facebook._secret = secret; + // If not, look for a user with the appropriate service user id. + // Update the user's email. + var selector = {}; + selector["services." + serviceName + ".id"] = serviceUserId; + var userByServiceUserId = Meteor.users.findOne(selector); + if (userByServiceUserId) { + var user = userByServiceUserId; + if (user.emails.indexOf(email) === -1) { + // The user may have changed the email address associated with + // this service. Store the new one in addition to the old one. + Meteor.users.update(user, {$push: {emails: email}}); + } + return user._id; + } else { + + // Create a new user + var attrs = {}; + attrs[serviceName] = _.extend({id: serviceUserId}, more); + return Meteor.users.insert({ + emails: [email], + services: attrs + }); + } + } }; - // 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(); - return; - } + Meteor.accounts._loginHandlers = []; - if (!Meteor.accounts.facebook._appId || !Meteor.accounts.facebook._appUrl) - throw new Meteor.accounts.facebook.SetupError("Need to call Meteor.accounts.facebook.setup first"); - if (!Meteor.accounts.facebook._secret) - throw new Meteor.accounts.facebook.SetupError("Need to call Meteor.accounts.facebook.setSecret first"); - - Meteor.accounts._unmatchedOauthRequests[req.query.state] = req; - - // We support /_oauth?close, /_oauth?redirect=URL. Any other /_oauth request - // just served a blank page - if ('close' in req.query) { // check with 'in' because we don't set a value - // Close the popup window - res.writeHead(200, {'Content-Type': 'text/html'}); - var content = - ''; - res.end(content, 'utf-8'); - } else if (req.query.redirect) { - res.writeHead(302, {'Location': req.query.redirect}); - res.end(); - } else { - res.writeHead(200, {'Content-Type': 'text/html'}); - res.end(content, 'utf-8'); - } - }).run(); - }); + // @param handler {Function} A function that receives an options object + // (as passed as an argument to the `login` method) and returns one of: + // - `undefined`, meaning don't handle; + // - `null`, meaning the user didn't actually log in; + // - {id: userId, accessToken: *}, if the user logged in successfully. + Meteor.accounts.registerLoginHandler = function(handler) { + Meteor.accounts._loginHandlers.push(handler); + }; Meteor.methods({ // @returns {Object|null} @@ -55,73 +67,9 @@ // If unsuccessful (for example, if the user closed the oauth login popup), // returns null 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 Meteor.Error("We only support facebook login for now. More soon!"); - - var fbAccessToken; - var unmatchedRequest = Meteor.accounts._unmatchedOauthRequests[options.oauth.state]; - if (unmatchedRequest) { - // We had previously received the HTTP request with the OAuth code - fbAccessToken = handleOauthRequest(unmatchedRequest); - delete Meteor.accounts._unmatchedOauthRequests[options.oauth.state]; - - // If the user didn't authorize the login, either explicitly - // or by closing the popup window, return null - if (!fbAccessToken) - return null; - } else { - return null; - } - - // 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 (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); @@ -131,7 +79,10 @@ id: this.userId() }; } else { - throw new Meteor.Error("Unrecognized options for login request"); + var result = tryAllLoginHandlers(options); + if (result !== null) + this.setUserId(result.id); + return result; } }, @@ -140,57 +91,28 @@ } }); - // @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') { - if (req.query.error) { - // Either the user didn't authorize access or we cancelled - // this outstanding login request (such as when the user - // closes the login popup window) - return null; - } + // Try all of the registered login handlers until one of them doesn't + // return `undefined`, meaning it handled this call to `login`. Return + // that return value. + var tryAllLoginHandlers = function (options) { + var result = undefined; - // Request an access token - var response = Meteor.http.get( - "https://graph.facebook.com/oauth/access_token?" + - "client_id=" + Meteor.accounts.facebook._appId + - "&redirect_uri=" + Meteor.accounts.facebook._appUrl + "/_oauth/facebook?close" + - "&client_secret=" + Meteor.accounts.facebook._secret + - "&code=" + req.query.code).content; + _.find(Meteor.accounts._loginHandlers, function(handler) { - // Errors come back as JSON but success looks like a query encoded in a url - var error_response = null; - try { - // Just try to parse so that we know if we failed or not, - // while storing the parsed results - var error_response = JSON.parse(response); - } catch (e) { - } - - if (error_response) { - if (error_response.error) { - throw new Meteor.Error("Error trying to get access token from Facebook", error_response); - } else { - throw new Meteor.Error("Unexpected response when trying to get access token from Facebook", error_response); - } + var maybeResult = handler(options); + if (maybeResult !== undefined) { + result = maybeResult; + return true; } else { - // Success! 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; + return false; } + }); + + if (result === undefined) { + throw new Meteor.Error("Unrecognized options for login request"); } else { - throw new Meteor.Error("Unknown OAuth provider: " + provider); + return result; } }; -})(); +}) (); diff --git a/packages/accounts/localstorage_token.js b/packages/accounts/localstorage_token.js index 781ab3dbc1..b59071020e 100644 --- a/packages/accounts/localstorage_token.js +++ b/packages/accounts/localstorage_token.js @@ -28,18 +28,17 @@ Meteor.loginFromLocalStorage = function () { Meteor.accounts._lastLoginTokenWhenPolled = loginToken; if (loginToken) { Meteor.apply('login', [{resume: loginToken}], {wait: true}, function(error, result) { - if (error) { - Meteor._debug("Server error on login", error); - return; - } + if (error) + throw error; Meteor.default_connection.setUserId(result.id); Meteor.default_connection.onReconnect = function() { Meteor.apply('login', [{resume: loginToken}], {wait: true}, function(error, result) { if (error) { Meteor.accounts.forceClientLoggedOut(); - Meteor._debug("Server error on login", error); - return; + throw error; + } else { + // nothing to do } }); }; diff --git a/packages/accounts/package.js b/packages/accounts/package.js index 1da6e15120..8b189d48f4 100644 --- a/packages/accounts/package.js +++ b/packages/accounts/package.js @@ -3,7 +3,7 @@ Package.describe({ }); Package.on_use(function(api) { - api.use('http', ['client', 'server']); + api.use('underscore', 'server'); api.use('localstorage-polyfill', 'client'); api.add_files('accounts_common.js', ['client', 'server']); diff --git a/packages/localstorage-polyfill/localstorage_polyfill.js b/packages/localstorage-polyfill/localstorage_polyfill.js index d8d473136e..c3b83d403b 100644 --- a/packages/localstorage-polyfill/localstorage_polyfill.js +++ b/packages/localstorage-polyfill/localstorage_polyfill.js @@ -1,6 +1,7 @@ Meteor.startup(function() { // Since we need document.body to be defined if (!window.localStorage) { window.localStorage = (function () { + // XXX eliminate dependency on jQuery, detect browsers ourselves if ($.browser.msie) { // If we are on IE, which support userData var userdata = document.createElement('span'); // could be anything userdata.style.behavior = 'url("#default#userData")'; @@ -30,8 +31,8 @@ Meteor.startup(function() { // Since we need document.body to be defined } else { Meteor._debug( "You are running a browser with no localStorage or userData " - + "support (presumable Opera Mini). Logging in from one tab " - + "will not cause another tab to be logged in."); + + "support. Logging in from one tab will not cause another " + + "tab to be logged in."); return { setItem: function () {}, diff --git a/packages/oauth2/oauth2_client.js b/packages/oauth2/oauth2_client.js new file mode 100644 index 0000000000..a1d13f9580 --- /dev/null +++ b/packages/oauth2/oauth2_client.js @@ -0,0 +1,56 @@ +(function () { + Meteor.accounts.oauth2.initiateLogin = function(state, url) { + // XXX should we use different dimensions, e.g. on mobile? + var popup = openCenteredPopup(url, 1000, 600); + + var checkPopupOpen = setInterval(function() { + if (popup.closed) { + clearInterval(checkPopupOpen); + tryLoginAfterPopupClosed(state); + } + }, 100); + }; + + 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(); + return newwindow; + }; + + // Send an OAuth login method to the server. If the user authorized + // access in the popup this should log the user in, otherwise + // nothing should happen. + var tryLoginAfterPopupClosed = function(oauthState) { + Meteor.apply('login', [ + {oauth: {version: 2, state: oauthState}} + ], {wait: true}, function(error, result) { + if (error) + throw error; + + if (!result) { + // The user either closed the OAuth popup or didn't authorize + // access. Do nothing. + return; + } else { + Meteor.accounts.loginAndStoreToken(result.token); + } + }); + }; +})(); \ No newline at end of file diff --git a/packages/oauth2/oauth2_common.js b/packages/oauth2/oauth2_common.js new file mode 100644 index 0000000000..cb23a48c2d --- /dev/null +++ b/packages/oauth2/oauth2_common.js @@ -0,0 +1 @@ +Meteor.accounts.oauth2 = {}; \ No newline at end of file diff --git a/packages/oauth2/oauth2_server.js b/packages/oauth2/oauth2_server.js new file mode 100644 index 0000000000..02ad2308e4 --- /dev/null +++ b/packages/oauth2/oauth2_server.js @@ -0,0 +1,71 @@ +(function () { + var connect = __meteor_bootstrap__.require("connect"); + + Meteor.accounts.oauth2.providers = {}; + + Meteor.accounts.registerLoginHandler(function (options) { + if (!options.oauth) + return undefined; // don't handle + + var result = Meteor.accounts.oauth2.loginResultForState[options.oauth.state]; + if (result === undefined) // not using `!result` since can be null + // We weren't notified of the user authorizing the login. + return null; + else + return result; + }); + + // When we get an incoming OAuth http request we complete the + // facebook handshake, account and token setup before responding. + // The results are stored in this map which is then read when the + // login method is called. Maps {oauthState} --> return value of + // `login` + Meteor.accounts.oauth2.loginResultForState = {}; + + // Listen on /_oauth/* + __meteor_bootstrap__.app + .use(connect.query()) + .use(function (req, res, next) { + Fiber(function() { + var bareUrl = req.url.substring(0, req.url.indexOf('?')); + var splitUrl = bareUrl.split('/'); + + // Any non-oauth request will continue down the default middlewares + if (splitUrl[1] !== '_oauth') { + next(); + return; + } + + // Make sure we prepare the login results before returning. + // This way the subsequent call to the `login` method will be + // immediate. + + var providerName = splitUrl[2]; + var provider = Meteor.accounts.oauth2.providers[providerName]; + // Get or create user id + var userId = provider.userIdForOauthReq(req); + // Generate and store a login token for reconnect + var loginToken = Meteor.accounts._loginTokens.insert({userId: userId}); + // Store results to subsequent call to `login` + Meteor.accounts.oauth2.loginResultForState[req.query.state] = + {token: loginToken, id: userId}; + + // We support /_oauth?close, /_oauth?redirect=URL. Any other /_oauth request + // just served a blank page + if ('close' in req.query) { // check with 'in' because we don't set a value + // Close the popup window + res.writeHead(200, {'Content-Type': 'text/html'}); + var content = + ''; + res.end(content, 'utf-8'); + } else if (req.query.redirect) { + res.writeHead(302, {'Location': req.query.redirect}); + res.end(); + } else { + res.writeHead(200, {'Content-Type': 'text/html'}); + res.end(content, 'utf-8'); + } + }).run(); + }); + +})(); \ No newline at end of file diff --git a/packages/oauth2/package.js b/packages/oauth2/package.js new file mode 100644 index 0000000000..1a184ded51 --- /dev/null +++ b/packages/oauth2/package.js @@ -0,0 +1,12 @@ +Package.describe({ + summary: "A basis for OAuth2-based account systems", +}); + +Package.on_use(function (api) { + api.use('jquery', 'client'); // XXX only used for browser detection. remove. + api.use('accounts', ['client', 'server']); + + api.add_files('oauth2_common.js', ['client', 'server']); + api.add_files('oauth2_server.js', 'server'); + api.add_files('oauth2_client.js', 'client'); +});