# HG changeset patch # User Quentin Raynaud <quentin@qraynaud.eu> # Date 1375564789 -7200 # Sat Aug 03 23:19:49 2013 +0200 # Branch oauth # Node ID 745e65c3db3cada48f738693f0e37f125cbbf669 # Parent c23c45bae4cca6653423c6163ca0a1631b739e99 Add OAuth support for connecting to Meetup.com diff --git a/.hgignore b/.hgignore --- a/.hgignore +++ b/.hgignore @@ -4,3 +4,6 @@ ^public/ ^bower_components/ ^\.ngapp.json$ + +syntax: glob +server/config.js diff --git a/client/html/templates/home.jade b/client/html/templates/home.jade --- a/client/html/templates/home.jade +++ b/client/html/templates/home.jade @@ -54,3 +54,4 @@ pre {{userform|json}} p(x-ng-if="userform.$valid") Form is valid. p(x-ng-if="userform.$invalid") Form in NOT valid (yet)! +a(href="/api/v1/oauth?provider=meetup", target="_self") Connect with Meetup.com \ No newline at end of file diff --git a/client/html/templates/oauth.jade b/client/html/templates/oauth.jade new file mode 100644 --- /dev/null +++ b/client/html/templates/oauth.jade @@ -0,0 +1,1 @@ +p.text-success Success!! diff --git a/client/js/nodejsparis/controllers/oauth.js b/client/js/nodejsparis/controllers/oauth.js new file mode 100644 --- /dev/null +++ b/client/js/nodejsparis/controllers/oauth.js @@ -0,0 +1,4 @@ +angular.module('nodejsparis') +.controller('njp-oauth', function($scope) { + $scope.ctrlName = 'njp-oauth'; +}); diff --git a/client/js/nodejsparis/nodejsparis.js b/client/js/nodejsparis/nodejsparis.js --- a/client/js/nodejsparis/nodejsparis.js +++ b/client/js/nodejsparis/nodejsparis.js @@ -1,10 +1,16 @@ angular.module('nodejsparis', []) .constant('njpVersion', jQuery('html').data('app-version')) -.config(function($routeProvider, njpVersion) { +.config(function($routeProvider, $locationProvider, njpVersion) { $routeProvider .when('/home', { templateUrl: '/' + njpVersion + '/templates/home.html', controller: 'njp-home' }) + .when('/oauth', { + templateUrl: '/' + njpVersion + '/templates/oauth.html', + controller: 'njp-oauth' + }) .otherwise({ redirectTo: '/home' }); + + $locationProvider.html5Mode(true); }); diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -15,7 +15,10 @@ "dependencies": { "express": "~3.3.4", "deferred": "~0.6.5", - "lodash": "~1.3.1" + "lodash": "~1.3.1", + "request": "~2.25.0", + "node.extend": "~1.0.7", + "uuid": "~1.4.1" }, "devDependencies": { "stylus": "~0.34.1", diff --git a/server/config.example.js b/server/config.example.js new file mode 100644 --- /dev/null +++ b/server/config.example.js @@ -0,0 +1,19 @@ +module.exports = { + oauth: { + url: '/api/v1/oauth', + redirect_to: '/oauth', + + meetup: { + enabled: true, + client_id: '<YOURID>', + client_secret: '<YOURSECRET>', + redirect_uri: '<YOURURI>', + + profile: { + params: { + group_urlname: 'Nodejs-Paris' + } + } + } + } +} diff --git a/server/libs/oauth.config.js b/server/libs/oauth.config.js new file mode 100644 --- /dev/null +++ b/server/libs/oauth.config.js @@ -0,0 +1,130 @@ +module.exports = { + google: { + start: 'redirect', + + redirect: { + method: 'REDIRECT', + url: 'https://accounts.google.com/o/oauth2/auth', + params: { + client_id: null, + redirect_uri: null, + scope: 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email', + response_type: 'code', + state: null, + }, + get: 'code', + next: 'token' + }, + token: { + method: 'POST', + url: 'https://accounts.google.com/o/oauth2/token', + params: { + code: null, + client_id: null, + client_secret: null, + redirect_uri: null, + grant_type: 'authorization_code' + }, + get: 'access_token', + next: 'userinfos' + }, + userinfos: { + method: 'GET', + url: 'https://www.googleapis.com/oauth2/v1/userinfo', + params: { + alt: 'json', + access_token: null + }, + res: 'userinfos' + } + }, + + facebook: { + start: 'redirect', + + redirect: { + method: 'REDIRECT', + url: 'https://www.facebook.com/dialog/oauth', + params: { + client_id: null, + redirect_uri: null, + state: null, + scope: 'email,user_birthday' + }, + get: 'code', + next: 'token' + }, + token: { + method: 'GET', + url: 'https://graph.facebook.com/oauth/access_token', + params: { + client_id: null, + client_secret: null, + redirect_uri: null, + code: null + }, + get: 'access_token', + next: 'graph' + }, + graph: { + method: 'GET', + url: 'https://graph.facebook.com/me', + params: { + access_token: null + }, + res: 'userinfos', + next: 'picture' + }, + picture: { + method: 'GET', + url: 'https://graph.facebook.com/me/picture', + params: { + access_token: null, + redirect: false, + width: 10000 + }, + res: 'userinfos', + res_add: { picture: 'data.url' } + } + }, + + meetup: { + start: 'redirect', + + redirect: { + method: 'REDIRECT', + url: 'https://secure.meetup.com/oauth2/authorize', + params: { + client_id: null, + scope: 'basic', + response_type: 'code', + redirect_uri: null, + state: null + }, + get: 'code', + next: 'token' + }, + token: { + method: 'POST', + url: 'https://secure.meetup.com/oauth2/access', + params: { + client_id: null, + client_secret: null, + grant_type: 'authorization_code', + redirect_uri: null, + code: null + }, + get: 'access_token', + next: 'profile' + }, + profile: { + method: 'GET', + url: ' https://api.meetup.com/2/profiles', + params: { + access_token: null, + member_id: 'self' + }, + res: 'userinfos' + } + } +}; diff --git a/server/libs/oauth.js b/server/libs/oauth.js new file mode 100644 --- /dev/null +++ b/server/libs/oauth.js @@ -0,0 +1,299 @@ +'use strict'; + +var querystring = require('querystring') + , request = require('request') + , extend = require('node.extend') + , uuid = require('uuid') +; + +// Returns resolved parameters for the given state +// (resolved parameters = no null values) +function getConfigParams(session, config, state) { + // Get the basic parameters object from the configuration + var params = config[state.step].params; + + // Create an empty obj that will get our values + var res = {}; + // For each parameters in the config + for (var p in params) { + // If the parameter is called 'state', resolve it to the state object & continue + if (p == 'state') { + res[p] = state.uuid; + continue; + } + // Otherwise, try to use the parameter value from the configuration + // If it is null, try to lookup for the same parameter in the configuration's root + // If it is null or undefined, try to look for the parameter in the session + // If it is still null or undefined, set the value to null + res[p] = params[p] || config[p] || session.oauth[p] || null; + } + + // Return the so constructed object + return res; +} + +// Same as getConfigParams but serialize the object to get into an URL +function getConfigParamsSrz(session, config, state) { + return querystring.stringify(getConfigParams(session, config, state)); +} + +// Get an obj attribute using the get/add_res syntax +function getObjValue(ref, obj) { + // Split the ref string using dot '.' and remove empty cells to get attributes' names + var attrs = ref.split('.').filter(function(o) { return !!o; }); + + // For each attribute in the ref string but the last one + for (var i = 0; i < attrs.length - 1; ++i) + // Get into the objet's corresponding attribute (if there is none, use an empty object) + obj = obj[attrs[i]] || {}; + + // Returns the object's attribute (or undefined is it does not exists) + return obj[attrs[i]]; +} + +// Create an object using the get/add_res syntax +function createObj(ref, value) { + // This will be our result + var res = {}; + + // Split the ref string using dot '.' and remove empty cells + var attrs = ref.split('.').filter(function(o) { return !!o; }); + + // tmp will be a "pointer" somewhere in our result object + var tmp = res; + // For each attributes' names in the ref string but the last one + for (var i = 0; i < attrs.length - 1; ++i) { + // Create an empty object in the tmp "pointer" mapped to the current attribute's + // name and move the pointer to this new empt object + tmp = tmp[attrs[i]] = {}; + } + // Add the value in the pointer object mapped with the last attribute's name + tmp[attrs[i]] = value; + + // Return the constructed object + return res; +} + +// Copy an object from "source" to "dest" using an object containing +// get/add_res syntax +function smartCopy(ref, dest, source) { + // If the ref is an array, they just copy each attributes + // it references from source to dest + if (Array.isArray(ref)) { + for (var i = 0; i < ref.length; ++i) + dest[ref[i]] = source[ref[i]]; + } + // If the ref is an object, is attribute & value can be a get/add_res syntax + else if (typeof(ref) == 'object') { + // For each attributes + for (var a in ref) { + // If the value corresponding to the attribute is an object, + // then recurse into it + if (typeof(ref[a]) == 'object') + dest[a] = smartCopy(ref[a], dest[a] || {}, source[a] || {}); + // Otherwise extend dest with an object creating using a and the value it references + else + extend(true, dest, createObj(a, getObjValue(ref[a] || a, source))); + } + } + // Otherwise dest is a string, just copy the attribute it references + else + dest[ref] = source[ref]; + + return dest; +} + +module.exports = function(app, config) { + // Exend the configuration from our config file with the given configuration + config = extend(true, {}, require('./oauth.config.js'), config); + + // This function runs the current step + function invokeStep(req, res, state) { + // Get the oauth configuration object + var oauth = config[state.provider]; + // Get the current step from it + var step = oauth[state.step]; + + // Get the url related to the step + var url = step.url; + + // If the method is not POST, then add the parameters to the URL + if (step.method != 'POST') + url += '?' + getConfigParamsSrz(req.session, oauth, state); + + // If the method is redirect, then we can redirect the user to the + // computed url + if (step.method == 'REDIRECT') { + // Save the session before redirecting the user + // This is needed because the user could be redirected back + // to our oauth service before the session is automatically saved + // and if the user is handled by another process of the cluster + // it might cause the process to fail badly + req.session.save(function() { + // Redirect the user + res.redirect(url); + }); + return; + } + + // Otherwise prepare a request config + var req_opts = { + url: url, // URL to request + jar: false, // Do not store cookies + method: step.method // GET|POST|... + }; + + // If the method is POST + if (step.method == 'POST') { + // Add to the request object the correct headers + req_opts.headers = {'content-type': 'application/x-www-form-urlencoded'}; + // And the configuration parameters (to the body) + req_opts.body = getConfigParamsSrz(req.session, oauth, state); + } + + // Run the request + request(req_opts, function(err, req_res, req_body) { + // Copy the result to the current request + req.body = req_body; + // If we have a string, try to resolve it into a JS object + if (typeof(req.body) == 'string') { + try { + // First try to parse it from JSON (standard) + req.body = JSON.parse(req.body); + } catch (e) { + // If it fails, try to parse an urlencoded string + // (facebook is non standard and do this) + req.body = querystring.parse(req.body); + } + } + + // If an error occured + if (err || !req.body || req.body.error) { + // Report it to the client + res.writeHead(500); + res.end('Error connecting through your provider.'); + return; + } + + // Otherwise, go to the next step + invokeNextStep(req, res, state); + }); + } + + // This function changes the state & prep next step, then + // calls invokeStep to run it + function invokeNextStep(req, res, state) { + // Get the current oauth config & step using the provided state + var oauth = config[state.provider]; + var step = oauth[state.step]; + + // If we don't have a body to our request, use the GET parameters instead + if (!req.body || !Object.keys(req.body).length) + req.body = req.query; + + // If we have to get some values from the prevous request before switching state + // then do it + if (step.get) + smartCopy(step.get, req.session.oauth, req.body); + + // If we have to set/modify some res value, do it now + if (step.res) { + // Modify + if (step.res_add) + smartCopy(step.res_add, req.session.oauth[step.res], req.body); + // Set + else + req.session.oauth[step.res] = req.body; + } + + if (step.next) { + state.step = step.next; + invokeStep(req, res, state); + } + else { + req.session.oauth.ok = true; + res.redirect(config.redirect_to || '/'); + } + } + + // This function init the process enough to invoke the first step + function invokeFirstStep(req, res) { + // Check that the provider to use is given in the query + // that it exists in our conf & is enabled + if (!req.query.provider || !(req.query.provider in config) || !config[req.query.provider].enabled) { + // Fails if not + req.session.oauth = { error: 'no_provider', error_msg: 'The variable provider should be given when using oauth.' }; + res.redirect(config.redirect_to || '/'); + return; + } + + // Init first state using the configuration + var state = { + provider: req.query.provider, + step: config[req.query.provider].start, + uuid: uuid.v4() + }; + + // Init a basic oauth object to store & report status + req.session.oauth = {}; + req.session.oauth[state.uuid] = state; + + // Run step + invokeStep(req, res, state); + } + + function getInfos(req, res) { + var infos = extend({}, req.session.oauth); + delete infos.access_token; + delete infos.code; + + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(infos)); + } + + // Entry point for using the oauth module + function oauth(req, res) { + // Delete oauth informations from session + if (req.query.logout === 'true') { + delete req.session.oauth; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + return; + } + + // Return oauth informations in JSON + if (req.query.infos === 'true') { + if (!req.session.oauth) + req.session.oauth = { error: 'invalid_state', error_msg: 'No oauth object in session.' }; + getInfos(req, res); + return; + } + + // If we are provided with a state + if (req.query.state) { + if (!req.session.oauth) + req.session.oauth = { error: 'invalid_state', error_msg: 'No oauth object in session.' }; + + // Get it in a JS form + var state = req.session.oauth[req.query.state]; + + // Check that state looks ok + if (!state.provider || !state.step) + req.session.oauth = { error: 'invalid_state', error_msg: 'State object is invalid.' }; + + // If there is an error, redirect the user where the config says so + if (req.session.oauth.error) + res.redirect(config.redirect_to || '/'); + + // Otherwise invoke next step + invokeNextStep(req, res, state); + return; + } + + // Otherwise invoke first step + invokeFirstStep(req, res); + } + + // Add the oauth module to the express routing + app.get(config.url, oauth); +}; diff --git a/server/server.js b/server/server.js --- a/server/server.js +++ b/server/server.js @@ -2,12 +2,17 @@ var fs = require('fs'); var path = require('path'); var app = express(); +var config = require('./config'); var port = 3000; -app.use(express.static('public/')); +app.use(express.static(path.join(__dirname, '../public'))); +app.use(express.cookieParser()); +app.use(express.session({secret: 'secret'})); -app.get('/', express.static('public/index.html')); +// Add oauth support +require('./libs/oauth')(app, config.oauth); +// Serve API mockups app.all('/api/*', function (req, res) { res.setHeader('Content-type', 'application/json; charset=utf-8'); fs.readFile( @@ -18,5 +23,15 @@ ); }); +// Return index.html +app.get('/*', function(req, res) { + fs.readFile( + path.join(__dirname, '../public/index.html'), + function (err, data) { + res.end(data); + } + ); +}); + app.listen(port); console.log('Listening on port ' + port + ' - ' + app.settings.env + ' mode');