From a73715491ba0187fd4438643eb286e9631acc519 Mon Sep 17 00:00:00 2001 From: Avital Oliver Date: Fri, 15 Jun 2012 13:45:36 -0700 Subject: [PATCH] Better interface for OAuth2 login services + more cleanup --- packages/accounts-facebook/facebook_client.js | 2 +- packages/accounts-facebook/facebook_common.js | 6 +- packages/accounts-facebook/facebook_server.js | 58 +++++++++-------- packages/accounts-google/google_client.js | 2 +- packages/accounts-google/google_common.js | 6 +- packages/accounts-google/google_server.js | 65 +++++++++++-------- packages/accounts-ui/login-buttons.js | 8 +-- packages/accounts/accounts_common.js | 5 ++ packages/oauth2/oauth2_server.js | 44 +++++++++++-- 9 files changed, 119 insertions(+), 77 deletions(-) diff --git a/packages/accounts-facebook/facebook_client.js b/packages/accounts-facebook/facebook_client.js index 60cca7f60f..2933a9b5d3 100644 --- a/packages/accounts-facebook/facebook_client.js +++ b/packages/accounts-facebook/facebook_client.js @@ -1,7 +1,7 @@ (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"); + throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.facebook.setup first"); var state = Meteor.uuid(); // XXX I think there's a smaller popup. Replace with appropriate URL. diff --git a/packages/accounts-facebook/facebook_common.js b/packages/accounts-facebook/facebook_common.js index f272f8a89c..0cccb5a8ed 100644 --- a/packages/accounts-facebook/facebook_common.js +++ b/packages/accounts-facebook/facebook_common.js @@ -2,11 +2,9 @@ if (!Meteor.accounts.facebook) { Meteor.accounts.facebook = {}; } -Meteor.accounts.facebook.setup = function(appId, appUrl) { +Meteor.accounts.facebook.config = 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 index 56d6113706..80e1c51d4e 100644 --- a/packages/accounts-facebook/facebook_server.js +++ b/packages/accounts-facebook/facebook_server.js @@ -4,37 +4,30 @@ 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, {name: identity.name}, - 'facebook', identity.id, {accessToken: accessToken}); - } - }; - - // @returns {String} Facebook access token - var getAccessToken = function (req) { - if (req.query.error) { + Meteor.accounts.oauth2.registerService('facebook', function(query) { + if (query.error) { // The user didn't authorize access + // XXX can/should we generalize this into the oauth abstration? return null; } + if (!Meteor.accounts.facebook._appId || !Meteor.accounts.facebook._appUrl) + throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.facebook.setup first"); + if (!Meteor.accounts.facebook._secret) + throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.facebook.setSecret first"); + + var accessToken = getAccessToken(query); + var identity = getIdentity(accessToken); + + return { + email: identity.email, + userData: {name: identity.name}, + serviceUserId: identity.id, + serviceData: {accessToken: accessToken} + }; + }); + + var getAccessToken = function (query) { // Request an access token var response = Meteor.http.get( "https://graph.facebook.com/oauth/access_token", { @@ -42,7 +35,7 @@ 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 + code: query.code } }).content; @@ -76,4 +69,13 @@ return fbAccessToken; } }; + + var getIdentity = function (accessToken) { + var result = Meteor.http.get("https://graph.facebook.com/me", { + params: {access_token: accessToken}}); + + if (result.error) + throw result.error; + return result.data; + }; }) (); \ No newline at end of file diff --git a/packages/accounts-google/google_client.js b/packages/accounts-google/google_client.js index 7bb0ed49fa..c09481e1cc 100644 --- a/packages/accounts-google/google_client.js +++ b/packages/accounts-google/google_client.js @@ -4,7 +4,7 @@ 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 + // XXX need to support configuring access_type and scope var loginUrl = 'https://accounts.google.com/o/oauth2/auth' + '?response_type=code' + diff --git a/packages/accounts-google/google_common.js b/packages/accounts-google/google_common.js index 18ce2c56fc..5a79e14d57 100644 --- a/packages/accounts-google/google_common.js +++ b/packages/accounts-google/google_common.js @@ -2,11 +2,7 @@ if (!Meteor.accounts.google) { Meteor.accounts.google = {}; } -Meteor.accounts.google.setup = function(clientId, appUrl) { +Meteor.accounts.google.config = 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 index 9b91268af1..9930ec2d4f 100644 --- a/packages/accounts-google/google_server.js +++ b/packages/accounts-google/google_server.js @@ -1,45 +1,56 @@ (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, {name: identity.name}, - 'google', identity.id, {accessToken: accessToken}); - } - }; - - var getAccessToken = function (req) { - if (req.query.error) { + Meteor.accounts.oauth2.registerService('google', function(query) { + if (query.error) { // The user didn't authorize access - // XXX can we generalize this into the oauth abstration? + // XXX can/should we generalize this into the oauth abstration? return null; } - var response = Meteor.http.post( + if (!Meteor.accounts.google._clientId || !Meteor.accounts.google._appUrl) + throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.google.config first"); + if (!Meteor.accounts.google._secret) + throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.google.setSecret first"); + + var accessToken = getAccessToken(query); + var identity = getIdentity(accessToken); + + return { + email: identity.email, + userData: {name: identity.name}, + serviceUserId: identity.id, + serviceData: {accessToken: accessToken} + }; + }); + + var getAccessToken = function (query) { + var result = Meteor.http.post( "https://accounts.google.com/o/oauth2/token", {params: { - code: req.query.code, + code: 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; + if (result.error) // if the http response was an error + throw result.error; + if (result.data.error) // if the http response was a json object with an error attribute + throw result.data; + return result.data.access_token; + }; - return response.access_token; + var getIdentity = function (accessToken) { + var result = Meteor.http.get( + "https://www.googleapis.com/oauth2/v1/userinfo", + {params: {access_token: accessToken}}); + + if (result.error) + throw result.error; + return result.data; }; })(); \ No newline at end of file diff --git a/packages/accounts-ui/login-buttons.js b/packages/accounts-ui/login-buttons.js index a3c839a539..3412824d4c 100644 --- a/packages/accounts-ui/login-buttons.js +++ b/packages/accounts-ui/login-buttons.js @@ -5,8 +5,8 @@ try { Meteor.loginWithFacebook(); } catch (e) { - if (e instanceof Meteor.accounts.facebook.SetupError) - alert("Facebook API key not set. Configure app details with Meteor.accounts.facebook.setup()"); + if (e instanceof Meteor.accounts.ConfigError) + alert("Facebook API key not set. Configure app details with Meteor.accounts.facebook.config()"); else throw e; } @@ -16,8 +16,8 @@ try { Meteor.loginWithGoogle(); } catch (e) { - if (e instanceof Meteor.accounts.google.SetupError) - alert("Google API key not set. Configure app details with Meteor.accounts.google.setup()"); + if (e instanceof Meteor.accounts.ConfigError) + alert("Google API key not set. Configure app details with Meteor.accounts.google.config()"); else throw e; }; diff --git a/packages/accounts/accounts_common.js b/packages/accounts/accounts_common.js index 1093d2eee1..43ee62bfaf 100644 --- a/packages/accounts/accounts_common.js +++ b/packages/accounts/accounts_common.js @@ -16,3 +16,8 @@ Meteor.users = new Meteor.Collection( null /*manager*/, null /*driver*/, true /*preventAutopublish*/); + +// Thrown when trying to use a login service which is not configured +Meteor.accounts.ConfigError = function(description) { + this.message = description; +}; diff --git a/packages/oauth2/oauth2_server.js b/packages/oauth2/oauth2_server.js index 02ad2308e4..12e45f9db6 100644 --- a/packages/oauth2/oauth2_server.js +++ b/packages/oauth2/oauth2_server.js @@ -1,13 +1,38 @@ (function () { var connect = __meteor_bootstrap__.require("connect"); - Meteor.accounts.oauth2.providers = {}; + Meteor.accounts.oauth2._services = {}; + // Register a handler for an OAuth2 service. The handler will be called + // when we get an incoming http request on /_oauth/{serviceName}. This + // handler should use that information to fetch data about the user + // logging in. + // + // @param name {String} e.g. "google", "facebook" + // @param handleOauthRequest {Function(query): userInfo} + // - query is an object with the parameters passed in the query string + // - userInfo {Object} with following keys: + // - email {String} + // - userData {Object} attributes to store directly on the user object, + // such as "name" + // - serviceUserId {?} The logging in user's id in the login service + // - serviceData {Object} attributes to store on the user record's + // specific login service's subobject, such as + // "accessToken" + Meteor.accounts.oauth2.registerService = function (name, handleOauthRequest) { + if (Meteor.accounts.oauth2._services[name]) + throw new Meteor.Error("Already registered the " + name + " OAuth2 service"); + Meteor.accounts.oauth2._services[name] = { + handleOauthRequest: handleOauthRequest + }; + }; + + // Listen to calls to `login` with an oauth option set Meteor.accounts.registerLoginHandler(function (options) { if (!options.oauth) return undefined; // don't handle - var result = Meteor.accounts.oauth2.loginResultForState[options.oauth.state]; + 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; @@ -20,7 +45,7 @@ // 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 = {}; + Meteor.accounts.oauth2._loginResultForState = {}; // Listen on /_oauth/* __meteor_bootstrap__.app @@ -40,14 +65,19 @@ // This way the subsequent call to the `login` method will be // immediate. - var providerName = splitUrl[2]; - var provider = Meteor.accounts.oauth2.providers[providerName]; + var serviceName = splitUrl[2]; + var service = Meteor.accounts.oauth2._services[serviceName]; + // Get or create user id - var userId = provider.userIdForOauthReq(req); + var userInfo = service.handleOauthRequest(req.query); + var userId = Meteor.accounts.updateOrCreateUser( + userInfo.email, userInfo.userData, serviceName, + userInfo.serviceUserId, userInfo.serviceData); + // 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] = + Meteor.accounts.oauth2._loginResultForState[req.query.state] = {token: loginToken, id: userId}; // We support /_oauth?close, /_oauth?redirect=URL. Any other /_oauth request