├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── doc └── images │ └── screenshot.png ├── example ├── .gitignore ├── README.md ├── client │ └── README.md ├── common │ └── models │ │ ├── account.js │ │ └── account.json └── server │ ├── boot │ ├── authentication.js │ ├── rest-api.js │ └── root.js │ ├── component-config.json │ ├── config.json │ ├── datasources.json │ ├── middleware.json │ ├── model-config.json │ └── server.js ├── gulpfile.coffee ├── gulpfile.js ├── lib ├── common │ └── index.js ├── facebook │ └── index.js ├── google │ └── index.js ├── index.js └── twitter │ └── index.js ├── package.json ├── source ├── common │ └── index.coffee ├── facebook │ └── index.coffee ├── google │ └── index.coffee ├── index.coffee └── twitter │ └── index.coffee └── test ├── facebook.coffee ├── google.coffee ├── mocha.opts └── twitter.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - '0.10' 5 | - '0.11' 6 | - '0.12' 7 | - '4.0' 8 | - '4.1' 9 | 10 | cache: 11 | directories: 12 | - node_modules 13 | 14 | notifications: 15 | email: 16 | - jeremie.drouet@gmail.com 17 | 18 | before_script: 19 | - npm install 20 | 21 | script: 22 | - npm test 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 moooink 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # loopback-satellizer 3 | 4 | [![Join the chat at https://gitter.im/moooink/loopback-component-satellizer](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/moooink/loopback-component-satellizer?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 5 | 6 | [![Build Status](https://travis-ci.org/moooink/loopback-component-satellizer.svg?branch=master)](https://travis-ci.org/moooink/loopback-component-satellizer) 7 | 8 | [![Stories in Ready](https://badge.waffle.io/moooink/loopback-component-satellizer.png?label=ready&title=Ready)](https://waffle.io/moooink/loopback-component-satellizer) 9 | 10 | # How to use it 11 | 12 | ![Example explorer](./doc/images/screenshot.png) 13 | 14 | ## Install the component 15 | 16 | ```bash 17 | npm install --save loopback-component-satellizer 18 | ``` 19 | 20 | ## Configuration for facebook 21 | 22 | In your loopback component-config.json file, add your configuration like this 23 | 24 | ```javascript 25 | "loopback-component-satellizer": { 26 | "facebook": { 27 | "model": "Account", 28 | "credentials": { 29 | "public": "this_is_a_client_id", 30 | "private": "this_is_a_private_key" 31 | }, 32 | "version": "v2.3", 33 | "fields": ["email"], 34 | "uri": "/facebook", 35 | "mapping": { 36 | "id": "facebook", 37 | "email": "email", 38 | "first_name": "firstName", 39 | "last_name": "lastName", 40 | "gender": "gender" 41 | } 42 | } 43 | } 44 | ``` 45 | 46 | Add the ACLs to allow (or not) the access to the API 47 | 48 | ```javascript 49 | "acls": [ 50 | { 51 | "accessType": "EXECUTE", 52 | "principalType": "ROLE", 53 | "principalId": "$everyone", 54 | "permission": "ALLOW", 55 | "property": "facebook" 56 | }, 57 | ``` 58 | 59 | Then configure satellizer in the client and take care of the conflicts between the satellizer authorization token and the loopback authorization token. 60 | 61 | ## Configuration for Google+ 62 | 63 | In your loopback component-config.json file, load the component 64 | 65 | ```javascript 66 | "loopback-component-satellizer": { 67 | "google": { 68 | "model": "Account", 69 | "credentials": { 70 | "public": "this_is_a_client_id", 71 | "private": "this_is_a_private_key" 72 | }, 73 | "uri": "/google", 74 | "mapping": { 75 | "sub": "google", 76 | "email": "email", 77 | "given_name": "firstName", 78 | "family_name": "lastName", 79 | "gender": "gender" 80 | } 81 | } 82 | } 83 | ``` 84 | 85 | Add the ACLs to allow (or not) the access to the API 86 | 87 | ```javascript 88 | "acls": [ 89 | { 90 | "accessType": "EXECUTE", 91 | "principalType": "ROLE", 92 | "principalId": "$everyone", 93 | "permission": "ALLOW", 94 | "property": "google" 95 | }, 96 | ``` 97 | 98 | Then configure satellizer in the client and take care of the conflicts between the satellizer authorization token and the loopback authorization token. 99 | -------------------------------------------------------------------------------- /doc/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moooink/loopback-component-satellizer/46b015dc736d28d4a32e3387f3b677440c237c19/doc/images/screenshot.png -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | *.csv 2 | *.dat 3 | *.iml 4 | *.log 5 | *.out 6 | *.pid 7 | *.seed 8 | *.sublime-* 9 | *.swo 10 | *.swp 11 | *.tgz 12 | *.xml 13 | .DS_Store 14 | .idea 15 | .project 16 | .strong-pm 17 | coverage 18 | node_modules 19 | npm-debug.log 20 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # My Application 2 | 3 | The project is generated by [LoopBack](http://loopback.io). -------------------------------------------------------------------------------- /example/client/README.md: -------------------------------------------------------------------------------- 1 | ## Client 2 | 3 | This is the place for your application front-end files. 4 | -------------------------------------------------------------------------------- /example/common/models/account.js: -------------------------------------------------------------------------------- 1 | module.exports = function(Account) { 2 | 3 | }; 4 | -------------------------------------------------------------------------------- /example/common/models/account.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Account", 3 | "plural": "accounts", 4 | "base": "User", 5 | "idInjection": true, 6 | "options": { 7 | "validateUpsert": true 8 | }, 9 | "properties": { 10 | "facebook": { 11 | "type": "string" 12 | }, 13 | "google": { 14 | "type": "string" 15 | }, 16 | "firstName": { 17 | "type": "string" 18 | }, 19 | "lastName": { 20 | "type": "string" 21 | }, 22 | "gender": { 23 | "type": "string" 24 | } 25 | }, 26 | "validations": [], 27 | "relations": {}, 28 | "acls": [ 29 | { 30 | "accessType": "EXECUTE", 31 | "principalType": "ROLE", 32 | "principalId": "$unauthenticated", 33 | "permission": "ALLOW", 34 | "property": "facebook" 35 | }, 36 | { 37 | "accessType": "EXECUTE", 38 | "principalType": "ROLE", 39 | "principalId": "$unauthenticated", 40 | "permission": "ALLOW", 41 | "property": "facebook-get" 42 | }, 43 | { 44 | "accessType": "EXECUTE", 45 | "principalType": "ROLE", 46 | "principalId": "$unauthenticated", 47 | "permission": "ALLOW", 48 | "property": "google" 49 | }, 50 | { 51 | "accessType": "EXECUTE", 52 | "principalType": "ROLE", 53 | "principalId": "$unauthenticated", 54 | "permission": "ALLOW", 55 | "property": "google-get" 56 | }, 57 | { 58 | "accessType": "EXECUTE", 59 | "principalType": "ROLE", 60 | "principalId": "$unauthenticated", 61 | "permission": "ALLOW", 62 | "property": "twitter" 63 | }, 64 | { 65 | "accessType": "EXECUTE", 66 | "principalType": "ROLE", 67 | "principalId": "$unauthenticated", 68 | "permission": "ALLOW", 69 | "property": "twitter-get" 70 | } 71 | ], 72 | "methods": [] 73 | } 74 | -------------------------------------------------------------------------------- /example/server/boot/authentication.js: -------------------------------------------------------------------------------- 1 | module.exports = function enableAuthentication(server) { 2 | // enable authentication 3 | server.enableAuth(); 4 | }; 5 | -------------------------------------------------------------------------------- /example/server/boot/rest-api.js: -------------------------------------------------------------------------------- 1 | module.exports = function mountRestApi(server) { 2 | var restApiRoot = server.get('restApiRoot'); 3 | server.use(restApiRoot, server.loopback.rest()); 4 | }; 5 | -------------------------------------------------------------------------------- /example/server/boot/root.js: -------------------------------------------------------------------------------- 1 | module.exports = function(server) { 2 | // Install a `/` route that returns server status 3 | var router = server.loopback.Router(); 4 | router.get('/', server.loopback.status()); 5 | server.use(router); 6 | }; 7 | -------------------------------------------------------------------------------- /example/server/component-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "loopback-component-explorer": { 3 | "mountPath": "/explorer" 4 | }, 5 | "loopback-component-satellizer": { 6 | "facebook": { 7 | "model": "Account", 8 | "credentials": { 9 | "public": "this_is_a_client_id", 10 | "private": "this_is_a_private_key" 11 | }, 12 | "version": "v2.3", 13 | "fields": ["email"], 14 | "uri": "/facebook", 15 | "redirectUri": "this_is_the_uri", 16 | "mapping": { 17 | "id": "facebook", 18 | "email": "email", 19 | "first_name": "firstName", 20 | "last_name": "lastName", 21 | "gender": "gender" 22 | } 23 | }, 24 | "google": { 25 | "model": "Account", 26 | "credentials": { 27 | "public": "this_is_a_client_id", 28 | "private": "this_is_a_private_key" 29 | }, 30 | "uri": "/google", 31 | "redirectUri": "this_is_the_uri", 32 | "mapping": { 33 | "sub": "google", 34 | "email": "email", 35 | "given_name": "firstName", 36 | "family_name": "lastName", 37 | "gender": "gender" 38 | } 39 | }, 40 | "twitter": { 41 | "model": "Account", 42 | "credentials": { 43 | "public": "this_is_a_client_id", 44 | "private": "this_is_a_private_key" 45 | }, 46 | "uri": "/twitter", 47 | "mapping": { 48 | "id": "twitter", 49 | "screen_name": "firstName" 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /example/server/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "restApiRoot": "/api", 3 | "host": "0.0.0.0", 4 | "port": 3000, 5 | "remoting": { 6 | "context": { 7 | "enableHttpContext": false 8 | }, 9 | "rest": { 10 | "normalizeHttpPath": false, 11 | "xml": false 12 | }, 13 | "json": { 14 | "strict": false, 15 | "limit": "100kb" 16 | }, 17 | "urlencoded": { 18 | "extended": true, 19 | "limit": "100kb" 20 | }, 21 | "cors": false, 22 | "errorHandler": { 23 | "disableStackTrace": false 24 | } 25 | }, 26 | "legacyExplorer": false, 27 | "provider": { 28 | "facebook": { 29 | "public": "this_is_a_public_key", 30 | "private": "this_is_a_private_key", 31 | "fields": ["email"] 32 | }, 33 | "google": { 34 | "public": "this_is_a_public_key", 35 | "private": "this_is_a_private_key" 36 | }, 37 | "twitter": { 38 | "public": "this_is_a_public_key", 39 | "private": "this_is_a_private_key" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /example/server/datasources.json: -------------------------------------------------------------------------------- 1 | { 2 | "db": { 3 | "name": "db", 4 | "connector": "memory" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /example/server/middleware.json: -------------------------------------------------------------------------------- 1 | { 2 | "initial:before": { 3 | "loopback#favicon": {}, 4 | "strong-express-metrics": {} 5 | }, 6 | "initial": { 7 | "compression": {}, 8 | "cors": { 9 | "params": { 10 | "origin": true, 11 | "credentials": true, 12 | "maxAge": 86400 13 | } 14 | } 15 | }, 16 | "session": { 17 | }, 18 | "auth": { 19 | }, 20 | "parse": { 21 | }, 22 | "routes": { 23 | }, 24 | "files": { 25 | }, 26 | "final": { 27 | "loopback#urlNotFound": {} 28 | }, 29 | "final:after": { 30 | "errorhandler": {} 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /example/server/model-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "sources": [ 4 | "loopback/common/models", 5 | "loopback/server/models", 6 | "../common/models", 7 | "./models" 8 | ] 9 | }, 10 | "User": { 11 | "dataSource": "db" 12 | }, 13 | "AccessToken": { 14 | "dataSource": "db", 15 | "public": false 16 | }, 17 | "ACL": { 18 | "dataSource": "db", 19 | "public": false 20 | }, 21 | "RoleMapping": { 22 | "dataSource": "db", 23 | "public": false 24 | }, 25 | "Role": { 26 | "dataSource": "db", 27 | "public": false 28 | }, 29 | "Account": { 30 | "dataSource": "db", 31 | "public": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /example/server/server.js: -------------------------------------------------------------------------------- 1 | var loopback = require('loopback'); 2 | var boot = require('loopback-boot'); 3 | 4 | var app = module.exports = loopback(); 5 | 6 | app.start = function() { 7 | // start the web server 8 | return app.listen(function() { 9 | app.emit('started'); 10 | console.log('Web server listening at: %s', app.get('url')); 11 | }); 12 | }; 13 | 14 | // Bootstrap the application, configure models, datasources and middleware. 15 | // Sub-apps like REST API are mounted via boot scripts. 16 | boot(app, __dirname, function(err) { 17 | if (err) throw err; 18 | 19 | // start the server if `$ node server.js` 20 | if (require.main === module) 21 | app.start(); 22 | }); 23 | -------------------------------------------------------------------------------- /gulpfile.coffee: -------------------------------------------------------------------------------- 1 | coffee = require 'gulp-coffee' 2 | gulp = require 'gulp' 3 | gutil = require 'gulp-util' 4 | 5 | gulp.task 'build', -> 6 | gulp.src [ 7 | './source/*.coffee' 8 | './source/**/*.coffee' 9 | ] 10 | .pipe coffee bare: true 11 | .on 'error', gutil.log 12 | .pipe gulp.dest './lib/' 13 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | require('coffee-script/register'); 2 | require('./gulpfile.coffee'); 3 | -------------------------------------------------------------------------------- /lib/common/index.js: -------------------------------------------------------------------------------- 1 | var debug; 2 | 3 | debug = require('debug')('loopback:satellizer:common'); 4 | 5 | module.exports = function(server, options) { 6 | var Model, authenticate, current, map; 7 | Model = server.models[options.model]; 8 | authenticate = function(account, callback) { 9 | var ttl; 10 | ttl = account.constructor.settings.maxTTL; 11 | return account.createAccessToken(ttl, function(err, token) { 12 | if (err) { 13 | return callback(err); 14 | } 15 | token.token = token.id; 16 | return callback(null, token); 17 | }); 18 | }; 19 | current = function(req, callback) { 20 | var AccessToken; 21 | debug('current'); 22 | if (!req.headers.authorization) { 23 | return callback(null, false); 24 | } 25 | AccessToken = Model.app.models.AccessToken; 26 | return AccessToken.findForRequest(req, function(err, accessToken) { 27 | if (err) { 28 | return callback(err); 29 | } 30 | if (!accessToken) { 31 | return callback(null, false); 32 | } 33 | return Model.findById(accessToken.userId, callback); 34 | }); 35 | }; 36 | map = function(config, source, destination) { 37 | var key, results, value; 38 | results = []; 39 | for (key in config) { 40 | value = config[key]; 41 | if (key in source) { 42 | results.push(destination[value] = destination[value] || source[key]); 43 | } else { 44 | results.push(void 0); 45 | } 46 | } 47 | return results; 48 | }; 49 | return { 50 | authenticate: authenticate, 51 | current: current, 52 | map: map 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /lib/facebook/index.js: -------------------------------------------------------------------------------- 1 | var async, common, debug, randomstring, request; 2 | 3 | async = require('async'); 4 | 5 | debug = require('debug')('loopback:satellizer:facebook'); 6 | 7 | request = require('request'); 8 | 9 | randomstring = require('randomstring'); 10 | 11 | common = require('../common'); 12 | 13 | module.exports = function(server, options) { 14 | var Common, Model, fetchAccessToken, fetchProfile, link, version; 15 | version = options.version ? options.version : 'v2.3'; 16 | Common = common(server, options); 17 | Model = server.models[options.model]; 18 | fetchAccessToken = function(code, clientId, redirectUri, callback) { 19 | var params, ref; 20 | debug('fetchAccessToken'); 21 | params = { 22 | url: "https://graph.facebook.com/" + version + "/oauth/access_token", 23 | qs: { 24 | code: code, 25 | client_id: clientId, 26 | client_secret: options.credentials["private"], 27 | redirect_uri: redirectUri 28 | }, 29 | json: true 30 | }; 31 | if (((ref = options.fields) != null ? ref.length : void 0) > 0) { 32 | params.qs.fields = options.fields.join(','); 33 | } 34 | return request.get(params, function(err, res, accessToken) { 35 | if (err) { 36 | debug(JSON.stringify(err)); 37 | return callback(err); 38 | } 39 | if (res.statusCode !== 200) { 40 | if (accessToken && accessToken instanceof Object && accessToken.error) { 41 | accessToken.error.status = 500; 42 | return callback(accessToken.error); 43 | } 44 | err = new Error(JSON.stringify(accessToken)); 45 | err.status = 500; 46 | debug(JSON.stringify(err)); 47 | return callback(err); 48 | } 49 | return callback(null, accessToken); 50 | }); 51 | }; 52 | fetchProfile = function(accessToken, callback) { 53 | var params, ref; 54 | debug('fetchProfile'); 55 | params = { 56 | url: "https://graph.facebook.com/" + version + "/me", 57 | qs: accessToken, 58 | json: true 59 | }; 60 | if (((ref = options.fields) != null ? ref.length : void 0) > 0) { 61 | params.qs.fields = options.fields.join(','); 62 | } 63 | return request.get(params, function(err, res, profile) { 64 | if (err) { 65 | debug(JSON.stringify(err)); 66 | return callback(err); 67 | } 68 | if (res.statusCode !== 200) { 69 | if (profile && profile instanceof Object && profile.error) { 70 | profile.error.status = 500; 71 | return callback(profile.error); 72 | } 73 | err = new Error(JSON.stringify(profile)); 74 | err.status = 500; 75 | debug(JSON.stringify(err)); 76 | return callback(err); 77 | } 78 | return callback(null, profile); 79 | }); 80 | }; 81 | link = function(req, profile, callback) { 82 | debug('link', JSON.stringify(profile)); 83 | return Common.current(req, function(err, found) { 84 | var query; 85 | if (err) { 86 | debug(JSON.stringify(err)); 87 | return callback(err); 88 | } 89 | if (found === null) { 90 | err = new Error('not_an_account'); 91 | err.status = 409; 92 | debug(JSON.stringify(err)); 93 | return callback(err); 94 | } 95 | if (found) { 96 | return link.existing(profile, found, callback); 97 | } 98 | query = { 99 | where: {} 100 | }; 101 | query.where[options.mapping.email] = profile.email; 102 | return Model.findOne(query, function(err, found) { 103 | if (err) { 104 | debug(JSON.stringify(err)); 105 | return callback(err); 106 | } 107 | if (!found) { 108 | return link.create(profile, callback); 109 | } 110 | return link.existing(profile, found, callback); 111 | }); 112 | }); 113 | }; 114 | link.create = function(profile, callback) { 115 | var tmp; 116 | debug('link.create', JSON.stringify(profile)); 117 | tmp = { 118 | password: randomstring.generate() 119 | }; 120 | Common.map(options.mapping, profile, tmp); 121 | return Model.create(tmp, function(err, created) { 122 | if (err) { 123 | debug(JSON.stringify(err)); 124 | } 125 | return callback(err, created); 126 | }); 127 | }; 128 | link.existing = function(profile, account, callback) { 129 | var err; 130 | debug('link.existing', JSON.stringify(profile)); 131 | if (account.facebook && account[options.mapping.id] !== profile.id) { 132 | err = new Error('account_conflict'); 133 | err.status = 409; 134 | debug(JSON.stringify(err)); 135 | return callback(err); 136 | } 137 | Common.map(options.mapping, profile, account); 138 | return account.save(function(err) { 139 | if (err) { 140 | debug(JSON.stringify(err)); 141 | } 142 | return callback(err, account); 143 | }); 144 | }; 145 | Model.facebook = function(req, code, clientId, redirectUri, callback) { 146 | debug(code + ", " + clientId + ", " + redirectUri); 147 | return async.waterfall([ 148 | function(done) { 149 | return fetchAccessToken(code, clientId, redirectUri, done); 150 | }, function(accessToken, done) { 151 | return fetchProfile(accessToken, done); 152 | }, function(profile, done) { 153 | return link(req, profile, done); 154 | }, function(account, done) { 155 | return Common.authenticate(account, done); 156 | } 157 | ], callback); 158 | }; 159 | Model['facebook-get'] = function(req, code, callback) { 160 | var clientId, redirectUri; 161 | debug('facebook-get', code); 162 | clientId = options.credentials["public"]; 163 | if (options.redirectUri) { 164 | redirectUri = options.redirectUri; 165 | } else { 166 | redirectUri = req.protocol + "://" + (req.get('host')) + req.baseUrl + options.uri; 167 | } 168 | return Model.facebook(req, code, clientId, redirectUri, callback); 169 | }; 170 | Model.remoteMethod('facebook-get', { 171 | accepts: [ 172 | { 173 | arg: 'req', 174 | type: 'object', 175 | http: { 176 | source: 'req' 177 | } 178 | }, { 179 | arg: 'code', 180 | type: 'string', 181 | http: { 182 | source: 'query' 183 | } 184 | } 185 | ], 186 | returns: { 187 | arg: 'result', 188 | type: 'object', 189 | root: true 190 | }, 191 | http: { 192 | verb: 'get', 193 | path: options.uri 194 | } 195 | }); 196 | Model.remoteMethod('facebook', { 197 | accepts: [ 198 | { 199 | arg: 'req', 200 | type: 'object', 201 | http: { 202 | source: 'req' 203 | } 204 | }, { 205 | arg: 'code', 206 | type: 'string', 207 | http: { 208 | source: 'form' 209 | } 210 | }, { 211 | arg: 'clientId', 212 | type: 'string', 213 | http: { 214 | source: 'form' 215 | } 216 | }, { 217 | arg: 'redirectUri', 218 | type: 'string', 219 | http: { 220 | source: 'form' 221 | } 222 | } 223 | ], 224 | returns: { 225 | arg: 'result', 226 | type: 'object', 227 | root: true 228 | }, 229 | http: { 230 | verb: 'post', 231 | path: options.uri 232 | } 233 | }); 234 | }; 235 | -------------------------------------------------------------------------------- /lib/google/index.js: -------------------------------------------------------------------------------- 1 | var async, common, debug, randomstring, request; 2 | 3 | async = require('async'); 4 | 5 | debug = require('debug')('loopback:satellizer:google'); 6 | 7 | request = require('request'); 8 | 9 | randomstring = require('randomstring'); 10 | 11 | common = require('../common'); 12 | 13 | module.exports = function(server, options) { 14 | var Common, Model, credentials, fetchAccessToken, fetchProfile, link; 15 | Common = common(server, options); 16 | Model = server.models[options.model]; 17 | credentials = options.credentials; 18 | fetchAccessToken = function(code, clientId, redirectUri, callback) { 19 | debug('fetchAccessToken'); 20 | return request.post('https://accounts.google.com/o/oauth2/token', { 21 | form: { 22 | code: code, 23 | client_id: clientId, 24 | client_secret: credentials["private"], 25 | redirect_uri: redirectUri, 26 | grant_type: 'authorization_code' 27 | }, 28 | json: true 29 | }, function(err, res, accessToken) { 30 | if (err) { 31 | debug(JSON.stringify(err)); 32 | return callback(err); 33 | } 34 | if (res.statusCode !== 200) { 35 | if (accessToken && accessToken instanceof Object && accessToken.error) { 36 | accessToken.error.status = 500; 37 | return callback(accessToken.error); 38 | } 39 | err = new Error(JSON.stringify(accessToken)); 40 | err.status = 500; 41 | debug(JSON.stringify(err)); 42 | return callback(err); 43 | } 44 | return callback(null, accessToken.access_token); 45 | }); 46 | }; 47 | fetchProfile = function(accessToken, callback) { 48 | debug('fetchProfile'); 49 | return request.get({ 50 | url: 'https://www.googleapis.com/plus/v1/people/me/openIdConnect', 51 | headers: { 52 | Authorization: "Bearer " + accessToken 53 | }, 54 | json: true 55 | }, function(err, res, profile) { 56 | if (err) { 57 | debug(JSON.stringify(err)); 58 | return callback(err); 59 | } 60 | if (res.statusCode !== 200) { 61 | if (profile && profile instanceof Object && profile.error) { 62 | profile.error.status = 500; 63 | return callback(profile.error); 64 | } 65 | err = new Error(JSON.stringify(profile)); 66 | err.status = 500; 67 | debug(JSON.stringify(err)); 68 | return callback(err); 69 | } 70 | return callback(null, profile); 71 | }); 72 | }; 73 | link = function(req, profile, callback) { 74 | debug('link'); 75 | return Common.current(req, function(err, found) { 76 | var query; 77 | if (err) { 78 | debug(err); 79 | return callback(err); 80 | } 81 | if (found === null) { 82 | err = new Error('not_an_account'); 83 | err.status = 409; 84 | debug(err); 85 | return callback(err); 86 | } 87 | if (found) { 88 | return link.existing(profile, found, callback); 89 | } 90 | query = { 91 | where: {} 92 | }; 93 | query.where[options.mapping.email] = profile.email; 94 | return Model.findOne(query, function(err, found) { 95 | if (err) { 96 | debug(err); 97 | return callback(err); 98 | } 99 | if (!found) { 100 | return link.create(profile, callback); 101 | } 102 | return link.existing(profile, found, callback); 103 | }); 104 | }); 105 | }; 106 | link.create = function(profile, callback) { 107 | var tmp; 108 | debug('link.create', profile.id); 109 | tmp = { 110 | password: randomstring.generate() 111 | }; 112 | Common.map(options.mapping, profile, tmp); 113 | return Model.create(tmp, function(err, created) { 114 | if (err) { 115 | debug(err); 116 | } 117 | return callback(err, created); 118 | }); 119 | }; 120 | link.existing = function(profile, account, callback) { 121 | var err; 122 | debug('link.existing'); 123 | if (account.google && account[options.mapping.sub] !== profile.sub) { 124 | err = new Error('account_conflict'); 125 | err.status = 409; 126 | debug(err); 127 | return callback(err); 128 | } 129 | Common.map(options.mapping, profile, account); 130 | return account.save(function(err) { 131 | if (err) { 132 | debug(err); 133 | } 134 | return callback(err, account); 135 | }); 136 | }; 137 | Model.google = function(req, code, clientId, redirectUri, callback) { 138 | debug('google', code + ", " + clientId + ", " + redirectUri); 139 | return async.waterfall([ 140 | function(done) { 141 | return fetchAccessToken(code, clientId, redirectUri, done); 142 | }, function(accessToken, done) { 143 | return fetchProfile(accessToken, done); 144 | }, function(profile, done) { 145 | return link(req, profile, done); 146 | }, function(account, done) { 147 | return Common.authenticate(account, done); 148 | } 149 | ], callback); 150 | }; 151 | Model['google-get'] = function(req, code, callback) { 152 | var clientId, redirectUri; 153 | debug('google-get', code); 154 | clientId = options.credentials["public"]; 155 | if (options.redirectUri) { 156 | redirectUri = options.redirectUri; 157 | } else { 158 | redirectUri = req.protocol + "://" + (req.get('host')) + req.baseUrl + options.uri; 159 | } 160 | return Model.google(req, code, clientId, redirectUri, callback); 161 | }; 162 | Model.remoteMethod('google-get', { 163 | accepts: [ 164 | { 165 | arg: 'req', 166 | type: 'object', 167 | http: { 168 | source: 'req' 169 | } 170 | }, { 171 | arg: 'code', 172 | type: 'string', 173 | http: { 174 | source: 'query' 175 | } 176 | } 177 | ], 178 | returns: { 179 | arg: 'result', 180 | type: 'object', 181 | root: true 182 | }, 183 | http: { 184 | verb: 'get', 185 | path: options.uri 186 | } 187 | }); 188 | Model.remoteMethod('google', { 189 | accepts: [ 190 | { 191 | arg: 'req', 192 | type: 'object', 193 | http: { 194 | source: 'req' 195 | } 196 | }, { 197 | arg: 'code', 198 | type: 'string', 199 | http: { 200 | source: 'form' 201 | } 202 | }, { 203 | arg: 'clientId', 204 | type: 'string', 205 | http: { 206 | source: 'form' 207 | } 208 | }, { 209 | arg: 'redirectUri', 210 | type: 'string', 211 | http: { 212 | source: 'form' 213 | } 214 | } 215 | ], 216 | returns: { 217 | arg: 'result', 218 | type: 'object', 219 | root: true 220 | }, 221 | http: { 222 | verb: 'post', 223 | path: options.uri 224 | } 225 | }); 226 | }; 227 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = function(app, config) { 3 | if (config.facebook) { 4 | require('./facebook')(app, config.facebook); 5 | } 6 | if (config.google) { 7 | require('./google')(app, config.google); 8 | } 9 | if (config.twitter) { 10 | return require('./twitter')(app, config.twitter); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /lib/twitter/index.js: -------------------------------------------------------------------------------- 1 | var async, common, debug, qs, randomstring, request; 2 | 3 | async = require('async'); 4 | 5 | debug = require('debug')('loopback:satellizer:twitter'); 6 | 7 | qs = require('querystring'); 8 | 9 | request = require('request'); 10 | 11 | randomstring = require('randomstring'); 12 | 13 | common = require('../common'); 14 | 15 | module.exports = function(server, options) { 16 | var Common, Model, callbackUrl, credentials, fetchAccessToken, fetchProfile, handleFirstRequest, link; 17 | Common = common(server, options); 18 | Model = server.models[options.model]; 19 | credentials = options.credentials; 20 | callbackUrl = options.callbackUrl; 21 | handleFirstRequest = function(callback) { 22 | return request.post({ 23 | url: 'https://api.twitter.com/oauth/request_token', 24 | oauth: { 25 | consumer_key: credentials["public"], 26 | consumer_secret: credentials["private"] 27 | } 28 | }, function(err, res, body) { 29 | if (err) { 30 | return callback(err); 31 | } 32 | return callback(null, qs.parse(body)); 33 | }); 34 | }; 35 | fetchAccessToken = function(oauthToken, oauthVerifier, callback) { 36 | return request.post({ 37 | url: 'https://api.twitter.com/oauth/access_token', 38 | oauth: { 39 | consumer_key: credentials["public"], 40 | consumer_secret: credentials["private"], 41 | token: oauthToken, 42 | verifier: oauthVerifier 43 | } 44 | }, function(err, res, accessToken) { 45 | if (err) { 46 | debug(JSON.stringify(err)); 47 | return callback(err); 48 | } 49 | if (res.statusCode !== 200) { 50 | err = new Error(accessToken); 51 | err.status = 500; 52 | debug(JSON.stringify(err)); 53 | return callback(err); 54 | } 55 | return callback(null, qs.parse(accessToken)); 56 | }); 57 | }; 58 | fetchProfile = function(accessToken, callback) { 59 | debug('fetchProfile'); 60 | return request.get({ 61 | url: 'https://api.twitter.com/1.1/users/show.json?screen_name=' + accessToken.screen_name, 62 | oauth: { 63 | consumer_key: credentials["public"], 64 | consumer_secret: credentials["private"], 65 | oauth_token: accessToken.oauth_token 66 | }, 67 | json: true 68 | }, function(err, res, profile) { 69 | if (err) { 70 | debug(JSON.stringify(err)); 71 | return callback(err); 72 | } 73 | if (res.statusCode !== 200) { 74 | err = new Error(JSON.stringify(profile)); 75 | err.status = 500; 76 | debug(JSON.stringify(err)); 77 | return callback(err); 78 | } 79 | return callback(null, profile); 80 | }); 81 | }; 82 | link = function(req, profile, callback) { 83 | debug('link'); 84 | return Common.current(req, function(err, found) { 85 | var query; 86 | if (err) { 87 | debug(err); 88 | return callback(err); 89 | } 90 | if (found === null) { 91 | err = new Error('not_an_account'); 92 | err.status = 409; 93 | debug(err); 94 | return callback(err); 95 | } 96 | if (found) { 97 | return link.existing(profile, found, callback); 98 | } 99 | query = { 100 | where: {} 101 | }; 102 | query.where[options.mapping.id] = profile.id; 103 | return Model.findOne(query, function(err, found) { 104 | if (err) { 105 | debug(err); 106 | return callback(err); 107 | } 108 | if (!found) { 109 | return link.create(profile, callback); 110 | } 111 | return link.existing(profile, found, callback); 112 | }); 113 | }); 114 | }; 115 | link.create = function(profile, callback) { 116 | var tmp; 117 | debug('link.create', profile.id); 118 | tmp = { 119 | email: profile.id + "@twitter.com", 120 | password: randomstring.generate() 121 | }; 122 | Common.map(options.mapping, profile, tmp); 123 | return Model.create(tmp, function(err, created) { 124 | if (err) { 125 | debug(err); 126 | } 127 | return callback(err, created); 128 | }); 129 | }; 130 | link.existing = function(profile, account, callback) { 131 | var err; 132 | debug('link.existing'); 133 | if (account[options.mapping.id] && account[options.mapping.id] !== profile.id) { 134 | err = new Error('account_conflict'); 135 | err.status = 409; 136 | debug(err); 137 | return callback(err); 138 | } 139 | Common.map(options.mapping, profile, account); 140 | return account.save(function(err) { 141 | if (err) { 142 | debug(err); 143 | } 144 | return callback(err, account); 145 | }); 146 | }; 147 | Model.twitter = function(req, oauthToken, oauthVerifier, callback) { 148 | debug(oauthToken + ", " + oauthVerifier); 149 | if (!oauthToken || !oauthVerifier) { 150 | return handleFirstRequest(callback); 151 | } 152 | return async.waterfall([ 153 | function(done) { 154 | return fetchAccessToken(oauthToken, oauthVerifier, done); 155 | }, function(accessToken, done) { 156 | return fetchProfile(accessToken, done); 157 | }, function(profile, done) { 158 | return link(req, profile, done); 159 | }, function(account, done) { 160 | return Common.authenticate(account, done); 161 | } 162 | ], callback); 163 | }; 164 | Model['twitter-get'] = Model.twitter; 165 | Model.remoteMethod('twitter-get', { 166 | accepts: [ 167 | { 168 | arg: 'req', 169 | type: 'object', 170 | http: { 171 | source: 'req' 172 | } 173 | }, { 174 | arg: 'oauth_token', 175 | type: 'string', 176 | http: { 177 | source: 'query' 178 | } 179 | }, { 180 | arg: 'oauth_verifier', 181 | type: 'string', 182 | http: { 183 | source: 'query' 184 | } 185 | } 186 | ], 187 | returns: { 188 | arg: 'result', 189 | type: 'object', 190 | root: true 191 | }, 192 | http: { 193 | verb: 'get', 194 | path: options.uri 195 | } 196 | }); 197 | Model.remoteMethod('twitter', { 198 | accepts: [ 199 | { 200 | arg: 'req', 201 | type: 'object', 202 | http: { 203 | source: 'req' 204 | } 205 | }, { 206 | arg: 'oauth_token', 207 | type: 'string', 208 | http: { 209 | source: 'form' 210 | } 211 | }, { 212 | arg: 'oauth_verifier', 213 | type: 'string', 214 | http: { 215 | source: 'form' 216 | } 217 | } 218 | ], 219 | returns: { 220 | arg: 'result', 221 | type: 'object', 222 | root: true 223 | }, 224 | http: { 225 | verb: 'post', 226 | path: options.uri 227 | } 228 | }); 229 | }; 230 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loopback-component-satellizer", 3 | "version": "2.0.2", 4 | "description": "A loopback module for satellizer", 5 | "main": "./lib/index.js", 6 | "contributors": [ 7 | { 8 | "name": "Jérémie Drouet", 9 | "email": "jeremie.drouet@gmail.com" 10 | }, 11 | { 12 | "name": "Jonathan Beurel", 13 | "email": "bejonster@gmail.com" 14 | } 15 | ], 16 | "scripts": { 17 | "pretest": "npm run build; rm -f ./node_modules/loopback-component-satellizer; ln -s $(pwd) ./node_modules/loopback-component-satellizer", 18 | "test": "mocha 'test/*.coffee'", 19 | "build": "gulp build" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/moooink/loopback-component-satellizer.git" 24 | }, 25 | "keywords": [ 26 | "loopback", 27 | "satellizer", 28 | "facebook", 29 | "twitter", 30 | "google" 31 | ], 32 | "author": "Moooink", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/moooink/loopback-component-satellizer/issues" 36 | }, 37 | "homepage": "https://github.com/moooink/loopback-component-satellizer#readme", 38 | "dependencies": { 39 | "async": "^1.2.0", 40 | "coffee-script": "^1.9.3", 41 | "debug": "^2.2.0", 42 | "loopback-component-explorer": "^2.1.1", 43 | "qs": "^6.0.2", 44 | "querystring": "^0.2.0", 45 | "randomstring": "^1.0.6", 46 | "request": "^2.57.0" 47 | }, 48 | "devDependencies": { 49 | "chai": "^3.2.0", 50 | "compression": "^1.0.3", 51 | "cors": "^2.5.2", 52 | "errorhandler": "^1.1.1", 53 | "gulp": "^3.8.11", 54 | "gulp-coffee": "^2.3.1", 55 | "gulp-util": "^3.0.4", 56 | "loopback": "^2.18.0", 57 | "loopback-boot": "^2.8.0", 58 | "loopback-datasource-juggler": "^2.29.2", 59 | "mocha": "^2.2.5", 60 | "nock": "^7.0.2", 61 | "serve-favicon": "^2.0.1", 62 | "strong-express-metrics": "^2.0.1", 63 | "supertest": "^1.0.1" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /source/common/index.coffee: -------------------------------------------------------------------------------- 1 | debug = require('debug') 'loopback:satellizer:common' 2 | 3 | module.exports = (server, options) -> 4 | 5 | Model = server.models[options.model] 6 | 7 | authenticate = (account, callback) -> 8 | ttl = account.constructor.settings.maxTTL 9 | account.createAccessToken ttl, (err, token) -> 10 | return callback err if err 11 | token.token = token.id 12 | callback null, token 13 | 14 | current = (req, callback) -> 15 | debug 'current' 16 | return callback null, false if not req.headers.authorization 17 | AccessToken = Model.app.models.AccessToken 18 | AccessToken.findForRequest req, (err, accessToken) -> 19 | if err 20 | return callback err 21 | return callback null, false if not accessToken 22 | Model.findById accessToken.userId, callback 23 | 24 | map = (config, source, destination) -> 25 | for key, value of config 26 | if key of source 27 | destination[value] = destination[value] or source[key] 28 | 29 | return { 30 | authenticate: authenticate 31 | current: current 32 | map: map 33 | } 34 | -------------------------------------------------------------------------------- /source/facebook/index.coffee: -------------------------------------------------------------------------------- 1 | async = require 'async' 2 | debug = require('debug') 'loopback:satellizer:facebook' 3 | request = require 'request' 4 | randomstring = require 'randomstring' 5 | 6 | common = require '../common' 7 | 8 | module.exports = (server, options) -> 9 | 10 | version = if options.version then options.version else 'v2.3' 11 | Common = common server, options 12 | Model = server.models[options.model] 13 | 14 | fetchAccessToken = (code, clientId, redirectUri, callback) -> 15 | debug 'fetchAccessToken' 16 | params = 17 | url: "https://graph.facebook.com/#{version}/oauth/access_token" 18 | qs: 19 | code: code 20 | client_id: clientId 21 | client_secret: options.credentials.private 22 | redirect_uri: redirectUri 23 | json: true 24 | if options.fields?.length > 0 25 | params.qs.fields = options.fields.join ',' 26 | request.get params, (err, res, accessToken) -> 27 | if err 28 | debug JSON.stringify err 29 | return callback err 30 | if res.statusCode isnt 200 31 | if accessToken and accessToken instanceof Object and accessToken.error 32 | accessToken.error.status = 500 33 | return callback accessToken.error 34 | err = new Error JSON.stringify accessToken 35 | err.status = 500 36 | debug JSON.stringify err 37 | return callback err 38 | callback null, accessToken 39 | 40 | fetchProfile = (accessToken, callback) -> 41 | debug 'fetchProfile' 42 | params = 43 | url: "https://graph.facebook.com/#{version}/me" 44 | qs: accessToken 45 | json: true 46 | if options.fields?.length > 0 47 | params.qs.fields = options.fields.join ',' 48 | request.get params, (err, res, profile) -> 49 | if err 50 | debug JSON.stringify err 51 | return callback err 52 | if res.statusCode isnt 200 53 | if profile and profile instanceof Object and profile.error 54 | profile.error.status = 500 55 | return callback profile.error 56 | err = new Error JSON.stringify profile 57 | err.status = 500 58 | debug JSON.stringify err 59 | return callback err 60 | callback null, profile 61 | 62 | link = (req, profile, callback) -> 63 | debug 'link', JSON.stringify(profile) 64 | Common.current req, (err, found) -> 65 | if err 66 | debug JSON.stringify err 67 | return callback err 68 | if found is null 69 | err = new Error 'not_an_account' 70 | err.status = 409 71 | debug JSON.stringify err 72 | return callback err 73 | if found 74 | return link.existing profile, found, callback 75 | # 76 | query = 77 | where: {} 78 | query.where[options.mapping.email] = profile.email 79 | # 80 | Model.findOne query, (err, found) -> 81 | if err 82 | debug JSON.stringify err 83 | return callback err 84 | return link.create profile, callback if not found 85 | return link.existing profile, found, callback 86 | 87 | link.create = (profile, callback) -> 88 | debug 'link.create', JSON.stringify(profile) 89 | tmp = 90 | password: randomstring.generate() 91 | Common.map options.mapping, profile, tmp 92 | Model.create tmp, (err, created) -> 93 | debug JSON.stringify err if err 94 | return callback err, created 95 | 96 | link.existing = (profile, account, callback) -> 97 | debug 'link.existing', JSON.stringify(profile) 98 | if account.facebook and account[options.mapping.id] != profile.id 99 | err = new Error 'account_conflict' 100 | err.status = 409 101 | debug JSON.stringify err 102 | return callback err 103 | Common.map options.mapping, profile, account 104 | account.save (err) -> 105 | debug JSON.stringify err if err 106 | return callback err, account 107 | 108 | Model.facebook = (req, code, clientId, redirectUri, callback) -> 109 | debug "#{code}, #{clientId}, #{redirectUri}" 110 | async.waterfall [ 111 | (done) -> 112 | fetchAccessToken code, clientId, redirectUri, done 113 | (accessToken, done) -> 114 | fetchProfile accessToken, done 115 | (profile, done) -> 116 | link req, profile, done 117 | (account, done) -> 118 | Common.authenticate account, done 119 | ], callback 120 | 121 | Model['facebook-get'] = (req, code, callback) -> 122 | debug 'facebook-get', code 123 | clientId = options.credentials.public 124 | if options.redirectUri 125 | redirectUri = options.redirectUri 126 | else 127 | redirectUri = "#{req.protocol}://#{req.get('host')}#{req.baseUrl}#{options.uri}" 128 | Model.facebook req, code, clientId, redirectUri, callback 129 | 130 | Model.remoteMethod 'facebook-get', 131 | accepts: [ 132 | { 133 | arg: 'req' 134 | type: 'object' 135 | http: 136 | source: 'req' 137 | } 138 | { 139 | arg: 'code' 140 | type: 'string' 141 | http: 142 | source: 'query' 143 | } 144 | ] 145 | returns: 146 | arg: 'result' 147 | type: 'object' 148 | root: true 149 | http: 150 | verb: 'get' 151 | path: options.uri 152 | 153 | 154 | Model.remoteMethod 'facebook', 155 | accepts: [ 156 | { 157 | arg: 'req' 158 | type: 'object' 159 | http: 160 | source: 'req' 161 | } 162 | { 163 | arg: 'code' 164 | type: 'string' 165 | http: 166 | source: 'form' 167 | } 168 | { 169 | arg: 'clientId' 170 | type: 'string' 171 | http: 172 | source: 'form' 173 | } 174 | { 175 | arg: 'redirectUri' 176 | type: 'string' 177 | http: 178 | source: 'form' 179 | } 180 | ] 181 | returns: 182 | arg: 'result' 183 | type: 'object' 184 | root: true 185 | http: 186 | verb: 'post' 187 | path: options.uri 188 | 189 | return 190 | -------------------------------------------------------------------------------- /source/google/index.coffee: -------------------------------------------------------------------------------- 1 | async = require 'async' 2 | debug = require('debug') 'loopback:satellizer:google' 3 | request = require 'request' 4 | randomstring = require 'randomstring' 5 | 6 | common = require '../common' 7 | 8 | module.exports = (server, options) -> 9 | 10 | Common = common server, options 11 | Model = server.models[options.model] 12 | 13 | credentials = options.credentials 14 | 15 | fetchAccessToken = (code, clientId, redirectUri, callback) -> 16 | debug 'fetchAccessToken' 17 | request.post 'https://accounts.google.com/o/oauth2/token', 18 | form: 19 | code: code 20 | client_id: clientId 21 | client_secret: credentials.private 22 | redirect_uri: redirectUri 23 | grant_type: 'authorization_code' 24 | json: true 25 | , (err, res, accessToken) -> 26 | if err 27 | debug JSON.stringify err 28 | return callback err 29 | if res.statusCode isnt 200 30 | if accessToken and accessToken instanceof Object and accessToken.error 31 | accessToken.error.status = 500 32 | return callback accessToken.error 33 | err = new Error JSON.stringify accessToken 34 | err.status = 500 35 | debug JSON.stringify err 36 | return callback err 37 | callback null, accessToken.access_token 38 | 39 | fetchProfile = (accessToken, callback) -> 40 | debug 'fetchProfile' 41 | request.get 42 | url: 'https://www.googleapis.com/plus/v1/people/me/openIdConnect' 43 | headers: 44 | Authorization: "Bearer #{accessToken}" 45 | json: true 46 | , (err, res, profile) -> 47 | if err 48 | debug JSON.stringify err 49 | return callback err 50 | if res.statusCode isnt 200 51 | if profile and profile instanceof Object and profile.error 52 | profile.error.status = 500 53 | return callback profile.error 54 | err = new Error JSON.stringify profile 55 | err.status = 500 56 | debug JSON.stringify err 57 | return callback err 58 | callback null, profile 59 | 60 | link = (req, profile, callback) -> 61 | debug 'link' 62 | Common.current req, (err, found) -> 63 | if err 64 | debug err 65 | return callback err 66 | if found is null 67 | err = new Error 'not_an_account' 68 | err.status = 409 69 | debug err 70 | return callback err 71 | if found 72 | return link.existing profile, found, callback 73 | # 74 | query = 75 | where: {} 76 | query.where[options.mapping.email] = profile.email 77 | # 78 | Model.findOne query, (err, found) -> 79 | if err 80 | debug err 81 | return callback err 82 | return link.create profile, callback if not found 83 | return link.existing profile, found, callback 84 | 85 | link.create = (profile, callback) -> 86 | debug 'link.create', profile.id 87 | tmp = 88 | password: randomstring.generate() 89 | Common.map options.mapping, profile, tmp 90 | Model.create tmp, (err, created) -> 91 | debug err if err 92 | return callback err, created 93 | 94 | link.existing = (profile, account, callback) -> 95 | debug 'link.existing' 96 | if account.google and account[options.mapping.sub] != profile.sub 97 | err = new Error 'account_conflict' 98 | err.status = 409 99 | debug err 100 | return callback err 101 | Common.map options.mapping, profile, account 102 | account.save (err) -> 103 | debug err if err 104 | return callback err, account 105 | 106 | Model.google = (req, code, clientId, redirectUri, callback) -> 107 | debug 'google', "#{code}, #{clientId}, #{redirectUri}" 108 | async.waterfall [ 109 | (done) -> 110 | fetchAccessToken code, clientId, redirectUri, done 111 | (accessToken, done) -> 112 | fetchProfile accessToken, done 113 | (profile, done) -> 114 | link req, profile, done 115 | (account, done) -> 116 | Common.authenticate account, done 117 | ], callback 118 | 119 | Model['google-get'] = (req, code, callback) -> 120 | debug 'google-get', code 121 | clientId = options.credentials.public 122 | if options.redirectUri 123 | redirectUri = options.redirectUri 124 | else 125 | redirectUri = "#{req.protocol}://#{req.get('host')}#{req.baseUrl}#{options.uri}" 126 | Model.google req, code, clientId, redirectUri, callback 127 | 128 | Model.remoteMethod 'google-get', 129 | accepts: [ 130 | { 131 | arg: 'req' 132 | type: 'object' 133 | http: 134 | source: 'req' 135 | } 136 | { 137 | arg: 'code' 138 | type: 'string' 139 | http: 140 | source: 'query' 141 | } 142 | ] 143 | returns: 144 | arg: 'result' 145 | type: 'object' 146 | root: true 147 | http: 148 | verb: 'get' 149 | path: options.uri 150 | 151 | Model.remoteMethod 'google', 152 | accepts: [ 153 | { 154 | arg: 'req' 155 | type: 'object' 156 | http: 157 | source: 'req' 158 | } 159 | { 160 | arg: 'code' 161 | type: 'string' 162 | http: 163 | source: 'form' 164 | } 165 | { 166 | arg: 'clientId' 167 | type: 'string' 168 | http: 169 | source: 'form' 170 | } 171 | { 172 | arg: 'redirectUri' 173 | type: 'string' 174 | http: 175 | source: 'form' 176 | } 177 | ] 178 | returns: 179 | arg: 'result' 180 | type: 'object' 181 | root: true 182 | http: 183 | verb: 'post' 184 | path: options.uri 185 | 186 | return 187 | -------------------------------------------------------------------------------- /source/index.coffee: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = (app, config) -> 4 | if config.facebook 5 | require('./facebook') app, config.facebook 6 | if config.google 7 | require('./google') app, config.google 8 | if config.twitter 9 | require('./twitter') app, config.twitter 10 | -------------------------------------------------------------------------------- /source/twitter/index.coffee: -------------------------------------------------------------------------------- 1 | async = require 'async' 2 | debug = require('debug') 'loopback:satellizer:twitter' 3 | qs = require 'querystring' 4 | request = require 'request' 5 | randomstring = require 'randomstring' 6 | 7 | common = require '../common' 8 | 9 | module.exports = (server, options) -> 10 | 11 | Common = common server, options 12 | Model = server.models[options.model] 13 | 14 | credentials = options.credentials 15 | callbackUrl = options.callbackUrl 16 | 17 | handleFirstRequest = (callback) -> 18 | request.post 19 | url: 'https://api.twitter.com/oauth/request_token' 20 | oauth: 21 | consumer_key: credentials.public 22 | consumer_secret: credentials.private 23 | , (err, res, body) -> 24 | return callback err if err 25 | callback null, qs.parse body 26 | 27 | fetchAccessToken = (oauthToken, oauthVerifier, callback) -> 28 | request.post 29 | url: 'https://api.twitter.com/oauth/access_token' 30 | oauth: 31 | consumer_key: credentials.public 32 | consumer_secret: credentials.private 33 | token: oauthToken 34 | verifier: oauthVerifier 35 | , (err, res, accessToken) -> 36 | if err 37 | debug JSON.stringify err 38 | return callback err 39 | if res.statusCode isnt 200 40 | err = new Error accessToken 41 | err.status = 500 42 | debug JSON.stringify err 43 | return callback err 44 | callback null, qs.parse accessToken 45 | 46 | fetchProfile = (accessToken, callback) -> 47 | debug 'fetchProfile' 48 | request.get 49 | url: 'https://api.twitter.com/1.1/users/show.json?screen_name=' + accessToken.screen_name 50 | oauth: 51 | consumer_key: credentials.public 52 | consumer_secret: credentials.private 53 | oauth_token: accessToken.oauth_token 54 | json: true 55 | , (err, res, profile) -> 56 | if err 57 | debug JSON.stringify err 58 | return callback err 59 | if res.statusCode isnt 200 60 | err = new Error JSON.stringify profile 61 | err.status = 500 62 | debug JSON.stringify err 63 | return callback err 64 | callback null, profile 65 | 66 | link = (req, profile, callback) -> 67 | debug 'link' 68 | Common.current req, (err, found) -> 69 | if err 70 | debug err 71 | return callback err 72 | if found is null 73 | err = new Error 'not_an_account' 74 | err.status = 409 75 | debug err 76 | return callback err 77 | if found 78 | return link.existing profile, found, callback 79 | # 80 | query = 81 | where: {} 82 | query.where[options.mapping.id] = profile.id 83 | # 84 | Model.findOne query, (err, found) -> 85 | if err 86 | debug err 87 | return callback err 88 | return link.create profile, callback if not found 89 | return link.existing profile, found, callback 90 | 91 | link.create = (profile, callback) -> 92 | debug 'link.create', profile.id 93 | tmp = 94 | email: "#{profile.id}@twitter.com" 95 | password: randomstring.generate() 96 | Common.map options.mapping, profile, tmp 97 | Model.create tmp, (err, created) -> 98 | debug err if err 99 | return callback err, created 100 | 101 | link.existing = (profile, account, callback) -> 102 | debug 'link.existing' 103 | if account[options.mapping.id] and account[options.mapping.id] != profile.id 104 | err = new Error 'account_conflict' 105 | err.status = 409 106 | debug err 107 | return callback err 108 | Common.map options.mapping, profile, account 109 | account.save (err) -> 110 | debug err if err 111 | return callback err, account 112 | 113 | Model.twitter = (req, oauthToken, oauthVerifier, callback) -> 114 | debug "#{oauthToken}, #{oauthVerifier}" 115 | # Initial request for satellizer 116 | return handleFirstRequest callback if not oauthToken or not oauthVerifier 117 | async.waterfall [ 118 | (done) -> 119 | fetchAccessToken oauthToken, oauthVerifier, done 120 | (accessToken, done) -> 121 | fetchProfile accessToken, done 122 | (profile, done) -> 123 | link req, profile, done 124 | (account, done) -> 125 | Common.authenticate account, done 126 | ], callback 127 | 128 | Model['twitter-get'] = Model.twitter 129 | 130 | Model.remoteMethod 'twitter-get', 131 | accepts: [ 132 | { 133 | arg: 'req' 134 | type: 'object' 135 | http: 136 | source: 'req' 137 | } 138 | { 139 | arg: 'oauth_token' 140 | type: 'string' 141 | http: 142 | source: 'query' 143 | } 144 | { 145 | arg: 'oauth_verifier' 146 | type: 'string' 147 | http: 148 | source: 'query' 149 | } 150 | ] 151 | returns: 152 | arg: 'result' 153 | type: 'object' 154 | root: true 155 | http: 156 | verb: 'get' 157 | path: options.uri 158 | 159 | 160 | Model.remoteMethod 'twitter', 161 | accepts: [ 162 | { 163 | arg: 'req' 164 | type: 'object' 165 | http: 166 | source: 'req' 167 | } 168 | { 169 | arg: 'oauth_token' 170 | type: 'string' 171 | http: 172 | source: 'form' 173 | } 174 | { 175 | arg: 'oauth_verifier' 176 | type: 'string' 177 | http: 178 | source: 'form' 179 | } 180 | ] 181 | returns: 182 | arg: 'result' 183 | type: 'object' 184 | root: true 185 | http: 186 | verb: 'post' 187 | path: options.uri 188 | 189 | return 190 | -------------------------------------------------------------------------------- /test/facebook.coffee: -------------------------------------------------------------------------------- 1 | expect = require('chai').expect 2 | loopback = require 'loopback' 3 | nock = require 'nock' 4 | request = require 'supertest' 5 | 6 | component = require '../lib/index.js' 7 | 8 | describe 'Facebook module', -> 9 | 10 | app = null 11 | agent = null 12 | Account = null 13 | 14 | beforeEach -> 15 | app = require '../example/server/server.js' 16 | app.datasources.db.automigrate() 17 | agent = request app 18 | Account = app.models.Account 19 | 20 | it 'should populate model', -> 21 | expect(Account).to.exist 22 | expect(Account.facebook).to.exist 23 | 24 | describe 'call to loopback method', -> 25 | 26 | first = null 27 | second = null 28 | 29 | answer = null 30 | 31 | profile = 32 | id: 'profile_id' 33 | email: 'user@example.com' 34 | first_name: 'my_first_name' 35 | last_name: 'my_last_name' 36 | birthday: new Date() 37 | gender: 'male' 38 | 39 | beforeEach -> 40 | first = nock 'https://graph.facebook.com' 41 | .get '/v2.3/oauth/access_token?code=this_is_a_code&client_id=this_is_a_client_id&client_secret=this_is_a_private_key&redirect_uri=this_is_the_uri&fields=email' 42 | .reply 200, 43 | token: 'my_wonderfull_token' 44 | 45 | beforeEach -> 46 | second = nock 'https://graph.facebook.com' 47 | .get '/v2.3/me?token=my_wonderfull_token&fields=email' 48 | .reply 200, profile 49 | 50 | describe 'with post method', -> 51 | 52 | beforeEach (done) -> 53 | agent.post '/api/accounts/facebook' 54 | .send 55 | code: 'this_is_a_code' 56 | clientId: 'this_is_a_client_id' 57 | redirectUri: 'this_is_the_uri' 58 | .end (err, res) -> 59 | answer = 60 | err: err 61 | res: res 62 | done() 63 | 64 | it 'should call facebook twice and return profile', -> 65 | expect(first.isDone()).to.eql true 66 | expect(second.isDone()).to.eql true 67 | 68 | it 'should return a token', -> 69 | expect(answer.err).to.not.exist 70 | expect(answer.res.statusCode).to.eql 200 71 | expect(answer.res.body).to.have.property 'id' 72 | expect(answer.res.body).to.have.property 'userId' 73 | # Allow satellizer to store its token 74 | expect(answer.res.body).to.have.property 'token' 75 | expect(answer.res.body).to.have.property 'ttl' 76 | 77 | it 'should create the account', (done) -> 78 | app.models.Account.count email: 'user@example.com', (err, nb) -> 79 | expect(err).to.not.exist 80 | expect(nb).to.eql 1 81 | done err 82 | 83 | it 'should map the profile in the account', (done) -> 84 | app.models.Account.findOne 85 | where: 86 | email: 'user@example.com' 87 | , (err, found) -> 88 | expect(err).to.not.exist 89 | expect(found).to.exist 90 | expect(found.facebook).to.eql profile.id 91 | expect(found.firstName).to.eql profile.first_name 92 | expect(found.lastName).to.eql profile.last_name 93 | expect(found.gender).to.eql profile.gender 94 | done err 95 | 96 | describe 'with get method', -> 97 | 98 | beforeEach (done) -> 99 | agent.get '/api/accounts/facebook' 100 | .query 101 | code: 'this_is_a_code' 102 | .end (err, res) -> 103 | answer = 104 | err: err 105 | res: res 106 | done() 107 | 108 | it 'should call facebook twice and return profile', -> 109 | expect(first.isDone()).to.eql true 110 | expect(second.isDone()).to.eql true 111 | 112 | it 'should return a token', -> 113 | expect(answer.err).to.not.exist 114 | expect(answer.res.statusCode).to.eql 200 115 | expect(answer.res.body).to.have.property 'id' 116 | expect(answer.res.body).to.have.property 'userId' 117 | # Allow satellizer to store its token 118 | expect(answer.res.body).to.have.property 'token' 119 | expect(answer.res.body).to.have.property 'ttl' 120 | 121 | it 'should create the account', (done) -> 122 | app.models.Account.count email: 'user@example.com', (err, nb) -> 123 | expect(err).to.not.exist 124 | expect(nb).to.eql 1 125 | done err 126 | 127 | it 'should map the profile in the account', (done) -> 128 | app.models.Account.findOne 129 | where: 130 | email: 'user@example.com' 131 | , (err, found) -> 132 | expect(err).to.not.exist 133 | expect(found).to.exist 134 | expect(found.facebook).to.eql profile.id 135 | expect(found.firstName).to.eql profile.first_name 136 | expect(found.lastName).to.eql profile.last_name 137 | expect(found.gender).to.eql profile.gender 138 | done err 139 | -------------------------------------------------------------------------------- /test/google.coffee: -------------------------------------------------------------------------------- 1 | expect = require('chai').expect 2 | loopback = require 'loopback' 3 | nock = require 'nock' 4 | request = require 'supertest' 5 | 6 | component = require '../lib/index.js' 7 | 8 | describe 'Google module', -> 9 | 10 | app = null 11 | agent = null 12 | Account = null 13 | 14 | beforeEach -> 15 | app = require '../example/server/server.js' 16 | app.datasources.db.automigrate() 17 | agent = request app 18 | Account = app.models.Account 19 | 20 | it 'should populate model', -> 21 | expect(Account).to.exist 22 | expect(Account.google).to.exist 23 | 24 | describe 'call to loopback method', -> 25 | 26 | first = null 27 | second = null 28 | 29 | answer = null 30 | 31 | profile = 32 | sub: 'profile_id' 33 | email: 'user@example.com' 34 | given_name: 'my_first_name' 35 | family_name: 'my_last_name' 36 | birthday: new Date() 37 | gender: 'male' 38 | 39 | beforeEach -> 40 | first = nock 'https://accounts.google.com' 41 | .post '/o/oauth2/token', 42 | code: 'this_is_a_code' 43 | client_id: 'this_is_a_client_id' 44 | client_secret: 'this_is_a_private_key' 45 | redirect_uri: 'this_is_the_uri' 46 | grant_type: 'authorization_code' 47 | .reply 200, 48 | token: 49 | token: 'my_wonderfull_token' 50 | 51 | beforeEach -> 52 | second = nock 'https://www.googleapis.com' 53 | .get '/plus/v1/people/me/openIdConnect' 54 | .reply 200, profile 55 | 56 | describe 'with post verb', -> 57 | 58 | beforeEach (done) -> 59 | agent.post '/api/accounts/google' 60 | .send 61 | code: 'this_is_a_code' 62 | clientId: 'this_is_a_client_id' 63 | redirectUri: 'this_is_the_uri' 64 | .end (err, res) -> 65 | answer = 66 | err: err 67 | res: res 68 | done() 69 | 70 | it 'should call google twice and return profile', -> 71 | expect(first.isDone()).to.eql true 72 | expect(second.isDone()).to.eql true 73 | 74 | it 'should return a token', -> 75 | expect(answer.err).to.not.exist 76 | expect(answer.res.statusCode).to.eql 200 77 | expect(answer.res.body).to.have.property 'id' 78 | expect(answer.res.body).to.have.property 'userId' 79 | # Allow satellizer to store its token 80 | expect(answer.res.body).to.have.property 'token' 81 | expect(answer.res.body).to.have.property 'ttl' 82 | 83 | it 'should create the account', (done) -> 84 | app.models.Account.count email: 'user@example.com', (err, nb) -> 85 | expect(err).to.not.exist 86 | expect(nb).to.eql 1 87 | done err 88 | 89 | it 'should map the profile in the account', (done) -> 90 | app.models.Account.findOne 91 | where: 92 | email: 'user@example.com' 93 | , (err, found) -> 94 | expect(err).to.not.exist 95 | expect(found).to.exist 96 | expect(found.google).to.eql profile.sub 97 | expect(found.firstName).to.eql profile.given_name 98 | expect(found.lastName).to.eql profile.family_name 99 | expect(found.gender).to.eql profile.gender 100 | done err 101 | 102 | describe 'with get verb', -> 103 | 104 | beforeEach (done) -> 105 | agent.get '/api/accounts/google' 106 | .query 107 | code: 'this_is_a_code' 108 | .end (err, res) -> 109 | answer = 110 | err: err 111 | res: res 112 | done() 113 | 114 | it 'should call google twice and return profile', -> 115 | expect(first.isDone()).to.eql true 116 | expect(second.isDone()).to.eql true 117 | 118 | it 'should return a token', -> 119 | expect(answer.err).to.not.exist 120 | expect(answer.res.statusCode).to.eql 200 121 | expect(answer.res.body).to.have.property 'id' 122 | expect(answer.res.body).to.have.property 'userId' 123 | # Allow satellizer to store its token 124 | expect(answer.res.body).to.have.property 'token' 125 | expect(answer.res.body).to.have.property 'ttl' 126 | 127 | it 'should create the account', (done) -> 128 | app.models.Account.count email: 'user@example.com', (err, nb) -> 129 | expect(err).to.not.exist 130 | expect(nb).to.eql 1 131 | done err 132 | 133 | it 'should map the profile in the account', (done) -> 134 | app.models.Account.findOne 135 | where: 136 | email: 'user@example.com' 137 | , (err, found) -> 138 | expect(err).to.not.exist 139 | expect(found).to.exist 140 | expect(found.google).to.eql profile.sub 141 | expect(found.firstName).to.eql profile.given_name 142 | expect(found.lastName).to.eql profile.family_name 143 | expect(found.gender).to.eql profile.gender 144 | done err 145 | 146 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require coffee-script/register 2 | -------------------------------------------------------------------------------- /test/twitter.coffee: -------------------------------------------------------------------------------- 1 | expect = require('chai').expect 2 | loopback = require 'loopback' 3 | nock = require 'nock' 4 | request = require 'supertest' 5 | 6 | component = require '../lib/index.js' 7 | 8 | describe 'Twitter module', -> 9 | 10 | app = null 11 | agent = null 12 | Account = null 13 | 14 | first = null 15 | second = null 16 | third = null 17 | 18 | beforeEach -> 19 | app = require '../example/server/server.js' 20 | app.datasources.db.automigrate() 21 | agent = request app 22 | Account = app.models.Account 23 | 24 | it 'should populate model', -> 25 | expect(Account).to.exist 26 | expect(Account.twitter).to.exist 27 | 28 | profile = 29 | id: 'profile_id' 30 | screen_name: 'user_example' 31 | profile_image_url: 'http://picture.com/_normal' 32 | 33 | describe 'the first call', -> 34 | 35 | beforeEach -> 36 | first = nock 'https://api.twitter.com' 37 | .post '/oauth/request_token' 38 | .reply 200, 'oauth_token=oauth_token&oauth_verifier=oauth_verifier' 39 | 40 | describe 'with POST verb', -> 41 | 42 | it 'should return the token', (done) -> 43 | agent.post '/api/accounts/twitter' 44 | .end (err, res) -> 45 | expect(err).to.not.exist 46 | expect(res.statusCode).to.eql 200 47 | expect(res.body).to.exist 48 | expect(first.isDone()).to.eql true 49 | done err 50 | 51 | describe 'with GET verb', -> 52 | 53 | it 'should return the token', (done) -> 54 | agent.get '/api/accounts/twitter' 55 | .end (err, res) -> 56 | expect(err).to.not.exist 57 | expect(res.statusCode).to.eql 200 58 | expect(res.body).to.exist 59 | expect(first.isDone()).to.eql true 60 | done err 61 | 62 | 63 | describe 'the second call', -> 64 | 65 | beforeEach -> 66 | second = nock 'https://api.twitter.com' 67 | .post '/oauth/access_token' 68 | .reply 200, 'oauth_token=oauth_token&screen_name=screen_name' 69 | 70 | beforeEach -> 71 | third = nock 'https://api.twitter.com' 72 | .get '/1.1/users/show.json?screen_name=screen_name' 73 | .reply 200, profile 74 | 75 | describe 'with POST verb', -> 76 | 77 | it 'should create the player', (done) -> 78 | agent.post '/api/accounts/twitter' 79 | .send 80 | oauth_token: 'oauthToken' 81 | oauth_verifier: 'oauthVerifier' 82 | .end (err, res) -> 83 | expect(err).to.not.exist 84 | expect(res.statusCode).to.eql 200 85 | expect(second.isDone()).to.eql true 86 | expect(third.isDone()).to.eql true 87 | done err 88 | 89 | describe 'with GET verb', -> 90 | 91 | it 'should create the player', (done) -> 92 | agent.get '/api/accounts/twitter' 93 | .query 94 | oauth_token: 'oauthToken' 95 | oauth_verifier: 'oauthVerifier' 96 | .end (err, res) -> 97 | expect(err).to.not.exist 98 | expect(res.statusCode).to.eql 200 99 | expect(second.isDone()).to.eql true 100 | expect(third.isDone()).to.eql true 101 | done err 102 | --------------------------------------------------------------------------------