Merge branch 'devel' into linker.

Conflicts:
	meteor
	packages/absolute-url/.gitignore
	packages/accounts-base/package.js
	packages/accounts-oauth/package.js
	packages/audit-argument-checks/.gitignore
	packages/coffeescript/.gitignore
	packages/coffeescript/package.js
	packages/localstorage-polyfill/.gitignore
	packages/oauth1/package.js
	packages/oauth2/package.js
	packages/random/random.js
	scripts/generate-dev-bundle.sh
	tools/packages.js
	tools/run.js
	tools/server/server.js
This commit is contained in:
David Glasser
2013-05-28 15:10:48 -07:00
121 changed files with 1698 additions and 1079 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -1 +1 @@
0.6.2.1
0.6.3.1

View File

@@ -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 "<code>tap</code>"}} 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}}
</dl>
Other DOM events are available as well, but for the events above,

View File

@@ -1 +1 @@
0.6.2.1
0.6.3

View File

@@ -0,0 +1 @@
local

View File

@@ -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.

View File

@@ -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`.

View File

@@ -0,0 +1,52 @@
<head>
<title>defer in inactive tab</title>
<meta name="viewport" content="width=device-width">
</head>
<body>
{{> route}}
</body>
<template name="route">
{{#if isParent}}
{{> parent}}
{{else}}
{{> child}}
{{/if}}
</template>
<template name="parent">
<h1>Test Defer in Inactive Tab</h1>
<p>
Step one: open second tab:
<button id="openTab">Open Tab</button>
</p>
<p>
Step two: run test:
<button id="runTest">Run Test</button>
</p>
<p>
In a successful test the test status will immediately change to
"test successful". (If you switch to the child tab yourself and
that makes the test claim to be successful, that's actually an
invalid test because you're letting the child tab become the
active tab).
</p>
<p style="padding: 1em; outline: 1px solid gray">
Test status: <b>{{testStatus}}</b>
</p>
<p>
After the test has run successfully you can close the child tab.
</p>
</template>
<template name="child">
<p>This is the child.</p>
<p>Switch back to the first tab and run the test.</p>
</template>

View File

@@ -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', '!');
});
}
});
}
}

View File

@@ -1 +1 @@
0.6.2.1
0.6.3

View File

@@ -1 +1 @@
0.6.2.1
0.6.3

View File

@@ -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.');

View File

@@ -1 +1 @@
0.6.2.1
0.6.3

2
meteor
View File

@@ -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.

View File

@@ -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)

View File

@@ -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);
}
});

View File

@@ -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

View File

@@ -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);
};

View File

@@ -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);
}
};

View File

@@ -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');

View File

@@ -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);
};

View File

@@ -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);
}
};

View File

@@ -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');

View File

@@ -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);
};

View File

@@ -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);
}
};

View File

@@ -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');

View File

@@ -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);
};

View File

@@ -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);
}
};

View File

@@ -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');

View File

@@ -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);
}
};
}

View File

@@ -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);
});

View File

@@ -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

View File

@@ -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');
});

View File

@@ -1 +0,0 @@
Accounts.oauth1 = {};

View File

@@ -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);
}
});

View File

@@ -1 +0,0 @@
Accounts.oauth2 = {};

View File

@@ -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);
}
});

View File

@@ -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');

View File

@@ -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);
};

View File

@@ -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"
};

View File

@@ -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
}
}
};
});

View File

@@ -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 () {

View File

@@ -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');

View File

@@ -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);
};

View File

@@ -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);
}
};

View File

@@ -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])

View File

@@ -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]);

View File

@@ -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({

View File

@@ -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') {

View File

@@ -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);
};

View File

@@ -0,0 +1,3 @@
if (typeof Facebook === 'undefined') {
Facebook = {};
}

View File

@@ -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);
};

View File

@@ -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');
});

View File

@@ -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});
};

View File

@@ -0,0 +1,3 @@
if (typeof Github === 'undefined') {
Github = {};
}

View File

@@ -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);
};

View File

@@ -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');
});

View File

@@ -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);
};

View File

@@ -0,0 +1,3 @@
if (typeof Google === 'undefined') {
Google = {};
}

View File

@@ -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);
};

View File

@@ -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');
});

View File

@@ -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});
};

View File

@@ -0,0 +1,3 @@
if (typeof Meetup === 'undefined') {
Meetup = {};
}

View File

@@ -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);
};

View File

@@ -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');
});

View File

@@ -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']);
});

View File

@@ -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();

View File

@@ -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));
}
});

View File

@@ -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';
});

View File

@@ -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"
}
}
}

View File

@@ -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;
});

View File

@@ -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});

View File

@@ -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',

View File

@@ -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'

View File

@@ -0,0 +1 @@
Oauth = {};

View File

@@ -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) {
'<html><head><script>window.close()</script></head></html>';
res.end(content, 'utf-8');
};

12
packages/oauth/package.js Normal file
View File

@@ -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');
});

View File

@@ -0,0 +1 @@
Oauth1 = {};

View File

@@ -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);
};

View File

@@ -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);
}
});

View File

@@ -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');
});

View File

@@ -0,0 +1 @@
Oauth2 = {};

View File

@@ -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);
};

View File

@@ -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);
}
});

View File

@@ -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');
});

View File

@@ -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;

View File

@@ -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']);
});

View File

@@ -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()
]);

View File

@@ -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");
});

Some files were not shown because too many files have changed in this diff Show More