diff --git a/History.md b/History.md index 4baa50e2d7..3de732487e 100644 --- a/History.md +++ b/History.md @@ -1,6 +1,33 @@ ## vNEXT +* Separate OAuth flow logic from Accounts into separate packages. The + `facebook`, `github`, `google`, `meetup`, `twitter`, and `weibo` + packages can be used to perform an OAuth exchange without creating an + account and logging in. #1024 + +* Make `Meteor.defer` work in an inactive tab in iOS. #1023 + +* Allow new `Random` instances to be constructed with specified seed. This + can be used to create repeatable test cases for code that picks random + values. #1033 + +* Fix CoffeeScript error reporting to include source file and line + number again. #1052 + +* Fix Mongo queries which nested JavaScript RegExp objects inside `$or`. #1089 + +* Upgrade Underscore from 1.4.2 to 1.4.4. #776 + +* Upgrade http-proxy from 0.8.5 to 0.10.1. #513 + +* Upgrade Connect from 1.9.2 to 2.7.10. + +Patches contributed by GitHub users awwx, johnston, and timhaines. + + +## v0.6.3 + * Add new `check` package for ensuring that a value matches a required type and structure. This is used to validate untrusted input from the client. See http://docs.meteor.com/#match for details. @@ -10,12 +37,15 @@ * With `autopublish` on, publish many useful fields on `Meteor.users`. -* Files in the 'client/compatibility/' subdirectory of a Meteor app do +* Files in the `client/compatibility/` subdirectory of a Meteor app do not get wrapped in a new variable scope. This is useful for third-party libraries which expect `var` statements at the outermost level to be global. -* When using the `http` package on the server synchronously, errors +* Add synthetic `tap` event for use on touch enabled devices. This is a + replacement for `click` that fires immediately. + +* When using the `http` package synchronously on the server, errors are thrown rather than passed in `result.error` * The `manager` option to the `Meteor.Collection` constructor is now called @@ -50,6 +80,7 @@ Patches contributed by GitHub users awwx, jagill, spang, and timhaines. + ## v0.6.2.1 * When authenticating with GitHub, include a user agent string. This @@ -143,8 +174,8 @@ Patches contributed by GitHub users andreas-karlsson and awwx. * `{{#with}}` helper now only includes its block if its argument is not falsey, and runs an `{{else}}` block if provided if the argument is falsey. #770, #866 -* Twitter login now stores profile_image_url and profile_image_url_https - attributes in the user.services.twitter namespace. #788 +* Twitter login now stores `profile_image_url` and `profile_image_url_https` + attributes in the `user.services.twitter` namespace. #788 * Allow packages to register file extensions with dots in the filename. @@ -212,7 +243,7 @@ mquandalle, Primigenus, raix, reustle, and timhaines. * Publish functions may now return an array of cursors to publish. Currently, the cursors must all be from different collections. #716 -* User documents have id's when onCreateUser and validateNewUser hooks run. +* User documents have id's when `onCreateUser` and `validateNewUser` hooks run. * Encode and store custom EJSON types in MongoDB. diff --git a/LICENSE.txt b/LICENSE.txt index a676543e98..934ab211a4 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -346,6 +346,12 @@ node-kexec: https://github.com/jprichardson/node-kexec Copyright (c) 2011-2012 JP Richardson +---------- +setImmediate: https://github.com/NobleJS/setImmediate +---------- + +Copyright (c) 2012 Barnesandnoble.com, llc, Donavon West, and Domenic Denicola + ============== Apache License diff --git a/docs/.meteor/release b/docs/.meteor/release index af7f6f3d0d..a50a1dcf8c 100644 --- a/docs/.meteor/release +++ b/docs/.meteor/release @@ -1 +1 @@ -0.6.2.1 +0.6.3.1 diff --git a/docs/client/api.html b/docs/client/api.html index f6354bdac7..7fcdbde1fc 100644 --- a/docs/client/api.html +++ b/docs/client/api.html @@ -2213,6 +2213,12 @@ The user presses a keyboard key. `keypress` is most useful for catching typing in text fields, while `keydown` and `keyup` can be used for arrow keys or modifier keys. {{/dtdd}} + +{{#dtdd "tap"}} Tap on an element. On touch-enabled +devices, this is a replacement to `click` that fires immediately. +These events are synthesized from `touchmove` and `touchend`. +{{/dtdd}} + Other DOM events are available as well, but for the events above, diff --git a/examples/leaderboard/.meteor/release b/examples/leaderboard/.meteor/release index af7f6f3d0d..844f6a91ac 100644 --- a/examples/leaderboard/.meteor/release +++ b/examples/leaderboard/.meteor/release @@ -1 +1 @@ -0.6.2.1 +0.6.3 diff --git a/examples/other/defer-in-inactive-tab/.meteor/.gitignore b/examples/other/defer-in-inactive-tab/.meteor/.gitignore new file mode 100644 index 0000000000..4083037423 --- /dev/null +++ b/examples/other/defer-in-inactive-tab/.meteor/.gitignore @@ -0,0 +1 @@ +local diff --git a/examples/other/defer-in-inactive-tab/.meteor/packages b/examples/other/defer-in-inactive-tab/.meteor/packages new file mode 100644 index 0000000000..1a791704ad --- /dev/null +++ b/examples/other/defer-in-inactive-tab/.meteor/packages @@ -0,0 +1,5 @@ +# Meteor packages used by this project, one per line. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. + diff --git a/examples/other/defer-in-inactive-tab/README.md b/examples/other/defer-in-inactive-tab/README.md new file mode 100644 index 0000000000..b039b0778b --- /dev/null +++ b/examples/other/defer-in-inactive-tab/README.md @@ -0,0 +1,13 @@ +# Defer in Inactive Tab + +Tests that `Meteor.defer` works in an inactive tab in iOS Safari. + +(`setTimeout` and `setInterval` events aren't delivered to inactive +tabs in iOS Safari until they become active again). + +Sadly we have to run the test manually because scripts aren't allowed +to open windows themselves except in response to user events. + +This test will not run on Chrome for iOS because the storage event is +not implemented in that browser. Also doesn't attempt to run on +versions of IE that don't support `window.addEventListener`. diff --git a/examples/other/defer-in-inactive-tab/test.html b/examples/other/defer-in-inactive-tab/test.html new file mode 100644 index 0000000000..ac8815279e --- /dev/null +++ b/examples/other/defer-in-inactive-tab/test.html @@ -0,0 +1,52 @@ + + defer in inactive tab + + + + + {{> route}} + + + + + + + diff --git a/examples/other/defer-in-inactive-tab/test.js b/examples/other/defer-in-inactive-tab/test.js new file mode 100644 index 0000000000..33844c2933 --- /dev/null +++ b/examples/other/defer-in-inactive-tab/test.js @@ -0,0 +1,57 @@ +if (Meteor.isClient) { + + var isParent = (window.location.pathname === '/'); + var isChild = ! isParent; + + Template.route.isParent = function () { + return isParent; + }; + + Template.parent.testStatus = function () { + return Session.get('testStatus'); + }; + + Template.parent.events({ + 'click #openTab': function () { + window.open('/child'); + }, + + 'click #runTest': function () { + if (localStorage.getItem('ping') === '!' || + localStorage.getItem('pong') === '!') { + Session.set('testStatus', 'Test already run. Close the second tab (if open), refresh this page, and run again.'); + } + else { + localStorage.setItem('ping', '!'); + } + } + }); + + if (isParent) { + Session.set('testStatus', ''); + + Meteor.startup(function () { + localStorage.setItem('ping', null); + localStorage.setItem('pong', null); + }); + window.addEventListener('storage', function (event) { + if (event.key === 'pong' && event.newValue === '!') { + Session.set('testStatus', 'test successful'); + } + }); + } + + if (isChild) { + window.addEventListener('storage', function (event) { + if (event.key === 'ping' && event.newValue === '!') { + // If we used setTimeout here in iOS Safari it wouldn't + // work (unless we switched tabs) because setTimeout and + // setInterval events don't fire in inactive tabs. + Meteor.defer(function () { + localStorage.setItem('pong', '!'); + }); + } + }); + } + +} diff --git a/examples/parties/.meteor/release b/examples/parties/.meteor/release index af7f6f3d0d..844f6a91ac 100644 --- a/examples/parties/.meteor/release +++ b/examples/parties/.meteor/release @@ -1 +1 @@ -0.6.2.1 +0.6.3 diff --git a/examples/todos/.meteor/release b/examples/todos/.meteor/release index af7f6f3d0d..844f6a91ac 100644 --- a/examples/todos/.meteor/release +++ b/examples/todos/.meteor/release @@ -1 +1 @@ -0.6.2.1 +0.6.3 diff --git a/examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.js b/examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.js index eb1b306d39..cdd2bbefbf 100644 --- a/examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.js +++ b/examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.js @@ -162,12 +162,12 @@ if (Meteor.isClient) { 'click #controlpane button': function (event) { if (this.key === "fakeConfig") { var service = this.value; - if (! Accounts.loginServiceConfiguration.findOne({service: service})) - Accounts.loginServiceConfiguration.insert( + if (! ServiceConfiguration.configurations.findOne({service: service})) + ServiceConfiguration.configurations.insert( {service: service, fake: true}); } else if (this.key === "unconfig") { var service = this.value; - Accounts.loginServiceConfiguration.remove({service: service}); + ServiceConfiguration.configurations.remove({service: service}); } else if (this.key === "messages") { if (this.value === "error") { Accounts._loginButtonsSession.errorMessage('An error occurred! Gee golly gosh.'); diff --git a/examples/wordplay/.meteor/release b/examples/wordplay/.meteor/release index af7f6f3d0d..844f6a91ac 100644 --- a/examples/wordplay/.meteor/release +++ b/examples/wordplay/.meteor/release @@ -1 +1 @@ -0.6.2.1 +0.6.3 diff --git a/meteor b/meteor index 8bfdf38617..a0ce387c76 100755 --- a/meteor +++ b/meteor @@ -1,6 +1,6 @@ #!/bin/bash -BUNDLE_VERSION=0.3.4 +BUNDLE_VERSION=0.3.7 # OS Check. Put here because here is where we download the precompiled # bundles that are arch specific. diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index e155b0c49c..f7a4ebd569 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -49,24 +49,9 @@ Meteor.users = new Meteor.Collection("users", {_preventAutopublish: true}); // There is an allow call in accounts_server that restricts this // collection. - -// Table containing documents with configuration options for each -// login service -Accounts.loginServiceConfiguration = new Meteor.Collection( - "meteor_accounts_loginServiceConfiguration", {_preventAutopublish: true}); -// Leave this collection open in insecure mode. In theory, someone could -// hijack your oauth connect requests to a different endpoint or appId, -// but you did ask for 'insecure'. The advantage is that it is much -// easier to write a configuration wizard that works only in insecure -// mode. - - -// Thrown when trying to use a login service which is not configured -Accounts.ConfigError = function(description) { - this.message = description; -}; -Accounts.ConfigError.prototype = new Error(); -Accounts.ConfigError.prototype.name = 'Accounts.ConfigError'; +// loginServiceConfiguration and ConfigError are maintained for backwards compatibility +Accounts.loginServiceConfiguration = ServiceConfiguration.configurations; +Accounts.ConfigError = ServiceConfiguration.ConfigError; // Thrown when the user cancels the login process (eg, closes an oauth // popup, declines retina scan, etc) diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index c0d710ac8f..696893f816 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -351,7 +351,7 @@ Meteor.default_server.onAutopublish(function () { // Publish all login service configuration fields other than secret. Meteor.publish("meteor.loginServiceConfiguration", function () { - return Accounts.loginServiceConfiguration.find({}, {fields: {secret: 0}}); + return ServiceConfiguration.configurations.find({}, {fields: {secret: 0}}); }, {is_auto: true}); // not techincally autopublish, but stops the warning. // Allow a one-time configuration for a login service. Modifications @@ -364,9 +364,9 @@ Meteor.methods({ // instead of ours). if (!Accounts[options.service]) throw new Meteor.Error(403, "Service unknown"); - if (Accounts.loginServiceConfiguration.findOne({service: options.service})) + if (ServiceConfiguration.configurations.findOne({service: options.service})) throw new Meteor.Error(403, "Service " + options.service + " already configured"); - Accounts.loginServiceConfiguration.insert(options); + ServiceConfiguration.configurations.insert(options); } }); diff --git a/packages/accounts-base/package.js b/packages/accounts-base/package.js index 070a88c3ec..6a9867c226 100644 --- a/packages/accounts-base/package.js +++ b/packages/accounts-base/package.js @@ -9,6 +9,7 @@ Package.on_use(function (api) { api.use('deps', 'client'); api.use('check', 'server'); api.use('random', ['client', 'server']); + api.use('service-configuration', ['client', 'server']); // need this because of the Meteor.users collection but in the future // we'd probably want to abstract this away diff --git a/packages/accounts-facebook/facebook_client.js b/packages/accounts-facebook/facebook_client.js index 614d7d3b56..149bfa8a0e 100644 --- a/packages/accounts-facebook/facebook_client.js +++ b/packages/accounts-facebook/facebook_client.js @@ -1,28 +1,4 @@ -Meteor.loginWithFacebook = function (options, callback) { - // support both (options, callback) and (callback). - if (!callback && typeof options === 'function') { - callback = options; - options = {}; - } - - var config = Accounts.loginServiceConfiguration.findOne({service: 'facebook'}); - if (!config) { - callback && callback(new Accounts.ConfigError("Service not configured")); - return; - } - - var state = Random.id(); - var mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent); - var display = mobile ? 'touch' : 'popup'; - - var scope = "email"; - if (options && options.requestPermissions) - scope = options.requestPermissions.join(','); - - var loginUrl = - 'https://www.facebook.com/dialog/oauth?client_id=' + config.appId + - '&redirect_uri=' + Meteor.absoluteUrl('_oauth/facebook?close') + - '&display=' + display + '&scope=' + scope + '&state=' + state; - - Accounts.oauth.initiateLogin(state, loginUrl, callback); -}; +Meteor.loginWithFacebook = function(options, callback) { + var credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback); + Facebook.requestCredential(options, credentialRequestCompleteCallback); +}; \ No newline at end of file diff --git a/packages/accounts-facebook/facebook_server.js b/packages/accounts-facebook/facebook_server.js index 7b3092ceaf..2699808eaa 100644 --- a/packages/accounts-facebook/facebook_server.js +++ b/packages/accounts-facebook/facebook_server.js @@ -1,4 +1,4 @@ -var querystring = Npm.require('querystring'); +Accounts.oauth.registerService('facebook'); Accounts.addAutopublishFields({ // publish all fields including access token, which can legitimately @@ -11,93 +11,3 @@ Accounts.addAutopublishFields({ 'services.facebook.id', 'services.facebook.username', 'services.facebook.gender' ] }); - -Accounts.oauth.registerService('facebook', 2, function(query) { - - var response = getTokenResponse(query); - var accessToken = response.accessToken; - var identity = getIdentity(accessToken); - - var serviceData = { - accessToken: accessToken, - expiresAt: (+new Date) + (1000 * response.expiresIn) - }; - - // include all fields from facebook - // http://developers.facebook.com/docs/reference/login/public-profile-and-friend-list/ - var whitelisted = ['id', 'email', 'name', 'first_name', - 'last_name', 'link', 'username', 'gender', 'locale', 'age_range']; - - var fields = _.pick(identity, whitelisted); - _.extend(serviceData, fields); - - return { - serviceData: serviceData, - options: {profile: {name: identity.name}} - }; -}); - -// checks whether a string parses as JSON -var isJSON = function (str) { - try { - JSON.parse(str); - return true; - } catch (e) { - return false; - } -}; - -// returns an object containing: -// - accessToken -// - expiresIn: lifetime of token in seconds -var getTokenResponse = function (query) { - var config = Accounts.loginServiceConfiguration.findOne({service: 'facebook'}); - if (!config) - throw new Accounts.ConfigError("Service not configured"); - - var responseContent; - try { - // Request an access token - responseContent = Meteor.http.get( - "https://graph.facebook.com/oauth/access_token", { - params: { - client_id: config.appId, - redirect_uri: Meteor.absoluteUrl("_oauth/facebook?close"), - client_secret: config.secret, - code: query.code - } - }).content; - } catch (err) { - throw new Error("Failed to complete OAuth handshake with Facebook. " + err.message); - } - - // If 'responseContent' parses as JSON, it is an error. - // XXX which facebook error causes this behvaior? - if (isJSON(responseContent)) { - throw new Error("Failed to complete OAuth handshake with Facebook. " + responseContent); - } - - // Success! Extract the facebook access token and expiration - // time from the response - var parsedResponse = querystring.parse(responseContent); - var fbAccessToken = parsedResponse.access_token; - var fbExpires = parsedResponse.expires; - - if (!fbAccessToken) { - throw new Error("Failed to complete OAuth handshake with facebook " + - "-- can't find access token in HTTP response. " + responseContent); - } - return { - accessToken: fbAccessToken, - expiresIn: fbExpires - }; -}; - -var getIdentity = function (accessToken) { - try { - return Meteor.http.get("https://graph.facebook.com/me", { - params: {access_token: accessToken}}).data; - } catch (err) { - throw new Error("Failed to fetch identity from Facebook. " + err.message); - } -}; diff --git a/packages/accounts-facebook/package.js b/packages/accounts-facebook/package.js index a9fb73a8aa..afb98a391a 100644 --- a/packages/accounts-facebook/package.js +++ b/packages/accounts-facebook/package.js @@ -4,13 +4,10 @@ Package.describe({ Package.on_use(function(api) { api.use('accounts-base', ['client', 'server']); - api.use('accounts-oauth2-helper', ['client', 'server']); - api.use('http', ['client', 'server']); - api.use('templating', 'client'); + api.use('accounts-oauth', ['client', 'server']); + api.use('facebook', ['client', 'server']); - api.add_files( - ['facebook_login_button.css', 'facebook_configure.html', 'facebook_configure.js'], - 'client'); + api.add_files('facebook_login_button.css', 'client'); api.add_files('facebook_common.js', ['client', 'server']); api.add_files('facebook_server.js', 'server'); diff --git a/packages/accounts-github/github_client.js b/packages/accounts-github/github_client.js index 5ef8365066..7649f2bebb 100644 --- a/packages/accounts-github/github_client.js +++ b/packages/accounts-github/github_client.js @@ -1,26 +1,4 @@ -Meteor.loginWithGithub = function (options, callback) { - // support both (options, callback) and (callback). - if (!callback && typeof options === 'function') { - callback = options; - options = {}; - } - - var config = Accounts.loginServiceConfiguration.findOne({service: 'github'}); - if (!config) { - callback && callback(new Accounts.ConfigError("Service not configured")); - return; - } - var state = Random.id(); - - var scope = (options && options.requestPermissions) || []; - var flatScope = _.map(scope, encodeURIComponent).join('+'); - - var loginUrl = - 'https://github.com/login/oauth/authorize' + - '?client_id=' + config.clientId + - '&scope=' + flatScope + - '&redirect_uri=' + Meteor.absoluteUrl('_oauth/github?close') + - '&state=' + state; - - Accounts.oauth.initiateLogin(state, loginUrl, callback, {width: 900, height: 450}); -}; +Meteor.loginWithGithub = function(options, callback) { + var credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback); + Github.requestCredential(options, credentialRequestCompleteCallback); +}; \ No newline at end of file diff --git a/packages/accounts-github/github_server.js b/packages/accounts-github/github_server.js index 0eabf02497..ca57098047 100644 --- a/packages/accounts-github/github_server.js +++ b/packages/accounts-github/github_server.js @@ -1,3 +1,5 @@ +Accounts.oauth.registerService('github'); + Accounts.addAutopublishFields({ // not sure whether the github api can be used from the browser, // thus not sure if we should be sending access tokens; but we do it @@ -5,67 +7,3 @@ Accounts.addAutopublishFields({ forLoggedInUser: ['services.github'], forOtherUsers: ['services.github.username'] }); - -Accounts.oauth.registerService('github', 2, function(query) { - - var accessToken = getAccessToken(query); - var identity = getIdentity(accessToken); - - return { - serviceData: { - id: identity.id, - accessToken: accessToken, - email: identity.email, - username: identity.login - }, - options: {profile: {name: identity.name}} - }; -}); - -// http://developer.github.com/v3/#user-agent-required -var userAgent = "Meteor"; -if (Meteor.release) - userAgent += "/" + Meteor.release; - -var getAccessToken = function (query) { - var config = Accounts.loginServiceConfiguration.findOne({service: 'github'}); - if (!config) - throw new Accounts.ConfigError("Service not configured"); - - var response; - try { - response = Meteor.http.post( - "https://github.com/login/oauth/access_token", { - headers: { - Accept: 'application/json', - "User-Agent": userAgent - }, - params: { - code: query.code, - client_id: config.clientId, - client_secret: config.secret, - redirect_uri: Meteor.absoluteUrl("_oauth/github?close"), - state: query.state - } - }); - } catch (err) { - throw new Error("Failed to complete OAuth handshake with Github. " + err.message); - } - if (response.data.error) { // if the http response was a json object with an error attribute - throw new Error("Failed to complete OAuth handshake with GitHub. " + response.data.error); - } else { - return response.data.access_token; - } -}; - -var getIdentity = function (accessToken) { - try { - return Meteor.http.get( - "https://api.github.com/user", { - headers: {"User-Agent": userAgent}, // http://developer.github.com/v3/#user-agent-required - params: {access_token: accessToken} - }).data; - } catch (err) { - throw new Error("Failed to fetch identity from GitHub. " + err.message); - } -}; diff --git a/packages/accounts-github/package.js b/packages/accounts-github/package.js index e1fc2f4eb8..7b740bea5e 100644 --- a/packages/accounts-github/package.js +++ b/packages/accounts-github/package.js @@ -4,13 +4,10 @@ Package.describe({ Package.on_use(function(api) { api.use('accounts-base', ['client', 'server']); - api.use('accounts-oauth2-helper', ['client', 'server']); - api.use('http', ['client', 'server']); - api.use('templating', 'client'); + api.use('accounts-oauth', ['client', 'server']); + api.use('github', ['client', 'server']); - api.add_files( - ['github_login_button.css', 'github_configure.html', 'github_configure.js'], - 'client'); + api.add_files('github_login_button.css', 'client'); api.add_files('github_common.js', ['client', 'server']); api.add_files('github_server.js', 'server'); diff --git a/packages/accounts-google/google_client.js b/packages/accounts-google/google_client.js index 3697aabe6e..96641ac3bd 100644 --- a/packages/accounts-google/google_client.js +++ b/packages/accounts-google/google_client.js @@ -1,39 +1,4 @@ -Meteor.loginWithGoogle = function (options, callback) { - // support both (options, callback) and (callback). - if (!callback && typeof options === 'function') { - callback = options; - options = {}; - } else if (!options) { - options = {}; - } - - var config = Accounts.loginServiceConfiguration.findOne({service: 'google'}); - if (!config) { - callback && callback(new Accounts.ConfigError("Service not configured")); - return; - } - - var state = Random.id(); - - // always need this to get user id from google. - var requiredScope = ['https://www.googleapis.com/auth/userinfo.profile']; - var scope = ['https://www.googleapis.com/auth/userinfo.email']; - if (options.requestPermissions) - scope = options.requestPermissions; - scope = _.union(scope, requiredScope); - var flatScope = _.map(scope, encodeURIComponent).join('+'); - - // https://developers.google.com/accounts/docs/OAuth2WebServer#formingtheurl - var accessType = options.requestOfflineToken ? 'offline' : 'online'; - - var loginUrl = - 'https://accounts.google.com/o/oauth2/auth' + - '?response_type=code' + - '&client_id=' + config.clientId + - '&scope=' + flatScope + - '&redirect_uri=' + Meteor.absoluteUrl('_oauth/google?close') + - '&state=' + state + - '&access_type=' + accessType; - - Accounts.oauth.initiateLogin(state, loginUrl, callback); -}; +Meteor.loginWithGoogle = function(options, callback) { + var credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback); + Google.requestCredential(options, credentialRequestCompleteCallback); +}; \ No newline at end of file diff --git a/packages/accounts-google/google_server.js b/packages/accounts-google/google_server.js index 4d4925e47d..5c8575fc18 100644 --- a/packages/accounts-google/google_server.js +++ b/packages/accounts-google/google_server.js @@ -1,6 +1,4 @@ -// https://developers.google.com/accounts/docs/OAuth2Login#userinfocall -var whitelisted = ['id', 'email', 'verified_email', 'name', 'given_name', - 'family_name', 'picture', 'locale', 'timezone', 'gender']; +Accounts.oauth.registerService('google'); Accounts.addAutopublishFields({ forLoggedInUser: _.map( @@ -8,82 +6,12 @@ Accounts.addAutopublishFields({ // transmitted over ssl or on // localhost). https://developers.google.com/accounts/docs/OAuth2UserAgent // refresh token probably shouldn't be sent down. - whitelisted.concat(['accessToken', 'expiresAt']), // don't publish refresh token + Google.whitelistedFields.concat(['accessToken', 'expiresAt']), // don't publish refresh token function (subfield) { return 'services.google.' + subfield; }), forOtherUsers: _.map( // even with autopublish, no legitimate web app should be // publishing all users' emails - _.without(whitelisted, 'email', 'verified_email'), + _.without(Google.whitelistedFields, 'email', 'verified_email'), function (subfield) { return 'services.google.' + subfield; }) }); - -Accounts.oauth.registerService('google', 2, function(query) { - - var response = getTokens(query); - var accessToken = response.accessToken; - var identity = getIdentity(accessToken); - - var serviceData = { - accessToken: accessToken, - expiresAt: (+new Date) + (1000 * response.expiresIn) - }; - - var fields = _.pick(identity, whitelisted); - _.extend(serviceData, fields); - - // only set the token in serviceData if it's there. this ensures - // that we don't lose old ones (since we only get this on the first - // log in attempt) - if (response.refreshToken) - serviceData.refreshToken = response.refreshToken; - - return { - serviceData: serviceData, - options: {profile: {name: identity.name}} - }; -}); - -// returns an object containing: -// - accessToken -// - expiresIn: lifetime of token in seconds -// - refreshToken, if this is the first authorization request -var getTokens = function (query) { - var config = Accounts.loginServiceConfiguration.findOne({service: 'google'}); - if (!config) - throw new Accounts.ConfigError("Service not configured"); - - var response; - try { - response = Meteor.http.post( - "https://accounts.google.com/o/oauth2/token", {params: { - code: query.code, - client_id: config.clientId, - client_secret: config.secret, - redirect_uri: Meteor.absoluteUrl("_oauth/google?close"), - grant_type: 'authorization_code' - }}); - } catch (err) { - throw new Error("Failed to complete OAuth handshake with Google. " + err.message); - } - - if (response.data.error) { // if the http response was a json object with an error attribute - throw new Error("Failed to complete OAuth handshake with Google. " + response.data.error); - } else { - return { - accessToken: response.data.access_token, - refreshToken: response.data.refresh_token, - expiresIn: response.data.expires_in - }; - } -}; - -var getIdentity = function (accessToken) { - try { - return Meteor.http.get( - "https://www.googleapis.com/oauth2/v1/userinfo", - {params: {access_token: accessToken}}).data; - } catch (err) { - throw new Error("Failed to fetch identity from Google. " + err.message); - } -}; diff --git a/packages/accounts-google/package.js b/packages/accounts-google/package.js index 13c69a10eb..aebf8bcb0f 100644 --- a/packages/accounts-google/package.js +++ b/packages/accounts-google/package.js @@ -5,13 +5,10 @@ Package.describe({ Package.on_use(function(api) { api.use(['underscore', 'random']); api.use('accounts-base', ['client', 'server']); - api.use('accounts-oauth2-helper', ['client', 'server']); - api.use('http', ['client', 'server']); - api.use('templating', 'client'); + api.use('accounts-oauth', ['client', 'server']); + api.use('google', ['client', 'server']); - api.add_files( - ['google_login_button.css', 'google_configure.html', 'google_configure.js'], - 'client'); + api.add_files('google_login_button.css', 'client'); api.add_files('google_common.js', ['client', 'server']); api.add_files('google_server.js', 'server'); diff --git a/packages/accounts-meetup/meetup_client.js b/packages/accounts-meetup/meetup_client.js index f5b2056bef..6d0d674a2f 100644 --- a/packages/accounts-meetup/meetup_client.js +++ b/packages/accounts-meetup/meetup_client.js @@ -1,33 +1,4 @@ -Meteor.loginWithMeetup = function (options, callback) { - // support both (options, callback) and (callback). - if (!callback && typeof options === 'function') { - callback = options; - options = {}; - } - - var config = Accounts.loginServiceConfiguration.findOne({service: 'meetup'}); - if (!config) { - callback && callback(new Accounts.ConfigError("Service not configured")); - return; - } - var state = Random.id(); - - var scope = (options && options.requestPermissions) || []; - var flatScope = _.map(scope, encodeURIComponent).join('+'); - - var loginUrl = - 'https://secure.meetup.com/oauth2/authorize' + - '?client_id=' + config.clientId + - '&response_type=code' + - '&scope=' + flatScope + - '&redirect_uri=' + Meteor.absoluteUrl('_oauth/meetup?close') + - '&state=' + state; - - // meetup box gets taller when permissions requested. - var height = 620; - if (_.without(scope, 'basic').length) - height += 130; - - Accounts.oauth.initiateLogin(state, loginUrl, callback, - {width: 900, height: height}); -}; +Meteor.loginWithMeetup = function(options, callback) { + var credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback); + Meetup.requestCredential(options, credentialRequestCompleteCallback); +}; \ No newline at end of file diff --git a/packages/accounts-meetup/meetup_server.js b/packages/accounts-meetup/meetup_server.js index 907d04725a..4b727ac031 100644 --- a/packages/accounts-meetup/meetup_server.js +++ b/packages/accounts-meetup/meetup_server.js @@ -1,3 +1,5 @@ +Accounts.oauth.registerService('meetup'); + Accounts.addAutopublishFields({ // publish all fields including access token, which can legitimately // be used from the client (if transmitted over ssl or on @@ -7,54 +9,3 @@ Accounts.addAutopublishFields({ }); -Accounts.oauth.registerService('meetup', 2, function(query) { - - var accessToken = getAccessToken(query); - var identity = getIdentity(accessToken); - - return { - serviceData: { - id: identity.id, - accessToken: accessToken - }, - options: {profile: {name: identity.name}} - }; -}); - -var getAccessToken = function (query) { - var config = Accounts.loginServiceConfiguration.findOne({service: 'meetup'}); - if (!config) - throw new Accounts.ConfigError("Service not configured"); - - var response; - try { - response = Meteor.http.post( - "https://secure.meetup.com/oauth2/access", {headers: {Accept: 'application/json'}, params: { - code: query.code, - client_id: config.clientId, - client_secret: config.secret, - grant_type: 'authorization_code', - redirect_uri: Meteor.absoluteUrl("_oauth/meetup?close"), - state: query.state - }}); - } catch (err) { - throw new Error("Failed to complete OAuth handshake with Meetup. " + err.message); - } - - if (response.data.error) { // if the http response was a json object with an error attribute - throw new Error("Failed to complete OAuth handshake with Meetup. " + response.data.error); - } else { - return response.data.access_token; - } -}; - -var getIdentity = function (accessToken) { - try { - var response = Meteor.http.get( - "https://secure.meetup.com/2/members", - {params: {member_id: 'self', access_token: accessToken}}); - return response.data.results && response.data.results[0]; - } catch (err) { - throw new Error("Failed to fetch identity from Meetup: " + err.message); - } -}; diff --git a/packages/accounts-meetup/package.js b/packages/accounts-meetup/package.js index 332253c2cc..db4f222b39 100644 --- a/packages/accounts-meetup/package.js +++ b/packages/accounts-meetup/package.js @@ -4,13 +4,10 @@ Package.describe({ Package.on_use(function(api) { api.use('accounts-base', ['client', 'server']); - api.use('accounts-oauth2-helper', ['client', 'server']); - api.use('http', ['client', 'server']); - api.use('templating', 'client'); + api.use('accounts-oauth', ['client', 'server']); + api.use('meetup', ['client', 'server']); - api.add_files( - ['meetup_login_button.css', 'meetup_configure.html', 'meetup_configure.js'], - 'client'); + api.add_files('meetup_login_button.css', 'client'); api.add_files('meetup_common.js', ['client', 'server']); api.add_files('meetup_server.js', 'server'); diff --git a/packages/accounts-oauth/oauth_client.js b/packages/accounts-oauth/oauth_client.js new file mode 100644 index 0000000000..5b189fdc05 --- /dev/null +++ b/packages/accounts-oauth/oauth_client.js @@ -0,0 +1,27 @@ +// 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. +Accounts.oauth.tryLoginAfterPopupClosed = function(credentialToken, callback) { + Accounts.callLoginMethod({ + methodArguments: [{oauth: {credentialToken: credentialToken}}], + userCallback: callback && function (err) { + // Allow server to specify a specify subclass of errors. We should come + // up with a more generic way to do this! + if (err && err instanceof Meteor.Error && + err.error === Accounts.LoginCancelledError.numericError) { + callback(new Accounts.LoginCancelledError(err.details)); + } else { + callback(err); + } + }}); +}; + +Accounts.oauth.credentialRequestCompleteHandler = function(callback) { + return function (credentialTokenOrError) { + if(credentialTokenOrError && credentialTokenOrError instanceof Error) { + callback(credentialTokenOrError); + } else { + Accounts.oauth.tryLoginAfterPopupClosed(credentialTokenOrError, callback); + } + }; +} diff --git a/packages/accounts-oauth-helper/oauth_common.js b/packages/accounts-oauth/oauth_common.js similarity index 100% rename from packages/accounts-oauth-helper/oauth_common.js rename to packages/accounts-oauth/oauth_common.js diff --git a/packages/accounts-oauth/oauth_server.js b/packages/accounts-oauth/oauth_server.js new file mode 100644 index 0000000000..775811289e --- /dev/null +++ b/packages/accounts-oauth/oauth_server.js @@ -0,0 +1,51 @@ +// Helper for registering OAuth based accounts packages. +// Adds an index to the user collection. +Accounts.oauth.registerService = function (name) { + // Accounts.updateOrCreateUserFromExternalService does a lookup by this id, + // so this should be a unique index. You might want to add indexes for other + // fields returned by your service (eg services.github.login) but you can do + // that in your app. + Meteor.users._ensureIndex('services.' + name + '.id', + {unique: 1, sparse: 1}); + +}; + +// For test cleanup only. (Mongo has a limit as to how many indexes it can have +// per collection.) +Accounts.oauth._unregisterService = function (name) { + var index = {}; + index['services.' + name + '.id'] = 1; + Meteor.users._dropIndex(index); +}; + + +// Listen to calls to `login` with an oauth option set. This is where +// users actually get logged in to meteor via oauth. +Accounts.registerLoginHandler(function (options) { + if (!options.oauth) + return undefined; // don't handle + + check(options.oauth, {credentialToken: String}); + + if (!Oauth.hasCredential(options.oauth.credentialToken)) { + // OAuth credentialToken is not recognized, which could be either because the popup + // was closed by the user before completion, or some sort of error where + // the oauth provider didn't talk to our server correctly and closed the + // popup somehow. + // + // we assume it was user canceled, and report it as such, using a + // Meteor.Error which the client can recognize. this will mask failures + // where things are misconfigured such that the server doesn't see the + // request but does close the window. This seems unlikely. + throw new Meteor.Error(Accounts.LoginCancelledError.numericError, + 'No matching login attempt found'); + } + var result = Oauth.retrieveCredential(options.oauth.credentialToken); + if (result instanceof Error) + // We tried to login, but there was a fatal error. Report it back + // to the user. + throw result; + else + return Accounts.updateOrCreateUserFromExternalService(result.serviceName, result.serviceData, result.options); +}); + diff --git a/packages/accounts-oauth/oauth_tests.js b/packages/accounts-oauth/oauth_tests.js new file mode 100644 index 0000000000..8f1493d0b7 --- /dev/null +++ b/packages/accounts-oauth/oauth_tests.js @@ -0,0 +1,2 @@ +// XXX Add a test to ensure that successful logins call Accounts.updateOrCreateUserFromExternalService +// XXX Add a test to ensure that a missing or failed loginResult is handled correctly diff --git a/packages/accounts-oauth-helper/package.js b/packages/accounts-oauth/package.js similarity index 82% rename from packages/accounts-oauth-helper/package.js rename to packages/accounts-oauth/package.js index b86b2f4ddf..8a65e59c94 100644 --- a/packages/accounts-oauth-helper/package.js +++ b/packages/accounts-oauth/package.js @@ -10,8 +10,14 @@ Package.on_use(function (api) { api.use('webapp', 'server'); api.use('accounts-base', ['client', 'server']); api.use('routepolicy', 'server'); + api.use('oauth', 'server'); api.add_files('oauth_common.js', ['client', 'server']); api.add_files('oauth_client.js', 'client'); api.add_files('oauth_server.js', 'server'); }); + + +Package.on_test(function (api) { + api.add_files("oauth_tests.js", 'server'); +}); diff --git a/packages/accounts-oauth1-helper/oauth1_common.js b/packages/accounts-oauth1-helper/oauth1_common.js deleted file mode 100644 index d4ce446298..0000000000 --- a/packages/accounts-oauth1-helper/oauth1_common.js +++ /dev/null @@ -1 +0,0 @@ -Accounts.oauth1 = {}; \ No newline at end of file diff --git a/packages/accounts-oauth1-helper/oauth1_tests.js b/packages/accounts-oauth1-helper/oauth1_tests.js deleted file mode 100644 index ebcd4e0609..0000000000 --- a/packages/accounts-oauth1-helper/oauth1_tests.js +++ /dev/null @@ -1,140 +0,0 @@ - -Tinytest.add("oauth1 - loginResultForState is stored", function (test) { - var http = Npm.require('http'); - var twitterfooId = Random.id(); - var twitterfooName = 'nickname' + Random.id(); - var twitterfooAccessToken = Random.id(); - var twitterfooAccessTokenSecret = Random.id(); - var state = Random.id(); - var serviceName = Random.id(); - - OAuth1Binding.prototype.prepareRequestToken = function() {}; - OAuth1Binding.prototype.prepareAccessToken = function() { - this.accessToken = twitterfooAccessToken; - this.accessTokenSecret = twitterfooAccessTokenSecret; - }; - - Accounts.loginServiceConfiguration.insert({service: serviceName}); - Accounts[serviceName] = {}; - - try { - // register a fake login service - Accounts.oauth.registerService(serviceName, 1, function (query) { - return { - serviceData: { - id: twitterfooId, - screenName: twitterfooName, - accessToken: twitterfooAccessToken, - accessTokenSecret: twitterfooAccessTokenSecret - } - }; - }); - - // simulate logging in using twitterfoo - Accounts.oauth1._requestTokens[state] = twitterfooAccessToken; - - var req = { - method: "POST", - url: "/_oauth/" + serviceName + "?close", - query: { - state: state, - oauth_token: twitterfooAccessToken - } - }; - Accounts.oauth._middleware(req, new http.ServerResponse(req)); - - // verify that a user is created - var selector = {}; - selector["services." + serviceName + ".screenName"] = twitterfooName; - var user = Meteor.users.findOne(selector); - test.notEqual(user, undefined); - test.equal(user.services[serviceName].accessToken, - twitterfooAccessToken); - test.equal(user.services[serviceName].accessTokenSecret, - twitterfooAccessTokenSecret); - - // and that that user has a login token - test.equal(user.services.resume.loginTokens.length, 1); - var token = user.services.resume.loginTokens[0].token; - test.notEqual(token, undefined); - - // and that the login result for that user is prepared - test.equal( - Accounts.oauth._loginResultForState[state].id, user._id); - test.equal( - Accounts.oauth._loginResultForState[state].token, token); - } finally { - Accounts.oauth._unregisterService(serviceName); - } -}); - - -Tinytest.add("oauth1 - error in user creation", function (test) { - var http = Npm.require('http'); - var state = Random.id(); - var twitterfailId = Random.id(); - var twitterfailName = 'nickname' + Random.id(); - var twitterfailAccessToken = Random.id(); - var twitterfailAccessTokenSecret = Random.id(); - var serviceName = Random.id(); - - Accounts.loginServiceConfiguration.insert({service: serviceName}); - Accounts[serviceName] = {}; - - // Wire up access token so that verification passes - Accounts.oauth1._requestTokens[state] = twitterfailAccessToken; - - try { - // register a failing login service - Accounts.oauth.registerService(serviceName, 1, function (query) { - return { - serviceData: { - id: twitterfailId, - screenName: twitterfailName, - accessToken: twitterfailAccessToken, - accessTokenSecret: twitterfailAccessTokenSecret - }, - options: { - profile: {invalid: true} - } - }; - }); - - // a way to fail new users. duplicated from passwords_tests, but - // shouldn't hurt. - Accounts.validateNewUser(function (user) { - return !(user.profile && user.profile.invalid); - }); - - // simulate logging in with failure - Meteor._suppress_log(1); - var req = { - method: "POST", - url: "/_oauth/" + serviceName + "?close", - query: { - state: state, - oauth_token: twitterfailAccessToken - } - }; - - Accounts.oauth._middleware(req, new http.ServerResponse(req)); - - // verify that a user is not created - var selector = {}; - selector["services." + serviceName + ".screenName"] = twitterfailName; - var user = Meteor.users.findOne(selector); - test.equal(user, undefined); - - // verify an error is stored in login state - test.equal(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}}]); - }); - } finally { - Accounts.oauth._unregisterService(serviceName); - } -}); - - diff --git a/packages/accounts-oauth2-helper/oauth2_common.js b/packages/accounts-oauth2-helper/oauth2_common.js deleted file mode 100644 index 0012a34cee..0000000000 --- a/packages/accounts-oauth2-helper/oauth2_common.js +++ /dev/null @@ -1 +0,0 @@ -Accounts.oauth2 = {}; \ No newline at end of file diff --git a/packages/accounts-oauth2-helper/oauth2_tests.js b/packages/accounts-oauth2-helper/oauth2_tests.js deleted file mode 100644 index aae5b87cab..0000000000 --- a/packages/accounts-oauth2-helper/oauth2_tests.js +++ /dev/null @@ -1,98 +0,0 @@ -Tinytest.add("oauth2 - loginResultForState is stored", function (test) { - var http = Npm.require('http'); - var foobookId = Random.id(); - var state = Random.id(); - var serviceName = Random.id(); - - Accounts.loginServiceConfiguration.insert({service: serviceName}); - Accounts[serviceName] = {}; - - try { - // register a fake login service - Accounts.oauth.registerService(serviceName, 2, function (query) { - return {serviceData: {id: foobookId}}; - }); - - // simulate logging in using foobook - var req = {method: "POST", - url: "/_oauth/" + serviceName + "?close", - query: {state: state}}; - Accounts.oauth._middleware(req, new http.ServerResponse(req)); - - // verify that a user is created - var selector = {}; - selector["services." + serviceName + ".id"] = foobookId; - var user = Meteor.users.findOne(selector); - test.notEqual(user, undefined); - test.equal(user.services[serviceName].id, foobookId); - - // and that that user has a login token - test.equal(user.services.resume.loginTokens.length, 1); - var token = user.services.resume.loginTokens[0].token; - test.notEqual(token, undefined); - - // and that the login result for that user is prepared - test.equal( - Accounts.oauth._loginResultForState[state].id, user._id); - test.equal( - Accounts.oauth._loginResultForState[state].token, token); - } finally { - Accounts.oauth._unregisterService(serviceName); - } -}); - - -Tinytest.add("oauth2 - error in user creation", function (test) { - var http = Npm.require('http'); - var state = Random.id(); - var failbookId = Random.id(); - var serviceName = Random.id(); - - Accounts.loginServiceConfiguration.insert({service: serviceName}); - Accounts[serviceName] = {}; - - try { - // register a failing login service - Accounts.oauth.registerService(serviceName, 2, function (query) { - return { - serviceData: { - id: failbookId - }, - options: { - profile: {invalid: true} - } - }; - }); - - // a way to fail new users. duplicated from passwords_tests, but - // shouldn't hurt. - Accounts.validateNewUser(function (user) { - return !(user.profile && user.profile.invalid); - }); - - // simulate logging in with failure - Meteor._suppress_log(1); - var req = {method: "POST", - url: "/_oauth/" + serviceName + "?close", - query: {state: state}}; - Accounts.oauth._middleware(req, new http.ServerResponse(req)); - - // verify that a user is not created - var selector = {}; - selector["services." + serviceName + ".id"] = failbookId; - var user = Meteor.users.findOne(selector); - test.equal(user, undefined); - - // verify an error is stored in login state - test.equal(Accounts.oauth._loginResultForState[state].error, 403); - - // verify error is handed back to login method. - test.throws(function () { - Meteor.apply('login', [{oauth: {version: 2, state: state}}]); - }); - } finally { - Accounts.oauth._unregisterService(serviceName); - } -}); - - diff --git a/packages/accounts-twitter/package.js b/packages/accounts-twitter/package.js index 1b40701e71..efcd66a53a 100644 --- a/packages/accounts-twitter/package.js +++ b/packages/accounts-twitter/package.js @@ -5,13 +5,13 @@ Package.describe({ Package.on_use(function(api) { api.use('underscore', ['server']); api.use('accounts-base', ['client', 'server']); - api.use('accounts-oauth1-helper', ['client', 'server']); + api.use('accounts-oauth', ['client', 'server']); + api.use('twitter', ['client', 'server']); + api.use('http', ['client', 'server']); api.use('templating', 'client'); - api.add_files( - ['twitter_login_button.css', 'twitter_configure.html', 'twitter_configure.js'], - 'client'); + api.add_files('twitter_login_button.css', 'client'); api.add_files('twitter_common.js', ['client', 'server']); api.add_files('twitter_server.js', 'server'); diff --git a/packages/accounts-twitter/twitter_client.js b/packages/accounts-twitter/twitter_client.js index 8c67c753d4..887d4ad510 100644 --- a/packages/accounts-twitter/twitter_client.js +++ b/packages/accounts-twitter/twitter_client.js @@ -1,31 +1,4 @@ -// XXX support options.requestPermissions as we do for Facebook, Google, Github -Meteor.loginWithTwitter = function (options, callback) { - // support both (options, callback) and (callback). - if (!callback && typeof options === 'function') { - callback = options; - options = {}; - } - - var config = Accounts.loginServiceConfiguration.findOne({service: 'twitter'}); - if (!config) { - callback && callback(new Accounts.ConfigError("Service not configured")); - return; - } - - var state = Random.id(); - // 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.absoluteUrl('_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; - - Accounts.oauth.initiateLogin(state, url, callback); -}; +Meteor.loginWithTwitter = function(options, callback) { + var credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback); + Twitter.requestCredential(options, credentialRequestCompleteCallback); +}; \ No newline at end of file diff --git a/packages/accounts-twitter/twitter_common.js b/packages/accounts-twitter/twitter_common.js index 3fdcd9d2bc..b1428d3bf4 100644 --- a/packages/accounts-twitter/twitter_common.js +++ b/packages/accounts-twitter/twitter_common.js @@ -1,10 +1,3 @@ if (!Accounts.twitter) { Accounts.twitter = {}; } - -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 index 0915fd3b3e..fea172a744 100644 --- a/packages/accounts-twitter/twitter_server.js +++ b/packages/accounts-twitter/twitter_server.js @@ -1,36 +1,11 @@ -// https://dev.twitter.com/docs/api/1.1/get/account/verify_credentials -var whitelisted = ['profile_image_url', 'profile_image_url_https', 'lang']; +Accounts.oauth.registerService('twitter'); var autopublishedFields = _.map( // don't send access token. https://dev.twitter.com/discussions/5025 - whitelisted.concat(['id', 'screenName']), + Twitter.whitelistedFields.concat(['id', 'screenName']), function (subfield) { return 'services.twitter.' + subfield; }); Accounts.addAutopublishFields({ forLoggedInUser: autopublishedFields, forOtherUsers: autopublishedFields }); - -Accounts.oauth.registerService('twitter', 1, function(oauthBinding) { - var identity = oauthBinding.get('https://api.twitter.com/1.1/account/verify_credentials.json').data; - - var serviceData = { - id: identity.id_str, - screenName: identity.screen_name, - accessToken: oauthBinding.accessToken, - accessTokenSecret: oauthBinding.accessTokenSecret - }; - - // include helpful fields from twitter - var fields = _.pick(identity, whitelisted); - _.extend(serviceData, fields); - - return { - serviceData: serviceData, - options: { - profile: { - name: identity.name - } - } - }; -}); diff --git a/packages/accounts-ui-unstyled/login_buttons_single.js b/packages/accounts-ui-unstyled/login_buttons_single.js index 511dd61be9..b1f86b647f 100644 --- a/packages/accounts-ui-unstyled/login_buttons_single.js +++ b/packages/accounts-ui-unstyled/login_buttons_single.js @@ -10,7 +10,7 @@ Template._loginButtonsLoggedOutSingleLoginButton.events({ loginButtonsSession.closeDropdown(); } else if (err instanceof Accounts.LoginCancelledError) { // do nothing - } else if (err instanceof Accounts.ConfigError) { + } else if (err instanceof ServiceConfiguration.ConfigError) { loginButtonsSession.configureService(serviceName); } else { loginButtonsSession.errorMessage(err.reason || "Unknown error"); @@ -30,7 +30,7 @@ Template._loginButtonsLoggedOutSingleLoginButton.events({ }); Template._loginButtonsLoggedOutSingleLoginButton.configured = function () { - return !!Accounts.loginServiceConfiguration.findOne({service: this.name}); + return !!ServiceConfiguration.configurations.findOne({service: this.name}); }; Template._loginButtonsLoggedOutSingleLoginButton.capitalizedName = function () { diff --git a/packages/accounts-weibo/package.js b/packages/accounts-weibo/package.js index ddb17ea9f7..2d88b4294e 100644 --- a/packages/accounts-weibo/package.js +++ b/packages/accounts-weibo/package.js @@ -4,13 +4,10 @@ Package.describe({ Package.on_use(function(api) { api.use('accounts-base', ['client', 'server']); - api.use('accounts-oauth2-helper', ['client', 'server']); - api.use('http', ['client', 'server']); - api.use('templating', 'client'); + api.use('accounts-oauth', ['client', 'server']); + api.use('weibo', ['client', 'server']); - api.add_files( - ['weibo_login_button.css', 'weibo_configure.html', 'weibo_configure.js'], - 'client'); + api.add_files('weibo_login_button.css', 'client'); api.add_files('weibo_common.js', ['client', 'server']); api.add_files('weibo_server.js', 'server'); diff --git a/packages/accounts-weibo/weibo_client.js b/packages/accounts-weibo/weibo_client.js index e76c94c0e6..644c0176e6 100644 --- a/packages/accounts-weibo/weibo_client.js +++ b/packages/accounts-weibo/weibo_client.js @@ -1,25 +1,4 @@ -// XXX support options.requestPermissions as we do for Facebook, Google, Github -Meteor.loginWithWeibo = function (options, callback) { - // support both (options, callback) and (callback). - if (!callback && typeof options === 'function') { - callback = options; - options = {}; - } - - var config = Accounts.loginServiceConfiguration.findOne({service: 'weibo'}); - if (!config) { - callback && callback(new Accounts.ConfigError("Service not configured")); - return; - } - - var state = Random.id(); - // XXX need to support configuring access_type and scope - var loginUrl = - 'https://api.weibo.com/oauth2/authorize' + - '?response_type=code' + - '&client_id=' + config.clientId + - '&redirect_uri=' + Meteor.absoluteUrl('_oauth/weibo?close', {replaceLocalhost: true}) + - '&state=' + state; - - Accounts.oauth.initiateLogin(state, loginUrl, callback); -}; +Meteor.loginWithWeibo = function(options, callback) { + var credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback); + Weibo.requestCredential(options, credentialRequestCompleteCallback); +}; \ No newline at end of file diff --git a/packages/accounts-weibo/weibo_server.js b/packages/accounts-weibo/weibo_server.js index 5ef6a39d42..18a3a12ae7 100644 --- a/packages/accounts-weibo/weibo_server.js +++ b/packages/accounts-weibo/weibo_server.js @@ -1,3 +1,5 @@ +Accounts.oauth.registerService('weibo'); + Accounts.addAutopublishFields({ // publish all fields including access token, which can legitimately // be used from the client (if transmitted over ssl or on localhost) @@ -5,72 +7,3 @@ Accounts.addAutopublishFields({ forOtherUsers: ['services.weibo.screenName'] }); -Accounts.oauth.registerService('weibo', 2, function(query) { - - var response = getTokenResponse(query); - var uid = parseInt(response.uid, 10); - - // different parts of weibo's api seem to expect numbers, or strings - // for uid. let's make sure they're both the same. - if (response.uid !== uid + "") - throw new Error("Expected 'uid' to parse to an integer: " + JSON.stringify(response)); - - var identity = getIdentity(response.access_token, uid); - - return { - serviceData: { - // We used to store this as a string, so keep it this way rather than - // add complexity to Account.updateOrCreateUserFromExternalService or - // force a database migration - id: uid + "", - accessToken: response.access_token, - screenName: identity.screen_name, - expiresAt: (+new Date) + (1000 * response.expires_in) - }, - options: {profile: {name: identity.screen_name}} - }; -}); - -// return an object containining: -// - uid -// - access_token -// - expires_in: lifetime of this token in seconds (5 years(!) right now) -var getTokenResponse = function (query) { - var config = Accounts.loginServiceConfiguration.findOne({service: 'weibo'}); - if (!config) - throw new Accounts.ConfigError("Service not configured"); - - var response; - try { - response = Meteor.http.post( - "https://api.weibo.com/oauth2/access_token", {params: { - code: query.code, - client_id: config.clientId, - client_secret: config.secret, - redirect_uri: Meteor.absoluteUrl("_oauth/weibo?close", {replaceLocalhost: true}), - grant_type: 'authorization_code' - }}); - } catch (err) { - throw new Error("Failed to complete OAuth handshake with Weibo. " + err.message); - } - - // result.headers["content-type"] is 'text/plain;charset=UTF-8', so - // the http package doesn't automatically populate result.data - response.data = JSON.parse(response.content); - - if (response.data.error) { // if the http response was a json object with an error attribute - throw new Error("Failed to complete OAuth handshake with Weibo. " + response.data.error); - } else { - return response.data; - } -}; - -var getIdentity = function (accessToken, userId) { - try { - return Meteor.http.get( - "https://api.weibo.com/2/users/show.json", - {params: {access_token: accessToken, uid: userId}}).data; - } catch (err) { - throw new Error("Failed to fetch identity from Weibo. " + err.message); - } -}; diff --git a/packages/check/match.js b/packages/check/match.js index f750e5a1ed..bdad10373a 100644 --- a/packages/check/match.js +++ b/packages/check/match.js @@ -113,8 +113,7 @@ var checkSubtree = function (value, pattern) { return; // Basic atomic types. - // XXX do we have to worry about if value is boxed (eg String)? will that - // happen? + // Do not match boxed objects (e.g. String, Boolean) for (var i = 0; i < typeofChecks.length; ++i) { if (pattern === typeofChecks[i][0]) { if (typeof value === typeofChecks[i][1]) diff --git a/packages/check/match_test.js b/packages/check/match_test.js index e22ff9405c..1253167883 100644 --- a/packages/check/match_test.js +++ b/packages/check/match_test.js @@ -75,6 +75,9 @@ Tinytest.add("check - check", function (test) { }); }); fails(true, Match.OneOf(String, Number, undefined, null, [Boolean])); + fails(new String("foo"), String); + fails(new Boolean(true), Boolean); + fails(new Number(123), Number); matches([1, 2, 3], [Number]); matches([], [Number]); diff --git a/packages/coffeescript/plugin/compile-coffeescript.js b/packages/coffeescript/plugin/compile-coffeescript.js index c2f72626fb..fdfa3b8a26 100644 --- a/packages/coffeescript/plugin/compile-coffeescript.js +++ b/packages/coffeescript/plugin/compile-coffeescript.js @@ -14,7 +14,11 @@ var handler = function (compileStep) { var output = coffee.compile(source, options); } catch (e) { // XXX better error handling, once the Plugin interface support it - throw new Error(e.message); + throw new Error( + compileStep.inputPath + ':' + + (e.location ? (e.location.first_line + ': ') : ' ') + + e.message + ); } compileStep.addJavaScript({ diff --git a/packages/ejson/ejson.js b/packages/ejson/ejson.js index 06d557c953..4b4a53cb12 100644 --- a/packages/ejson/ejson.js +++ b/packages/ejson/ejson.js @@ -213,8 +213,8 @@ EJSON.parse = function (item) { }; EJSON.isBinary = function (obj) { - return (typeof Uint8Array !== 'undefined' && obj instanceof Uint8Array) || - (obj && obj.$Uint8ArrayPolyfill); + return !!((typeof Uint8Array !== 'undefined' && obj instanceof Uint8Array) || + (obj && obj.$Uint8ArrayPolyfill)); }; EJSON.equals = function (a, b, options) { @@ -298,14 +298,18 @@ EJSON.clone = function (v) { return new Date(v.getTime()); if (EJSON.isBinary(v)) { ret = EJSON.newBinary(v.length); - for (i = 0; i < v.length; i++) { + for (var i = 0; i < v.length; i++) { ret[i] = v[i]; } return ret; } - // Clone arrays (and turn 'arguments' into an array). if (_.isArray(v) || _.isArguments(v)) { - return _.map(v, EJSON.clone); + // For some reason, _.map doesn't work in this context on Opera (weird test + // failures). + ret = []; + for (i = 0; i < v.length; i++) + ret[i] = EJSON.clone(v[i]); + return ret; } // handle general user-defined typed Objects if they have a clone method if (typeof v.clone === 'function') { diff --git a/packages/facebook/facebook_client.js b/packages/facebook/facebook_client.js new file mode 100644 index 0000000000..d9c2511cde --- /dev/null +++ b/packages/facebook/facebook_client.js @@ -0,0 +1,33 @@ +// Request Facebook credentials for the user +// @param options {optional} +// @param credentialRequestCompleteCallback {Function} Callback function to call on +// completion. Takes one argument, credentialToken on success, or Error on +// error. +Facebook.requestCredential = function (options, credentialRequestCompleteCallback) { + // support both (options, callback) and (callback). + if (!credentialRequestCompleteCallback && typeof options === 'function') { + credentialRequestCompleteCallback = options; + options = {}; + } + + var config = ServiceConfiguration.configurations.findOne({service: 'facebook'}); + if (!config) { + credentialRequestCompleteCallback && credentialRequestCompleteCallback(new ServiceConfiguration.ConfigError("Service not configured")); + return; + } + + var credentialToken = Random.id(); + var mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent); + var display = mobile ? 'touch' : 'popup'; + + var scope = "email"; + if (options && options.requestPermissions) + scope = options.requestPermissions.join(','); + + var loginUrl = + 'https://www.facebook.com/dialog/oauth?client_id=' + config.appId + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/facebook?close') + + '&display=' + display + '&scope=' + scope + '&state=' + credentialToken; + + Oauth.initiateLogin(credentialToken, loginUrl, credentialRequestCompleteCallback); +}; diff --git a/packages/facebook/facebook_common.js b/packages/facebook/facebook_common.js new file mode 100644 index 0000000000..e3d7b26bcc --- /dev/null +++ b/packages/facebook/facebook_common.js @@ -0,0 +1,3 @@ +if (typeof Facebook === 'undefined') { + Facebook = {}; +} diff --git a/packages/accounts-facebook/facebook_configure.html b/packages/facebook/facebook_configure.html similarity index 100% rename from packages/accounts-facebook/facebook_configure.html rename to packages/facebook/facebook_configure.html diff --git a/packages/accounts-facebook/facebook_configure.js b/packages/facebook/facebook_configure.js similarity index 100% rename from packages/accounts-facebook/facebook_configure.js rename to packages/facebook/facebook_configure.js diff --git a/packages/facebook/facebook_server.js b/packages/facebook/facebook_server.js new file mode 100644 index 0000000000..59ee6cc729 --- /dev/null +++ b/packages/facebook/facebook_server.js @@ -0,0 +1,96 @@ +var querystring = Npm.require('querystring'); + + +Oauth.registerService('facebook', 2, null, function(query) { + + var response = getTokenResponse(query); + var accessToken = response.accessToken; + var identity = getIdentity(accessToken); + + var serviceData = { + accessToken: accessToken, + expiresAt: (+new Date) + (1000 * response.expiresIn) + }; + + // include all fields from facebook + // http://developers.facebook.com/docs/reference/login/public-profile-and-friend-list/ + var whitelisted = ['id', 'email', 'name', 'first_name', + 'last_name', 'link', 'username', 'gender', 'locale', 'age_range']; + + var fields = _.pick(identity, whitelisted); + _.extend(serviceData, fields); + + return { + serviceData: serviceData, + options: {profile: {name: identity.name}} + }; +}); + +// checks whether a string parses as JSON +var isJSON = function (str) { + try { + JSON.parse(str); + return true; + } catch (e) { + return false; + } +}; + +// returns an object containing: +// - accessToken +// - expiresIn: lifetime of token in seconds +var getTokenResponse = function (query) { + var config = ServiceConfiguration.configurations.findOne({service: 'facebook'}); + if (!config) + throw new ServiceConfiguration.ConfigError("Service not configured"); + + var responseContent; + try { + // Request an access token + responseContent = Meteor.http.get( + "https://graph.facebook.com/oauth/access_token", { + params: { + client_id: config.appId, + redirect_uri: Meteor.absoluteUrl("_oauth/facebook?close"), + client_secret: config.secret, + code: query.code + } + }).content; + } catch (err) { + throw new Error("Failed to complete OAuth handshake with Facebook. " + err.message); + } + + // If 'responseContent' parses as JSON, it is an error. + // XXX which facebook error causes this behvaior? + if (isJSON(responseContent)) { + throw new Error("Failed to complete OAuth handshake with Facebook. " + responseContent); + } + + // Success! Extract the facebook access token and expiration + // time from the response + var parsedResponse = querystring.parse(responseContent); + var fbAccessToken = parsedResponse.access_token; + var fbExpires = parsedResponse.expires; + + if (!fbAccessToken) { + throw new Error("Failed to complete OAuth handshake with facebook " + + "-- can't find access token in HTTP response. " + responseContent); + } + return { + accessToken: fbAccessToken, + expiresIn: fbExpires + }; +}; + +var getIdentity = function (accessToken) { + try { + return Meteor.http.get("https://graph.facebook.com/me", { + params: {access_token: accessToken}}).data; + } catch (err) { + throw new Error("Failed to fetch identity from Facebook. " + err.message); + } +}; + +Facebook.retrieveCredential = function(credentialToken) { + return Oauth.retrieveCredential(credentialToken); +}; diff --git a/packages/facebook/package.js b/packages/facebook/package.js new file mode 100644 index 0000000000..f787120c99 --- /dev/null +++ b/packages/facebook/package.js @@ -0,0 +1,20 @@ +Package.describe({ + summary: "Facebook OAuth flow", + // internal for now. Should be external when it has a richer API to do + // actual API things with the service, not just handle the OAuth flow. + internal: true +}); + +Package.on_use(function(api) { + api.use('oauth2', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['facebook_configure.html', 'facebook_configure.js'], + 'client'); + + 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/github/github_client.js b/packages/github/github_client.js new file mode 100644 index 0000000000..8a74721272 --- /dev/null +++ b/packages/github/github_client.js @@ -0,0 +1,32 @@ +// Request Github credentials for the user +// @param options {optional} +// @param credentialRequestCompleteCallback {Function} Callback function to call on +// completion. Takes one argument, credentialToken on success, or Error on +// error. +Github.requestCredential = function (options, credentialRequestCompleteCallback) { + // support both (options, callback) and (callback). + if (!credentialRequestCompleteCallback && typeof options === 'function') { + credentialRequestCompleteCallback = options; + options = {}; + } + + var config = ServiceConfiguration.configurations.findOne({service: 'github'}); + if (!config) { + credentialRequestCompleteCallback && credentialRequestCompleteCallback(new ServiceConfiguration.ConfigError("Service not configured")); + return; + } + var credentialToken = Random.id(); + + var scope = (options && options.requestPermissions) || []; + var flatScope = _.map(scope, encodeURIComponent).join('+'); + + var loginUrl = + 'https://github.com/login/oauth/authorize' + + '?client_id=' + config.clientId + + '&scope=' + flatScope + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/github?close') + + '&state=' + credentialToken; + + Oauth.initiateLogin(credentialToken, loginUrl, credentialRequestCompleteCallback, + {width: 900, height: 450}); +}; diff --git a/packages/github/github_common.js b/packages/github/github_common.js new file mode 100644 index 0000000000..b01a5bec8a --- /dev/null +++ b/packages/github/github_common.js @@ -0,0 +1,3 @@ +if (typeof Github === 'undefined') { + Github = {}; +} diff --git a/packages/accounts-github/github_configure.html b/packages/github/github_configure.html similarity index 100% rename from packages/accounts-github/github_configure.html rename to packages/github/github_configure.html diff --git a/packages/accounts-github/github_configure.js b/packages/github/github_configure.js similarity index 100% rename from packages/accounts-github/github_configure.js rename to packages/github/github_configure.js diff --git a/packages/github/github_server.js b/packages/github/github_server.js new file mode 100644 index 0000000000..00841cedf8 --- /dev/null +++ b/packages/github/github_server.js @@ -0,0 +1,67 @@ +Oauth.registerService('github', 2, null, function(query) { + + var accessToken = getAccessToken(query); + var identity = getIdentity(accessToken); + + return { + serviceData: { + id: identity.id, + accessToken: accessToken, + email: identity.email, + username: identity.login + }, + options: {profile: {name: identity.name}} + }; +}); + +// http://developer.github.com/v3/#user-agent-required +var userAgent = "Meteor"; +if (Meteor.release) + userAgent += "/" + Meteor.release; + +var getAccessToken = function (query) { + var config = ServiceConfiguration.configurations.findOne({service: 'github'}); + if (!config) + throw new ServiceConfiguration.ConfigError("Service not configured"); + + var response; + try { + response = Meteor.http.post( + "https://github.com/login/oauth/access_token", { + headers: { + Accept: 'application/json', + "User-Agent": userAgent + }, + params: { + code: query.code, + client_id: config.clientId, + client_secret: config.secret, + redirect_uri: Meteor.absoluteUrl("_oauth/github?close"), + state: query.state + } + }); + } catch (err) { + throw new Error("Failed to complete OAuth handshake with Github. " + err.message); + } + if (response.data.error) { // if the http response was a json object with an error attribute + throw new Error("Failed to complete OAuth handshake with GitHub. " + response.data.error); + } else { + return response.data.access_token; + } +}; + +var getIdentity = function (accessToken) { + try { + return Meteor.http.get( + "https://api.github.com/user", { + headers: {"User-Agent": userAgent}, // http://developer.github.com/v3/#user-agent-required + params: {access_token: accessToken} + }).data; + } catch (err) { + throw new Error("Failed to fetch identity from GitHub. " + err.message); + } +}; + +Github.retrieveCredential = function(credentialToken) { + return Oauth.retrieveCredential(credentialToken); +}; diff --git a/packages/github/package.js b/packages/github/package.js new file mode 100644 index 0000000000..e730a702ff --- /dev/null +++ b/packages/github/package.js @@ -0,0 +1,20 @@ +Package.describe({ + summary: "Github OAuth flow", + // internal for now. Should be external when it has a richer API to do + // actual API things with the service, not just handle the OAuth flow. + internal: true +}); + +Package.on_use(function(api) { + api.use('oauth2', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['github_configure.html', 'github_configure.js'], + 'client'); + + api.add_files('github_common.js', ['client', 'server']); + api.add_files('github_server.js', 'server'); + api.add_files('github_client.js', 'client'); +}); diff --git a/packages/google/google_client.js b/packages/google/google_client.js new file mode 100644 index 0000000000..83b8c189cd --- /dev/null +++ b/packages/google/google_client.js @@ -0,0 +1,44 @@ +// Request Google credentials for the user +// @param options {optional} +// @param credentialRequestCompleteCallback {Function} Callback function to call on +// completion. Takes one argument, credentialToken on success, or Error on +// error. +Google.requestCredential = function (options, credentialRequestCompleteCallback) { + // support both (options, callback) and (callback). + if (!credentialRequestCompleteCallback && typeof options === 'function') { + credentialRequestCompleteCallback = options; + options = {}; + } else if (!options) { + options = {}; + } + + var config = ServiceConfiguration.configurations.findOne({service: 'google'}); + if (!config) { + credentialRequestCompleteCallback && credentialRequestCompleteCallback(new ServiceConfiguration.ConfigError("Service not configured")); + return; + } + + var credentialToken = Random.id(); + + // always need this to get user id from google. + var requiredScope = ['https://www.googleapis.com/auth/userinfo.profile']; + var scope = ['https://www.googleapis.com/auth/userinfo.email']; + if (options.requestPermissions) + scope = options.requestPermissions; + scope = _.union(scope, requiredScope); + var flatScope = _.map(scope, encodeURIComponent).join('+'); + + // https://developers.google.com/accounts/docs/OAuth2WebServer#formingtheurl + var accessType = options.requestOfflineToken ? 'offline' : 'online'; + + var loginUrl = + 'https://accounts.google.com/o/oauth2/auth' + + '?response_type=code' + + '&client_id=' + config.clientId + + '&scope=' + flatScope + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/google?close') + + '&state=' + credentialToken + + '&access_type=' + accessType; + + Oauth.initiateLogin(credentialToken, loginUrl, credentialRequestCompleteCallback); +}; diff --git a/packages/google/google_common.js b/packages/google/google_common.js new file mode 100644 index 0000000000..167abf1779 --- /dev/null +++ b/packages/google/google_common.js @@ -0,0 +1,3 @@ +if (typeof Google === 'undefined') { + Google = {}; +} diff --git a/packages/accounts-google/google_configure.html b/packages/google/google_configure.html similarity index 100% rename from packages/accounts-google/google_configure.html rename to packages/google/google_configure.html diff --git a/packages/accounts-google/google_configure.js b/packages/google/google_configure.js similarity index 100% rename from packages/accounts-google/google_configure.js rename to packages/google/google_configure.js diff --git a/packages/google/google_server.js b/packages/google/google_server.js new file mode 100644 index 0000000000..f6f21da9b4 --- /dev/null +++ b/packages/google/google_server.js @@ -0,0 +1,78 @@ +// https://developers.google.com/accounts/docs/OAuth2Login#userinfocall +Google.whitelistedFields = ['id', 'email', 'verified_email', 'name', 'given_name', + 'family_name', 'picture', 'locale', 'timezone', 'gender']; + + +Oauth.registerService('google', 2, null, function(query) { + + var response = getTokens(query); + var accessToken = response.accessToken; + var identity = getIdentity(accessToken); + + var serviceData = { + accessToken: accessToken, + expiresAt: (+new Date) + (1000 * response.expiresIn) + }; + + var fields = _.pick(identity, Google.whitelistedFields); + _.extend(serviceData, fields); + + // only set the token in serviceData if it's there. this ensures + // that we don't lose old ones (since we only get this on the first + // log in attempt) + if (response.refreshToken) + serviceData.refreshToken = response.refreshToken; + + return { + serviceData: serviceData, + options: {profile: {name: identity.name}} + }; +}); + +// returns an object containing: +// - accessToken +// - expiresIn: lifetime of token in seconds +// - refreshToken, if this is the first authorization request +var getTokens = function (query) { + var config = ServiceConfiguration.configurations.findOne({service: 'google'}); + if (!config) + throw new ServiceConfiguration.ConfigError("Service not configured"); + + var response; + try { + response = Meteor.http.post( + "https://accounts.google.com/o/oauth2/token", {params: { + code: query.code, + client_id: config.clientId, + client_secret: config.secret, + redirect_uri: Meteor.absoluteUrl("_oauth/google?close"), + grant_type: 'authorization_code' + }}); + } catch (err) { + throw new Error("Failed to complete OAuth handshake with Google. " + err.message); + } + + if (response.data.error) { // if the http response was a json object with an error attribute + throw new Error("Failed to complete OAuth handshake with Google. " + response.data.error); + } else { + return { + accessToken: response.data.access_token, + refreshToken: response.data.refresh_token, + expiresIn: response.data.expires_in + }; + } +}; + +var getIdentity = function (accessToken) { + try { + return Meteor.http.get( + "https://www.googleapis.com/oauth2/v1/userinfo", + {params: {access_token: accessToken}}).data; + } catch (err) { + throw new Error("Failed to fetch identity from Google. " + err.message); + } +}; + +Google.retrieveCredential = function(credentialToken) { + return Oauth.retrieveCredential(credentialToken); +}; \ No newline at end of file diff --git a/packages/google/package.js b/packages/google/package.js new file mode 100644 index 0000000000..2070e6a8c1 --- /dev/null +++ b/packages/google/package.js @@ -0,0 +1,20 @@ +Package.describe({ + summary: "Google OAuth flow", + // internal for now. Should be external when it has a richer API to do + // actual API things with the service, not just handle the OAuth flow. + internal: true +}); + +Package.on_use(function(api) { + api.use('oauth2', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['google_configure.html', 'google_configure.js'], + 'client'); + + 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/meetup/meetup_client.js b/packages/meetup/meetup_client.js new file mode 100644 index 0000000000..1f8cbc4ee0 --- /dev/null +++ b/packages/meetup/meetup_client.js @@ -0,0 +1,38 @@ +// Request Meetup credentials for the user +// @param options {optional} +// @param credentialRequestCompleteCallback {Function} Callback function to call on +// completion. Takes one argument, credentialToken on success, or Error on +// error. +Meetup.requestCredential = function (options, credentialRequestCompleteCallback) { + // support both (options, callback) and (callback). + if (!credentialRequestCompleteCallback && typeof options === 'function') { + credentialRequestCompleteCallback = options; + options = {}; + } + + var config = ServiceConfiguration.configurations.findOne({service: 'meetup'}); + if (!config) { + credentialRequestCompleteCallback && credentialRequestCompleteCallback(new ServiceConfiguration.ConfigError("Service not configured")); + return; + } + var credentialToken = Random.id(); + + var scope = (options && options.requestPermissions) || []; + var flatScope = _.map(scope, encodeURIComponent).join('+'); + + var loginUrl = + 'https://secure.meetup.com/oauth2/authorize' + + '?client_id=' + config.clientId + + '&response_type=code' + + '&scope=' + flatScope + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/meetup?close') + + '&state=' + credentialToken; + + // meetup box gets taller when permissions requested. + var height = 620; + if (_.without(scope, 'basic').length) + height += 130; + + Oauth.initiateLogin(credentialToken, loginUrl, credentialRequestCompleteCallback, + {width: 900, height: height}); +}; diff --git a/packages/meetup/meetup_common.js b/packages/meetup/meetup_common.js new file mode 100644 index 0000000000..15e2594fe1 --- /dev/null +++ b/packages/meetup/meetup_common.js @@ -0,0 +1,3 @@ +if (typeof Meetup === 'undefined') { + Meetup = {}; +} diff --git a/packages/accounts-meetup/meetup_configure.html b/packages/meetup/meetup_configure.html similarity index 100% rename from packages/accounts-meetup/meetup_configure.html rename to packages/meetup/meetup_configure.html diff --git a/packages/accounts-meetup/meetup_configure.js b/packages/meetup/meetup_configure.js similarity index 100% rename from packages/accounts-meetup/meetup_configure.js rename to packages/meetup/meetup_configure.js diff --git a/packages/meetup/meetup_server.js b/packages/meetup/meetup_server.js new file mode 100644 index 0000000000..aa5890d9c7 --- /dev/null +++ b/packages/meetup/meetup_server.js @@ -0,0 +1,56 @@ +Oauth.registerService('meetup', 2, null, function(query) { + + var accessToken = getAccessToken(query); + var identity = getIdentity(accessToken); + + return { + serviceData: { + id: identity.id, + accessToken: accessToken + }, + options: {profile: {name: identity.name}} + }; +}); + +var getAccessToken = function (query) { + var config = ServiceConfiguration.configurations.findOne({service: 'meetup'}); + if (!config) + throw new ServiceConfiguration.ConfigError("Service not configured"); + + var response; + try { + response = Meteor.http.post( + "https://secure.meetup.com/oauth2/access", {headers: {Accept: 'application/json'}, params: { + code: query.code, + client_id: config.clientId, + client_secret: config.secret, + grant_type: 'authorization_code', + redirect_uri: Meteor.absoluteUrl("_oauth/meetup?close"), + state: query.state + }}); + } catch (err) { + throw new Error("Failed to complete OAuth handshake with Meetup. " + err.message); + } + + if (response.data.error) { // if the http response was a json object with an error attribute + throw new Error("Failed to complete OAuth handshake with Meetup. " + response.data.error); + } else { + return response.data.access_token; + } +}; + +var getIdentity = function (accessToken) { + try { + var response = Meteor.http.get( + "https://secure.meetup.com/2/members", + {params: {member_id: 'self', access_token: accessToken}}); + return response.data.results && response.data.results[0]; + } catch (err) { + throw new Error("Failed to fetch identity from Meetup: " + err.message); + } +}; + + +Meetup.retrieveCredential = function(credentialToken) { + return Oauth.retrieveCredential(credentialToken); +}; \ No newline at end of file diff --git a/packages/meetup/package.js b/packages/meetup/package.js new file mode 100644 index 0000000000..c6733133fb --- /dev/null +++ b/packages/meetup/package.js @@ -0,0 +1,20 @@ +Package.describe({ + summary: "Meetup OAuth flow", + // internal for now. Should be external when it has a richer API to do + // actual API things with the service, not just handle the OAuth flow. + internal: true +}); + +Package.on_use(function(api) { + api.use('oauth2', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['meetup_configure.html', 'meetup_configure.js'], + 'client'); + + api.add_files('meetup_common.js', ['client', 'server']); + api.add_files('meetup_server.js', 'server'); + api.add_files('meetup_client.js', 'client'); +}); diff --git a/packages/meteor/package.js b/packages/meteor/package.js index 1b58e70c77..5b98a457cf 100644 --- a/packages/meteor/package.js +++ b/packages/meteor/package.js @@ -16,6 +16,7 @@ Package.on_use(function (api, where) { api.add_files('client_environment.js', 'client'); api.add_files('server_environment.js', 'server'); api.add_files('helpers.js', ['client', 'server']); + api.add_files('setimmediate.js', ['client', 'server']); api.add_files('timers.js', ['client', 'server']); api.add_files('errors.js', ['client', 'server']); api.add_files('fiber_helpers.js', 'server'); @@ -45,4 +46,6 @@ Package.on_test(function (api) { api.add_files('fiber_helpers_test.js', ['server']); api.add_files('url_tests.js', ['client', 'server']); + + api.add_files('timers_tests.js', ['client', 'server']); }); diff --git a/packages/meteor/setimmediate.js b/packages/meteor/setimmediate.js new file mode 100644 index 0000000000..8cf75fbdfc --- /dev/null +++ b/packages/meteor/setimmediate.js @@ -0,0 +1,141 @@ +// Chooses one of three setImmediate implementations: +// +// * Native setImmediate (IE 10, Node 0.9+) +// +// * postMessage (many browsers) +// +// * setTimeout (fallback) +// +// The postMessage implementation is based on +// https://github.com/NobleJS/setImmediate/tree/1.0.1 +// +// Don't use `nextTick` for Node since it runs its callbacks before +// I/O, which is stricter than we're looking for. +// +// Not installed as a polyfill, as our public API is `Meteor.defer`. +// Since we're not trying to be a polyfill, we have some +// simplifications: +// +// If one invocation of a setImmediate callback pauses itself by a +// call to alert/prompt/showModelDialog, the NobleJS polyfill +// implementation ensured that no setImmedate callback would run until +// the first invocation completed. While correct per the spec, what it +// would mean for us in practice is that any reactive updates relying +// on Meteor.defer would be hung in the main window until the modal +// dialog was dismissed. Thus we only ensure that a setImmediate +// function is called in a later event loop. +// +// We don't need to support using a string to be eval'ed for the +// callback, arguments to the function, or clearImmediate. + +"use strict"; + +var global = this; + + +// IE 10, Node >= 9.1 + +function useSetImmediate() { + if (! global.setImmediate) + return null; + else { + var setImmediate = function (fn) { + global.setImmediate(fn); + }; + setImmediate.implementation = 'setImmediate'; + return setImmediate; + } +} + + +// Android 2.3.6, Chrome 26, Firefox 20, IE 8-9, iOS 5.1.1 Safari + +function usePostMessage() { + // The test against `importScripts` prevents this implementation + // from being installed inside a web worker, where + // `global.postMessage` means something completely different and + // can't be used for this purpose. + + if (!global.postMessage || global.importScripts) { + return null; + } + + // Avoid synchronous post message implementations. + + var postMessageIsAsynchronous = true; + var oldOnMessage = global.onmessage; + global.onmessage = function () { + postMessageIsAsynchronous = false; + }; + global.postMessage("", "*"); + global.onmessage = oldOnMessage; + + if (! postMessageIsAsynchronous) + return null; + + var funcIndex = 0; + var funcs = {}; + + // Installs an event handler on `global` for the `message` event: see + // * https://developer.mozilla.org/en/DOM/window.postMessage + // * http://www.whatwg.org/specs/web-apps/current-work/multipage/comms.html#crossDocumentMessages + + // XXX use Random.id() here? + var MESSAGE_PREFIX = "Meteor._setImmediate." + Math.random() + '.'; + + function isStringAndStartsWith(string, putativeStart) { + return (typeof string === "string" && + string.substring(0, putativeStart.length) === putativeStart); + } + + function onGlobalMessage(event) { + // This will catch all incoming messages (even from other + // windows!), so we need to try reasonably hard to avoid letting + // anyone else trick us into firing off. We test the origin is + // still this window, and that a (randomly generated) + // unpredictable identifying prefix is present. + if (event.source === global && + isStringAndStartsWith(event.data, MESSAGE_PREFIX)) { + var index = event.data.substring(MESSAGE_PREFIX.length); + try { + if (funcs[index]) + funcs[index](); + } + finally { + delete funcs[index]; + } + } + } + + if (global.addEventListener) { + global.addEventListener("message", onGlobalMessage, false); + } else { + global.attachEvent("onmessage", onGlobalMessage); + } + + var setImmediate = function (fn) { + // Make `global` post a message to itself with the handle and + // identifying prefix, thus asynchronously invoking our + // onGlobalMessage listener above. + ++funcIndex; + funcs[funcIndex] = fn; + global.postMessage(MESSAGE_PREFIX + funcIndex, "*"); + }; + setImmediate.implementation = 'postMessage'; + return setImmediate; +} + + +function useTimeout() { + var setImmediate = function (fn) { + global.setTimeout(fn, 0); + }; + setImmediate.implementation = 'setTimeout'; + return setImmediate; +} + + +Meteor._setImmediate = + useSetImmediate() || + usePostMessage() || + useTimeout(); diff --git a/packages/meteor/timers.js b/packages/meteor/timers.js index ac70858e23..698d2ab507 100644 --- a/packages/meteor/timers.js +++ b/packages/meteor/timers.js @@ -1,36 +1,31 @@ +var withoutInvocation = function (f) { + if (Meteor._CurrentInvocation) { + if (Meteor._CurrentInvocation.get() && Meteor._CurrentInvocation.get().isSimulation) + throw new Error("Can't set timers inside simulations"); + return function () { Meteor._CurrentInvocation.withValue(null, f); }; + } + else + return f; +}; + +var bindAndCatch = function (context, f) { + return Meteor.bindEnvironment(withoutInvocation(f), function (e) { + // XXX report nicely (or, should we catch it at all?) + Meteor._debug("Exception from " + context + ":", e); + }); +}; + _.extend(Meteor, { // Meteor.setTimeout and Meteor.setInterval callbacks scheduled // inside a server method are not part of the method invocation and // should clear out the CurrentInvocation environment variable. setTimeout: function (f, duration) { - if (Meteor._CurrentInvocation) { - if (Meteor._CurrentInvocation.get() && Meteor._CurrentInvocation.get().isSimulation) - throw new Error("Can't set timers inside simulations"); - - var f_with_ci = f; - f = function () { Meteor._CurrentInvocation.withValue(null, f_with_ci); }; - } - - return setTimeout(Meteor.bindEnvironment(f, function (e) { - // XXX report nicely (or, should we catch it at all?) - Meteor._debug("Exception from setTimeout callback:", e.stack); - }), duration); + return setTimeout(bindAndCatch("setTimeout callback", f), duration); }, setInterval: function (f, duration) { - if (Meteor._CurrentInvocation) { - if (Meteor._CurrentInvocation.get() && Meteor._CurrentInvocation.get().isSimulation) - throw new Error("Can't set timers inside simulations"); - - var f_with_ci = f; - f = function () { Meteor._CurrentInvocation.withValue(null, f_with_ci); }; - } - - return setInterval(Meteor.bindEnvironment(f, function (e) { - // XXX report nicely (or, should we catch it at all?) - Meteor._debug("Exception from setInterval callback:", e); - }), duration); + return setInterval(bindAndCatch("setInterval callback", f), duration); }, clearInterval: function(x) { @@ -41,16 +36,11 @@ _.extend(Meteor, { return clearTimeout(x); }, - // won't be necessary once we clobber the global setTimeout - // // XXX consider making this guarantee ordering of defer'd callbacks, like // Deps.afterFlush or Node's nextTick (in practice). Then tests can do: // callSomethingThatDefersSomeWork(); // Meteor.defer(expect(somethingThatValidatesThatTheWorkHappened)); defer: function (f) { - // Older Firefox will pass an argument to the setTimeout callback - // function, indicating the "actual lateness." It's non-standard, - // so for defer, standardize on not having it. - Meteor.setTimeout(function () {f();}, 0); + Meteor._setImmediate(bindAndCatch("defer callback", f)); } }); diff --git a/packages/meteor/timers_tests.js b/packages/meteor/timers_tests.js new file mode 100644 index 0000000000..246f7e7b39 --- /dev/null +++ b/packages/meteor/timers_tests.js @@ -0,0 +1,21 @@ +Tinytest.addAsync('timers - defer', function (test, onComplete) { + var x = 'a'; + Meteor.defer(function () { + test.equal(x, 'b'); + onComplete(); + }); + x = 'b'; +}); + +Tinytest.addAsync('timers - nested defer', function (test, onComplete) { + var x = 'a'; + Meteor.defer(function () { + test.equal(x, 'b'); + Meteor.defer(function () { + test.equal(x, 'c'); + onComplete(); + }); + x = 'c'; + }); + x = 'b'; +}); diff --git a/packages/mongo-livedata/.npm/npm-shrinkwrap.json b/packages/mongo-livedata/.npm/npm-shrinkwrap.json index 81fdc99dda..f4a1429a74 100644 --- a/packages/mongo-livedata/.npm/npm-shrinkwrap.json +++ b/packages/mongo-livedata/.npm/npm-shrinkwrap.json @@ -1,10 +1,13 @@ { "dependencies": { "mongodb": { - "version": "1.2.13", + "version": "1.3.6", "dependencies": { "bson": { - "version": "0.1.8" + "version": "0.1.9" + }, + "kerberos": { + "version": "0.0.2" } } } diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index 4a0432db01..5396c539ed 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -257,8 +257,6 @@ Meteor.Collection._rewriteSelector = function (selector) { var ret = {}; _.each(selector, function (value, key) { if (value instanceof RegExp) { - // XXX should also do this translation at lower levels (eg if the outer - // level is $and/$or/$nor, or if there's an $elemMatch) ret[key] = {$regex: value.source}; var regexOptions = ''; // JS RegExp objects support 'i', 'm', and 'g'. Mongo regex $options @@ -270,6 +268,12 @@ Meteor.Collection._rewriteSelector = function (selector) { if (regexOptions) ret[key].$options = regexOptions; } + else if (_.contains(['$or','$and','$nor'], key)) { + // Translate lower levels of $and/$or/$nor + ret[key] = _.map(value, function (v) { + return Meteor.Collection._rewriteSelector(v); + }); + } else ret[key] = value; }); diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index 948a7962bd..72a29559d3 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -823,6 +823,47 @@ Tinytest.add('mongo-livedata - rewrite selector', function (test) { {x: {$regex: '^o+B'}}); test.equal(Meteor.Collection._rewriteSelector('foo'), {_id: 'foo'}); + + test.equal( + Meteor.Collection._rewriteSelector( + {'$or': [ + {x: /^o/}, + {y: /^p/}, + {z: 'q'} + ]} + ), + {'$or': [ + {x: {$regex: '^o'}}, + {y: {$regex: '^p'}}, + {z: 'q'} + ]} + ); + + test.equal( + Meteor.Collection._rewriteSelector( + {'$or': [ + {'$and': [ + {x: /^a/i}, + {y: /^b/} + ]}, + {'$nor': [ + {s: /^c/}, + {t: /^d/i} + ]} + ]} + ), + {'$or': [ + {'$and': [ + {x: {$regex: '^a', $options: 'i'}}, + {y: {$regex: '^b'}} + ]}, + {'$nor': [ + {s: {$regex: '^c'}}, + {t: {$regex: '^d', $options: 'i'}} + ]} + ]} + ); + var oid = new Meteor.Collection.ObjectID(); test.equal(Meteor.Collection._rewriteSelector(oid), {_id: oid}); diff --git a/packages/mongo-livedata/package.js b/packages/mongo-livedata/package.js index b2269b8b72..25a92cb5e1 100644 --- a/packages/mongo-livedata/package.js +++ b/packages/mongo-livedata/package.js @@ -12,7 +12,7 @@ Package.describe({ internal: true }); -Npm.depends({mongodb: "1.2.13"}); +Npm.depends({mongodb: "1.3.6"}); Package.on_use(function (api) { api.use(['random', 'ejson', 'json', 'underscore', 'minimongo', 'logging', diff --git a/packages/accounts-oauth-helper/oauth_client.js b/packages/oauth/oauth_client.js similarity index 55% rename from packages/accounts-oauth-helper/oauth_client.js rename to packages/oauth/oauth_client.js index 42944a2356..4d59040721 100644 --- a/packages/accounts-oauth-helper/oauth_client.js +++ b/packages/oauth/oauth_client.js @@ -1,13 +1,13 @@ // Open a popup window pointing to a OAuth handshake page // -// @param state {String} The OAuth state generated by the client +// @param credentialToken {String} The OAuth credentialToken generated by the client // @param url {String} url to page -// @param callback {Function} Callback function to call on -// completion. Takes one argument, null on success, or Error on +// @param credentialRequestCompleteCallback {Function} Callback function to call on +// completion. Takes one argument, credentialToken on success, or Error on // error. // @param dimensions {optional Object(width, height)} The dimensions of // the popup. If not passed defaults to something sane -Accounts.oauth.initiateLogin = function(state, url, callback, dimensions) { +Oauth.initiateLogin = function(credentialToken, url, credentialRequestCompleteCallback, dimensions) { // default dimensions that worked well for facebook and google var popup = openCenteredPopup( url, @@ -15,33 +15,26 @@ Accounts.oauth.initiateLogin = function(state, url, callback, dimensions) { (dimensions && dimensions.height) || 331); var checkPopupOpen = setInterval(function() { - // Fix for #328 - added a second test criteria (popup.closed === undefined) - // to humour this Android quirk: - // http://code.google.com/p/android/issues/detail?id=21061 - if (popup.closed || popup.closed === undefined) { + try { + // Fix for #328 - added a second test criteria (popup.closed === undefined) + // to humour this Android quirk: + // http://code.google.com/p/android/issues/detail?id=21061 + var popupClosed = popup.closed || popup.closed === undefined; + } catch (e) { + // For some unknown reason, IE9 (and others?) sometimes (when + // the popup closes too quickly?) throws "SCRIPT16386: No such + // interface supported" when trying to read 'popup.closed'. Try + // again in 100ms. + return; + } + + if (popupClosed) { clearInterval(checkPopupOpen); - tryLoginAfterPopupClosed(state, callback); + credentialRequestCompleteCallback(credentialToken); } }, 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(state, callback) { - Accounts.callLoginMethod({ - methodArguments: [{oauth: {state: state}}], - userCallback: callback && function (err) { - // Allow server to specify a specify subclass of errors. We should come - // up with a more generic way to do this! - if (err && err instanceof Meteor.Error && - err.error === Accounts.LoginCancelledError.numericError) { - callback(new Accounts.LoginCancelledError(err.details)); - } else { - callback(err); - } - }}); -}; var openCenteredPopup = function(url, width, height) { var screenX = typeof window.screenX !== 'undefined' diff --git a/packages/oauth/oauth_common.js b/packages/oauth/oauth_common.js new file mode 100644 index 0000000000..d33fd55ea4 --- /dev/null +++ b/packages/oauth/oauth_common.js @@ -0,0 +1 @@ +Oauth = {}; \ No newline at end of file diff --git a/packages/accounts-oauth-helper/oauth_server.js b/packages/oauth/oauth_server.js similarity index 61% rename from packages/accounts-oauth-helper/oauth_server.js rename to packages/oauth/oauth_server.js index 04a43a2807..7430e0656b 100644 --- a/packages/accounts-oauth-helper/oauth_server.js +++ b/packages/oauth/oauth_server.js @@ -2,7 +2,8 @@ var Fiber = Npm.require('fibers'); Meteor._routePolicy.declare('/_oauth/', 'network'); -Accounts.oauth._services = {}; +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 @@ -11,6 +12,7 @@ Accounts.oauth._services = {}; // // @param name {String} e.g. "google", "facebook" // @param version {Number} OAuth version (1 or 2) +// @param urls For OAuth1 only, specify the service's urls // @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 @@ -18,70 +20,41 @@ Accounts.oauth._services = {}; // - {serviceData:, (optional options:)} where serviceData should end // up in the user's services[name] field // - `null` if the user declined to give permissions -Accounts.oauth.registerService = function (name, version, handleOauthRequest) { - if (Accounts.oauth._services[name]) +Oauth.registerService = function (name, version, urls, handleOauthRequest) { + if (Oauth._services[name]) throw new Error("Already registered the " + name + " OAuth service"); - // Accounts.updateOrCreateUserFromExternalService does a lookup by this id, - // so this should be a unique index. You might want to add indexes for other - // fields returned by your service (eg services.github.login) but you can do - // that in your app. - Meteor.users._ensureIndex('services.' + name + '.id', - {unique: 1, sparse: 1}); - - Accounts.oauth._services[name] = { + Oauth._services[name] = { serviceName: name, version: version, + urls: urls, handleOauthRequest: handleOauthRequest }; }; -// For test cleanup only. (Mongo has a limit as to how many indexes it can have -// per collection.) -Accounts.oauth._unregisterService = function (name) { - delete Accounts.oauth._services[name]; - var index = {}; - index['services.' + name + '.id'] = 1; - Meteor.users._dropIndex(index); +// For test cleanup only. +Oauth._unregisterService = function (name) { + delete Oauth._services[name]; }; + // 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` +// method is called. Maps credentialToken --> return value of `login` // // XXX we should periodically clear old entries -Accounts.oauth._loginResultForState = {}; +Oauth._loginResultForCredentialToken = {}; -// Listen to calls to `login` with an oauth option set. This is where -// users actually get logged in to meteor via oauth. -Accounts.registerLoginHandler(function (options) { - if (!options.oauth) - return undefined; // don't handle +Oauth.hasCredential = function(credentialToken) { + return _.has(Oauth._loginResultForCredentialToken, credentialToken); +} - check(options.oauth, {state: String}); - - if (!_.has(Accounts.oauth._loginResultForState, options.oauth.state)) { - // OAuth state is not recognized, which could be either because the popup - // was closed by the user before completion, or some sort of error where - // the oauth provider didn't talk to our server correctly and closed the - // popup somehow. - // - // we assume it was user canceled, and report it as such, using a - // Meteor.Error which the client can recognize. this will mask failures - // where things are misconfigured such that the server doesn't see the - // request but does close the window. This seems unlikely. - throw new Meteor.Error(Accounts.LoginCancelledError.numericError, - 'No matching login attempt found'); - } - var result = Accounts.oauth._loginResultForState[options.oauth.state]; - 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; -}); +Oauth.retrieveCredential = function(credentialToken) { + result = Oauth._loginResultForCredentialToken[credentialToken]; + delete Oauth._loginResultForCredentialToken[credentialToken]; + return result; +} // Listen to incoming OAuth http requests __meteor_bootstrap__.app @@ -90,11 +63,12 @@ __meteor_bootstrap__.app // calls and nothing else is wrapping this in a fiber // automatically Fiber(function () { - Accounts.oauth._middleware(req, res, next); + Oauth._middleware(req, res, next); }).run(); }); -Accounts.oauth._middleware = function (req, res, next) { + +Oauth._middleware = function (req, res, next) { // Make sure to catch any exceptions because otherwise we'd crash // the runner try { @@ -105,7 +79,7 @@ Accounts.oauth._middleware = function (req, res, next) { return; } - var service = Accounts.oauth._services[serviceName]; + var service = Oauth._services[serviceName]; // Skip everything if there's no service set by the oauth middleware if (!service) @@ -115,9 +89,9 @@ Accounts.oauth._middleware = function (req, res, next) { ensureConfigured(serviceName); if (service.version === 1) - Accounts.oauth1._handleRequest(service, req.query, res); + Oauth1._handleRequest(service, req.query, res); else if (service.version === 2) - Accounts.oauth2._handleRequest(service, req.query, res); + Oauth2._handleRequest(service, req.query, res); else throw new Error("Unexpected OAuth version " + service.version); } catch (err) { @@ -129,12 +103,12 @@ Accounts.oauth._middleware = function (req, res, next) { // 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) - Accounts.oauth._loginResultForState[req.query.state] = err; + Oauth._loginResultForCredentialToken[req.query.state] = 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 - // Accounts.oauth_renderOauthResults, this will still + // 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. @@ -167,12 +141,12 @@ var oauthServiceName = function (req) { // Make sure we're configured var ensureConfigured = function(serviceName) { - if (!Accounts.loginServiceConfiguration.findOne({service: serviceName})) { - throw new Accounts.ConfigError("Service not configured"); + if (!ServiceConfiguration.configurations.findOne({service: serviceName})) { + throw new ServiceConfiguration.ConfigError("Service not configured"); }; }; -Accounts.oauth._renderOauthResults = function(res, query) { +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 @@ -192,3 +166,4 @@ var closePopup = function(res) { ''; res.end(content, 'utf-8'); }; + diff --git a/packages/oauth/package.js b/packages/oauth/package.js new file mode 100644 index 0000000000..82cc08f0ce --- /dev/null +++ b/packages/oauth/package.js @@ -0,0 +1,12 @@ +Package.describe({ + summary: "Common code for OAuth-based services", + internal: true +}); + +Package.on_use(function (api) { + api.use('routepolicy', 'server'); + + api.add_files('oauth_common.js', ['client', 'server']); + api.add_files('oauth_client.js', 'client'); + api.add_files('oauth_server.js', 'server'); +}); \ No newline at end of file diff --git a/packages/accounts-oauth1-helper/oauth1_binding.js b/packages/oauth1/oauth1_binding.js similarity index 100% rename from packages/accounts-oauth1-helper/oauth1_binding.js rename to packages/oauth1/oauth1_binding.js diff --git a/packages/oauth1/oauth1_common.js b/packages/oauth1/oauth1_common.js new file mode 100644 index 0000000000..2d25e3b32b --- /dev/null +++ b/packages/oauth1/oauth1_common.js @@ -0,0 +1 @@ +Oauth1 = {}; \ No newline at end of file diff --git a/packages/accounts-oauth1-helper/oauth1_server.js b/packages/oauth1/oauth1_server.js similarity index 65% rename from packages/accounts-oauth1-helper/oauth1_server.js rename to packages/oauth1/oauth1_server.js index 71d6ddecc6..6339573302 100644 --- a/packages/accounts-oauth1-helper/oauth1_server.js +++ b/packages/oauth1/oauth1_server.js @@ -1,15 +1,15 @@ // A place to store request tokens pending verification -Accounts.oauth1._requestTokens = {}; +Oauth1._requestTokens = {}; // connect middleware -Accounts.oauth1._handleRequest = function (service, query, res) { +Oauth1._handleRequest = function (service, query, res) { - var config = Accounts.loginServiceConfiguration.findOne({service: service.serviceName}); + var config = ServiceConfiguration.configurations.findOne({service: service.serviceName}); if (!config) { - throw new Accounts.ConfigError("Service " + service.serviceName + " not configured"); + throw new ServiceConfiguration.ConfigError("Service " + service.serviceName + " not configured"); } - var urls = Accounts[service.serviceName]._urls; + var urls = service.urls; var oauthBinding = new OAuth1Binding( config.consumerKey, config.secret, urls); @@ -20,7 +20,7 @@ Accounts.oauth1._handleRequest = function (service, query, res) { oauthBinding.prepareRequestToken(query.requestTokenAndRedirect); // Keep track of request token so we can verify it on the next step - Accounts.oauth1._requestTokens[query.state] = oauthBinding.requestToken; + Oauth1._requestTokens[query.state] = oauthBinding.requestToken; // redirect to provider login, which will redirect back to "step 2" below var redirectUrl = urls.authenticate + '?oauth_token=' + oauthBinding.requestToken; @@ -33,8 +33,8 @@ Accounts.oauth1._handleRequest = function (service, query, res) { // 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 = Accounts.oauth1._requestTokens[query.state]; - delete Accounts.oauth1._requestTokens[query.state]; + var requestToken = Oauth1._requestTokens[query.state]; + delete Oauth1._requestTokens[query.state]; // Verify user authorized access and the oauth_token matches // the requestToken from previous step @@ -49,14 +49,16 @@ Accounts.oauth1._handleRequest = function (service, query, res) { // Run service-specific handler. var oauthResult = service.handleOauthRequest(oauthBinding); - // Get or create user doc and login token for reconnect. - Accounts.oauth._loginResultForState[query.state] = - Accounts.updateOrCreateUserFromExternalService( - service.serviceName, oauthResult.serviceData, oauthResult.options); + // Add the login result to the result map + Oauth._loginResultForCredentialToken[query.state] = { + serviceName: service.serviceName, + serviceData: oauthResult.serviceData, + options: oauthResult.options + }; } } // Either close the window, redirect, or render nothing // if all else fails - Accounts.oauth._renderOauthResults(res, query); + Oauth._renderOauthResults(res, query); }; diff --git a/packages/oauth1/oauth1_tests.js b/packages/oauth1/oauth1_tests.js new file mode 100644 index 0000000000..51f286d5ad --- /dev/null +++ b/packages/oauth1/oauth1_tests.js @@ -0,0 +1,73 @@ +Tinytest.add("oauth1 - loginResultForCredentialToken is stored", function (test) { + var http = Npm.require('http'); + var twitterfooId = Random.id(); + var twitterfooName = 'nickname' + Random.id(); + var twitterfooAccessToken = Random.id(); + var twitterfooAccessTokenSecret = Random.id(); + var twitterOption1 = Random.id(); + var credentialToken = Random.id(); + var serviceName = Random.id(); + + var urls = { + requestToken: "https://example.com/oauth/request_token", + authorize: "https://example.com/oauth/authorize", + accessToken: "https://example.com/oauth/access_token", + authenticate: "https://example.com/oauth/authenticate" + }; + + OAuth1Binding.prototype.prepareRequestToken = function() {}; + OAuth1Binding.prototype.prepareAccessToken = function() { + this.accessToken = twitterfooAccessToken; + this.accessTokenSecret = twitterfooAccessTokenSecret; + }; + + ServiceConfiguration.configurations.insert({service: serviceName}); + + try { + // register a fake login service + Oauth.registerService(serviceName, 1, urls, function (query) { + return { + serviceData: { + id: twitterfooId, + screenName: twitterfooName, + accessToken: twitterfooAccessToken, + accessTokenSecret: twitterfooAccessTokenSecret + }, + options: { + option1: twitterOption1 + } + }; + }); + + // simulate logging in using twitterfoo + Oauth1._requestTokens[credentialToken] = twitterfooAccessToken; + + var req = { + method: "POST", + url: "/_oauth/" + serviceName + "?close", + query: { + state: credentialToken, + oauth_token: twitterfooAccessToken + } + }; + Oauth._middleware(req, new http.ServerResponse(req)); + + // Test that right data is placed on the loginResult map + test.equal( + Oauth._loginResultForCredentialToken[credentialToken].serviceName, serviceName); + test.equal( + Oauth._loginResultForCredentialToken[credentialToken].serviceData.id, twitterfooId); + test.equal( + Oauth._loginResultForCredentialToken[credentialToken].serviceData.screenName, twitterfooName); + test.equal( + Oauth._loginResultForCredentialToken[credentialToken].serviceData.accessToken, twitterfooAccessToken); + test.equal( + Oauth._loginResultForCredentialToken[credentialToken].serviceData.accessTokenSecret, twitterfooAccessTokenSecret); + test.equal( + Oauth._loginResultForCredentialToken[credentialToken].options.option1, twitterOption1); + + } finally { + Oauth._unregisterService(serviceName); + } +}); + diff --git a/packages/accounts-oauth1-helper/package.js b/packages/oauth1/package.js similarity index 69% rename from packages/accounts-oauth1-helper/package.js rename to packages/oauth1/package.js index c88c07c0f3..3b19ac9fbb 100644 --- a/packages/accounts-oauth1-helper/package.js +++ b/packages/oauth1/package.js @@ -4,8 +4,8 @@ Package.describe({ }); Package.on_use(function (api) { - api.use('accounts-oauth-helper', ['client', 'server']); - api.use('accounts-base', ['client', 'server']); + api.use('service-configuration', ['client', 'server']); + api.use('oauth', 'client'); api.add_files('oauth1_binding.js', 'server'); api.add_files('oauth1_common.js', ['client', 'server']); @@ -15,7 +15,7 @@ Package.on_use(function (api) { Package.on_test(function (api) { api.use('tinytest'); api.use('random'); - api.use('accounts-base'); - api.use('accounts-oauth1-helper', 'server'); + api.use('service-configuration', 'server'); + api.use('oauth1', 'server'); api.add_files("oauth1_tests.js", 'server'); }); diff --git a/packages/oauth2/oauth2_common.js b/packages/oauth2/oauth2_common.js new file mode 100644 index 0000000000..ef8a3f613b --- /dev/null +++ b/packages/oauth2/oauth2_common.js @@ -0,0 +1 @@ +Oauth2 = {}; \ No newline at end of file diff --git a/packages/accounts-oauth2-helper/oauth2_server.js b/packages/oauth2/oauth2_server.js similarity index 52% rename from packages/accounts-oauth2-helper/oauth2_server.js rename to packages/oauth2/oauth2_server.js index b7e83d6c52..e39f34228f 100644 --- a/packages/accounts-oauth2-helper/oauth2_server.js +++ b/packages/oauth2/oauth2_server.js @@ -1,5 +1,5 @@ // connect middleware -Accounts.oauth2._handleRequest = function (service, query, res) { +Oauth2._handleRequest = function (service, query, res) { // check if user authorized access if (!query.error) { // Prepare the login results before returning. This way the @@ -8,13 +8,15 @@ Accounts.oauth2._handleRequest = function (service, query, res) { // Run service-specific handler. var oauthResult = service.handleOauthRequest(query); - // Get or create user doc and login token for reconnect. - Accounts.oauth._loginResultForState[query.state] = - Accounts.updateOrCreateUserFromExternalService( - service.serviceName, oauthResult.serviceData, oauthResult.options); + // Add the login result to the result map + Oauth._loginResultForCredentialToken[query.state] = { + serviceName: service.serviceName, + serviceData: oauthResult.serviceData, + options: oauthResult.options + }; } // Either close the window, redirect, or render nothing // if all else fails - Accounts.oauth._renderOauthResults(res, query); + Oauth._renderOauthResults(res, query); }; diff --git a/packages/oauth2/oauth2_tests.js b/packages/oauth2/oauth2_tests.js new file mode 100644 index 0000000000..5bc23e3bb5 --- /dev/null +++ b/packages/oauth2/oauth2_tests.js @@ -0,0 +1,36 @@ +Tinytest.add("oauth2 - loginResultForCredentialToken is stored", function (test) { + var http = Npm.require('http'); + var foobookId = Random.id(); + var foobookOption1 = Random.id(); + var credentialToken = Random.id(); + var serviceName = Random.id(); + + ServiceConfiguration.configurations.insert({service: serviceName}); + + try { + // register a fake login service + Oauth.registerService(serviceName, 2, null, function (query) { + return { + serviceData: {id: foobookId}, + options: {option1: foobookOption1} + }; + }); + + // simulate logging in using foobook + var req = {method: "POST", + url: "/_oauth/" + serviceName + "?close", + query: {state: credentialToken}}; + Oauth._middleware(req, new http.ServerResponse(req)); + + // Test that the login result for that user is prepared + test.equal( + Oauth._loginResultForCredentialToken[credentialToken].serviceName, serviceName); + test.equal( + Oauth._loginResultForCredentialToken[credentialToken].serviceData.id, foobookId); + test.equal( + Oauth._loginResultForCredentialToken[credentialToken].options.option1, foobookOption1); + + } finally { + Oauth._unregisterService(serviceName); + } +}); diff --git a/packages/accounts-oauth2-helper/package.js b/packages/oauth2/package.js similarity index 58% rename from packages/accounts-oauth2-helper/package.js rename to packages/oauth2/package.js index d9204362c2..4019d866ae 100644 --- a/packages/accounts-oauth2-helper/package.js +++ b/packages/oauth2/package.js @@ -4,17 +4,15 @@ Package.describe({ }); Package.on_use(function (api) { - api.use('accounts-oauth-helper', ['client', 'server']); - api.use('accounts-base', ['client', 'server']); + api.use('service-configuration', ['client', 'server']); + api.use('oauth', 'client'); api.add_files('oauth2_common.js', ['client', 'server']); api.add_files('oauth2_server.js', 'server'); }); Package.on_test(function (api) { - api.use('tinytest'); - api.use('random'); - api.use('accounts-base'); - api.use('accounts-oauth2-helper', 'server'); + api.use('service-configuration', 'server'); + api.use('oauth2', 'server'); api.add_files("oauth2_tests.js", 'server'); }); diff --git a/packages/past/past.js b/packages/past/past.js index 054cf33c37..7bdffe3263 100644 --- a/packages/past/past.js +++ b/packages/past/past.js @@ -1,3 +1,9 @@ +// This file is used to set up aliases and methods to preserve backwards +// compatibility on some deprecated methods. Care should be taken when +// adding aliases and methods that the target will not be undefined, as +// the past package is loaded early. In some cases, it may be best to +// define the alias in the package it refers to. + // Old under_score version of camelCase public API names. Meteor.is_client = Meteor.isClient; Meteor.is_server = Meteor.isServer; diff --git a/packages/random/package.js b/packages/random/package.js index 18f30588a1..96f9f43070 100644 --- a/packages/random/package.js +++ b/packages/random/package.js @@ -7,3 +7,8 @@ Package.on_use(function (api, where) { where = where || ['client', 'server']; api.add_files('random.js', where); }); + +Package.on_test(function(api) { + api.use('random'); + api.add_files('random_tests.js', ['client', 'server']); +}); diff --git a/packages/random/random.js b/packages/random/random.js index 7b8aecdafb..0e02f3ca73 100644 --- a/packages/random/random.js +++ b/packages/random/random.js @@ -1,9 +1,6 @@ -// @export Random -Random = {}; - // see http://baagoe.org/en/wiki/Better_random_numbers_for_javascript // for a full discussion and Alea implementation. -Random._Alea = function () { +var Alea = function () { function Mash() { var n = 0xefc8249d; @@ -76,6 +73,53 @@ Random._Alea = function () { } (Array.prototype.slice.call(arguments))); }; +var UNMISTAKABLE_CHARS = "23456789ABCDEFGHJKLMNPQRSTWXYZabcdefghijkmnopqrstuvwxyz"; + +var create = function (/* arguments */) { + + var random = Alea.apply(null, arguments); + + var self = {}; + + var bind = function (fn) { + return _.bind(fn, self); + }; + + return _.extend(self, { + _Alea: Alea, + + create: create, + + fraction: random, + + choice: bind(function (arrayOrString) { + var index = Math.floor(this.fraction() * arrayOrString.length); + if (typeof arrayOrString === "string") + return arrayOrString.substr(index, 1); + else + return arrayOrString[index]; + }), + + id: bind(function() { + var digits = []; + // Length of 17 preserves around 96 bits of entropy, which is the + // amount of state in our PRNG + for (var i = 0; i < 17; i++) { + digits[i] = this.choice(UNMISTAKABLE_CHARS); + } + return digits.join(""); + }), + + hexString: bind(function (digits) { + var hexDigits = []; + for (var i = 0; i < digits; ++i) { + hexDigits.push(this.choice("0123456789abcdef")); + } + return hexDigits.join(''); + }) + }); +}; + // instantiate RNG. Heuristically collect entropy from various sources // client sources @@ -105,33 +149,7 @@ var pid = (typeof process !== 'undefined' && process.pid) || 1; // XXX On the server, use the crypto module (OpenSSL) instead of this PRNG. // (Make Random.fraction be generated from Random.hexString instead of the // other way around, and generate Random.hexString from crypto.randomBytes.) -Random.fraction = new Random._Alea([ - new Date(), height, width, agent, pid, Math.random()]); - -Random.choice = function (arrayOrString) { - var index = Math.floor(Random.fraction() * arrayOrString.length); - if (typeof arrayOrString === "string") - return arrayOrString.substr(index, 1); - else - return arrayOrString[index]; -}; - -var UNMISTAKABLE_CHARS = "23456789ABCDEFGHJKLMNPQRSTWXYZabcdefghijkmnopqrstuvwxyz"; -Random.id = function() { - var digits = []; - // Length of 17 preserves around 96 bits of entropy, which is the - // amount of state in our PRNG - for (var i = 0; i < 17; i++) { - digits[i] = Random.choice(UNMISTAKABLE_CHARS); - } - return digits.join(""); -}; - -var HEX_DIGITS = "0123456789abcdef"; -Random.hexString = function (digits) { - var hexDigits = []; - for (var i = 0; i < digits; ++i) { - hexDigits.push(Random.choice("0123456789abcdef")); - } - return hexDigits.join(''); -}; +// @export Random +Random = create([ + new Date(), height, width, agent, pid, Math.random() +]); diff --git a/packages/random/random_tests.js b/packages/random/random_tests.js new file mode 100644 index 0000000000..52e5b852e5 --- /dev/null +++ b/packages/random/random_tests.js @@ -0,0 +1,15 @@ +Tinytest.add('random', function (test) { + // Deterministic with a specified seed, which should generate the + // same sequence in all environments. + // + // For repeatable unit test failures using deterministic random + // number sequences it's fine if a new Meteor release changes the + // algorithm being used and it starts generating a different + // sequence for a seed, as long as the sequence is consistent for + // a particular release. + var random = Random.create(0); + test.equal(random.id(), "cp9hWvhg8GSvuZ9os"); + test.equal(random.id(), "3f3k6Xo7rrHCifQhR"); + test.equal(random.id(), "shxDnjWWmnKPEoLhM"); + test.equal(random.id(), "6QTjB8C5SEqhmz4ni"); +}); diff --git a/packages/service-configuration/package.js b/packages/service-configuration/package.js new file mode 100644 index 0000000000..cf7e246a46 --- /dev/null +++ b/packages/service-configuration/package.js @@ -0,0 +1,8 @@ +Package.describe({ + summary: "Manage the configuration for third-party services", + internal: true +}); + +Package.on_use(function(api) { + api.add_files('service_configuration_common.js', ['client', 'server']); +}); diff --git a/packages/service-configuration/service_configuration_common.js b/packages/service-configuration/service_configuration_common.js new file mode 100644 index 0000000000..e9349565a5 --- /dev/null +++ b/packages/service-configuration/service_configuration_common.js @@ -0,0 +1,23 @@ +if (typeof ServiceConfiguration === 'undefined') { + // @export ServiceConfiguration + ServiceConfiguration = {}; +} + + +// Table containing documents with configuration options for each +// login service +ServiceConfiguration.configurations = new Meteor.Collection( + "meteor_accounts_loginServiceConfiguration", {_preventAutopublish: true}); +// Leave this collection open in insecure mode. In theory, someone could +// hijack your oauth connect requests to a different endpoint or appId, +// but you did ask for 'insecure'. The advantage is that it is much +// easier to write a configuration wizard that works only in insecure +// mode. + + +// Thrown when trying to use a login service which is not configured +ServiceConfiguration.ConfigError = function(description) { + this.message = description; +}; +ServiceConfiguration.ConfigError.prototype = new Error(); +ServiceConfiguration.ConfigError.prototype.name = 'ServiceConfiguration.ConfigError'; diff --git a/packages/test-in-browser/driver.css b/packages/test-in-browser/driver.css index 2913c17355..897ab9dbe7 100644 --- a/packages/test-in-browser/driver.css +++ b/packages/test-in-browser/driver.css @@ -71,7 +71,6 @@ body { border-left: 2px solid #def; padding: 4px; position: relative; - width: 600px; } .test_table .test .testrow { position: relative; overflow: hidden; /*hasLayout*/ } diff --git a/packages/test-in-console/driver.js b/packages/test-in-console/driver.js index 4964c33ede..6335cba63a 100644 --- a/packages/test-in-console/driver.js +++ b/packages/test-in-console/driver.js @@ -30,7 +30,7 @@ var resultSet = {}; var toReport = []; var hrefPath = document.location.href.split("/"); -var platform = hrefPath.length && hrefPath[hrefPath.length - 1]; +var platform = decodeURIComponent(hrefPath.length && hrefPath[hrefPath.length - 1]); if (!platform) platform = "local"; var doReport = Meteor && diff --git a/packages/twitter/package.js b/packages/twitter/package.js new file mode 100644 index 0000000000..36b2e302aa --- /dev/null +++ b/packages/twitter/package.js @@ -0,0 +1,21 @@ +Package.describe({ + summary: "Twitter OAuth flow", + // internal for now. Should be external when it has a richer API to do + // actual API things with the service, not just handle the OAuth flow. + internal: true +}); + +Package.on_use(function(api) { + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + api.use('service-configuration', ['client', 'server']); + api.use('oauth1', ['client', 'server']); + + api.add_files( + ['twitter_configure.html', 'twitter_configure.js'], + 'client'); + + 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/twitter/twitter_client.js b/packages/twitter/twitter_client.js new file mode 100644 index 0000000000..7a6ea679de --- /dev/null +++ b/packages/twitter/twitter_client.js @@ -0,0 +1,35 @@ +// Request Twitter credentials for the user +// @param options {optional} XXX support options.requestPermissions +// @param credentialRequestCompleteCallback {Function} Callback function to call on +// completion. Takes one argument, credentialToken on success, or Error on +// error. +Twitter.requestCredential = function (options, credentialRequestCompleteCallback) { + // support both (options, callback) and (callback). + if (!credentialRequestCompleteCallback && typeof options === 'function') { + credentialRequestCompleteCallback = options; + options = {}; + } + + var config = ServiceConfiguration.configurations.findOne({service: 'twitter'}); + if (!config) { + credentialRequestCompleteCallback && credentialRequestCompleteCallback(new ServiceConfiguration.ConfigError("Service not configured")); + return; + } + + var credentialToken = Random.id(); + // We need to keep credentialToken across the next two 'steps' so we're adding + // a credentialToken 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.absoluteUrl('_oauth/twitter?close&state=' + credentialToken); + + // url to app, enters "step 1" as described in + // packages/accounts-oauth1-helper/oauth1_server.js + var url = '/_oauth/twitter/?requestTokenAndRedirect=' + + encodeURIComponent(callbackUrl) + + '&state=' + credentialToken; + + Oauth.initiateLogin(credentialToken, url, credentialRequestCompleteCallback); +}; diff --git a/packages/twitter/twitter_common.js b/packages/twitter/twitter_common.js new file mode 100644 index 0000000000..11fcb20788 --- /dev/null +++ b/packages/twitter/twitter_common.js @@ -0,0 +1,10 @@ +if (typeof Twitter === 'undefined') { + Twitter = {}; +} + +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_configure.html b/packages/twitter/twitter_configure.html similarity index 100% rename from packages/accounts-twitter/twitter_configure.html rename to packages/twitter/twitter_configure.html diff --git a/packages/accounts-twitter/twitter_configure.js b/packages/twitter/twitter_configure.js similarity index 100% rename from packages/accounts-twitter/twitter_configure.js rename to packages/twitter/twitter_configure.js diff --git a/packages/twitter/twitter_server.js b/packages/twitter/twitter_server.js new file mode 100644 index 0000000000..8d67554229 --- /dev/null +++ b/packages/twitter/twitter_server.js @@ -0,0 +1,31 @@ +// https://dev.twitter.com/docs/api/1.1/get/account/verify_credentials +Twitter.whitelistedFields = ['profile_image_url', 'profile_image_url_https', 'lang']; + +Oauth.registerService('twitter', 1, Twitter._urls, function(oauthBinding) { + var identity = oauthBinding.get('https://api.twitter.com/1.1/account/verify_credentials.json').data; + + var serviceData = { + id: identity.id_str, + screenName: identity.screen_name, + accessToken: oauthBinding.accessToken, + accessTokenSecret: oauthBinding.accessTokenSecret + }; + + // include helpful fields from twitter + var fields = _.pick(identity, Twitter.whitelistedFields); + _.extend(serviceData, fields); + + return { + serviceData: serviceData, + options: { + profile: { + name: identity.name + } + } + }; +}); + + +Twitter.retrieveCredential = function(credentialToken) { + return Oauth.retrieveCredential(credentialToken); +}; diff --git a/packages/universal-events/events-w3c.js b/packages/universal-events/events-w3c.js index acdaa040d7..466e6c85ae 100644 --- a/packages/universal-events/events-w3c.js +++ b/packages/universal-events/events-w3c.js @@ -85,6 +85,11 @@ _.extend(UniversalEventListener._impl.w3c.prototype, { ret.push('mouseout'); } + if (type === 'tap') { + ret.push('touchmove'); + ret.push('touchend'); + } + return ret; }, @@ -194,12 +199,22 @@ _.extend(UniversalEventListener._impl.w3c.prototype, { (event.currentTarget !== event.relatedTarget && ! DomUtils.elementContains( event.currentTarget, event.relatedTarget)))) { - if (event.type === 'mouseover'){ + if (event.type === 'mouseover') { sendUIEvent('mouseenter', event.currentTarget, false); } else if (event.type === 'mouseout') { sendUIEvent('mouseleave', event.currentTarget, false); } } + + if (event.type === 'touchmove') { + event.currentTarget._notTapping = true; + } + if (event.type === 'touchend') { + if (!event.currentTarget._notTapping) { + sendUIEvent('tap', event.currentTarget, true); + } + delete event.currentTarget._notTapping; + } } }); diff --git a/packages/weibo/package.js b/packages/weibo/package.js new file mode 100644 index 0000000000..59be95331e --- /dev/null +++ b/packages/weibo/package.js @@ -0,0 +1,20 @@ +Package.describe({ + summary: "Weibo OAuth flow", + // internal for now. Should be external when it has a richer API to do + // actual API things with the service, not just handle the OAuth flow. + internal: true +}); + +Package.on_use(function(api) { + api.use('oauth2', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['weibo_configure.html', 'weibo_configure.js'], + 'client'); + + api.add_files('weibo_common.js', ['client', 'server']); + api.add_files('weibo_server.js', 'server'); + api.add_files('weibo_client.js', 'client'); +}); diff --git a/packages/weibo/weibo_client.js b/packages/weibo/weibo_client.js new file mode 100644 index 0000000000..f70eb831c6 --- /dev/null +++ b/packages/weibo/weibo_client.js @@ -0,0 +1,29 @@ +// Request Weibo credentials for the user +// @param options {optional} +// @param credentialRequestCompleteCallback {Function} Callback function to call on +// completion. Takes one argument, credentialToken on success, or Error on +// error. +Weibo.requestCredential = function (options, credentialRequestCompleteCallback) { + // support both (options, callback) and (callback). + if (!credentialRequestCompleteCallback && typeof options === 'function') { + credentialRequestCompleteCallback = options; + options = {}; + } + + var config = ServiceConfiguration.configurations.findOne({service: 'weibo'}); + if (!config) { + credentialRequestCompleteCallback && credentialRequestCompleteCallback(new ServiceConfiguration.ConfigError("Service not configured")); + return; + } + + var credentialToken = Random.id(); + // XXX need to support configuring access_type and scope + var loginUrl = + 'https://api.weibo.com/oauth2/authorize' + + '?response_type=code' + + '&client_id=' + config.clientId + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/weibo?close', {replaceLocalhost: true}) + + '&state=' + credentialToken; + + Oauth.initiateLogin(credentialToken, loginUrl, credentialRequestCompleteCallback); +}; diff --git a/packages/weibo/weibo_common.js b/packages/weibo/weibo_common.js new file mode 100644 index 0000000000..7f26d9defe --- /dev/null +++ b/packages/weibo/weibo_common.js @@ -0,0 +1,3 @@ +if (typeof Weibo === 'undefined') { + Weibo = {}; +} diff --git a/packages/accounts-weibo/weibo_configure.html b/packages/weibo/weibo_configure.html similarity index 100% rename from packages/accounts-weibo/weibo_configure.html rename to packages/weibo/weibo_configure.html diff --git a/packages/accounts-weibo/weibo_configure.js b/packages/weibo/weibo_configure.js similarity index 100% rename from packages/accounts-weibo/weibo_configure.js rename to packages/weibo/weibo_configure.js diff --git a/packages/weibo/weibo_server.js b/packages/weibo/weibo_server.js new file mode 100644 index 0000000000..4c6437899f --- /dev/null +++ b/packages/weibo/weibo_server.js @@ -0,0 +1,73 @@ +Oauth.registerService('weibo', 2, null, function(query) { + + var response = getTokenResponse(query); + var uid = parseInt(response.uid, 10); + + // different parts of weibo's api seem to expect numbers, or strings + // for uid. let's make sure they're both the same. + if (response.uid !== uid + "") + throw new Error("Expected 'uid' to parse to an integer: " + JSON.stringify(response)); + + var identity = getIdentity(response.access_token, uid); + + return { + serviceData: { + // We used to store this as a string, so keep it this way rather than + // add complexity to Account.updateOrCreateUserFromExternalService or + // force a database migration + id: uid + "", + accessToken: response.access_token, + screenName: identity.screen_name, + expiresAt: (+new Date) + (1000 * response.expires_in) + }, + options: {profile: {name: identity.screen_name}} + }; +}); + +// return an object containining: +// - uid +// - access_token +// - expires_in: lifetime of this token in seconds (5 years(!) right now) +var getTokenResponse = function (query) { + var config = ServiceConfiguration.configurations.findOne({service: 'weibo'}); + if (!config) + throw new ServiceConfiguration.ConfigError("Service not configured"); + + var response; + try { + response = Meteor.http.post( + "https://api.weibo.com/oauth2/access_token", {params: { + code: query.code, + client_id: config.clientId, + client_secret: config.secret, + redirect_uri: Meteor.absoluteUrl("_oauth/weibo?close", {replaceLocalhost: true}), + grant_type: 'authorization_code' + }}); + } catch (err) { + throw new Error("Failed to complete OAuth handshake with Weibo. " + err.message); + } + + // result.headers["content-type"] is 'text/plain;charset=UTF-8', so + // the http package doesn't automatically populate result.data + response.data = JSON.parse(response.content); + + if (response.data.error) { // if the http response was a json object with an error attribute + throw new Error("Failed to complete OAuth handshake with Weibo. " + response.data.error); + } else { + return response.data; + } +}; + +var getIdentity = function (accessToken, userId) { + try { + return Meteor.http.get( + "https://api.weibo.com/2/users/show.json", + {params: {access_token: accessToken, uid: userId}}).data; + } catch (err) { + throw new Error("Failed to fetch identity from Weibo. " + err.message); + } +}; + +Weibo.retrieveCredential = function(credentialToken) { + return Oauth.retrieveCredential(credentialToken); +}; \ No newline at end of file diff --git a/scripts/admin/banner.txt b/scripts/admin/banner.txt index f9b37dc2f7..0ee2d5c306 100644 --- a/scripts/admin/banner.txt +++ b/scripts/admin/banner.txt @@ -1,5 +1 @@ -=> Meteor 0.6.2.1 released: Unbreak 'Sign in with GitHub' by sending a - user agent string on API requests. - - This is being downloaded in the background. Update your project - to Meteor 0.6.2.1 by running 'meteor update'. +=> Meteor 0.6.3.1: Small fix to 'client/compatibility' from 0.6.3 diff --git a/scripts/admin/notices.json b/scripts/admin/notices.json index 8f389ce64b..5b726dfaad 100644 --- a/scripts/admin/notices.json +++ b/scripts/admin/notices.json @@ -21,14 +21,23 @@ "release": "0.6.2.1" }, { - "release": "NEXT", + "release": "0.6.3", "packageNotices": { "coffeescript": ["CoffeeScript has been updated to 1.6.2 from 1.5.0. See", "http://coffeescript.org/#changelog"], "localstorage-polyfill": [ "The localstorage-polyfill package has been replaced by the localstorage", "package, which creates an object at Meteor._localStorage instead of", - "pretending to be window.localStorage."] + "pretending to be window.localStorage."], + "http": [ + "When using the `http` package synchronously on the server, errors", + "are now thrown rather than passed in `result.error`."] } + }, + { + "release": "0.6.3.1" + }, + { + "release": "NEXT" } ] diff --git a/scripts/generate-dev-bundle.sh b/scripts/generate-dev-bundle.sh index 4feea7805e..078cb31647 100755 --- a/scripts/generate-dev-bundle.sh +++ b/scripts/generate-dev-bundle.sh @@ -101,7 +101,7 @@ npm install handlebars@1.0.7 npm install clean-css@0.8.3 npm install request@2.12.0 npm install keypress@0.1.0 -npm install http-proxy@0.8.5 +npm install http-proxy@0.10.1 # not 0.10.2, which contains a sketchy websocket change npm install underscore@1.4.4 npm install fstream@0.1.21 npm install tar@0.1.14 diff --git a/tools/mongo_exit_codes.js b/tools/mongo_exit_codes.js index 4e5dfe2e79..97421fb660 100644 --- a/tools/mongo_exit_codes.js +++ b/tools/mongo_exit_codes.js @@ -58,7 +58,9 @@ exports.Codes = { 100 : { code: 100, symbol: "EXIT_UNCAUGHT", longText: "MongoDB had an unspecified uncaught exception.\n" + - "Check to make sure that MongoDB is able to write to its database directory." + "This can be caused by MongoDB being unable to write to a local database.\n" + + "Check that you have permissions to write to .meteor/local. MongoDB does\n" + + "not support filesystems like NFS that do not allow file locking." } };