From 3d5ca8f35ddf593e1718a89924e6d3633b9d7d7e Mon Sep 17 00:00:00 2001 From: Ayeni Olusegun Date: Tue, 3 Jan 2017 10:09:36 +0100 Subject: [PATCH] feat(driver): add instagram driver * feat(social-auth):create social login via instagram * feat(social-auth):Add instagram auth tests * fix(social-auth):Fix standard and linting issues * fix data bugs * removing client id and client secret from config file * fix(adonis-ally):change twitter OauthException parameter from github to twitter in twitter driver file * feat(social-auth):wrote twitter tests * worked on the changes requested --- .gitignore | 3 + examples/config.js | 15 +++ examples/instagram.js | 34 +++++++ package.json | 2 +- src/Drivers/Instagram.js | 193 ++++++++++++++++++++++++++++++++++++++ src/Drivers/Twitter.js | 2 +- src/Drivers/index.js | 3 +- test/unit/drivers.spec.js | 83 ++++++++++++++++ 8 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 examples/instagram.js create mode 100644 src/Drivers/Instagram.js diff --git a/.gitignore b/.gitignore index 66bc721..533c4f3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ coverage node_modules .DS_Store npm-debug.log +.env +.idea +.idea/ diff --git a/examples/config.js b/examples/config.js index 108339e..da010ae 100644 --- a/examples/config.js +++ b/examples/config.js @@ -60,6 +60,21 @@ module.exports = { clientId: Env.get('GITHUB_CLIENT_ID'), clientSecret: Env.get('GITHUB_CLIENT_SECRET'), redirectUri: `${Env.get('APP_URL')}/authenticated/github` + }, + + /* + |-------------------------------------------------------------------------- + | Instagram Configuration + |-------------------------------------------------------------------------- + | + | You can access your application credentials from the instagram developers + | console. https://www.instagram.com/developer/ + | + */ + instagram: { + clientId: Env.get('INSTAGRAM_CLIENT_ID'), + clientSecret: Env.get('INSTAGRAM_CLIENT_SECRET'), + redirectUri: `${Env.get('APP_URL')}/authenticated/instagram` } } } diff --git a/examples/instagram.js b/examples/instagram.js new file mode 100644 index 0000000..77b8f41 --- /dev/null +++ b/examples/instagram.js @@ -0,0 +1,34 @@ +'use strict' + +const Ioc = require('adonis-fold').Ioc +const config = require('./setup/config') +const http = require('./setup/http') +const AllyManager = require('../src/AllyManager') +Ioc.bind('Adonis/Src/Config', () => { + return config +}) + +http.get('/instagram', function * (request, response) { + const ally = new AllyManager(request, response) + const instagram = ally.driver('instagram') + response.writeHead(200, {'content-type': 'text/html'}) + const url = yield instagram.getRedirectUrl() + response.write(`Login With Instagram`) + response.end() +}) + +http.get('/instagram/authenticated', function * (request, response) { + const ally = new AllyManager(request, response) + const instagram = ally.driver('instagram') + try { + const user = yield instagram.getUser() + response.writeHead(200, {'content-type': 'application/json'}) + response.write(JSON.stringify({ original: user.getOriginal(), profile: user.toJSON() })) + } catch (e) { + response.writeHead(500, {'content-type': 'application/json'}) + response.write(JSON.stringify({ error: e })) + } + response.end() +}) + +http.start().listen(8000) diff --git a/package.json b/package.json index 0db3bf5..bad60e3 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "cz-conventional-changelog": "^1.2.0", "istanbul": "^0.4.5", "mocha": "^3.0.2", - "standard": "^8.0.0" + "standard": "^8.6.0" }, "standard": { "global": [ diff --git a/src/Drivers/Instagram.js b/src/Drivers/Instagram.js new file mode 100644 index 0000000..abd8caa --- /dev/null +++ b/src/Drivers/Instagram.js @@ -0,0 +1,193 @@ +'use strict' + +/* + * adonis-ally + * + * (c) Ayeni Olusegun + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +const CE = require('../Exceptions') +const OAuth2Scheme = require('../Schemes/OAuth2') +const AllyUser = require('../AllyUser') +const got = require('got') +const utils = require('../../lib/utils') +const _ = utils.mixLodash(require('lodash')) + +class Instagram extends OAuth2Scheme { + + constructor (Config) { + const config = Config.get('services.ally.instagram') + + if (!_.hasAll(config, ['clientId', 'clientSecret', 'redirectUri'])) { + throw CE.OAuthException.missingConfig('instagram') + } + + super(config.clientId, config.clientSecret, config.headers) + + /** + * Oauth specific values to be used when creating the redirect + * url or fetching user profile. + */ + this._scope = this._getInitialScopes(config.scope) + this._redirectUri = config.redirectUri + this._redirectUriOptions = _.merge({response_type: 'code'}, config.options) + } + + /** + * Injections to be made by the IoC container + * + * @return {Array} + */ + static get inject () { + return ['Adonis/Src/Config'] + } + + /** + * Scope seperator for seperating multiple + * scopes. + * + * @return {String} + */ + get scopeSeperator () { + return ' ' + } + + /** + * Base url to be used for constructing + * facebook oauth urls. + * + * @return {String} + */ + get baseUrl () { + return 'https://api.instagram.com/' + } + + /** + * Relative url to be used for redirecting + * user. + * + * @return {String} [description] + */ + get authorizeUrl () { + return 'oauth/authorize' + } + + /** + * Relative url to be used for exchanging + * access token. + * + * @return {String} + */ + get accessTokenUrl () { + return 'oauth/access_token' + } + + /** + * Returns initial scopes to be used right from the + * config file. Otherwise it will fallback to the + * commonly used scopes + * + * @param {Array} scopes + * + * @return {Array} + * + * @private + */ + _getInitialScopes (scopes) { + return _.size(scopes) ? scopes : ['basic'] + } + + /** + * Returns the user profile as an object using the + * access token + * + * @param {String} accessToken + * + * @return {Object} + * + * @private + */ + * _getUserProfile (accessToken) { + const profileUrl = `${this.baseUrl}v1/users/self?access_token=${accessToken}` + const response = yield got(profileUrl, { + headers: { + 'Accept': 'application/json' + }, + json: true + }) + return response.body + } + + /** + * Returns the redirect url for a given provider. + * + * @param {Array} scope + * + * @return {String} + */ + * getRedirectUrl (scope) { + scope = _.size(scope) ? scope : this._scope + return this.getUrl(this._redirectUri, scope, this._redirectUriOptions) + } + + /** + * Parses the redirect errors returned by facebook + * and returns the error message. + * + * @param {Object} queryParams + * + * @return {String} + */ + parseRedirectError (queryParams) { + return queryParams.error_description || queryParams.error || 'Oauth failed during redirect' + } + + /** + * Returns the user profile with it's access token, refresh token + * and token expiry + * + * @param {Object} queryParams + * + * @return {Object} + */ + * getUser (queryParams) { + const code = queryParams.code + + /** + * Throw an exception when query string does not have + * code. + */ + if (!code) { + const errorMessage = this.parseRedirectError(queryParams) + throw CE.OAuthException.tokenExchangeException(errorMessage, null, errorMessage) + } + + const accessTokenResponse = yield this.getAccessToken(code, this._redirectUri, { + grant_type: 'authorization_code' + }) + const userProfile = yield this._getUserProfile(accessTokenResponse.accessToken) + const user = new AllyUser() + user + .setOriginal(userProfile) + .setFields( + userProfile.data.id, + userProfile.data.full_name, + null, + userProfile.data.username, + userProfile.data.profile_picture + ) + .setToken( + accessTokenResponse.accessToken, + accessTokenResponse.refreshToken, + null, + null + ) + + return user + } +} + +module.exports = Instagram diff --git a/src/Drivers/Twitter.js b/src/Drivers/Twitter.js index 4b74dd2..289681e 100644 --- a/src/Drivers/Twitter.js +++ b/src/Drivers/Twitter.js @@ -21,7 +21,7 @@ class Twitter extends OAuthScheme { const config = Config.get('services.ally.twitter') if (!_.hasAll(config, ['clientId', 'clientSecret', 'redirectUri'])) { - throw CE.OAuthException.missingConfig('github') + throw CE.OAuthException.missingConfig('twitter') } super(config.clientId, config.clientSecret, config.redirectUri) diff --git a/src/Drivers/index.js b/src/Drivers/index.js index 1dc6f4b..84bc25d 100644 --- a/src/Drivers/index.js +++ b/src/Drivers/index.js @@ -14,5 +14,6 @@ module.exports = { github: require('./Github'), google: require('./Google'), linkedin: require('./LinkedIn'), - twitter: require('./Twitter') + twitter: require('./Twitter'), + instagram: require('./Instagram') } diff --git a/test/unit/drivers.spec.js b/test/unit/drivers.spec.js index d38918a..d85d148 100644 --- a/test/unit/drivers.spec.js +++ b/test/unit/drivers.spec.js @@ -17,6 +17,8 @@ const Google = drivers.google const Facebook = drivers.facebook const Github = drivers.github const LinkedIn = drivers.linkedin +const Instagram = drivers.instagram +const Twitter = drivers.twitter const assert = chai.assert require('co-mocha') @@ -256,4 +258,85 @@ describe('Oauth Drivers', function () { assert.equal(redirectToUrl, providerUrl) }) }) + + context('Instagram', function () { + it('should throw an exception when config has not been defined', function () { + const instagram = () => new Instagram({get: function () { return null }}) + assert.throw(instagram, 'OAuthException: E_MISSING_OAUTH_CONFIG: Make sure to define instagram configuration inside config/services.js file') + }) + + it('should throw an exception when clientid is missing', function () { + const instagram = () => new Instagram({get: function () { return {clientSecret: '1', redirectUri: '2'} }}) + assert.throw(instagram, 'OAuthException: E_MISSING_OAUTH_CONFIG: Make sure to define instagram configuration inside config/services.js file') + }) + + it('should throw an exception when clientSecret is missing', function () { + const instagram = () => new Instagram({get: function () { return {clientId: '1', redirectUri: '2'} }}) + assert.throw(instagram, 'OAuthException: E_MISSING_OAUTH_CONFIG: Make sure to define instagram configuration inside config/services.js file') + }) + + it('should throw an exception when redirectUri is missing', function () { + const instagram = () => new Instagram({get: function () { return {clientId: '1', clientSecret: '2'} }}) + assert.throw(instagram, 'OAuthException: E_MISSING_OAUTH_CONFIG: Make sure to define instagram configuration inside config/services.js file') + }) + + it('should generate the redirect_uri with correct signature', function * () { + const instagram = new Instagram(config) + const redirectUrl = qs.escape(config.get().redirectUri) + const scope = qs.escape(['basic'].join(' ')) + const providerUrl = `https://api.instagram.com/oauth/authorize?redirect_uri=${redirectUrl}&scope=${scope}&response_type=code&client_id=${config.get().clientId}` + const redirectToUrl = yield instagram.getRedirectUrl() + assert.equal(redirectToUrl, providerUrl) + }) + + it('should make use of the scopes defined in the config file', function * () { + const customConfig = { + get: function () { + return { + clientId: 12, + clientSecret: 123, + redirectUri: 'http://localhost', + scope: ['basic'] + } + } + } + const instagram = new Instagram(customConfig) + const redirectUrl = qs.escape(customConfig.get().redirectUri) + const scope = qs.escape(['basic'].join(' ')) + const providerUrl = `https://api.instagram.com/oauth/authorize?redirect_uri=${redirectUrl}&scope=${scope}&response_type=code&client_id=${customConfig.get().clientId}` + const redirectToUrl = yield instagram.getRedirectUrl() + assert.equal(redirectToUrl, providerUrl) + }) + + it('should make use of the scopes passed to the generate method', function * () { + const instagram = new Instagram(config) + const redirectUrl = qs.escape(config.get().redirectUri) + const scope = qs.escape(['basic'].join(' ')) + const providerUrl = `https://api.instagram.com/oauth/authorize?redirect_uri=${redirectUrl}&scope=${scope}&response_type=code&client_id=${config.get().clientId}` + const redirectToUrl = yield instagram.getRedirectUrl(['basic']) + assert.equal(redirectToUrl, providerUrl) + }) + }) + + context('Twitter', function () { + it('should throw an exception when config has not been defined', function () { + const twitter = () => new Twitter({get: function () { return null }}) + assert.throw(twitter, 'OAuthException: E_MISSING_OAUTH_CONFIG: Make sure to define twitter configuration inside config/services.js file') + }) + + it('should throw an exception when clientid is missing', function () { + const twitter = () => new Twitter({get: function () { return {clientSecret: '1', redirectUri: '2'} }}) + assert.throw(twitter, 'OAuthException: E_MISSING_OAUTH_CONFIG: Make sure to define twitter configuration inside config/services.js file') + }) + + it('should throw an exception when clientSecret is missing', function () { + const twitter = () => new Twitter({get: function () { return {clientId: '1', redirectUri: '2'} }}) + assert.throw(twitter, 'OAuthException: E_MISSING_OAUTH_CONFIG: Make sure to define twitter configuration inside config/services.js file') + }) + + it('should throw an exception when redirectUri is missing', function () { + const twitter = () => new Twitter({get: function () { return {clientId: '1', clientSecret: '2'} }}) + assert.throw(twitter, 'OAuthException: E_MISSING_OAUTH_CONFIG: Make sure to define twitter configuration inside config/services.js file') + }) + }) })