diff --git a/examples/todos/.meteor/packages b/examples/todos/.meteor/packages index c71f4c1a54..0aff14a890 100644 --- a/examples/todos/.meteor/packages +++ b/examples/todos/.meteor/packages @@ -12,3 +12,4 @@ accounts-weibo accounts-google accounts-facebook accounts-passwords +accounts-twitter diff --git a/examples/todos/accounts/config.js b/examples/todos/accounts/config.js index 3f1f07714f..55cd07f40f 100644 --- a/examples/todos/accounts/config.js +++ b/examples/todos/accounts/config.js @@ -1,5 +1,5 @@ Meteor.accounts.config({ - requireEmail: true, + requireEmail: false, requireUsername: false, validateEmails: true }); diff --git a/packages/accounts-facebook/facebook_client.js b/packages/accounts-facebook/facebook_client.js index fabbf60bb9..3d34aac30e 100644 --- a/packages/accounts-facebook/facebook_client.js +++ b/packages/accounts-facebook/facebook_client.js @@ -17,7 +17,7 @@ '&redirect_uri=' + Meteor.accounts.facebook._appUrl + '/_oauth/facebook?close' + '&display=' + display + '&scope=' + scope + '&state=' + state; - Meteor.accounts.oauth2.initiateLogin(state, loginUrl); + Meteor.accounts.oauth.initiateLogin(state, loginUrl); }; })(); diff --git a/packages/accounts-facebook/facebook_common.js b/packages/accounts-facebook/facebook_common.js index 8fd6105955..d3ba6e0771 100644 --- a/packages/accounts-facebook/facebook_common.js +++ b/packages/accounts-facebook/facebook_common.js @@ -1,5 +1,6 @@ if (!Meteor.accounts.facebook) { Meteor.accounts.facebook = {}; + Meteor.accounts.facebook._requireConfigs = ['_appId', '_appUrl']; } Meteor.accounts.facebook.config = function(appId, appUrl, options) { diff --git a/packages/accounts-facebook/facebook_server.js b/packages/accounts-facebook/facebook_server.js index 91eece0944..35b21146eb 100644 --- a/packages/accounts-facebook/facebook_server.js +++ b/packages/accounts-facebook/facebook_server.js @@ -4,16 +4,7 @@ Meteor.accounts.facebook._secret = secret; }; - Meteor.accounts.oauth2.registerService('facebook', function(query) { - if (query.error) { - // The user didn't authorize access - return null; - } - - if (!Meteor.accounts.facebook._appId || !Meteor.accounts.facebook._appUrl) - throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.facebook.config first"); - if (!Meteor.accounts.facebook._secret) - throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.facebook.setSecret first"); + Meteor.accounts.oauth.registerService('facebook', 2, function(query) { var accessToken = getAccessToken(query); var identity = getIdentity(accessToken); diff --git a/packages/accounts-google/google_client.js b/packages/accounts-google/google_client.js index 10fb1f9d7b..431b853fe9 100644 --- a/packages/accounts-google/google_client.js +++ b/packages/accounts-google/google_client.js @@ -25,7 +25,7 @@ '&redirect_uri=' + Meteor.accounts.google._appUrl + '/_oauth/google?close' + '&state=' + state; - Meteor.accounts.oauth2.initiateLogin(state, loginUrl); + Meteor.accounts.oauth.initiateLogin(state, loginUrl); }; }) (); diff --git a/packages/accounts-google/google_common.js b/packages/accounts-google/google_common.js index 5f6e4ddeaf..e5071871bc 100644 --- a/packages/accounts-google/google_common.js +++ b/packages/accounts-google/google_common.js @@ -1,5 +1,6 @@ if (!Meteor.accounts.google) { Meteor.accounts.google = {}; + Meteor.accounts.google._requireConfigs = ['_clientId', '_appUrl']; } Meteor.accounts.google.config = function(clientId, appUrl, options) { diff --git a/packages/accounts-google/google_server.js b/packages/accounts-google/google_server.js index 4bb726c55a..b13ce08748 100644 --- a/packages/accounts-google/google_server.js +++ b/packages/accounts-google/google_server.js @@ -4,16 +4,7 @@ Meteor.accounts.google._secret = secret; }; - Meteor.accounts.oauth2.registerService('google', function(query) { - if (query.error) { - // The user didn't authorize access - return null; - } - - 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"); + Meteor.accounts.oauth.registerService('google', 2, function(query) { var accessToken = getAccessToken(query); var identity = getIdentity(accessToken); diff --git a/packages/accounts-oauth2-helper/oauth2_client.js b/packages/accounts-oauth-helper/oauth_client.js similarity index 90% rename from packages/accounts-oauth2-helper/oauth2_client.js rename to packages/accounts-oauth-helper/oauth_client.js index e6bf2ac4cf..c6a91b5608 100644 --- a/packages/accounts-oauth2-helper/oauth2_client.js +++ b/packages/accounts-oauth-helper/oauth_client.js @@ -3,7 +3,7 @@ // // @param state {String} The OAuth state generated by the client // @param url {String} url to page - Meteor.accounts.oauth2.initiateLogin = function(state, url) { + Meteor.accounts.oauth.initiateLogin = function(state, url) { // XXX these dimensions worked well for facebook and google, but // it's sort of weird to have these here. Maybe an optional // argument instead? @@ -22,10 +22,8 @@ // nothing should happen. var tryLoginAfterPopupClosed = function(state) { Meteor.apply('login', [ - {oauth: {version: 2, state: state}} + {oauth: {state: state}} ], {wait: true}, function(error, result) { - // XXX this is the wrong thing to do with the error! It should be - // delivered to the user via a callback. if (error) throw error; diff --git a/packages/accounts-oauth-helper/oauth_common.js b/packages/accounts-oauth-helper/oauth_common.js new file mode 100644 index 0000000000..3c6e8b8558 --- /dev/null +++ b/packages/accounts-oauth-helper/oauth_common.js @@ -0,0 +1 @@ +Meteor.accounts.oauth = {}; \ No newline at end of file diff --git a/packages/accounts-oauth-helper/oauth_server.js b/packages/accounts-oauth-helper/oauth_server.js new file mode 100644 index 0000000000..ca523b9143 --- /dev/null +++ b/packages/accounts-oauth-helper/oauth_server.js @@ -0,0 +1,181 @@ +(function () { + var connect = __meteor_bootstrap__.require("connect"); + + Meteor.accounts.oauth._services = {}; + + // Register a handler for an OAuth 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 version {Number} OAuth version (1 or 2) + // @param handleOauthRequest {Function(oauthBinding|query)} + // - (For OAuth1 only) oauthBinding {OAuth1Binding} bound to the appropriate provider + // - (For OAuth2 only) query {Object} parameters passed in query string + // - return value is: + // - {options: (options), extra: (optional extra)} (same as the + // arguments to Meteor.accounts.updateOrCreateUser) + // - `null` if the user declined to give permissions + Meteor.accounts.oauth.registerService = function (name, version, handleOauthRequest) { + if (Meteor.accounts.oauth._services[name]) + throw new Error("Already registered the " + name + " OAuth service"); + + Meteor.accounts.oauth._services[name] = { + serviceName: name, + version: version, + handleOauthRequest: handleOauthRequest + }; + }; + + // When we get an incoming OAuth http request we complete the oauth + // 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 state --> return value of `login` + // + // XXX we should periodically clear old entries + Meteor.accounts.oauth._loginResultForState = {}; + + // 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.oauth._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 if (result instanceof Error) + // We tried to login, but there was a fatal error. Report it back + // to the user. + throw result; + else + return result; + }); + + // Listen to incoming OAuth http requests + __meteor_bootstrap__.app + .use(connect.query()) + .use(function(req, res, next) { + // Need to create a Fiber since we're using synchronous http + // calls and nothing else is wrapping this in a fiber + // automatically + Fiber(function () { + Meteor.accounts.oauth._middleware(req, res, next); + }).run(); + }); + + Meteor.accounts.oauth._middleware = function (req, res, next) { + // Make sure to catch any exceptions because otherwise we'd crash + // the runner + try { + var serviceName = oauthServiceName(req); + if (!serviceName) { + // not an oauth request. pass to next middleware. + next(); + return; + } + + var service = Meteor.accounts.oauth._services[serviceName]; + + // Skip everything if there's no service set by the oauth middleware + if (!service) + throw new Error("Unexpected OAuth service " + serviceName); + + // Make sure we're configured + ensureConfigured(serviceName); + + if (service.version === 1) + Meteor.accounts.oauth1._handleRequest(service, req.query, res); + else if (service.version === 2) + Meteor.accounts.oauth2._handleRequest(service, req.query, res); + else + throw new Error("Unexpected OAuth version " + service.version); + } catch (err) { + // if we got thrown an error, save it off, it will get passed to + // the approporiate login call (if any) and reported there. + // + // The other option would be to display it in the popup tab that + // is still open at this point, ignoring the 'close' or 'redirect' + // we were passed. But then the developer wouldn't be able to + // style the error or react to it in any way. + if (req.query.state && err instanceof Error) + Meteor.accounts.oauth._loginResultForState[req.query.state] = err; + + // also log to the server console, so the developer sees it. + Meteor._debug("Exception in oauth request handler", err); + + // XXX the following is actually wrong. if someone wants to + // redirect rather than close once we are done with the OAuth + // flow, as supported by + // Meteor.accounts.oauth_renderOauthResults, this will still + // close the popup instead. Once we fully support the redirect + // flow (by supporting that in places such as + // packages/facebook/facebook_client.js) we should revisit this. + // + // close the popup. because nobody likes them just hanging + // there. when someone sees this multiple times they might + // think to check server logs (we hope?) + closePopup(res); + } + }; + + // Handle /_oauth/* paths and extract the service name + // + // @returns {String|null} e.g. "facebook", or null if this isn't an + // oauth request + var oauthServiceName = function (req) { + + // req.url will be "/_oauth/?" + var barePath = req.url.substring(0, req.url.indexOf('?')); + var splitPath = barePath.split('/'); + + // Any non-oauth request will continue down the default + // middlewares. + if (splitPath[1] !== '_oauth') + return null; + + // Find service based on url + var serviceName = splitPath[2]; + return serviceName; + }; + + // Make sure we're configured + var ensureConfigured = function(serviceName) { + var service = Meteor.accounts[serviceName]; + + _.each(service._requireConfigs, function(key) { + if (!service[key]) + throw new Meteor.accounts.ConfigError( + "Need to call Meteor.accounts." + serviceName + ".config first"); + }); + + if (Meteor.is_server && !service._secret) + throw new Meteor.accounts.ConfigError( + "Need to call Meteor.accounts." + serviceName + ".setSecret first"); + }; + + Meteor.accounts.oauth._renderOauthResults = function(res, query) { + // We support ?close and ?redirect=URL. Any other query should + // just serve a blank page + if ('close' in query) { // check with 'in' because we don't set a value + closePopup(res); + } else if (query.redirect) { + res.writeHead(302, {'Location': query.redirect}); + res.end(); + } else { + res.writeHead(200, {'Content-Type': 'text/html'}); + res.end('', 'utf-8'); + } + }; + + var closePopup = function(res) { + res.writeHead(200, {'Content-Type': 'text/html'}); + var content = + ''; + res.end(content, 'utf-8'); + }; + +})(); + + diff --git a/packages/accounts-oauth-helper/package.js b/packages/accounts-oauth-helper/package.js new file mode 100644 index 0000000000..c773ae24d6 --- /dev/null +++ b/packages/accounts-oauth-helper/package.js @@ -0,0 +1,12 @@ +Package.describe({ + summary: "Common code for OAuth-based login services", + internal: true +}); + +Package.on_use(function (api) { + api.use('accounts', ['client', 'server']); + + api.add_files('oauth_common.js', ['client', 'server']); + api.add_files('oauth_client.js', 'client'); + api.add_files('oauth_server.js', 'server'); +}); diff --git a/packages/accounts-oauth1-helper/oauth1_binding.js b/packages/accounts-oauth1-helper/oauth1_binding.js new file mode 100644 index 0000000000..8f03ef8451 --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_binding.js @@ -0,0 +1,137 @@ +var crypto = __meteor_bootstrap__.require("crypto"); +var querystring = __meteor_bootstrap__.require("querystring"); + +// An OAuth1 wrapper around http calls which helps get tokens and +// takes care of HTTP headers +// +// @param consumerKey {String} As supplied by the OAuth1 provider +// @param consumerSecret {String} As supplied by the OAuth1 provider +// @param urls {Object} +// - requestToken (String): url +// - authorize (String): url +// - accessToken (String): url +// - authenticate (String): url +OAuth1Binding = function(consumerKey, consumerSecret, urls) { + this._consumerKey = consumerKey; + this._secret = consumerSecret; + this._urls = urls; +}; + +OAuth1Binding.prototype.prepareRequestToken = function(callbackUrl) { + var self = this; + + var headers = self._buildHeader({ + oauth_callback: callbackUrl + }); + + var response = self._call('POST', self._urls.requestToken, headers); + var tokens = querystring.parse(response.content); + + // XXX should we also store oauth_token_secret here? + if (!tokens.oauth_callback_confirmed) + throw new Error("oauth_callback_confirmed false when requesting oauth1 token", tokens); + self.requestToken = tokens.oauth_token; +}; + +OAuth1Binding.prototype.prepareAccessToken = function(query) { + var self = this; + + var headers = self._buildHeader({ + oauth_token: query.oauth_token + }); + + var params = { + oauth_verifier: query.oauth_verifier + }; + + var response = self._call('POST', self._urls.accessToken, headers, params); + var tokens = querystring.parse(response.content); + + self.accessToken = tokens.oauth_token; + self.accessTokenSecret = tokens.oauth_token_secret; +}; + +OAuth1Binding.prototype.call = function(method, url) { + var self = this; + + var headers = self._buildHeader({ + oauth_token: self.accessToken + }); + + var response = self._call(method, url, headers); + return response.data; +}; + +OAuth1Binding.prototype.get = function(url) { + return this.call('GET', url); +}; + +OAuth1Binding.prototype._buildHeader = function(headers) { + var self = this; + return _.extend({ + oauth_consumer_key: self._consumerKey, + oauth_nonce: Meteor.uuid().replace(/\W/g, ''), + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: (new Date().valueOf()/1000).toFixed().toString(), + oauth_version: '1.0' + }, headers); +}; + +OAuth1Binding.prototype._getSignature = function(method, url, rawHeaders, accessTokenSecret) { + var self = this; + var headers = self._encodeHeader(rawHeaders); + + var parameters = _.map(headers, function(val, key) { + return key + '=' + val; + }).sort().join('&'); + + var signatureBase = [ + method, + encodeURIComponent(url), + encodeURIComponent(parameters) + ].join('&'); + + var signingKey = encodeURIComponent(self._secret) + '&'; + if (accessTokenSecret) + signingKey += encodeURIComponent(accessTokenSecret); + + return crypto.createHmac('SHA1', signingKey).update(signatureBase).digest('base64'); +}; + +OAuth1Binding.prototype._call = function(method, url, headers, params) { + var self = this; + + // Get the signature + headers.oauth_signature = self._getSignature(method, url, headers, self.accessTokenSecret); + + // Make a authorization string according to oauth1 spec + var authString = self._getAuthHeaderString(headers); + + // Make signed request + var response = Meteor.http.call(method, url, { + params: params, + headers: { + Authorization: authString + } + }); + + if (response.error) { + Meteor._debug('Error sending OAuth1 HTTP call', method, url, params, authString); + throw response.error; + } + + return response; +}; + +OAuth1Binding.prototype._encodeHeader = function(header) { + return _.reduce(header, function(memo, val, key) { + memo[encodeURIComponent(key)] = encodeURIComponent(val); + return memo; + }, {}); +}; + +OAuth1Binding.prototype._getAuthHeaderString = function(headers) { + return 'OAuth ' + _.map(headers, function(val, key) { + return encodeURIComponent(key) + '="' + encodeURIComponent(val) + '"'; + }).sort().join(', '); +}; diff --git a/packages/accounts-oauth1-helper/oauth1_common.js b/packages/accounts-oauth1-helper/oauth1_common.js new file mode 100644 index 0000000000..3b746c7e43 --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_common.js @@ -0,0 +1 @@ +Meteor.accounts.oauth1 = {}; \ No newline at end of file diff --git a/packages/accounts-oauth1-helper/oauth1_server.js b/packages/accounts-oauth1-helper/oauth1_server.js new file mode 100644 index 0000000000..3203f08955 --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_server.js @@ -0,0 +1,66 @@ +(function () { + var connect = __meteor_bootstrap__.require("connect"); + + // A place to store request tokens pending verification + Meteor.accounts.oauth1._requestTokens = {}; + + // connect middleware + Meteor.accounts.oauth1._handleRequest = function (service, query, res) { + + var config = Meteor.accounts[service.serviceName]; + var oauthBinding = new OAuth1Binding(config._consumerKey, config._secret, config._urls); + + if (query.requestTokenAndRedirect) { + // step 1 - get and store a request token + + // Get a request token to start auth process + oauthBinding.prepareRequestToken(query.requestTokenAndRedirect); + + // Keep track of request token so we can verify it on the next step + Meteor.accounts.oauth1._requestTokens[query.state] = oauthBinding.requestToken; + + // redirect to provider login, which will redirect back to "step 2" below + var redirectUrl = config._urls.authenticate + '?oauth_token=' + oauthBinding.requestToken; + res.writeHead(302, {'Location': redirectUrl}); + res.end(); + + } else { + // step 2, redirected from provider login - complete the login + // process: if the user authorized permissions, get an access + // token and access token secret and log in as user + + // Get the user's request token so we can verify it and clear it + var requestToken = Meteor.accounts.oauth1._requestTokens[query.state]; + delete Meteor.accounts.oauth1._requestTokens[query.state]; + + // Verify user authorized access and the oauth_token matches + // the requestToken from previous step + if (query.oauth_token && query.oauth_token === requestToken) { + + // Prepare the login results before returning. This way the + // subsequent call to the `login` method will be immediate. + + // Get the access token for signing requests + oauthBinding.prepareAccessToken(query); + + // Get or create user id + var oauthResult = service.handleOauthRequest(oauthBinding); + var userId = Meteor.accounts.updateOrCreateUser( + oauthResult.options, oauthResult.extra); + + // Generate and store a login token for reconnect + // XXX this could go in accounts_server.js instead + var loginToken = Meteor.accounts._loginTokens.insert({userId: userId}); + + // Store results to subsequent call to `login` + Meteor.accounts.oauth._loginResultForState[query.state] = + {token: loginToken, id: userId}; + } + } + + // Either close the window, redirect, or render nothing + // if all else fails + Meteor.accounts.oauth._renderOauthResults(res, query); + }; + +})(); diff --git a/packages/accounts-oauth1-helper/oauth1_tests.js b/packages/accounts-oauth1-helper/oauth1_tests.js new file mode 100644 index 0000000000..e7a004cc7a --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_tests.js @@ -0,0 +1,140 @@ + +Tinytest.add("oauth1 - loginResultForState is stored", function (test) { + var http = __meteor_bootstrap__.require('http'); + var twitterfooId = Meteor.uuid(); + var twitterfooName = 'nickname' + Meteor.uuid(); + var twitterfooAccessToken = Meteor.uuid(); + var twitterfooAccessTokenSecret = Meteor.uuid(); + + OAuth1Binding.prototype.prepareRequestToken = function() {}; + OAuth1Binding.prototype.prepareAccessToken = function() { + this.accessToken = twitterfooAccessToken; + this.accessTokenSecret = twitterfooAccessTokenSecret; + }; + + // XXX XXX test isolation fail! Avital: but actually -- why would + // we run server tests more than once? or even more so in parallel? + Meteor.accounts._loginTokens.remove({}); + Meteor.accounts.oauth._loginResultForState = {}; + Meteor.accounts.oauth._services = {}; + + Meteor.accounts.twitterfoo = {}; + Meteor.accounts.twitterfoo._requireConfigs = []; + Meteor.accounts.twitterfoo._secret = 'XXX'; + + // register a fake login service - twitterfoo + Meteor.accounts.oauth.registerService("twitterfoo", 1, function (query) { + return { + options: { + services: { + twitter: { + id: twitterfooId, + screenName: twitterfooName, + accessToken: twitterfooAccessToken, + accessTokenSecret: twitterfooAccessTokenSecret + } + } + } + }; + }); + + // simulate logging in using twitterfoo + Meteor.accounts.oauth1._requestTokens['STATE'] = twitterfooAccessToken; + + var req = { + method: "POST", + url: "/_oauth/twitterfoo?close", + query: { + state: "STATE", + oauth_token: twitterfooAccessToken + } + }; + + Meteor.accounts.oauth._middleware(req, new http.ServerResponse(req)); + + // verify that a user is created + var user = Meteor.users.findOne({"services.twitter.screenName": twitterfooName}); + test.notEqual(user, undefined); + test.equal(user.services.twitter.accessToken, twitterfooAccessToken); + test.equal(user.services.twitter.accessTokenSecret, twitterfooAccessTokenSecret); + + // and that that user has a login token + var token = Meteor.accounts._loginTokens.findOne({userId: user._id}); + test.notEqual(token, undefined); + + // and that the login result for that user is prepared + test.equal( + Meteor.accounts.oauth._loginResultForState['STATE'].id, user._id); + test.equal( + Meteor.accounts.oauth._loginResultForState['STATE'].token, token._id); +}); + + +Tinytest.add("oauth1 - error in user creation", function (test) { + var http = __meteor_bootstrap__.require('http'); + var state = Meteor.uuid(); + var twitterfailId = Meteor.uuid(); + var twitterfailName = 'nickname' + Meteor.uuid(); + var twitterfailAccessToken = Meteor.uuid(); + var twitterfailAccessTokenSecret = Meteor.uuid(); + + Meteor.accounts.twitterfail = {}; + Meteor.accounts.twitterfail._requireConfigs = []; + Meteor.accounts.twitterfail._secret = 'XXX'; + + // Wire up access token so that verification passes + Meteor.accounts.oauth1._requestTokens[state] = twitterfailAccessToken; + + // register a failing login service + Meteor.accounts.oauth.registerService("twitterfail", 1, function (query) { + return { + options: { + services: { + twitter: { + id: twitterfailId, + screenName: twitterfailName, + accessToken: twitterfailAccessToken, + accessTokenSecret: twitterfailAccessTokenSecret + } + } + }, + extra: { + invalid: true + } + }; + }); + + // a way to fail new users. duplicated from passwords_tests, but + // shouldn't hurt. + Meteor.accounts.validateNewUser(function (user) { + return !user.invalid; + }); + + // simulate logging in with failure + Meteor._suppress_log(1); + var req = { + method: "POST", + url: "/_oauth/twitterfail?close", + query: { + state: state, + oauth_token: twitterfailAccessToken + } + }; + + Meteor.accounts.oauth._middleware(req, new http.ServerResponse(req)); + + // verify that a user is not created + var user = Meteor.users.findOne({"services.twitter.screenName": twitterfailName}); + test.equal(user, undefined); + + // verify an error is stored in login state + test.equal(Meteor.accounts.oauth._loginResultForState[state].error, 403); + + // verify error is handed back to login method. + test.throws(function () { + Meteor.apply('login', [{oauth: {version: 1, state: state}}]); + }); + +}); + + diff --git a/packages/accounts-oauth1-helper/package.js b/packages/accounts-oauth1-helper/package.js new file mode 100644 index 0000000000..df47f39784 --- /dev/null +++ b/packages/accounts-oauth1-helper/package.js @@ -0,0 +1,17 @@ +Package.describe({ + summary: "Common code for OAuth1-based login services", + internal: true +}); + +Package.on_use(function (api) { + api.use('accounts-oauth-helper', 'client'); + + api.add_files('oauth1_binding.js', 'server'); + api.add_files('oauth1_common.js', ['client', 'server']); + api.add_files('oauth1_server.js', 'server'); +}); + +Package.on_test(function (api) { + api.use('accounts-oauth1-helper', 'server'); + api.add_files("oauth1_tests.js", 'server'); +}); diff --git a/packages/accounts-oauth2-helper/oauth2_server.js b/packages/accounts-oauth2-helper/oauth2_server.js index 03e8819f92..718d7fd4b6 100644 --- a/packages/accounts-oauth2-helper/oauth2_server.js +++ b/packages/accounts-oauth2-helper/oauth2_server.js @@ -1,134 +1,31 @@ (function () { var connect = __meteor_bootstrap__.require("connect"); - 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)} - // - query is an object with the parameters passed in the query string - // - return value is: - // - {options: (options), extra: (optional extra)} (same as the - // arguments to Meteor.accounts.updateOrCreateUser) - // - `null` if the user declined to give permissions - Meteor.accounts.oauth2.registerService = function (name, handleOauthRequest) { - if (Meteor.accounts.oauth2._services[name]) - throw new 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]; - if (result === undefined) // not using `!result` since can be null - // We weren't notified of the user authorizing the login. - return null; - else if (result instanceof Error) - // We tried to login, but there was a fatal error. Report it back - // to the user. - throw result; - else - return result; - }); - - // When we get an incoming OAuth http request we complete the oauth - // 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 state --> return value of `login` - // - // XXX we should periodically clear old entries - Meteor.accounts.oauth2._loginResultForState = {}; - // connect middleware - Meteor.accounts.oauth2._handleRequest = function (req, res, next) { - // req.url will be "/_oauth/?" - // NOTE: query param is mandatory. - var barePath = req.url.substring(0, req.url.indexOf('?')); - var splitPath = barePath.split('/'); + Meteor.accounts.oauth2._handleRequest = function (service, query, res) { + // check if user authorized access + if (!query.error) { + // Prepare the login results before returning. This way the + // subsequent call to the `login` method will be immediate. - // Any non-oauth request will continue down the default middlewares - if (splitPath[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 serviceName = splitPath[2]; - var service = Meteor.accounts.oauth2._services[serviceName]; - - try { // Get or create user id - var oauthResult = service && service.handleOauthRequest(req.query); + var oauthResult = service.handleOauthRequest(query); - // could be null if user declined permissions, or if there was an - // error of some sort. - if (oauthResult && req.query.state) { - var userId = Meteor.accounts.updateOrCreateUser( - oauthResult.options, oauthResult.extra); + var userId = Meteor.accounts.updateOrCreateUser( + oauthResult.options, oauthResult.extra); - // Generate and store a login token for reconnect - // XXX this could go in accounts_server.js instead - var loginToken = Meteor.accounts._loginTokens.insert({userId: userId}); + // Generate and store a login token for reconnect + // XXX this could go in accounts_server.js instead + 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}; - } - } catch (err) { - // if we got thrown an error, save it off, it will get passed to - // the approporiate login call (if any) and reported there. - // - // The other option would be to display it in the popup tab that - // is still open at this point, ignoring the 'close' or 'redirect' - // we were passed. But then the developer wouldn't be able to - // style the error or react to it in any way. - if (req.query.state && err instanceof Error) - Meteor.accounts.oauth2._loginResultForState[req.query.state] = err; - - // also log to the server console, so the developer sees it. - Meteor._debug("Exception in oauth2 handler", err); + // Store results to subsequent call to `login` + Meteor.accounts.oauth._loginResultForState[query.state] = + {token: loginToken, id: userId}; } - // We support ?close and ?redirect=URL. Any other query should - // just serve 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('', 'utf-8'); - } + // Either close the window, redirect, or render nothing + // if all else fails + Meteor.accounts.oauth._renderOauthResults(res, query); }; - // Listen on /_oauth/* - __meteor_bootstrap__.app - .use(connect.query()) - .use(function(req, res, next) { - // Need to create a Fiber since we're using synchronous http - // calls and nothing else is wrapping this in a fiber - // automatically - Fiber(function () { - Meteor.accounts.oauth2._handleRequest(req, res, next); - }).run(); - }); - })(); diff --git a/packages/accounts-oauth2-helper/oauth2_tests.js b/packages/accounts-oauth2-helper/oauth2_tests.js index 5d119e4060..1544225cd7 100644 --- a/packages/accounts-oauth2-helper/oauth2_tests.js +++ b/packages/accounts-oauth2-helper/oauth2_tests.js @@ -6,11 +6,15 @@ Tinytest.add("oauth2 - loginResultForState is stored", function (test) { // XXX XXX test isolation fail! Avital: but actually -- why would // we run server tests more than once? or even more so in parallel? Meteor.accounts._loginTokens.remove({}); - Meteor.accounts.oauth2._loginResultForState = {}; - Meteor.accounts.oauth2._services = {}; + Meteor.accounts.oauth._loginResultForState = {}; + Meteor.accounts.oauth._services = {}; + + Meteor.accounts.foobook = {}; + Meteor.accounts.foobook._requireConfigs = []; + Meteor.accounts.foobook._secret = 'XXX'; // register a fake login service - foobook - Meteor.accounts.oauth2.registerService("foobook", function (query) { + Meteor.accounts.oauth.registerService("foobook", 2, function (query) { return { options: { email: email, @@ -23,7 +27,7 @@ Tinytest.add("oauth2 - loginResultForState is stored", function (test) { var req = {method: "POST", url: "/_oauth/foobook?close", query: {state: "STATE"}}; - Meteor.accounts.oauth2._handleRequest(req, new http.ServerResponse(req)); + Meteor.accounts.oauth._middleware(req, new http.ServerResponse(req)); // verify that a user is created var user = Meteor.users.findOne({"emails.email": email}); @@ -37,9 +41,9 @@ Tinytest.add("oauth2 - loginResultForState is stored", function (test) { // and that the login result for that user is prepared test.equal( - Meteor.accounts.oauth2._loginResultForState['STATE'].id, user._id); + Meteor.accounts.oauth._loginResultForState['STATE'].id, user._id); test.equal( - Meteor.accounts.oauth2._loginResultForState['STATE'].token, token._id); + Meteor.accounts.oauth._loginResultForState['STATE'].token, token._id); }); @@ -48,8 +52,12 @@ Tinytest.add("oauth2 - error in user creation", function (test) { var email = Meteor.uuid() + "@example.com"; var state = Meteor.uuid(); + Meteor.accounts.failbook = {}; + Meteor.accounts.failbook._requireConfigs = []; + Meteor.accounts.failbook._secret = 'XXX'; + // register a failing login service - Meteor.accounts.oauth2.registerService("failbook", function (query) { + Meteor.accounts.oauth.registerService("failbook", 2, function (query) { return { options: { email: email, @@ -72,14 +80,14 @@ Tinytest.add("oauth2 - error in user creation", function (test) { var req = {method: "POST", url: "/_oauth/failbook?close", query: {state: state}}; - Meteor.accounts.oauth2._handleRequest(req, new http.ServerResponse(req)); + Meteor.accounts.oauth._middleware(req, new http.ServerResponse(req)); // verify that a user is not created var user = Meteor.users.findOne({"emails.email": email}); test.equal(user, undefined); // verify an error is stored in login state - test.equal(Meteor.accounts.oauth2._loginResultForState[state].error, 403); + test.equal(Meteor.accounts.oauth._loginResultForState[state].error, 403); // verify error is handed back to login method. test.throws(function () { @@ -87,3 +95,5 @@ Tinytest.add("oauth2 - error in user creation", function (test) { }); }); + + diff --git a/packages/accounts-oauth2-helper/package.js b/packages/accounts-oauth2-helper/package.js index a76d0bf0f6..09f09dded6 100644 --- a/packages/accounts-oauth2-helper/package.js +++ b/packages/accounts-oauth2-helper/package.js @@ -4,11 +4,11 @@ Package.describe({ }); Package.on_use(function (api) { + api.use('accounts-oauth-helper', 'client'); 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'); }); Package.on_test(function (api) { diff --git a/packages/accounts-twitter/package.js b/packages/accounts-twitter/package.js new file mode 100644 index 0000000000..a8e34036e9 --- /dev/null +++ b/packages/accounts-twitter/package.js @@ -0,0 +1,13 @@ +Package.describe({ + summary: "Login service for Twitter accounts" +}); + +Package.on_use(function(api) { + api.use('accounts', ['client', 'server']); + api.use('accounts-oauth1-helper', ['client', 'server']); + api.use('http', ['client', 'server']); + + api.add_files('twitter_common.js', ['client', 'server']); + api.add_files('twitter_server.js', 'server'); + api.add_files('twitter_client.js', 'client'); +}); diff --git a/packages/accounts-twitter/twitter_client.js b/packages/accounts-twitter/twitter_client.js new file mode 100644 index 0000000000..b75dcf378e --- /dev/null +++ b/packages/accounts-twitter/twitter_client.js @@ -0,0 +1,24 @@ +(function () { + Meteor.loginWithTwitter = function () { + if (!Meteor.accounts.twitter._appUrl) + throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.twitter.config first"); + + var state = Meteor.uuid(); + // We need to keep state across the next two 'steps' so we're adding + // a state parameter to the url and the callback url that we'll be returned + // to by oauth provider + + // url back to app, enters "step 2" as described in + // packages/accounts-oauth1-helper/oauth1_server.js + var callbackUrl = Meteor.accounts.twitter._appUrl + '/_oauth/twitter?close&state=' + state; + + // url to app, enters "step 1" as described in + // packages/accounts-oauth1-helper/oauth1_server.js + var url = '/_oauth/twitter/?requestTokenAndRedirect=' + + encodeURIComponent(callbackUrl) + + '&state=' + state; + + Meteor.accounts.oauth.initiateLogin(state, url); + }; + +})(); diff --git a/packages/accounts-twitter/twitter_common.js b/packages/accounts-twitter/twitter_common.js new file mode 100644 index 0000000000..19fea5f6cb --- /dev/null +++ b/packages/accounts-twitter/twitter_common.js @@ -0,0 +1,16 @@ +if (!Meteor.accounts.twitter) { + Meteor.accounts.twitter = {}; + Meteor.accounts.twitter._requireConfigs = ['_consumerKey', '_appUrl']; +} + +Meteor.accounts.twitter.config = function(consumerKey, appUrl) { + Meteor.accounts.twitter._consumerKey = consumerKey; + Meteor.accounts.twitter._appUrl = appUrl; +}; + +Meteor.accounts.twitter._urls = { + requestToken: "https://api.twitter.com/oauth/request_token", + authorize: "https://api.twitter.com/oauth/authorize", + accessToken: "https://api.twitter.com/oauth/access_token", + authenticate: "https://api.twitter.com/oauth/authenticate" +}; diff --git a/packages/accounts-twitter/twitter_server.js b/packages/accounts-twitter/twitter_server.js new file mode 100644 index 0000000000..285e6fd34a --- /dev/null +++ b/packages/accounts-twitter/twitter_server.js @@ -0,0 +1,26 @@ +(function () { + + Meteor.accounts.twitter.setSecret = function (consumerSecret) { + Meteor.accounts.twitter._secret = consumerSecret; + }; + + Meteor.accounts.oauth.registerService('twitter', 1, function(oauthBinding) { + var identity = oauthBinding.get('https://api.twitter.com/1/account/verify_credentials.json'); + + return { + options: { + services: { + twitter: { + id: identity.id, + screenName: identity.screen_name, + accessToken: oauthBinding.accessToken, + accessTokenSecret: oauthBinding.accessTokenSecret + } + } + }, + extra: { + name: identity.name + } + }; + }); +}) (); diff --git a/packages/accounts-ui/login_buttons.js b/packages/accounts-ui/login_buttons.js index f060816094..506960a27a 100644 --- a/packages/accounts-ui/login_buttons.js +++ b/packages/accounts-ui/login_buttons.js @@ -69,6 +69,19 @@ }; }, + 'click #login-buttons-Twitter': function () { + try { + Meteor.loginWithTwitter(); + } catch (e) { + if (e instanceof Meteor.accounts.ConfigError) + alert("Twitter API key not set. Configure app details with " + + "Meteor.accounts.twitter.config() and " + + "Meteor.accounts.twitter.setSecret()"); + else + throw e; + }; + }, + 'click #login-buttons-logout': function() { Meteor.logout(); resetSession(); @@ -540,6 +553,8 @@ ret.push({name: 'Google'}); if (Meteor.accounts.weibo) ret.push({name: 'Weibo'}); + if (Meteor.accounts.twitter) + ret.push({name: 'Twitter'}); // make sure to put accounts last, since this is the order in the // ui as well diff --git a/packages/accounts-ui/login_buttons_images.css b/packages/accounts-ui/login_buttons_images.css index 8d11767e12..88f9b54546 100644 --- a/packages/accounts-ui/login_buttons_images.css +++ b/packages/accounts-ui/login_buttons_images.css @@ -1,3 +1,5 @@ +/* These should be in their respective packages */ + #login-buttons-image-Google { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAADCklEQVQ4jSXSy2ucVRjA4d97zvdNJpPJbTJJE9rYaCINShZtRCFIA1bbLryBUlyoLQjqVl12W7UbN4qb1gtuYhFRRBCDBITaesFbbI3RFBLSptEY05l0ZjLfnMvrov/Bs3gAcF71x6VVHTk+o8nDH+hrH89rUK9Z9Yaen57S3wVtGaMBNGC0IegWKIDxTtVaOHVugZVmH3HX3Zz+4l+W1xvkOjuZfPsspY4CNkZELEgEIJKwYlBjEwjec/mfCMVuorVs76R8+P0KYMmP30U2dT8eIZqAR2ipRcWjEYxGSCRhV08e04oYMoxYLi97EI9YCJ0FHBYbIVGDlUBLwRlLIuYW6chEmQt/rJO09RJjhjEJEYvJYGNhkbUhw43OXtIWDFRq9G87nAaSK6sVRm8r8fzRMWbOX2Xx7ypd7ZET03sQhDOz73DqSJOrd+7HSo4QIu0Nx/4rOzx+cRXZ9+z7+uqJ+3hiepxK3fHZT2tMjXYzOtzL6dmznPzhLexgN0QlxAAYxAlqUqRmkf5j59RlNQ6MFHhgcpCTTx8EUb5e+plD7x4jjg1ANCAgrRQAdR7xKXjBlGyLYi7PxaUmb8z8xcpGHVXLHaXdjI0egKyJiQYTEhSPREVIEUBNC+Mqm+xpz3j0njLPHB2nsh1QgeG+IS48dYbD5YNoo0ZUAbVEuTUoKuBSZOarX/WhyQn6eg2+usDWf0s0tq8zNPYk+WI/Lnge++hlvlyfQ3NdECzGRWKwEEA0qNY251n69kV6+Y0kbaCZoebG2X3oU7pKoyxuXOPe945zs9DCeosGIXoBDyaLdf6ce4Hbk+/Y299ksKtAuaeNsiyw8c1LKIZ95b0MdgxA5giixACpTxEPSau6QdFfI5/2cLPmEW+JAQrtJUJzDXF1dkwHzVodJMX4HFEcQQMaFdPeM0Jb/4PUtzzaLKAhRyJFwo6lbegRNFfk819muV5dR4JBQoQdQ2xFiDmSNDHiaptamR9Gq5cQ18AledrGDpOfeI5Lq8u88smbhMRisoSAgAYghdfn5H/JkHuRZ1owLAAAAABJRU5ErkJggg==); } @@ -9,3 +11,7 @@ #login-buttons-image-Weibo { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAKySURBVDhPY2AgEpR5sjf/nS/6//UkoX+XJltuCvVxkcOp9cyZM1w/r13TuXvmDD9MkYIwg7qrNrubnzFb6J5intPHqrnvCnIwyKIYsmrVKuaFYWEFW2Sk79zX0f6/REHhKFABC0zRsky+rXMSeZdKCTLIHqgUvLAknW8L3IAQDw/RFlbWnQ801P+DNN8D4n0qyk94GRiEjTg5Lbz4+YOCdbhjVmTxbZwex7PUW58t8O1Ukf9gA2IDAoRPWFudfayt9f+mpsb/6yrK/28qKf4/ISf7YZu83K07QMNe6On9nyWusMtVm813azH/UWctZo/vc8TABjB3CApufAzSqKjw/7apyf+nMdH/XxUX/X+RnfX/qY/3/5tqqv/vq6v936KsfB2onltaiEHGx5AteFep4EmGUEHB1Adamv9v6er8fztp0v//79////nr1/+3X778B4N///5/O3jw/0N39//nlBQ/louLd4MMAWImcPhsU1G6DfLvt717wepnz537X0FB4T8fL+//AH///2/evgWL/7l///9dE+P/b4AWTZSWXg/UzAj2/w2gs59mZYEV7d+//z8rE9N/JUXF/w62tiD//a+urIS4BAgeA712Cxg2F40M36alpXGBDTgmI/3hdUU5WEFjff3/wvx8MNvcxARsQE1VFUQ30Et37Oz+P1RV+b/J0nIjUATigmgBvtzH5mb//9++/f/mkyf/A4KC/nv7+oI1W1hb/3/1+fP//9+//39ekP//CVDzTlnZxxtnz1ZBSUDeDAyZh7W13nybOeP/7W1b/09rbf2/FhgWHy9c+P912bL/D11d/l+WEP8/SUR4Ox8DA6pmmEkpHh4ya0JCim4lJGx7kZp8821CwrN7Hh4Pr7m6nDoSET61PjDQichsA3T7//+s/16/5gXSkIAa1AAAh8dhOVd5xHAAAAAASUVORK5CYII=); } + +#login-buttons-image-Twitter { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAByklEQVQ4jaVTz0sbQRh92V10l006GaKJCtEtmqMYU0Qpwqb4B6zgXdT0WEr7B0ih4MGLP05CUWMvHkQwglhvGhsvKmJOBhTUQjWU2slilKarrAfdZROTQ8m7fPMx33szb75vXKZpohpwVbEBCNaCMUYopXppAWOMxDNsOPf3H1WIeDoSURYYYwQAKKW6y7KgLe2vam11KyMRZcEpEP6SOkwbUgc4ATAKUF8YW2fXhZejvaHPsc7gvH2DnCfQGEtdxrd/5NRJteUDpVTf+5kLp2WlA6JsCyZv9ChplPKdTfJZkYWhEF3bvnV3fb36NZSY3dP6Q/5V4hFvIAaKPckE8W5pLBIQdwHAthBdPtpJuhpeAwDu74DrP4/R1/Ts4cwBWg/gN+DowoSqTBPezAMAeAHw+suSw4Q7schFApF6af19a+2yLVIB7xR+0Zk75yCveu82FMnMViKHCXcSa3PPVBJAX5BszL2SP2kNwvdy5M1e+S2AogME4HFYPibPpxKZC03nRAp/M+Dx2UWDzTXfpttrx72ikCoVtrrAAwgdXBk9iazxxtpskfhs1O86aHXXpAEcA7ivJGDBDcDnyAsA2FMsi1KB/0bVv/EBBBSY9mZ7PAsAAAAASUVORK5CYII=); +} diff --git a/packages/accounts-weibo/weibo_client.js b/packages/accounts-weibo/weibo_client.js index 040246c705..9371dc08cd 100644 --- a/packages/accounts-weibo/weibo_client.js +++ b/packages/accounts-weibo/weibo_client.js @@ -12,7 +12,7 @@ '&redirect_uri=' + Meteor.accounts.weibo._appUrl + '/_oauth/weibo?close' + '&state=' + state; - Meteor.accounts.oauth2.initiateLogin(state, loginUrl); + Meteor.accounts.oauth.initiateLogin(state, loginUrl); }; }) (); diff --git a/packages/accounts-weibo/weibo_common.js b/packages/accounts-weibo/weibo_common.js index 940750acb4..9f9d3f0692 100644 --- a/packages/accounts-weibo/weibo_common.js +++ b/packages/accounts-weibo/weibo_common.js @@ -1,5 +1,6 @@ if (!Meteor.accounts.weibo) { Meteor.accounts.weibo = {}; + Meteor.accounts.weibo._requireConfigs = ['_clientId', '_appUrl']; } Meteor.accounts.weibo.config = function(clientId, appUrl) { diff --git a/packages/accounts-weibo/weibo_server.js b/packages/accounts-weibo/weibo_server.js index a50908957a..9a4797af9d 100644 --- a/packages/accounts-weibo/weibo_server.js +++ b/packages/accounts-weibo/weibo_server.js @@ -4,26 +4,18 @@ Meteor.accounts.weibo._secret = secret; }; - Meteor.accounts.oauth2.registerService('weibo', function(query) { - if (query.error) { - // The user didn't authorize access - return null; - } + Meteor.accounts.oauth.registerService('weibo', 2, function(query) { - if (!Meteor.accounts.weibo._clientId || !Meteor.accounts.weibo._appUrl) - throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.weibo.config first"); - if (!Meteor.accounts.weibo._secret) - throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.weibo.setSecret first"); - - var result = getAccessToken(query); - var identity = getIdentity(result.access_token, parseInt(result.uid, 10)); + var accessToken = getAccessToken(query); + var identity = getIdentity(accessToken.access_token, parseInt(accessToken.uid, 10)); return { options: { services: { weibo: { - id: result.uid, - accessToken: result.accessToken + id: accessToken.uid, + accessToken: accessToken.accessToken, + screenName: identity.screen_name } } },