From 03b77061342373f683097cca5e4e7d1f626a891e Mon Sep 17 00:00:00 2001 From: Mike Bannister Date: Sun, 29 Jul 2012 18:37:29 -0400 Subject: [PATCH] first pass at oauth1 with twitter --- .../accounts-oauth1-helper/oauth1_client.js | 62 ++++++++ .../accounts-oauth1-helper/oauth1_common.js | 1 + .../accounts-oauth1-helper/oauth1_server.js | 150 ++++++++++++++++++ .../accounts-oauth1-helper/oauth1_tests.js | 39 +++++ packages/accounts-oauth1-helper/package.js | 19 +++ .../accounts-oauth2-helper/oauth2_server.js | 2 +- packages/accounts-twitter/package.js | 13 ++ packages/accounts-twitter/twitter_client.js | 13 ++ packages/accounts-twitter/twitter_common.js | 16 ++ packages/accounts-twitter/twitter_server.js | 20 +++ packages/accounts-ui/login_buttons.js | 15 ++ packages/accounts-ui/login_buttons_images.css | 6 + packages/oauth1/oauth1.js | 122 ++++++++++++++ packages/oauth1/package.js | 11 ++ 14 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 packages/accounts-oauth1-helper/oauth1_client.js create mode 100644 packages/accounts-oauth1-helper/oauth1_common.js create mode 100644 packages/accounts-oauth1-helper/oauth1_server.js create mode 100644 packages/accounts-oauth1-helper/oauth1_tests.js create mode 100644 packages/accounts-oauth1-helper/package.js create mode 100644 packages/accounts-twitter/package.js create mode 100644 packages/accounts-twitter/twitter_client.js create mode 100644 packages/accounts-twitter/twitter_common.js create mode 100644 packages/accounts-twitter/twitter_server.js create mode 100644 packages/oauth1/oauth1.js create mode 100644 packages/oauth1/package.js diff --git a/packages/accounts-oauth1-helper/oauth1_client.js b/packages/accounts-oauth1-helper/oauth1_client.js new file mode 100644 index 0000000000..f859ff69cf --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_client.js @@ -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; + }; +})(); \ No newline at end of file diff --git a/packages/accounts-oauth1-helper/oauth1_common.js b/packages/accounts-oauth1-helper/oauth1_common.js new file mode 100644 index 0000000000..3b746c7e43 --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_common.js @@ -0,0 +1 @@ +Meteor.accounts.oauth1 = {}; \ No newline at end of file diff --git a/packages/accounts-oauth1-helper/oauth1_server.js b/packages/accounts-oauth1-helper/oauth1_server.js new file mode 100644 index 0000000000..cced70c2ce --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_server.js @@ -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/?" + 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 = + ''; + 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(); + }); + +})(); diff --git a/packages/accounts-oauth1-helper/oauth1_tests.js b/packages/accounts-oauth1-helper/oauth1_tests.js new file mode 100644 index 0000000000..826793bb60 --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_tests.js @@ -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); +}); diff --git a/packages/accounts-oauth1-helper/package.js b/packages/accounts-oauth1-helper/package.js new file mode 100644 index 0000000000..57d67e85d3 --- /dev/null +++ b/packages/accounts-oauth1-helper/package.js @@ -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'); +}); diff --git a/packages/accounts-oauth2-helper/oauth2_server.js b/packages/accounts-oauth2-helper/oauth2_server.js index 03e8819f92..1409589579 100644 --- a/packages/accounts-oauth2-helper/oauth2_server.js +++ b/packages/accounts-oauth2-helper/oauth2_server.js @@ -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]; diff --git a/packages/accounts-twitter/package.js b/packages/accounts-twitter/package.js new file mode 100644 index 0000000000..a8e34036e9 --- /dev/null +++ b/packages/accounts-twitter/package.js @@ -0,0 +1,13 @@ +Package.describe({ + summary: "Login service for Twitter accounts" +}); + +Package.on_use(function(api) { + api.use('accounts', ['client', 'server']); + api.use('accounts-oauth1-helper', ['client', 'server']); + api.use('http', ['client', 'server']); + + api.add_files('twitter_common.js', ['client', 'server']); + api.add_files('twitter_server.js', 'server'); + api.add_files('twitter_client.js', 'client'); +}); diff --git a/packages/accounts-twitter/twitter_client.js b/packages/accounts-twitter/twitter_client.js new file mode 100644 index 0000000000..55f44f0973 --- /dev/null +++ b/packages/accounts-twitter/twitter_client.js @@ -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); + }; + +})(); diff --git a/packages/accounts-twitter/twitter_common.js b/packages/accounts-twitter/twitter_common.js new file mode 100644 index 0000000000..62f4d8d373 --- /dev/null +++ b/packages/accounts-twitter/twitter_common.js @@ -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" + }; +}; diff --git a/packages/accounts-twitter/twitter_server.js b/packages/accounts-twitter/twitter_server.js new file mode 100644 index 0000000000..6dcae80017 --- /dev/null +++ b/packages/accounts-twitter/twitter_server.js @@ -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} + }; + }); +}) (); diff --git a/packages/accounts-ui/login_buttons.js b/packages/accounts-ui/login_buttons.js index f060816094..506960a27a 100644 --- a/packages/accounts-ui/login_buttons.js +++ b/packages/accounts-ui/login_buttons.js @@ -69,6 +69,19 @@ }; }, + 'click #login-buttons-Twitter': function () { + try { + Meteor.loginWithTwitter(); + } catch (e) { + if (e instanceof Meteor.accounts.ConfigError) + alert("Twitter API key not set. Configure app details with " + + "Meteor.accounts.twitter.config() and " + + "Meteor.accounts.twitter.setSecret()"); + else + throw e; + }; + }, + 'click #login-buttons-logout': function() { Meteor.logout(); resetSession(); @@ -540,6 +553,8 @@ ret.push({name: 'Google'}); if (Meteor.accounts.weibo) ret.push({name: 'Weibo'}); + if (Meteor.accounts.twitter) + ret.push({name: 'Twitter'}); // make sure to put accounts last, since this is the order in the // ui as well diff --git a/packages/accounts-ui/login_buttons_images.css b/packages/accounts-ui/login_buttons_images.css index 8d11767e12..88f9b54546 100644 --- a/packages/accounts-ui/login_buttons_images.css +++ b/packages/accounts-ui/login_buttons_images.css @@ -1,3 +1,5 @@ +/* These should be in their respective packages */ + #login-buttons-image-Google { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAADCklEQVQ4jSXSy2ucVRjA4d97zvdNJpPJbTJJE9rYaCINShZtRCFIA1bbLryBUlyoLQjqVl12W7UbN4qb1gtuYhFRRBCDBITaesFbbI3RFBLSptEY05l0ZjLfnMvrov/Bs3gAcF71x6VVHTk+o8nDH+hrH89rUK9Z9Yaen57S3wVtGaMBNGC0IegWKIDxTtVaOHVugZVmH3HX3Zz+4l+W1xvkOjuZfPsspY4CNkZELEgEIJKwYlBjEwjec/mfCMVuorVs76R8+P0KYMmP30U2dT8eIZqAR2ipRcWjEYxGSCRhV08e04oYMoxYLi97EI9YCJ0FHBYbIVGDlUBLwRlLIuYW6chEmQt/rJO09RJjhjEJEYvJYGNhkbUhw43OXtIWDFRq9G87nAaSK6sVRm8r8fzRMWbOX2Xx7ypd7ZET03sQhDOz73DqSJOrd+7HSo4QIu0Nx/4rOzx+cRXZ9+z7+uqJ+3hiepxK3fHZT2tMjXYzOtzL6dmznPzhLexgN0QlxAAYxAlqUqRmkf5j59RlNQ6MFHhgcpCTTx8EUb5e+plD7x4jjg1ANCAgrRQAdR7xKXjBlGyLYi7PxaUmb8z8xcpGHVXLHaXdjI0egKyJiQYTEhSPREVIEUBNC+Mqm+xpz3j0njLPHB2nsh1QgeG+IS48dYbD5YNoo0ZUAbVEuTUoKuBSZOarX/WhyQn6eg2+usDWf0s0tq8zNPYk+WI/Lnge++hlvlyfQ3NdECzGRWKwEEA0qNY251n69kV6+Y0kbaCZoebG2X3oU7pKoyxuXOPe945zs9DCeosGIXoBDyaLdf6ce4Hbk+/Y299ksKtAuaeNsiyw8c1LKIZ95b0MdgxA5giixACpTxEPSau6QdFfI5/2cLPmEW+JAQrtJUJzDXF1dkwHzVodJMX4HFEcQQMaFdPeM0Jb/4PUtzzaLKAhRyJFwo6lbegRNFfk819muV5dR4JBQoQdQ2xFiDmSNDHiaptamR9Gq5cQ18AledrGDpOfeI5Lq8u88smbhMRisoSAgAYghdfn5H/JkHuRZ1owLAAAAABJRU5ErkJggg==); } @@ -9,3 +11,7 @@ #login-buttons-image-Weibo { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAKySURBVDhPY2AgEpR5sjf/nS/6//UkoX+XJltuCvVxkcOp9cyZM1w/r13TuXvmDD9MkYIwg7qrNrubnzFb6J5intPHqrnvCnIwyKIYsmrVKuaFYWEFW2Sk79zX0f6/REHhKFABC0zRsky+rXMSeZdKCTLIHqgUvLAknW8L3IAQDw/RFlbWnQ801P+DNN8D4n0qyk94GRiEjTg5Lbz4+YOCdbhjVmTxbZwex7PUW58t8O1Ukf9gA2IDAoRPWFudfayt9f+mpsb/6yrK/28qKf4/ISf7YZu83K07QMNe6On9nyWusMtVm813azH/UWctZo/vc8TABjB3CApufAzSqKjw/7apyf+nMdH/XxUX/X+RnfX/qY/3/5tqqv/vq6v936KsfB2onltaiEHGx5AteFep4EmGUEHB1Adamv9v6er8fztp0v//79////nr1/+3X778B4N///5/O3jw/0N39//nlBQ/louLd4MMAWImcPhsU1G6DfLvt717wepnz537X0FB4T8fL+//AH///2/evgWL/7l///9dE+P/b4AWTZSWXg/UzAj2/w2gs59mZYEV7d+//z8rE9N/JUXF/w62tiD//a+urIS4BAgeA712Cxg2F40M36alpXGBDTgmI/3hdUU5WEFjff3/wvx8MNvcxARsQE1VFUQ30Et37Oz+P1RV+b/J0nIjUATigmgBvtzH5mb//9++/f/mkyf/A4KC/nv7+oI1W1hb/3/1+fP//9+//39ekP//CVDzTlnZxxtnz1ZBSUDeDAyZh7W13nybOeP/7W1b/09rbf2/FhgWHy9c+P912bL/D11d/l+WEP8/SUR4Ox8DA6pmmEkpHh4ya0JCim4lJGx7kZp8821CwrN7Hh4Pr7m6nDoSET61PjDQichsA3T7//+s/16/5gXSkIAa1AAAh8dhOVd5xHAAAAAASUVORK5CYII=); } + +#login-buttons-image-Twitter { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAByklEQVQ4jaVTz0sbQRh92V10l006GaKJCtEtmqMYU0Qpwqb4B6zgXdT0WEr7B0ih4MGLP05CUWMvHkQwglhvGhsvKmJOBhTUQjWU2slilKarrAfdZROTQ8m7fPMx33szb75vXKZpohpwVbEBCNaCMUYopXppAWOMxDNsOPf3H1WIeDoSURYYYwQAKKW6y7KgLe2vam11KyMRZcEpEP6SOkwbUgc4ATAKUF8YW2fXhZejvaHPsc7gvH2DnCfQGEtdxrd/5NRJteUDpVTf+5kLp2WlA6JsCyZv9ChplPKdTfJZkYWhEF3bvnV3fb36NZSY3dP6Q/5V4hFvIAaKPckE8W5pLBIQdwHAthBdPtpJuhpeAwDu74DrP4/R1/Ts4cwBWg/gN+DowoSqTBPezAMAeAHw+suSw4Q7schFApF6af19a+2yLVIB7xR+0Zk75yCveu82FMnMViKHCXcSa3PPVBJAX5BszL2SP2kNwvdy5M1e+S2AogME4HFYPibPpxKZC03nRAp/M+Dx2UWDzTXfpttrx72ikCoVtrrAAwgdXBk9iazxxtpskfhs1O86aHXXpAEcA7ivJGDBDcDnyAsA2FMsi1KB/0bVv/EBBBSY9mZ7PAsAAAAASUVORK5CYII=); +} diff --git a/packages/oauth1/oauth1.js b/packages/oauth1/oauth1.js new file mode 100644 index 0000000000..1e7580c224 --- /dev/null +++ b/packages/oauth1/oauth1.js @@ -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; + }, {}); +}; diff --git a/packages/oauth1/package.js b/packages/oauth1/package.js new file mode 100644 index 0000000000..a7afe9424e --- /dev/null +++ b/packages/oauth1/package.js @@ -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! +});