first pass at oauth1 with twitter

This commit is contained in:
Mike Bannister
2012-07-29 18:37:29 -04:00
committed by Nick Martin
parent 410a8fcaea
commit 03b7706134
14 changed files with 488 additions and 1 deletions

View File

@@ -0,0 +1,62 @@
(function () {
// Open a popup window pointing to a OAuth1 handshake page
//
// @param state {String} The OAuth1 state generated by the client
// @param url {String} url to page
Meteor.accounts.oauth1.initiateLogin = function(state, url) {
// XXX these dimensions worked well for facebook and google, but
// it's sort of weird to have these here. Maybe an optional
// argument instead?
var popup = openCenteredPopup(url, 650, 331);
var checkPopupOpen = setInterval(function() {
if (popup.closed) {
clearInterval(checkPopupOpen);
tryLoginAfterPopupClosed(state);
}
}, 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) {
Meteor.apply('login', [
{oauth: {version: 1, state: state}}
], {wait: true}, function(error, result) {
if (error)
throw error;
if (!result) {
// The user either closed the OAuth popup or didn't authorize
// access. Do nothing.
return;
} else {
Meteor.accounts.makeClientLoggedIn(result.id, result.token);
}
});
};
var openCenteredPopup = function(url, width, height) {
var screenX = typeof window.screenX !== 'undefined'
? window.screenX : window.screenLeft;
var screenY = typeof window.screenY !== 'undefined'
? window.screenY : window.screenTop;
var outerWidth = typeof window.outerWidth !== 'undefined'
? window.outerWidth : document.body.clientWidth;
var outerHeight = typeof window.outerHeight !== 'undefined'
? window.outerHeight : (document.body.clientHeight - 22);
// Use `outerWidth - width` and `outerHeight - height` for help in
// positioning the popup centered relative to the current window
var left = screenX + (outerWidth - width) / 2;
var top = screenY + (outerHeight - height) / 2;
var features = ('width=' + width + ',height=' + height +
',left=' + left + ',top=' + top);
var newwindow = window.open(url, 'Login', features);
if (newwindow.focus)
newwindow.focus();
return newwindow;
};
})();

View File

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

View File

@@ -0,0 +1,150 @@
var crypto = __meteor_bootstrap__.require("crypto");
var querystring = __meteor_bootstrap__.require("querystring");
(function () {
var connect = __meteor_bootstrap__.require("connect");
Meteor.accounts.oauth1._services = {};
// Register a handler for an OAuth1 service. The handler will be called
// when we get an incoming http request on /_oauth1/{serviceName}. This
// handler should use that information to fetch data about the user
// logging in.
//
// @param name {String} e.g. "flickr", "twitter"
// @param handleOauthRequest {Function(query)}
// - query is an object with the parameters passed in the query string
// - return value is:
// - {options: (options), extra: (optional extra)} (same as the
// arguments to Meteor.accounts.updateOrCreateUser)
// - `null` if the user declined to give permissions
// XXX In the context of oauth1 the name handleOauthRequest doesn't make as much sense
Meteor.accounts.oauth1.registerService = function (name, handleOauthRequest) {
if (Meteor.accounts.oauth1._services[name])
throw new Error("Already registered the " + name + " OAuth1 service");
Meteor.accounts.oauth1._services[name] = {
handleOauthRequest: handleOauthRequest
};
};
// Listen to calls to `login` with an oauth option set
Meteor.accounts.registerLoginHandler(function (options) {
if (!options.oauth || options.oauth.version !== 1)
return undefined; // don't handle
var result = Meteor.accounts.oauth1._loginResultForState[options.oauth.state];
if (result === undefined) // not using `!result` since can be null
// We weren't notified of the user authorizing the login.
return null;
else
return result;
});
// When we get an incoming OAuth http request we complete the oauth
// handshake, account and token setup before responding. The
// results are stored in this map which is then read when the login
// method is called. Maps state --> return value of `login`
//
// XXX we should periodically clear old entries
Meteor.accounts.oauth1._loginResultForState = {};
// connect middleware
Meteor.accounts.oauth1._handleRequest = function (req, res, next) {
// req.url will be "/_oauth1/<service name>?<action>"
var barePath = req.url.substring(0, req.url.indexOf('?'));
var splitPath = barePath.split('/');
// Any non-oauth request will continue down the default middlewares
if (splitPath[1] !== '_oauth1') {
next();
return;
}
// Make sure we prepare the login results before returning.
// This way the subsequent call to the `login` method will be
// immediate.
var serviceName = splitPath[2];
// XXX check against a list of installed services too
if (!serviceName)
throw new Meteor.accounts.ConfigError("Service could not be found");
// Make sure we're configured
if (!Meteor.accounts[serviceName]._appId || !Meteor.accounts[serviceName]._appUrl)
throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts." + serviceName + ".config first");
if (!Meteor.accounts[serviceName]._secret)
throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts." + serviceName + ".setSecret first");
var service = Meteor.accounts.oauth1._services[serviceName];
var config = Meteor.accounts[serviceName];
var oauth = new OAuth(config);
if (req.query.callbackUrl) {
// Get a request token to start auth process
oauth.getRequestToken(req.query.callbackUrl);
var redirectUrl = config._urls.authenticate + '?oauth_token=' + oauth.requestToken;
res.writeHead(302, {'Location': redirectUrl});
res.end();
} else {
// XXX does checking for the verifier really make sense?
if (!req.query.oauth_token || !req.query.oauth_verifier) {
// The user didn't authorize access
return null;
}
// Get the oauth token for signing requests
oauth.getAccessToken(req.query.oauth_token);
// Get or create user id
var oauthResult = service.handleOauthRequest(oauth);
if (oauthResult) { // could be null if user declined permissions
var userId = Meteor.accounts.updateOrCreateUser(oauthResult.options, oauthResult.extra);
// Generate and store a login token for reconnect
// XXX this could go in accounts_server.js instead
var loginToken = Meteor.accounts._loginTokens.insert({userId: userId});
// Store results to subsequent call to `login`
Meteor.accounts.oauth1._loginResultForState[req.query.state] =
{token: loginToken, id: userId};
}
// We support ?close and ?redirect=URL. Any other query should
// just serve a blank page
if ('close' in req.query) { // check with 'in' because we don't set a value
// Close the popup window
res.writeHead(200, {'Content-Type': 'text/html'});
var content =
'<html><head><script>window.close()</script></head></html>';
res.end(content, 'utf-8');
} else if (req.query.redirect) {
res.writeHead(302, {'Location': req.query.redirect});
res.end();
} else {
res.writeHead(200, {'Content-Type': 'text/html'});
res.end('', 'utf-8');
}
}
};
// Listen on /_oauth/*
__meteor_bootstrap__.app
.use(connect.query())
.use(function(req, res, next) {
// Need to create a Fiber since we're using synchronous http
// calls and nothing else is wrapping this in a fiber
// automatically
Fiber(function () {
Meteor.accounts.oauth1._handleRequest(req, res, next);
}).run();
});
})();

View File

@@ -0,0 +1,39 @@
Tinytest.add("oauth2 - loginResultForState is stored", function (test) {
var http = __meteor_bootstrap__.require('http');
var email = Meteor.uuid() + "@example.com";
Meteor.accounts._loginTokens.remove({});
Meteor.accounts.oauth1._loginResultForState = {};
Meteor.accounts.oauth1._services = {};
// register a fake login service - foobook
Meteor.accounts.oauth1.registerService("foobook", function (query) {
return {
options: {
email: email,
services: {foobook: {id: 1}}
}
};
});
// simulate logging in using foobook
var req = {method: "POST",
url: "/_oauth1/foobook?close",
query: {state: "STATE"}};
Meteor.accounts.oauth1._handleRequest(req, new http.ServerResponse(req));
// verify that a user is created
var user = Meteor.users.findOne({emails: email});
test.notEqual(user, undefined);
test.equal(user.services.foobook.id, 1);
// and that that user has a login token
var token = Meteor.accounts._loginTokens.findOne({userId: user._id});
test.notEqual(token, undefined);
// and that the login result for that user is prepared
test.equal(
Meteor.accounts.oauth1._loginResultForState['STATE'].id, user._id);
test.equal(
Meteor.accounts.oauth1._loginResultForState['STATE'].token, token._id);
});

View File

@@ -0,0 +1,19 @@
Package.describe({
summary: "Common code for OAuth1-based login services",
internal: true
});
Package.on_use(function (api) {
api.use('accounts', ['client', 'server']);
api.use('oauth1', 'server');
api.add_files('oauth1_common.js', ['client', 'server']);
api.add_files('oauth1_server.js', 'server');
api.add_files('oauth1_client.js', 'client');
});
Package.on_test(function (api) {
// XXX Fix these!
// api.use('accounts-oauth1-helper', 'server');
// api.add_files("oauth1_tests.js", 'server');
});

View File

@@ -26,7 +26,7 @@
// Listen to calls to `login` with an oauth option set
Meteor.accounts.registerLoginHandler(function (options) {
if (!options.oauth)
if (!options.oauth || options.oauth.version !== 2)
return undefined; // don't handle
var result = Meteor.accounts.oauth2._loginResultForState[options.oauth.state];

View File

@@ -0,0 +1,13 @@
Package.describe({
summary: "Login service for Twitter accounts"
});
Package.on_use(function(api) {
api.use('accounts', ['client', 'server']);
api.use('accounts-oauth1-helper', ['client', 'server']);
api.use('http', ['client', 'server']);
api.add_files('twitter_common.js', ['client', 'server']);
api.add_files('twitter_server.js', 'server');
api.add_files('twitter_client.js', 'client');
});

View File

@@ -0,0 +1,13 @@
(function () {
Meteor.loginWithTwitter = function () {
if (!Meteor.accounts.twitter._appId || !Meteor.accounts.twitter._appUrl)
throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.twitter.config first");
var state = Meteor.uuid();
var callbackUrl = Meteor.accounts.twitter._appUrl + '/_oauth1/twitter?close&state=' + state;
var url = '/_oauth1/twitter/request_token?callbackUrl=' + encodeURIComponent(callbackUrl)
Meteor.accounts.oauth1.initiateLogin(state, url);
};
})();

View File

@@ -0,0 +1,16 @@
if (!Meteor.accounts.twitter) {
Meteor.accounts.twitter = {};
}
Meteor.accounts.twitter.config = function(appId, appUrl, options) {
Meteor.accounts.twitter._appId = appId;
Meteor.accounts.twitter._appUrl = appUrl;
Meteor.accounts.twitter._options = options;
Meteor.accounts.twitter._urls = {
requestToken: "https://api.twitter.com/oauth/request_token",
authorize: "https://api.twitter.com/oauth/authorize",
accessToken: "https://api.twitter.com/oauth/access_token",
authenticate: "https://api.twitter.com/oauth/authenticate"
};
};

View File

@@ -0,0 +1,20 @@
(function () {
Meteor.accounts.twitter.setSecret = function (secret) {
Meteor.accounts.twitter._secret = secret;
};
Meteor.accounts.oauth1.registerService('twitter', function(oauth) {
var identity = oauth.get('https://api.twitter.com/1/account/verify_credentials.json');
return {
options: {
// XXX Figure out what to do here
email: identity.screen_name + '@OAUTH1_TWITTER',
services: {twitter: {id: identity.id, accessToken: oauth.accessToken}}
},
extra: {name: identity.name}
};
});
}) ();

View File

@@ -69,6 +69,19 @@
};
},
'click #login-buttons-Twitter': function () {
try {
Meteor.loginWithTwitter();
} catch (e) {
if (e instanceof Meteor.accounts.ConfigError)
alert("Twitter API key not set. Configure app details with "
+ "Meteor.accounts.twitter.config() and "
+ "Meteor.accounts.twitter.setSecret()");
else
throw e;
};
},
'click #login-buttons-logout': function() {
Meteor.logout();
resetSession();
@@ -540,6 +553,8 @@
ret.push({name: 'Google'});
if (Meteor.accounts.weibo)
ret.push({name: 'Weibo'});
if (Meteor.accounts.twitter)
ret.push({name: 'Twitter'});
// make sure to put accounts last, since this is the order in the
// ui as well

View File

@@ -1,3 +1,5 @@
/* These should be in their respective packages */
#login-buttons-image-Google {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAADCklEQVQ4jSXSy2ucVRjA4d97zvdNJpPJbTJJE9rYaCINShZtRCFIA1bbLryBUlyoLQjqVl12W7UbN4qb1gtuYhFRRBCDBITaesFbbI3RFBLSptEY05l0ZjLfnMvrov/Bs3gAcF71x6VVHTk+o8nDH+hrH89rUK9Z9Yaen57S3wVtGaMBNGC0IegWKIDxTtVaOHVugZVmH3HX3Zz+4l+W1xvkOjuZfPsspY4CNkZELEgEIJKwYlBjEwjec/mfCMVuorVs76R8+P0KYMmP30U2dT8eIZqAR2ipRcWjEYxGSCRhV08e04oYMoxYLi97EI9YCJ0FHBYbIVGDlUBLwRlLIuYW6chEmQt/rJO09RJjhjEJEYvJYGNhkbUhw43OXtIWDFRq9G87nAaSK6sVRm8r8fzRMWbOX2Xx7ypd7ZET03sQhDOz73DqSJOrd+7HSo4QIu0Nx/4rOzx+cRXZ9+z7+uqJ+3hiepxK3fHZT2tMjXYzOtzL6dmznPzhLexgN0QlxAAYxAlqUqRmkf5j59RlNQ6MFHhgcpCTTx8EUb5e+plD7x4jjg1ANCAgrRQAdR7xKXjBlGyLYi7PxaUmb8z8xcpGHVXLHaXdjI0egKyJiQYTEhSPREVIEUBNC+Mqm+xpz3j0njLPHB2nsh1QgeG+IS48dYbD5YNoo0ZUAbVEuTUoKuBSZOarX/WhyQn6eg2+usDWf0s0tq8zNPYk+WI/Lnge++hlvlyfQ3NdECzGRWKwEEA0qNY251n69kV6+Y0kbaCZoebG2X3oU7pKoyxuXOPe945zs9DCeosGIXoBDyaLdf6ce4Hbk+/Y299ksKtAuaeNsiyw8c1LKIZ95b0MdgxA5giixACpTxEPSau6QdFfI5/2cLPmEW+JAQrtJUJzDXF1dkwHzVodJMX4HFEcQQMaFdPeM0Jb/4PUtzzaLKAhRyJFwo6lbegRNFfk819muV5dR4JBQoQdQ2xFiDmSNDHiaptamR9Gq5cQ18AledrGDpOfeI5Lq8u88smbhMRisoSAgAYghdfn5H/JkHuRZ1owLAAAAABJRU5ErkJggg==);
}
@@ -9,3 +11,7 @@
#login-buttons-image-Weibo {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAKySURBVDhPY2AgEpR5sjf/nS/6//UkoX+XJltuCvVxkcOp9cyZM1w/r13TuXvmDD9MkYIwg7qrNrubnzFb6J5intPHqrnvCnIwyKIYsmrVKuaFYWEFW2Sk79zX0f6/REHhKFABC0zRsky+rXMSeZdKCTLIHqgUvLAknW8L3IAQDw/RFlbWnQ801P+DNN8D4n0qyk94GRiEjTg5Lbz4+YOCdbhjVmTxbZwex7PUW58t8O1Ukf9gA2IDAoRPWFudfayt9f+mpsb/6yrK/28qKf4/ISf7YZu83K07QMNe6On9nyWusMtVm813azH/UWctZo/vc8TABjB3CApufAzSqKjw/7apyf+nMdH/XxUX/X+RnfX/qY/3/5tqqv/vq6v936KsfB2onltaiEHGx5AteFep4EmGUEHB1Adamv9v6er8fztp0v//79////nr1/+3X778B4N///5/O3jw/0N39//nlBQ/louLd4MMAWImcPhsU1G6DfLvt717wepnz537X0FB4T8fL+//AH///2/evgWL/7l///9dE+P/b4AWTZSWXg/UzAj2/w2gs59mZYEV7d+//z8rE9N/JUXF/w62tiD//a+urIS4BAgeA712Cxg2F40M36alpXGBDTgmI/3hdUU5WEFjff3/wvx8MNvcxARsQE1VFUQ30Et37Oz+P1RV+b/J0nIjUATigmgBvtzH5mb//9++/f/mkyf/A4KC/nv7+oI1W1hb/3/1+fP//9+//39ekP//CVDzTlnZxxtnz1ZBSUDeDAyZh7W13nybOeP/7W1b/09rbf2/FhgWHy9c+P912bL/D11d/l+WEP8/SUR4Ox8DA6pmmEkpHh4ya0JCim4lJGx7kZp8821CwrN7Hh4Pr7m6nDoSET61PjDQichsA3T7//+s/16/5gXSkIAa1AAAh8dhOVd5xHAAAAAASUVORK5CYII=);
}
#login-buttons-image-Twitter {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAByklEQVQ4jaVTz0sbQRh92V10l006GaKJCtEtmqMYU0Qpwqb4B6zgXdT0WEr7B0ih4MGLP05CUWMvHkQwglhvGhsvKmJOBhTUQjWU2slilKarrAfdZROTQ8m7fPMx33szb75vXKZpohpwVbEBCNaCMUYopXppAWOMxDNsOPf3H1WIeDoSURYYYwQAKKW6y7KgLe2vam11KyMRZcEpEP6SOkwbUgc4ATAKUF8YW2fXhZejvaHPsc7gvH2DnCfQGEtdxrd/5NRJteUDpVTf+5kLp2WlA6JsCyZv9ChplPKdTfJZkYWhEF3bvnV3fb36NZSY3dP6Q/5V4hFvIAaKPckE8W5pLBIQdwHAthBdPtpJuhpeAwDu74DrP4/R1/Ts4cwBWg/gN+DowoSqTBPezAMAeAHw+suSw4Q7schFApF6af19a+2yLVIB7xR+0Zk75yCveu82FMnMViKHCXcSa3PPVBJAX5BszL2SP2kNwvdy5M1e+S2AogME4HFYPibPpxKZC03nRAp/M+Dx2UWDzTXfpttrx72ikCoVtrrAAwgdXBk9iazxxtpskfhs1O86aHXXpAEcA7ivJGDBDcDnyAsA2FMsi1KB/0bVv/EBBBSY9mZ7PAsAAAAASUVORK5CYII=);
}

