diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..05642c5 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +github: jaredhanson +patreon: jaredhanson +ko_fi: jaredhanson diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..90ab29f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.6.1] - 2021-09-24 +### Fixed +- Error in cases where the authorization server returns a successful access +token response which is missing an `access_token` parameter. + +## [1.6.0] - 2021-07-01 +### Added + +- Support for `store: true` option to `Strategy` constructor, which initializes +a state store capable of storing application-level state. +- Support for `state` object passed as option to `authenticate`, which will be +persisted in the session by state store. +- `callbackURL` property added to metadata passed to state store. diff --git a/README.md b/README.md index b0cac30..9812fba 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,16 @@ that is not already supported are encouraged to sub-class this strategy. If you choose to open source the new provider-specific strategy, please add it to the list so other people can find it. +--- + +

+ Advertisement +
+ Learn OAuth 2.0 - Get started as an API Security Expert
Just imagine what could happen to YOUR professional career if you had skills in OAuth > 8500 satisfied students +

+ +--- + [![npm](https://img.shields.io/npm/v/passport-oauth2.svg)](https://www.npmjs.com/package/passport-oauth2) [![build](https://img.shields.io/travis/jaredhanson/passport-oauth2.svg)](https://travis-ci.org/jaredhanson/passport-oauth2) [![coverage](https://img.shields.io/coveralls/jaredhanson/passport-oauth2.svg)](https://coveralls.io/github/jaredhanson/passport-oauth2) @@ -103,23 +113,10 @@ $ make test-cov $ make view-cov ``` -## Support - -#### Funding - -This software is provided to you as open source, free of charge. The time and -effort to develop and maintain this project is dedicated by [@jaredhanson](https://github.com/jaredhanson). -If you (or your employer) benefit from this project, please consider a financial -contribution. Your contribution helps continue the efforts that produce this -and other open source software. - -Funds are accepted via [PayPal](https://paypal.me/jaredhanson), [Venmo](https://venmo.com/jaredhanson), -and [other](http://jaredhanson.net/pay) methods. Any amount is appreciated. - ## License [The MIT License](http://opensource.org/licenses/MIT) Copyright (c) 2011-2016 Jared Hanson <[http://jaredhanson.net/](http://jaredhanson.net/)> - Sponsor + diff --git a/lib/state/pkcesession.js b/lib/state/pkcesession.js index 0864014..69ab443 100644 --- a/lib/state/pkcesession.js +++ b/lib/state/pkcesession.js @@ -39,13 +39,14 @@ PKCESessionStore.prototype.store = function(req, verifier, state, meta, callback if (!req.session) { return callback(new Error('OAuth 2.0 authentication requires session support when using state. Did you forget to use express-session middleware?')); } var key = this._key; - var state = { + var sstate = { handle: uid(24), code_verifier: verifier }; + if (state) { sstate.state = state; } if (!req.session[key]) { req.session[key] = {}; } - req.session[key].state = state; - callback(null, state.handle); + req.session[key].state = sstate; + callback(null, sstate.handle); }; /** @@ -81,7 +82,7 @@ PKCESessionStore.prototype.verify = function(req, providedState, callback) { return callback(null, false, { message: 'Invalid authorization request state.' }); } - return callback(null, state.code_verifier); + return callback(null, state.code_verifier, state.state); }; // Expose constructor. diff --git a/lib/state/store.js b/lib/state/store.js new file mode 100644 index 0000000..a49e6e5 --- /dev/null +++ b/lib/state/store.js @@ -0,0 +1,88 @@ +var uid = require('uid2'); + +/** + * Creates an instance of `SessionStore`. + * + * This is the state store implementation for the OAuth2Strategy used when + * the `state` option is enabled. It generates a random state and stores it in + * `req.session` and verifies it when the service provider redirects the user + * back to the application. + * + * This state store requires session support. If no session exists, an error + * will be thrown. + * + * Options: + * + * - `key` The key in the session under which to store the state + * + * @constructor + * @param {Object} options + * @api public + */ +function SessionStore(options) { + if (!options.key) { throw new TypeError('Session-based state store requires a session key'); } + this._key = options.key; +} + +/** + * Store request state. + * + * This implementation simply generates a random string and stores the value in + * the session, where it will be used for verification when the user is + * redirected back to the application. + * + * @param {Object} req + * @param {Function} callback + * @api protected + */ +SessionStore.prototype.store = function(req, state, meta, callback) { + if (!req.session) { return callback(new Error('OAuth 2.0 authentication requires session support when using state. Did you forget to use express-session middleware?')); } + + var key = this._key; + var sstate = { + handle: uid(24) + }; + if (state) { sstate.state = state; } + if (!req.session[key]) { req.session[key] = {}; } + req.session[key].state = sstate; + callback(null, sstate.handle); +}; + +/** + * Verify request state. + * + * This implementation simply compares the state parameter in the request to the + * value generated earlier and stored in the session. + * + * @param {Object} req + * @param {String} providedState + * @param {Function} callback + * @api protected + */ +SessionStore.prototype.verify = function(req, providedState, callback) { + if (!req.session) { return callback(new Error('OAuth 2.0 authentication requires session support when using state. Did you forget to use express-session middleware?')); } + + var key = this._key; + if (!req.session[key]) { + return callback(null, false, { message: 'Unable to verify authorization request state.' }); + } + + var state = req.session[key].state; + if (!state) { + return callback(null, false, { message: 'Unable to verify authorization request state.' }); + } + + delete req.session[key].state; + if (Object.keys(req.session[key]).length === 0) { + delete req.session[key]; + } + + if (state.handle !== providedState) { + return callback(null, false, { message: 'Invalid authorization request state.' }); + } + + return callback(null, true, state.state); +}; + +// Expose constructor. +module.exports = SessionStore; diff --git a/lib/strategy.js b/lib/strategy.js index 8ac16e4..b05aacc 100644 --- a/lib/strategy.js +++ b/lib/strategy.js @@ -7,9 +7,10 @@ var passport = require('passport-strategy') , util = require('util') , utils = require('./utils') , OAuth2 = require('oauth').OAuth2 - , NullStateStore = require('./state/null') - , SessionStateStore = require('./state/session') - , PKCESessionStateStore = require('./state/pkcesession') + , NullStore = require('./state/null') + , NonceStore = require('./state/session') + , StateStore = require('./state/store') + , PKCEStateStore = require('./state/pkcesession') , AuthorizationError = require('./errors/authorizationerror') , TokenError = require('./errors/tokenerror') , InternalOAuthError = require('./errors/internaloautherror'); @@ -101,15 +102,15 @@ function OAuth2Strategy(options, verify) { this._pkceMethod = (options.pkce === true) ? 'S256' : options.pkce; this._key = options.sessionKey || ('oauth2:' + url.parse(options.authorizationURL).hostname); - if (options.store) { + if (options.store && typeof options.store == 'object') { this._stateStore = options.store; + } else if (options.store) { + this._stateStore = options.pkce ? new PKCEStateStore({ key: this._key }) : new StateStore({ key: this._key }); + } else if (options.state) { + this._stateStore = options.pkce ? new PKCEStateStore({ key: this._key }) : new NonceStore({ key: this._key }); } else { - if (options.state) { - this._stateStore = options.pkce ? new PKCESessionStateStore({ key: this._key }) : new SessionStateStore({ key: this._key }); - } else { - if (options.pkce) { throw new TypeError('OAuth2Strategy requires `state: true` option when PKCE is enabled'); } - this._stateStore = new NullStateStore(); - } + if (options.pkce) { throw new TypeError('OAuth2Strategy requires `state: true` option when PKCE is enabled'); } + this._stateStore = new NullStore(); } this._trustProxy = options.proxy; this._passReqToCallback = options.passReqToCallback; @@ -151,7 +152,8 @@ OAuth2Strategy.prototype.authenticate = function(req, options) { var meta = { authorizationURL: this._oauth2._authorizeUrl, tokenURL: this._oauth2._accessTokenUrl, - clientID: this._oauth2._clientId + clientID: this._oauth2._clientId, + callbackURL: callbackURL } if (req.query && req.query.code) { @@ -173,6 +175,7 @@ OAuth2Strategy.prototype.authenticate = function(req, options) { self._oauth2.getOAuthAccessToken(code, params, function(err, accessToken, refreshToken, params) { if (err) { return self.error(self._createOAuthError('Failed to obtain access token', err)); } + if (!accessToken) { return self.error(new Error('Failed to obtain access token')); } self._loadUserProfile(accessToken, function(err, profile) { if (err) { return self.error(err); } @@ -250,7 +253,17 @@ OAuth2Strategy.prototype.authenticate = function(req, options) { } var state = options.state; - if (state) { + if (state && typeof state == 'string') { + // NOTE: In passport-oauth2@1.5.0 and earlier, `state` could be passed as + // an object. However, it would result in an empty string being + // serialized as the value of the query parameter by `url.format()`, + // effectively ignoring the option. This implies that `state` was + // only functional when passed as a string value. + // + // This fact is taken advantage of here to fall into the `else` + // branch below when `state` is passed as an object. In that case + // the state will be automatically managed and persisted by the + // state store. params.state = state; var parsed = url.parse(this._oauth2._authorizeUrl, true); @@ -275,7 +288,9 @@ OAuth2Strategy.prototype.authenticate = function(req, options) { try { var arity = this._stateStore.store.length; if (arity == 5) { - this._stateStore.store(req, verifier, undefined, meta, stored); + this._stateStore.store(req, verifier, state, meta, stored); + } else if (arity == 4) { + this._stateStore.store(req, state, meta, stored); } else if (arity == 3) { this._stateStore.store(req, meta, stored); } else { // arity == 2 diff --git a/package.json b/package.json index c8a939c..182007f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "passport-oauth2", - "version": "1.5.0", + "version": "1.6.1", "description": "OAuth 2.0 authentication strategy for Passport.", "keywords": [ "passport", @@ -24,6 +24,10 @@ "bugs": { "url": "http://github.com/jaredhanson/passport-oauth2/issues" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + }, "license": "MIT", "licenses": [ { diff --git a/test/oauth2.pkce.test.js b/test/oauth2.pkce.test.js index 845fe48..90a5f47 100644 --- a/test/oauth2.pkce.test.js +++ b/test/oauth2.pkce.test.js @@ -89,6 +89,109 @@ describe('OAuth2Strategy', function() { expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); expect(request.session['oauth2:www.example.com'].state.code_verifier).to.have.length(43); expect(request.session['oauth2:www.example.com'].state.code_verifier).to.equal('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + expect(request.session['oauth2:www.example.com'].state.state).to.be.undefined; + }); + }); + + describe('handling a request to be redirected for authorization with state', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate({ state: { returnTo: '/somewhere' }}); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + expect(u.query.code_challenge).to.have.length(43); + expect(u.query.code_challenge).to.equal('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') + expect(u.query.code_challenge_method).to.equal('S256'); + }); + + it('should save verifier in session', function() { + var u = uri.parse(url, true); + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.have.length(43); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.equal('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + expect(request.session['oauth2:www.example.com'].state.state).to.deep.equal({ returnTo: '/somewhere' }); + }); + }); + + describe('handling a request to be redirected for authorization with state set to boolean true', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate({ state: true }); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + expect(u.query.code_challenge).to.have.length(43); + expect(u.query.code_challenge).to.equal('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') + expect(u.query.code_challenge_method).to.equal('S256'); + }); + + it('should save verifier in session', function() { + var u = uri.parse(url, true); + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.have.length(43); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.equal('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + expect(request.session['oauth2:www.example.com'].state.state).to.equal(true); + }); + }); + + describe('handling a request to be redirected for authorization with state set to boolean false', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate({ state: false }); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + expect(u.query.code_challenge).to.have.length(43); + expect(u.query.code_challenge).to.equal('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') + expect(u.query.code_challenge_method).to.equal('S256'); + }); + + it('should save verifier in session', function() { + var u = uri.parse(url, true); + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.have.length(43); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.equal('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + expect(request.session['oauth2:www.example.com'].state.state).to.be.undefined; }); }); @@ -161,6 +264,48 @@ describe('OAuth2Strategy', function() { it('should supply info', function() { expect(info).to.be.an.object; expect(info.message).to.equal('Hello'); + expect(info.state).to.be.undefined; + }); + + it('should remove state with verifier from session', function() { + expect(request.session['oauth2:www.example.com']).to.be.undefined; + }); + }); + + describe('processing response to authorization request with state', function() { + var request + , user + , info; + + before(function(done) { + chai.passport.use(strategy) + .success(function(u, i) { + user = u; + info = i; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + req.session['oauth2:www.example.com']['state'] = { handle: 'DkbychwKu8kBaJoLE5yeR5NK', code_verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', state: { returnTo: '/somewhere' } }; + }) + .authenticate(); + }); + + it('should supply user', function() { + expect(user).to.be.an.object; + expect(user.id).to.equal('1234'); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Hello'); + expect(info.state).to.deep.equal({ returnTo: '/somewhere' }); }); it('should remove state with verifier from session', function() { diff --git a/test/oauth2.state.custom.test.js b/test/oauth2.state.custom.test.js index dea5b9b..0c6defe 100644 --- a/test/oauth2.state.custom.test.js +++ b/test/oauth2.state.custom.test.js @@ -6,6 +6,7 @@ var OAuth2Strategy = require('../lib/strategy') , uri = require('url'); + // TODO: renam ethis file to oauth2.store.custom describe('OAuth2Strategy', function() { describe('with custom state store that accepts meta argument', function() { diff --git a/test/oauth2.state.session.test.js b/test/oauth2.state.session.test.js index 93f5865..4e69787 100644 --- a/test/oauth2.state.session.test.js +++ b/test/oauth2.state.session.test.js @@ -6,6 +6,7 @@ var OAuth2Strategy = require('../lib/strategy') , uri = require('url'); + // TODO: rename this file to oauth2.state.nonce describe('OAuth2Strategy', function() { describe('using default session state store', function() { diff --git a/test/oauth2.store.pkce.test.js b/test/oauth2.store.pkce.test.js new file mode 100644 index 0000000..8fb6bf4 --- /dev/null +++ b/test/oauth2.store.pkce.test.js @@ -0,0 +1,889 @@ +var chai = require('chai') + , uri = require('url') + , OAuth2Strategy = require('../lib/strategy'); + + +describe('OAuth2Strategy', function() { + + describe('with store and PKCE true transformation method', function() { + var mockCrypto = { + pseudoRandomBytes: function(len) { + if (len !== 32) { throw new Error('xyz'); } + // https://tools.ietf.org/html/rfc7636#appendix-B + return new Buffer( + [116, 24, 223, 180, 151, 153, 224, 37, 79, 250, 96, 125, 216, 173, + 187, 186, 22, 212, 37, 77, 105, 214, 191, 240, 91, 88, 5, 88, 83, + 132, 141, 121] + ); + } + } + + var OAuth2Strategy = require('proxyquire')('../lib/strategy', { crypto: mockCrypto }); + var strategy = new OAuth2Strategy({ + authorizationURL: 'https://www.example.com/oauth2/authorize', + tokenURL: 'https://www.example.com/oauth2/token', + clientID: 'ABC123', + clientSecret: 'secret', + callbackURL: 'https://www.example.net/auth/example/callback', + store: true, + pkce: true + }, + function(accessToken, refreshToken, profile, done) { + if (accessToken == '2YotnFZFEjr1zCsicMWpAA' && refreshToken == 'tGzv3JOkF0XG5Qx2TlKWIA') { + return done(null, { id: '1234' }, { message: 'Hello' }); + } + return done(null, false); + }); + + strategy._oauth2.getOAuthAccessToken = function(code, options, callback) { + if (code !== 'SplxlOBeZQQYbYS6WxSbIA') { return callback(new Error('incorrect code argument')); } + if (options.grant_type !== 'authorization_code') { return callback(new Error('incorrect options.grant_type argument')); } + if (options.redirect_uri !== 'https://www.example.net/auth/example/callback') { return callback(new Error('incorrect options.redirect_uri argument')); } + if (options.code_verifier !== 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk') { return callback(new Error('incorrect options.verifier loaded from session')); } + + return callback(null, '2YotnFZFEjr1zCsicMWpAA', 'tGzv3JOkF0XG5Qx2TlKWIA', { token_type: 'example' }); + } + + describe('handling a request to be redirected for authorization', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate(); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + expect(u.query.code_challenge).to.have.length(43); + expect(u.query.code_challenge).to.equal('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') + expect(u.query.code_challenge_method).to.equal('S256'); + }); + + it('should save verifier in session', function() { + var u = uri.parse(url, true); + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.have.length(43); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.equal('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + expect(request.session['oauth2:www.example.com'].state.state).to.be.undefined; + }); + }); + + describe('handling a request to be redirected for authorization with state', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate({ state: { returnTo: '/somewhere' }}); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + expect(u.query.code_challenge).to.have.length(43); + expect(u.query.code_challenge).to.equal('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') + expect(u.query.code_challenge_method).to.equal('S256'); + }); + + it('should save verifier in session', function() { + var u = uri.parse(url, true); + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.have.length(43); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.equal('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + expect(request.session['oauth2:www.example.com'].state.state).to.deep.equal({ returnTo: '/somewhere' }); + }); + }); + + describe('handling a request to be redirected for authorization with state as boolean true', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate({ state: true }); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + expect(u.query.code_challenge).to.have.length(43); + expect(u.query.code_challenge).to.equal('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') + expect(u.query.code_challenge_method).to.equal('S256'); + }); + + it('should save verifier in session', function() { + var u = uri.parse(url, true); + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.have.length(43); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.equal('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + expect(request.session['oauth2:www.example.com'].state.state).to.equal(true); + }); + }); + + describe('handling a request to be redirected for authorization with state as boolean false', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate({ state: false }); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + expect(u.query.code_challenge).to.have.length(43); + expect(u.query.code_challenge).to.equal('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') + expect(u.query.code_challenge_method).to.equal('S256'); + }); + + it('should save verifier in session', function() { + var u = uri.parse(url, true); + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.have.length(43); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.equal('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + expect(request.session['oauth2:www.example.com'].state.state).to.be.undefined; + }); + }); + + describe('that redirects to service provider with other data in session', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + req.session['oauth2:www.example.com'].foo = 'bar'; + }) + .authenticate(); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + }); + + it('should save state in session', function() { + var u = uri.parse(url, true); + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.have.length(43); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.equal('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + }); + + it('should preserve other data in session', function() { + expect(request.session['oauth2:www.example.com'].foo).to.equal('bar'); + }); + }); // that redirects to service provider with other data in session + + describe('processing response to authorization request', function() { + var request + , user + , info; + + before(function(done) { + chai.passport.use(strategy) + .success(function(u, i) { + user = u; + info = i; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + req.session['oauth2:www.example.com']['state'] = { handle: 'DkbychwKu8kBaJoLE5yeR5NK', code_verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' }; + }) + .authenticate(); + }); + + it('should supply user', function() { + expect(user).to.be.an.object; + expect(user.id).to.equal('1234'); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Hello'); + expect(info.state).to.be.undefined; + }); + + it('should remove state with verifier from session', function() { + expect(request.session['oauth2:www.example.com']).to.be.undefined; + }); + }); + + describe('processing response to authorization request with state', function() { + var request + , user + , info; + + before(function(done) { + chai.passport.use(strategy) + .success(function(u, i) { + user = u; + info = i; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + req.session['oauth2:www.example.com']['state'] = { handle: 'DkbychwKu8kBaJoLE5yeR5NK', code_verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', state: { returnTo: '/somewhere' } }; + }) + .authenticate(); + }); + + it('should supply user', function() { + expect(user).to.be.an.object; + expect(user.id).to.equal('1234'); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Hello'); + expect(info.state).to.deep.equal({ returnTo: '/somewhere' }); + }); + + it('should remove state with verifier from session', function() { + expect(request.session['oauth2:www.example.com']).to.be.undefined; + }); + }); + + describe('that was approved with other data in the session', function() { + var request + , user + , info; + + before(function(done) { + chai.passport.use(strategy) + .success(function(u, i) { + user = u; + info = i; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + req.session['oauth2:www.example.com']['state'] = { handle: 'DkbychwKu8kBaJoLE5yeR5NK', code_verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' }; + req.session['oauth2:www.example.com'].foo = 'bar'; + }) + .authenticate(); + }); + + it('should supply user', function() { + expect(user).to.be.an.object; + expect(user.id).to.equal('1234'); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Hello'); + }); + + it('should preserve other data from session', function() { + expect(request.session['oauth2:www.example.com'].state).to.be.undefined; + expect(request.session['oauth2:www.example.com'].foo).to.equal('bar'); + }); + }); // that was approved with other data in the session + + describe('that errors due to lack of session support in app', function() { + var request, err; + + before(function(done) { + chai.passport.use(strategy) + .error(function(e) { + err = e; + done(); + }) + .req(function(req) { + request = req; + }) + .authenticate(); + }); + + it('should error', function() { + expect(err).to.be.an.instanceof(Error) + expect(err.message).to.equal('OAuth 2.0 authentication requires session support when using state. Did you forget to use express-session middleware?'); + }); + }); // that errors due to lack of session support in app + }); + + describe('with store and PKCE plain transformation method', function() { + var mockCrypto = { + pseudoRandomBytes: function(len) { + if (len !== 32) { throw new Error('xyz'); } + return new Buffer( + [116, 24, 223, 180, 151, 153, 224, 37, 79, 250, 96, 125, 216, 173, + 187, 186, 22, 212, 37, 77, 105, 214, 191, 240, 91, 88, 5, 88, 83, + 132, 141, 121] + ); + } + } + + var OAuth2Strategy = require('proxyquire')('../lib/strategy', { crypto: mockCrypto }); + var strategy = new OAuth2Strategy({ + authorizationURL: 'https://www.example.com/oauth2/authorize', + tokenURL: 'https://www.example.com/oauth2/token', + clientID: 'ABC123', + clientSecret: 'secret', + callbackURL: 'https://www.example.net/auth/example/callback', + store: true, + pkce: 'plain' + }, + function(accessToken, refreshToken, profile, done) { + if (accessToken == '2YotnFZFEjr1zCsicMWpAA' && refreshToken == 'tGzv3JOkF0XG5Qx2TlKWIA') { + return done(null, { id: '1234' }, { message: 'Hello' }); + } + return done(null, false); + }); + + strategy._oauth2.getOAuthAccessToken = function(code, options, callback) { + if (code !== 'SplxlOBeZQQYbYS6WxSbIA') { return callback(new Error('incorrect code argument')); } + if (options.grant_type !== 'authorization_code') { return callback(new Error('incorrect options.grant_type argument')); } + if (options.redirect_uri !== 'https://www.example.net/auth/example/callback') { return callback(new Error('incorrect options.redirect_uri argument')); } + if (options.code_verifier !== 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk') { return callback(new Error('incorrect options.verifier loaded from session')); } + + return callback(null, '2YotnFZFEjr1zCsicMWpAA', 'tGzv3JOkF0XG5Qx2TlKWIA', { token_type: 'example' }); + } + + describe('handling a request to be redirected for authorization', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate(); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + expect(u.query.code_challenge).to.have.length(43); + expect(u.query.code_challenge).to.equal('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk') + expect(u.query.code_challenge_method).to.equal('plain'); + }); + + it('should save verifier in session', function() { + var u = uri.parse(url, true); + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.have.length(43); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.equal('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + }); + }); + + describe('processing response to authorization request', function() { + var request + , user + , info; + + before(function(done) { + chai.passport.use(strategy) + .success(function(u, i) { + user = u; + info = i; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + req.session['oauth2:www.example.com']['state'] = { handle: 'DkbychwKu8kBaJoLE5yeR5NK', code_verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' }; + }) + .authenticate(); + }); + + it('should supply user', function() { + expect(user).to.be.an.object; + expect(user.id).to.equal('1234'); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Hello'); + }); + + it('should remove state with verifier from session', function() { + expect(request.session['oauth2:www.example.com']).to.be.undefined; + }); + }); + }); + + describe('with store and PKCE S256 transformation method', function() { + var mockCrypto = { + pseudoRandomBytes: function(len) { + if (len !== 32) { throw new Error('xyz'); } + // https://tools.ietf.org/html/rfc7636#appendix-B + return new Buffer( + [116, 24, 223, 180, 151, 153, 224, 37, 79, 250, 96, 125, 216, 173, + 187, 186, 22, 212, 37, 77, 105, 214, 191, 240, 91, 88, 5, 88, 83, + 132, 141, 121] + ); + } + } + + var OAuth2Strategy = require('proxyquire')('../lib/strategy', { crypto: mockCrypto }); + var strategy = new OAuth2Strategy({ + authorizationURL: 'https://www.example.com/oauth2/authorize', + tokenURL: 'https://www.example.com/oauth2/token', + clientID: 'ABC123', + clientSecret: 'secret', + callbackURL: 'https://www.example.net/auth/example/callback', + store: true, + pkce: 'S256' + }, + function(accessToken, refreshToken, profile, done) { + if (accessToken == '2YotnFZFEjr1zCsicMWpAA' && refreshToken == 'tGzv3JOkF0XG5Qx2TlKWIA') { + return done(null, { id: '1234' }, { message: 'Hello' }); + } + return done(null, false); + }); + + strategy._oauth2.getOAuthAccessToken = function(code, options, callback) { + if (code !== 'SplxlOBeZQQYbYS6WxSbIA') { return callback(new Error('incorrect code argument')); } + if (options.grant_type !== 'authorization_code') { return callback(new Error('incorrect options.grant_type argument')); } + if (options.redirect_uri !== 'https://www.example.net/auth/example/callback') { return callback(new Error('incorrect options.redirect_uri argument')); } + if (options.code_verifier !== 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk') { return callback(new Error('incorrect options.verifier loaded from session')); } + + return callback(null, '2YotnFZFEjr1zCsicMWpAA', 'tGzv3JOkF0XG5Qx2TlKWIA', { token_type: 'example' }); + } + + describe('handling a request to be redirected for authorization', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate(); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + expect(u.query.code_challenge).to.have.length(43); + expect(u.query.code_challenge).to.equal('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') + expect(u.query.code_challenge_method).to.equal('S256'); + }); + + it('should save verifier in session', function() { + var u = uri.parse(url, true); + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.have.length(43); + expect(request.session['oauth2:www.example.com'].state.code_verifier).to.equal('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + }); + }); + + describe('processing response to authorization request', function() { + var request + , user + , info; + + before(function(done) { + chai.passport.use(strategy) + .success(function(u, i) { + user = u; + info = i; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + req.session['oauth2:www.example.com']['state'] = { handle: 'DkbychwKu8kBaJoLE5yeR5NK', code_verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' }; + }) + .authenticate(); + }); + + it('should supply user', function() { + expect(user).to.be.an.object; + expect(user.id).to.equal('1234'); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Hello'); + }); + + it('should remove state with verifier from session', function() { + expect(request.session['oauth2:www.example.com']).to.be.undefined; + }); + }); + }); + + describe('with exceptions', function() { + var mockCrypto = { + pseudoRandomBytes: function(len) { + if (len !== 32) { throw new Error('xyz'); } + // https://tools.ietf.org/html/rfc7636#appendix-B + return new Buffer( + [116, 24, 223, 180, 151, 153, 224, 37, 79, 250, 96, 125, 216, 173, + 187, 186, 22, 212, 37, 77, 105, 214, 191, 240, 91, 88, 5, 88, 83, + 132, 141, 121] + ); + } + } + + describe('with store and unknown encoding method', function() { + + var OAuth2Strategy = require('proxyquire')('../lib/strategy', { crypto: mockCrypto }); + var strategy = new OAuth2Strategy({ + authorizationURL: 'https://www.example.com/oauth2/authorize', + tokenURL: 'https://www.example.com/oauth2/token', + clientID: 'ABC123', + clientSecret: 'secret', + callbackURL: 'https://www.example.net/auth/example/callback', + store: true, + pkce: 'unknown' + }, + function(accessToken, refreshToken, profile, done) { + if (accessToken == '2YotnFZFEjr1zCsicMWpAA' && refreshToken == 'tGzv3JOkF0XG5Qx2TlKWIA') { + return done(null, { id: '1234' }, { message: 'Hello' }); + } + return done(null, false); + }); + + var err; + + before(function(done) { + chai.passport.use(strategy) + .error(function(e) { + err = e; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate(); + }); + + it('should error', function() { + expect(err.message).to.equal('Unsupported code verifier transformation method: unknown'); + }); + }); + + describe('with store and unknown verifier', function() { + + var OAuth2Strategy = require('proxyquire')('../lib/strategy', { crypto: mockCrypto }); + var strategy = new OAuth2Strategy({ + authorizationURL: 'https://www.example.com/oauth2/authorize', + tokenURL: 'https://www.example.com/oauth2/token', + clientID: 'ABC123', + clientSecret: 'secret', + callbackURL: 'https://www.example.net/auth/example/callback', + store: true, + pkce: 'S256' + }, + function(accessToken, refreshToken, profile, done) { + if (accessToken == '2YotnFZFEjr1zCsicMWpAA' && refreshToken == 'tGzv3JOkF0XG5Qx2TlKWIA') { + return done(null, { id: '1234' }, { message: 'Hello' }); + } + return done(null, false); + }); + + var err; + + before(function(done) { + chai.passport.use(strategy) + .fail(function(i) { + info = i; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + req.session['oauth2:www.example.com']['state'] = { handle: 'DkbychwKu8kBaJoLE5yeR5NK'}; + }) + .authenticate(); + }); + + it('should not supply info', function() { + expect(info).to.be.undefined; + }); + }); + + describe('store and that fails due to state being invalid', function() { + var OAuth2Strategy = require('proxyquire')('../lib/strategy', { crypto: mockCrypto }); + var strategy = new OAuth2Strategy({ + authorizationURL: 'https://www.example.com/oauth2/authorize', + tokenURL: 'https://www.example.com/oauth2/token', + clientID: 'ABC123', + clientSecret: 'secret', + callbackURL: 'https://www.example.net/auth/example/callback', + store: true, + pkce: 'S256' + }, + function(accessToken, refreshToken, profile, done) { + if (accessToken == '2YotnFZFEjr1zCsicMWpAA' && refreshToken == 'tGzv3JOkF0XG5Qx2TlKWIA') { + return done(null, { id: '1234' }, { message: 'Hello' }); + } + return done(null, false); + }); + + + var request + , info, status; + + before(function(done) { + chai.passport.use(strategy) + .fail(function(i, s) { + info = i; + status = s; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK-WRONG'; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + req.session['oauth2:www.example.com']['state'] = { handle: 'DkbychwKu8kBaJoLE5yeR5NK', code_verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' }; + }) + .authenticate(); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Invalid authorization request state.'); + }); + + it('should supply status', function() { + expect(status).to.equal(403); + }); + + it('should remove state from session', function() { + expect(request.session['oauth2:www.example.com']).to.be.undefined; + }); + }); // that fails due to state being invalid + + describe('store and that fails due to provider-specific state not found in session', function() { + var OAuth2Strategy = require('proxyquire')('../lib/strategy', { crypto: mockCrypto }); + var strategy = new OAuth2Strategy({ + authorizationURL: 'https://www.example.com/oauth2/authorize', + tokenURL: 'https://www.example.com/oauth2/token', + clientID: 'ABC123', + clientSecret: 'secret', + callbackURL: 'https://www.example.net/auth/example/callback', + store: true, + pkce: 'S256' + }, + function(accessToken, refreshToken, profile, done) { + if (accessToken == '2YotnFZFEjr1zCsicMWpAA' && refreshToken == 'tGzv3JOkF0XG5Qx2TlKWIA') { + return done(null, { id: '1234' }, { message: 'Hello' }); + } + return done(null, false); + }); + + + var request + , info, status; + + before(function(done) { + chai.passport.use(strategy) + .fail(function(i, s) { + info = i; + status = s; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + }) + .authenticate(); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Unable to verify authorization request state.'); + }); + + it('should supply status', function() { + expect(status).to.equal(403); + }); + }); // that fails due to state not found in session + + describe('store and that fails due to provider-specific state lacking state value', function() { + var OAuth2Strategy = require('proxyquire')('../lib/strategy', { crypto: mockCrypto }); + var strategy = new OAuth2Strategy({ + authorizationURL: 'https://www.example.com/oauth2/authorize', + tokenURL: 'https://www.example.com/oauth2/token', + clientID: 'ABC123', + clientSecret: 'secret', + callbackURL: 'https://www.example.net/auth/example/callback', + store: true, + pkce: 'S256' + }, + function(accessToken, refreshToken, profile, done) { + if (accessToken == '2YotnFZFEjr1zCsicMWpAA' && refreshToken == 'tGzv3JOkF0XG5Qx2TlKWIA') { + return done(null, { id: '1234' }, { message: 'Hello' }); + } + return done(null, false); + }); + + + var request + , info, status; + + before(function(done) { + chai.passport.use(strategy) + .fail(function(i, s) { + info = i; + status = s; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + }) + .authenticate(); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Unable to verify authorization request state.'); + }); + + it('should supply status', function() { + expect(status).to.equal(403); + }); + }); // that fails due to provider-specific state lacking state value + + describe('store and that errors due to lack of session support in app', function() { + var OAuth2Strategy = require('proxyquire')('../lib/strategy', { crypto: mockCrypto }); + var strategy = new OAuth2Strategy({ + authorizationURL: 'https://www.example.com/oauth2/authorize', + tokenURL: 'https://www.example.com/oauth2/token', + clientID: 'ABC123', + clientSecret: 'secret', + callbackURL: 'https://www.example.net/auth/example/callback', + store: true, + pkce: 'S256' + }, + function(accessToken, refreshToken, profile, done) { + if (accessToken == '2YotnFZFEjr1zCsicMWpAA' && refreshToken == 'tGzv3JOkF0XG5Qx2TlKWIA') { + return done(null, { id: '1234' }, { message: 'Hello' }); + } + return done(null, false); + }); + + + var request + , err; + + before(function(done) { + chai.passport.use(strategy) + .error(function(e) { + err = e; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + }) + .authenticate(); + }); + + it('should error', function() { + expect(err).to.be.an.instanceof(Error) + expect(err.message).to.equal('OAuth 2.0 authentication requires session support when using state. Did you forget to use express-session middleware?'); + }); + }); // that errors due to lack of session support in app + + }); +}); diff --git a/test/oauth2.store.test.js b/test/oauth2.store.test.js new file mode 100644 index 0000000..c23743e --- /dev/null +++ b/test/oauth2.store.test.js @@ -0,0 +1,642 @@ +var OAuth2Strategy = require('../lib/strategy') + , AuthorizationError = require('../lib/errors/authorizationerror') + , TokenError = require('../lib/errors/tokenerror') + , InternalOAuthError = require('../lib/errors/internaloautherror') + , chai = require('chai') + , uri = require('url'); + + + // TODO: rename this file to oauth2.state.nonce +describe('OAuth2Strategy', function() { + + describe('using default session state store through store option', function() { + + describe('issuing authorization request', function() { + var strategy = new OAuth2Strategy({ + authorizationURL: 'https://www.example.com/oauth2/authorize', + tokenURL: 'https://www.example.com/oauth2/token', + clientID: 'ABC123', + clientSecret: 'secret', + callbackURL: 'https://www.example.net/auth/example/callback', + store: true + }, + function(accessToken, refreshToken, profile, done) {}); + + + describe('that redirects to service provider', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate(); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + }); + + it('should save state in session', function() { + var u = uri.parse(url, true); + + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.state).to.be.undefined; + }); + }); // that redirects to service provider + + describe('that redirects to service provider with state', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate({ state: { returnTo: '/somewhere' } }); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + }); + + it('should save state in session', function() { + var u = uri.parse(url, true); + + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.state).to.deep.equal({ returnTo: '/somewhere' }); + }); + }); // that redirects to service provider with state + + describe('that redirects to service provider with state set to boolean true', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate({ state: true }); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + }); + + it('should save state in session', function() { + var u = uri.parse(url, true); + + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.state).to.equal(true); + }); + }); // that redirects to service provider with state set to boolean true + + describe('that redirects to service provider with state set to boolean false', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate({ state: false }); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + }); + + it('should save state in session', function() { + var u = uri.parse(url, true); + + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.state).to.be.undefined; + }); + }); // that redirects to service provider with state set to boolean false + + describe('that redirects to service provider with other data in session', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + req.session['oauth2:www.example.com'].foo = 'bar'; + }) + .authenticate(); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + }); + + it('should save state in session', function() { + var u = uri.parse(url, true); + + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:www.example.com'].state.state).to.be.undefined; + }); + + it('should preserve other data in session', function() { + expect(request.session['oauth2:www.example.com'].foo).to.equal('bar'); + }); + }); // that redirects to service provider with other data in session + + describe('that errors due to lack of session support in app', function() { + var request, err; + + before(function(done) { + chai.passport.use(strategy) + .error(function(e) { + err = e; + done(); + }) + .req(function(req) { + request = req; + }) + .authenticate(); + }); + + it('should error', function() { + expect(err).to.be.an.instanceof(Error) + expect(err.message).to.equal('OAuth 2.0 authentication requires session support when using state. Did you forget to use express-session middleware?'); + }); + }); // that errors due to lack of session support in app + + }); // issuing authorization request + + describe('issuing authorization request to authorization server using authorization endpoint that has query parameters including state', function() { + var strategy = new OAuth2Strategy({ + authorizationURL: 'https://www.example.com/oauth2/authorize?foo=bar&state=baz', + tokenURL: 'https://www.example.com/oauth2/token', + clientID: 'ABC123', + clientSecret: 'secret', + callbackURL: 'https://www.example.net/auth/example/callback', + store: true + }, + function(accessToken, refreshToken, profile, done) {}); + + + describe('that redirects to service provider', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate(); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.foo).equal('bar'); + expect(u.query.state).to.have.length(24); + }); + + it('should save state in session', function() { + var u = uri.parse(url, true); + + expect(request.session['oauth2:www.example.com'].state.handle).to.have.length(24); + expect(request.session['oauth2:www.example.com'].state.handle).to.equal(u.query.state); + }); + }); // that redirects to service provider + + }); // issuing authorization request to authorization server using authorization endpoint that has query parameters including state + + describe('processing response to authorization request', function() { + var strategy = new OAuth2Strategy({ + authorizationURL: 'https://www.example.com/oauth2/authorize', + tokenURL: 'https://www.example.com/oauth2/token', + clientID: 'ABC123', + clientSecret: 'secret', + callbackURL: 'https://www.example.net/auth/example/callback', + store: true + }, + function(accessToken, refreshToken, profile, done) { + if (accessToken !== '2YotnFZFEjr1zCsicMWpAA') { return done(new Error('incorrect accessToken argument')); } + if (refreshToken !== 'tGzv3JOkF0XG5Qx2TlKWIA') { return done(new Error('incorrect refreshToken argument')); } + if (typeof profile !== 'object') { return done(new Error('incorrect profile argument')); } + if (Object.keys(profile).length !== 0) { return done(new Error('incorrect profile argument')); } + + return done(null, { id: '1234' }, { message: 'Hello' }); + }); + + strategy._oauth2.getOAuthAccessToken = function(code, options, callback) { + if (code !== 'SplxlOBeZQQYbYS6WxSbIA') { return callback(new Error('incorrect code argument')); } + if (options.grant_type !== 'authorization_code') { return callback(new Error('incorrect options.grant_type argument')); } + if (options.redirect_uri !== 'https://www.example.net/auth/example/callback') { return callback(new Error('incorrect options.redirect_uri argument')); } + + return callback(null, '2YotnFZFEjr1zCsicMWpAA', 'tGzv3JOkF0XG5Qx2TlKWIA', { token_type: 'example' }); + } + + + describe('that was approved', function() { + var request + , user + , info; + + before(function(done) { + chai.passport.use(strategy) + .success(function(u, i) { + user = u; + info = i; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + req.session['oauth2:www.example.com']['state'] = { handle: 'DkbychwKu8kBaJoLE5yeR5NK' }; + }) + .authenticate(); + }); + + it('should supply user', function() { + expect(user).to.be.an.object; + expect(user.id).to.equal('1234'); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Hello'); + expect(info.state).to.be.undefined; + }); + + it('should remove state from session', function() { + expect(request.session['oauth2:www.example.com']).to.be.undefined; + }); + }); // that was approved + + describe('that was approved with state', function() { + var request + , user + , info; + + before(function(done) { + chai.passport.use(strategy) + .success(function(u, i) { + user = u; + info = i; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + req.session['oauth2:www.example.com']['state'] = { handle: 'DkbychwKu8kBaJoLE5yeR5NK', state: { returnTo: '/somewhere' } }; + }) + .authenticate(); + }); + + it('should supply user', function() { + expect(user).to.be.an.object; + expect(user.id).to.equal('1234'); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Hello'); + expect(info.state).to.deep.equal({ returnTo: '/somewhere' }); + }); + + it('should remove state from session', function() { + expect(request.session['oauth2:www.example.com']).to.be.undefined; + }); + }); // that was approved with state + + describe('that was approved with other data in the session', function() { + var request + , user + , info; + + before(function(done) { + chai.passport.use(strategy) + .success(function(u, i) { + user = u; + info = i; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + req.session['oauth2:www.example.com']['state'] = { handle: 'DkbychwKu8kBaJoLE5yeR5NK' }; + req.session['oauth2:www.example.com'].foo = 'bar'; + }) + .authenticate(); + }); + + it('should supply user', function() { + expect(user).to.be.an.object; + expect(user.id).to.equal('1234'); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Hello'); + }); + + it('should preserve other data from session', function() { + expect(request.session['oauth2:www.example.com'].state).to.be.undefined; + expect(request.session['oauth2:www.example.com'].foo).to.equal('bar'); + }); + }); // that was approved with other data in the session + + describe('that fails due to state being invalid', function() { + var request + , info, status; + + before(function(done) { + chai.passport.use(strategy) + .fail(function(i, s) { + info = i; + status = s; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK-WRONG'; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + req.session['oauth2:www.example.com']['state'] = { handle: 'DkbychwKu8kBaJoLE5yeR5NK' }; + }) + .authenticate(); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Invalid authorization request state.'); + }); + + it('should supply status', function() { + expect(status).to.equal(403); + }); + + it('should remove state from session', function() { + expect(request.session['oauth2:www.example.com']).to.be.undefined; + }); + }); // that fails due to state being invalid + + describe('that fails due to provider-specific state not found in session', function() { + var request + , info, status; + + before(function(done) { + chai.passport.use(strategy) + .fail(function(i, s) { + info = i; + status = s; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + }) + .authenticate(); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Unable to verify authorization request state.'); + }); + + it('should supply status', function() { + expect(status).to.equal(403); + }); + }); // that fails due to state not found in session + + describe('that fails due to provider-specific state lacking state value', function() { + var request + , info, status; + + before(function(done) { + chai.passport.use(strategy) + .fail(function(i, s) { + info = i; + status = s; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + req.session['oauth2:www.example.com'] = {}; + }) + .authenticate(); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Unable to verify authorization request state.'); + }); + + it('should supply status', function() { + expect(status).to.equal(403); + }); + }); // that fails due to provider-specific state lacking state value + + describe('that errors due to lack of session support in app', function() { + var request + , err; + + before(function(done) { + chai.passport.use(strategy) + .error(function(e) { + err = e; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + }) + .authenticate(); + }); + + it('should error', function() { + expect(err).to.be.an.instanceof(Error) + expect(err.message).to.equal('OAuth 2.0 authentication requires session support when using state. Did you forget to use express-session middleware?'); + }); + }); // that errors due to lack of session support in app + + }); // processing response to authorization request + + }); // using default session state store + + + describe('using default session state store through store option with session key option', function() { + var strategy = new OAuth2Strategy({ + authorizationURL: 'https://www.example.com/oauth2/authorize', + tokenURL: 'https://www.example.com/oauth2/token', + clientID: 'ABC123', + clientSecret: 'secret', + callbackURL: 'https://www.example.net/auth/example/callback', + store: true, + sessionKey: 'oauth2:example' + }, + function(accessToken, refreshToken, profile, done) { + if (accessToken !== '2YotnFZFEjr1zCsicMWpAA') { return done(new Error('incorrect accessToken argument')); } + if (refreshToken !== 'tGzv3JOkF0XG5Qx2TlKWIA') { return done(new Error('incorrect refreshToken argument')); } + if (typeof profile !== 'object') { return done(new Error('incorrect profile argument')); } + if (Object.keys(profile).length !== 0) { return done(new Error('incorrect profile argument')); } + + return done(null, { id: '1234' }, { message: 'Hello' }); + }); + + strategy._oauth2.getOAuthAccessToken = function(code, options, callback) { + if (code !== 'SplxlOBeZQQYbYS6WxSbIA') { return callback(new Error('incorrect code argument')); } + if (options.grant_type !== 'authorization_code') { return callback(new Error('incorrect options.grant_type argument')); } + if (options.redirect_uri !== 'https://www.example.net/auth/example/callback') { return callback(new Error('incorrect options.redirect_uri argument')); } + + return callback(null, '2YotnFZFEjr1zCsicMWpAA', 'tGzv3JOkF0XG5Qx2TlKWIA', { token_type: 'example' }); + } + + + describe('issuing authorization request', function() { + + describe('that redirects to service provider', function() { + var request, url; + + before(function(done) { + chai.passport.use(strategy) + .redirect(function(u) { + url = u; + done(); + }) + .req(function(req) { + request = req; + req.session = {}; + }) + .authenticate(); + }); + + it('should be redirected', function() { + var u = uri.parse(url, true); + expect(u.query.state).to.have.length(24); + }); + + it('should save state in session', function() { + var u = uri.parse(url, true); + + expect(request.session['oauth2:example'].state.handle).to.have.length(24); + expect(request.session['oauth2:example'].state.handle).to.equal(u.query.state); + expect(request.session['oauth2:example'].state.state).to.be.undefined; + }); + }); // that redirects to service provider + + }); // issuing authorization request + + describe('processing response to authorization request', function() { + + describe('that was approved', function() { + var request + , user + , info; + + before(function(done) { + chai.passport.use(strategy) + .success(function(u, i) { + user = u; + info = i; + done(); + }) + .req(function(req) { + request = req; + + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + req.query.state = 'DkbychwKu8kBaJoLE5yeR5NK'; + req.session = {}; + req.session['oauth2:example'] = {}; + req.session['oauth2:example']['state'] = { handle: 'DkbychwKu8kBaJoLE5yeR5NK' }; + }) + .authenticate(); + }); + + it('should supply user', function() { + expect(user).to.be.an.object; + expect(user.id).to.equal('1234'); + }); + + it('should supply info', function() { + expect(info).to.be.an.object; + expect(info.message).to.equal('Hello'); + }); + + it('should remove state from session', function() { + expect(request.session['oauth2:example']).to.be.undefined; + }); + }); // that was approved + + }); // processing response to authorization request + + }); // using default session state store with session key option + +}); diff --git a/test/oauth2.test.js b/test/oauth2.test.js index e829751..36552b6 100644 --- a/test/oauth2.test.js +++ b/test/oauth2.test.js @@ -1256,6 +1256,45 @@ describe('OAuth2Strategy', function() { }); }); // that errors due to token request error, in node-oauth object literal form with text body + describe('that errors due to not receiving an access token', function() { + var strategy = new OAuth2Strategy({ + authorizationURL: 'https://www.example.com/oauth2/authorize', + tokenURL: 'https://www.example.com/oauth2/token', + clientID: 'ABC123', + clientSecret: 'secret', + callbackURL: 'https://www.example.net/auth/example/callback', + }, + function(accessToken, refreshToken, params, profile, done) { + return done(new Error('something went wrong')); + }); + + strategy._oauth2.getOAuthAccessToken = function(code, options, callback) { + return callback(null, undefined, undefined, undefined); + } + + + var err; + + before(function(done) { + chai.passport.use(strategy) + .error(function(e) { + err = e; + done(); + }) + .req(function(req) { + req.query = {}; + req.query.code = 'SplxlOBeZQQYbYS6WxSbIA'; + }) + .authenticate(); + }); + + it('should error', function() { + expect(err).to.be.an.instanceof(Error); + expect(err).to.not.be.an.instanceof(InternalOAuthError) + expect(err.message).to.equal('Failed to obtain access token'); + }); + }); // that errors due to not receiving an access token + describe('that errors due to verify callback supplying error', function() { var strategy = new OAuth2Strategy({ authorizationURL: 'https://www.example.com/oauth2/authorize',