diff --git a/src/index.js b/src/index.js index 509d26b..7022d3f 100644 --- a/src/index.js +++ b/src/index.js @@ -28,7 +28,6 @@ export default class FacebookTokenStrategy extends OAuth2Strategy { const options = _options || {}; const verify = _verify; const _fbGraphVersion = options.fbGraphVersion || 'v2.6'; - options.authorizationURL = options.authorizationURL || `https://www.facebook.com/${_fbGraphVersion}/dialog/oauth`; options.tokenURL = options.tokenURL || `https://graph.facebook.com/${_fbGraphVersion}/oauth/access_token`; @@ -42,10 +41,14 @@ export default class FacebookTokenStrategy extends OAuth2Strategy { this._profileFields = options.profileFields || ['id', 'displayName', 'name', 'emails']; this._profileImage = options.profileImage || {}; this._clientSecret = options.clientSecret; + this._clientID = options.clientID; this._enableProof = typeof options.enableProof === 'boolean' ? options.enableProof : true; this._passReqToCallback = options.passReqToCallback; this._oauth2.useAuthorizationHeaderforGET(false); this._fbGraphVersion = _fbGraphVersion; + this.tokenURL = options.tokenURL; + this._getLongLivedToken = options.getLongLivedToken || false; + this._storeLongLiveToken = options.storeLongLiveToken || false; } /** @@ -56,7 +59,7 @@ export default class FacebookTokenStrategy extends OAuth2Strategy { authenticate(req, options) { const accessToken = this.lookup(req, this._accessTokenField); const refreshToken = this.lookup(req, this._refreshTokenField); - + const self=this; if (!accessToken) return this.fail({message: `You should provide ${this._accessTokenField}`}); this._loadUserProfile(accessToken, (error, profile) => { @@ -68,11 +71,32 @@ export default class FacebookTokenStrategy extends OAuth2Strategy { return this.success(user, info); }; + const passCallback=(req,accessToken,refreshToken,profile) => { + if (self._storeLongLiveToken){ + accessToken=req.body.longLivedToken + } + + if (self._passReqToCallback) { + self._verify(req, accessToken, refreshToken, profile, verified); + } else { + self._verify(accessToken, refreshToken, profile, verified); + } + }; + if(self._getLongLivedToken) { + self._getLLT(accessToken, function (err, longLivedToken, expires) { + if(err){ + self.error(err); + return; + } - if (this._passReqToCallback) { - this._verify(req, accessToken, refreshToken, profile, verified); + req.body.longLivedToken = longLivedToken; + if(expires !== null){ + req.body.longLivedTokenExpires = expires; + } + passCallback(req, accessToken, refreshToken, profile); + }); } else { - this._verify(accessToken, refreshToken, profile, verified); + passCallback(req, accessToken, refreshToken, profile); } }); } @@ -210,4 +234,50 @@ export default class FacebookTokenStrategy extends OAuth2Strategy { return profileFields.reduce((acc, field) => acc.concat(map[field] || field), []).join(','); } + /** + * Attempts to get a Long-Lived Token from Facebook. + * Requires a valid clientID (AppID), clientSecret (AppSecret) and accessToken + * + * @param {String} accessToken + * @param {Function} done + * @api private + */ + _getLLT(accessToken,done){ + let url = this.tokenURL + "?" + + "grant_type=fb_exchange_token" + "&" + + "client_id=" + this._clientID + "&" + + "client_secret=" + this._clientSecret + "&" + + "fb_exchange_token=" + accessToken; + url = uri.parse(url); + + if (this._enableProof) { + // Secure API call by adding proof of the app secret. This is required when + // the "Require AppSecret Proof for Server API calls" setting has been + // enabled. The proof is a SHA256 hash of the access token, using the app + // secret as the key. + // + // For further details, refer to: + // https://developers.facebook.com/docs/reference/api/securing-graph-api/ + const proof = crypto.createHmac('sha256', this._clientSecret).update(accessToken).digest('hex'); + url.search = (url.search ? url.search + '&' : '') + 'appsecret_proof=' + encodeURIComponent(proof); + } + url = uri.format(url); + this._oauth2.getProtectedResource(url, accessToken, function (err, body, res) { + if (err) { + return done(new InternalOAuthError('failed to get long-lived token', err)); } + try { + body=JSON.parse(body); + if(typeof body.access_token === "undefined"){ + return done(new InternalOAuthError('facebook was unable to provide a long-lived token')); + } + if(typeof body.expires_in === "undefined"){ + body.expires_in = null; + } + return done(null,body.access_token,body.expires_in) + } catch(e) { + done(e); + } + }); + } + } diff --git a/test/fixtures/longLiveToken.js b/test/fixtures/longLiveToken.js new file mode 100644 index 0000000..aa3d676 --- /dev/null +++ b/test/fixtures/longLiveToken.js @@ -0,0 +1,8 @@ +/** + * Created by andreaboetto on 03/05/17. + */ +export default JSON.stringify({ + "access_token":"LONGLIVETOKEN", + "token_type":"bearer", + "expires_in":5182904 +}); diff --git a/test/unit/index.test.js b/test/unit/index.test.js index ae1ac5a..32fd0ca 100644 --- a/test/unit/index.test.js +++ b/test/unit/index.test.js @@ -2,6 +2,7 @@ import chai, { assert } from 'chai'; import sinon from 'sinon'; import FacebookTokenStrategy from '../../src/index'; import fakeProfile from '../fixtures/profile'; +import fakeLongLiveToken from '../fixtures/longLiveToken'; const STRATEGY_CONFIG = { clientID: '123', @@ -25,7 +26,7 @@ describe('FacebookTokenStrategy:init', () => { it('Should properly throw exception when options is empty', () => { assert.throw(() => new FacebookTokenStrategy(), Error); }); - + it('Should use the default fb graph version when no explicit version is specified', () => { let strategy = new FacebookTokenStrategy(STRATEGY_CONFIG, BLANK_FUNCTION); assert.equal(strategy._fbGraphVersion, 'v2.6'); @@ -33,22 +34,23 @@ describe('FacebookTokenStrategy:init', () => { assert.equal(strategy._oauth2._authorizeUrl,'https://www.facebook.com/v2.6/dialog/oauth'); assert.equal(strategy._profileURL,'https://graph.facebook.com/v2.6/me'); }); - + it('Should use the explicit version, if specified', () => { let strategy = new FacebookTokenStrategy({ clientID: '123', clientSecret: '123', fbGraphVersion: 'v2.4' }, BLANK_FUNCTION); - assert.equal(strategy._fbGraphVersion, 'v2.4'); + assert.equal(strategy._fbGraphVersion, 'v2.4'); assert.equal(strategy._oauth2._accessTokenUrl,'https://graph.facebook.com/v2.4/oauth/access_token'); assert.equal(strategy._oauth2._authorizeUrl,'https://www.facebook.com/v2.4/dialog/oauth'); - assert.equal(strategy._profileURL,'https://graph.facebook.com/v2.4/me'); + assert.equal(strategy._profileURL,'https://graph.facebook.com/v2.4/me'); }); - + }); describe('FacebookTokenStrategy:authenticate', () => { + describe('Authenticate without passReqToCallback', () => { let strategy; @@ -213,7 +215,204 @@ describe('FacebookTokenStrategy:authenticate', () => { }); }); + describe('Authenticate without passReqToCallback with long live token, not stored', () => { + let strategy; + + before(() => { + strategy = new FacebookTokenStrategy({ + clientID: '123', + clientSecret: '123', + getLongLivedToken: true + }, (accessToken, refreshToken, profile, next) => { + assert.equal(accessToken, 'access_token'); + assert.equal(refreshToken, 'refresh_token'); + assert.typeOf(profile, 'object'); + assert.typeOf(next, 'function'); + return next(null, profile, {info: 'foo'}); + }); + sinon.stub(strategy._oauth2, 'get', (url, accessToken, next) => next(null, fakeProfile, null)); + sinon.stub(strategy, '_getLLT', (accessToken, next) => next(null, fakeProfile, null)); + }); + + after(() => strategy._oauth2.get.restore()); + }); + describe('Authenticate without passReqToCallback with long live token stored', () => { + let strategy; + + before(() => { + strategy = new FacebookTokenStrategy({ + clientID: '123', + clientSecret: '123', + getLongLivedToken: true, + storeLongLiveToken:true + }, (accessToken, refreshToken, profile, next) => { + assert.equal(accessToken, 'LONGLIVETOKEN'); + assert.equal(refreshToken, 'refresh_token'); + assert.typeOf(profile, 'object'); + assert.typeOf(next, 'function'); + return next(null, profile, {info: 'foo'}); + }); + sinon.stub(strategy._oauth2, 'get', (url, accessToken, next) => next(null, fakeProfile, null)); + sinon.stub(strategy, '_getLLT', (accessToken, next) => next(null, fakeProfile, null)); + }); + + after(() => strategy._oauth2.get.restore()); + }); + + describe('Authenticate with passReqToCallback with long live token not stored', () => { + let strategy; + + before(() => { + strategy = new FacebookTokenStrategy({ + clientID: '123', + clientSecret: '123', + passReqToCallback: true, + getLongLivedToken: true + }, (req, accessToken, refreshToken, profile, next) => { + assert.typeOf(req, 'object'); + assert.equal(accessToken, 'access_token'); + assert.equal(req.body.longLivedToken, 'LONGLIVETOKEN'); + assert.equal(refreshToken, 'refresh_token'); + assert.typeOf(profile, 'object'); + assert.typeOf(next, 'function'); + return next(null, profile, {info: 'foo'}); + }); + sinon.stub(strategy._oauth2, 'get', (url, accessToken, next) => next(null, fakeProfile, null)); + sinon.stub(strategy._oauth2, 'getProtectedResource', (url, accessToken, next) => next(null, JSON.stringify({ + "access_token":"LONGLIVETOKEN", + "token_type":"bearer" + }), null)); + }); + + after(() => {strategy._oauth2.get.restore(); strategy._oauth2.getProtectedResource.restore()}); + + it('Should properly call _verify with req', done => { + chai + .passport + .use(strategy) + .success((user, info) => { + assert.typeOf(user, 'object'); + assert.typeOf(info, 'object'); + assert.deepEqual(info, {info: 'foo'}); + done(); + }) + .req(req => { + req.body = { + access_token: 'access_token', + refresh_token: 'refresh_token' + } + }) + .authenticate({}); + }); + }); + describe('Authenticate with passReqToCallback with long live token stored', () => { + let strategy; + + before(() => { + strategy = new FacebookTokenStrategy({ + clientID: '123', + clientSecret: '123', + passReqToCallback: true, + getLongLivedToken: true, + storeLongLiveToken:true + }, (req, accessToken, refreshToken, profile, next) => { + assert.typeOf(req, 'object'); + assert.equal(accessToken, 'LONGLIVETOKEN'); + assert.equal(req.body.longLivedToken, 'LONGLIVETOKEN'); + assert.equal(refreshToken, 'refresh_token'); + assert.typeOf(profile, 'object'); + assert.typeOf(next, 'function'); + return next(null, profile, {info: 'foo'}); + }); + sinon.stub(strategy._oauth2, 'get', (url, accessToken, next) => next(null, fakeProfile, null)); + sinon.stub(strategy._oauth2, 'getProtectedResource', (url, accessToken, next) => next(null, fakeLongLiveToken, null)); + }); + + after(() => {strategy._oauth2.get.restore(); strategy._oauth2.getProtectedResource.restore()}); + + it('Should properly call _verify with req', done => { + chai + .passport + .use(strategy) + .success((user, info) => { + assert.typeOf(user, 'object'); + assert.typeOf(info, 'object'); + assert.deepEqual(info, {info: 'foo'}); + done(); + }) + .req(req => { + req.body = { + access_token: 'access_token', + refresh_token: 'refresh_token' + } + }) + .authenticate({}); + }); + }); + describe('Failed authentications', () => { + it('Should properly return error on _getLLT', done => { + let strategy = new FacebookTokenStrategy({ + clientID: '123', + clientSecret: '123', + getLongLivedToken: true + }, (accessToken, refreshToken, profile, next) => { + assert.equal(accessToken, 'access_token'); + assert.equal(refreshToken, 'refresh_token'); + assert.typeOf(profile, 'object'); + assert.typeOf(next, 'function'); + return next(null, profile, {info: 'foo'}); + }); + sinon.stub(strategy._oauth2, 'getProtectedResource', (url, access_token, next) => next(new Error('Some error occurred'),null,null)); + + strategy._getLLT('accessToken', (error, longLivedToken, expires) => { + console.log("ERROR",error); + assert.instanceOf(error, Error); + strategy._oauth2.getProtectedResource.restore(); + done(); + }); + }); + it('Should properly return error on facebook response invalid json data', done => { + let strategy = new FacebookTokenStrategy({ + clientID: '123', + clientSecret: '123', + getLongLivedToken: true + }, (accessToken, refreshToken, profile, next) => { + assert.equal(accessToken, 'access_token'); + assert.equal(refreshToken, 'refresh_token'); + assert.typeOf(profile, 'object'); + assert.typeOf(next, 'function'); + return next(null, profile, {info: 'foo'}); + }); + sinon.stub(strategy._oauth2, 'getProtectedResource', (url, access_token, next) => next(null,"not validJsone",null)); + + strategy._getLLT('accessToken', (error, longLivedToken, expires) => { + assert.instanceOf(error, Error); + strategy._oauth2.getProtectedResource.restore(); + done(); + }); + }); + it('Should properly return error on facebook response invalid json data content', done => { + let strategy = new FacebookTokenStrategy({ + clientID: '123', + clientSecret: '123', + getLongLivedToken: true + }, (accessToken, refreshToken, profile, next) => { + assert.equal(accessToken, 'access_token'); + assert.equal(refreshToken, 'refresh_token'); + assert.typeOf(profile, 'object'); + assert.typeOf(next, 'function'); + return next(null, profile, {info: 'foo'}); + }); + sinon.stub(strategy._oauth2, 'getProtectedResource', (url, access_token, next) => next(null,"{}",null)); + + strategy._getLLT('accessToken', (error, longLivedToken, expires) => { + console.log("ERROR",error); + assert.instanceOf(error, Error); + strategy._oauth2.getProtectedResource.restore(); + done(); + }); + }); it('Should properly return error on loadUserProfile', done => { let strategy = new FacebookTokenStrategy(STRATEGY_CONFIG, (accessToken, refreshToken, profile, next) => { assert.equal(accessToken, 'access_token'); @@ -297,6 +496,7 @@ describe('FacebookTokenStrategy:authenticate', () => { }) .authenticate({}); }); + }); });