122
packages/oauth1/oauth1.js Normal file
View File

@@ -0,0 +1,122 @@
// XXX Use oauth verifier
OAuth = function(config) {
this.config = config;
};
OAuth.prototype._getAuthHeaderString = function(headers) {
return 'OAuth ' + _.map(headers, function(val, key) {
return encodeURIComponent(key) + '="' + encodeURIComponent(val) + '"';
}).sort().join(', ');
};
OAuth.prototype.getRequestToken = function(callbackUrl) {
var headers = this._buildHeader({
oauth_callback: callbackUrl
});
headers.oauth_signature = this._getSignature('POST', this.config._urls.requestToken, headers);
var authString = this._getAuthHeaderString(headers);
var response = Meteor.http.post(this.config._urls.requestToken, {
headers: {
Authorization: authString
}
});
if (response.error)
throw response.error;
var tokens = querystring.parse(response.content);
this.requestToken = tokens.oauth_token;
};
OAuth.prototype.getAccessToken = function(oauthToken) {
var headers = this._buildHeader({
oauth_token: oauthToken
});
headers.oauth_signature = this._getSignature('POST', this.config._urls.accessToken, headers);
var authString = this._getAuthHeaderString(headers);
var response = Meteor.http.post(this.config._urls.accessToken, {
headers: {
Authorization: authString
}
});
if (response.error)
throw response.error;
var tokens = querystring.parse(response.content);
this.accessToken = tokens.oauth_token;
this.accessTokenSecret = tokens.oauth_token_secret;
};
OAuth.prototype.call = function(method, url) {
var headers = this._buildHeader({
oauth_token: this.accessToken
});
headers.oauth_signature = this._getSignature(method.toUpperCase(), url, headers, this.accessTokenSecret);
var authString = this._getAuthHeaderString(headers);
var response = Meteor.http[method.toLowerCase()](url, {
headers: {
Authorization: authString
}
});
if (response.error)
throw response.error;
return response.data;
};
OAuth.prototype.get = function(url) {
return this.call('get', url);
};
OAuth.prototype._buildHeader = function(headers) {
return _.extend({
oauth_consumer_key: this.config._appId,
oauth_nonce: Meteor.uuid().replace(/\W/g, ''),
oauth_signature_method: 'HMAC-SHA1',
oauth_timestamp: (new Date().valueOf()/1000).toFixed().toString(),
oauth_version: '1.0'
}, headers);
};
OAuth.prototype._getSignature = function(method, url, rawHeaders, oauthSecret) {
var headers = this._encodeHeader(rawHeaders);
var parameters = _.map(headers, function(val, key) {
return key + '=' + val;
}).sort().join('&');
var signatureBase = [
method,
encodeURIComponent(url),
encodeURIComponent(parameters)
].join('&');
var signingKey = encodeURIComponent(this.config._secret) + '&';
if (oauthSecret)
signingKey += encodeURIComponent(oauthSecret);
return crypto.createHmac('SHA1', signingKey).update(signatureBase).digest('base64');
};
OAuth.prototype._encodeHeader = function(header) {
return _.reduce(header, function(memo, val, key) {
memo[encodeURIComponent(key)] = encodeURIComponent(val);
return memo;
}, {});
};

View File

@@ -0,0 +1,11 @@
Package.describe({
summary: "Code for oauth1 clients",
});
Package.on_use(function (api) {
api.add_files('oauth1.js', 'server');
});
Package.on_test(function (api) {
// XXX Add some!
});