heroku-bouncer
Advanced tools
Comparing version 3.1.2 to 4.0.1
106
index.js
'use strict'; | ||
var express = require('express'); | ||
var middleware = require('./lib/middleware'); | ||
var router = require('./lib/router'); | ||
var routes = require('./lib/router'); | ||
/** | ||
* `heroku-bouncer` is a function which exposes two things: A piece of | ||
* middleware to handle Heroku OAuth for a node app, and a router which exposes | ||
* the required OAuth endpoints (such as a callback URL), and a logout path. | ||
* `heroku-bouncer` provides a router and a piece of middleware for handling | ||
* Heroku OAuth sessions in a web app. | ||
* | ||
* var bouncer = require('heroku-bouncer')({ | ||
* encryptionSecret : process.env.USER_SESSION_SECRET, | ||
* oAuthClientID : process.env.HEROKU_OAUTH_ID, | ||
* oAuthClientSecret: process.env.HEROKU_OAUTH_SECRET | ||
* }); | ||
* | ||
* app.use(bouncer); | ||
* | ||
* @class Main | ||
@@ -17,60 +25,64 @@ */ | ||
* @param {Object} options | ||
* @param {String} options.herokuBouncerSecret a secret used to encrypt | ||
* information in the session | ||
* @param {String} options.herokuOAuthID an ID for a Heroku OAuth client | ||
* @param {String} options.herokuOAuthSecret a secret for a Heroku OAuth client | ||
* @param {String} [options.herokaiOnlyRedirect='https://www.heroku.com'] a URL | ||
* to redirect to when a user is not a Herokai and `herokaiOnly` is `true` | ||
* @param {String} [options.sessionSyncNonce] if present, determines the name of | ||
* a cookie shared across properties under the same domain in order to keep | ||
* their sessions synchronized | ||
* @param {Array} [options.ignoreRoutes=[]] an array of route regular | ||
* expressions to match request routes again. If a request route matches one | ||
* of these, it passes through the authentication stack instantly. | ||
* @param {String} [options.herokuAuthURL='https://id.heroku.com'] the | ||
* authentication URL used | ||
* @param {Boolean} [options.herokaiOnly=false] whether or not to restrict this | ||
* app to Herokai (users with @heroku.com email addresses) | ||
* @example | ||
* ```javascript | ||
* var bouncer = require('heroku-bouncer')({ | ||
* herokuBouncerSecret: process.env.HEROKU_BOUNCER_SECRET, | ||
* herokuOAuthID : process.env.HEROKU_OAUTH_ID, | ||
* herokuOAuthSecret : process.env.HEROKU_OAUTH_SECRET | ||
* }); | ||
* | ||
* app.use(bouncer.middleware); | ||
* app.use(bouncer.router); | ||
* @param {String} options.encryptionSecret a user information encryption secret | ||
* @param {String} options.oAuthClientID a Heroku OAuth client ID | ||
* @param {String} options.oAuthClientSecret a Heroku OAuth client secret | ||
* @param {String} [options.herokuAPIHost=null] optionally override the host | ||
* that API requests are sent to (defaults in the Node Heorku client to | ||
* 'api.heroku.com'). | ||
* @param {String} [options.sessionSyncNonce=null] the name of a cookie shared | ||
* across different apps on the same domain to keep sessions synchronized | ||
* @param {Array} [options.ignoredRoutes=[] an array of regular expressions | ||
* against which routes are tested to determine if they skip the | ||
* authentication stack. Only used when there is no current session. | ||
* @param {String} [options.oAuthServerURL='https://id.heroku.com'] the URL of | ||
* the Heroku OAuth server app | ||
* @param {Function} [options.herokaiOnlyHandler=null] if provided, this route | ||
* handler will be called on requests by non-Herokai | ||
* ``` | ||
*/ | ||
module.exports = function(options) { | ||
var router = new express.Router(); | ||
options = options || {}; | ||
enforceOptions(options); | ||
setOptions(options); | ||
return { | ||
middleware: middleware(options), | ||
router : router(options) | ||
}; | ||
router.middleware = middleware(options); | ||
router.router = routes(options); | ||
router.use(router.middleware); | ||
router.use(router.router); | ||
return router; | ||
}; | ||
function enforceOptions(options) { | ||
if (!options.herokuBouncerSecret) { | ||
throw new Error('No `herokuBouncerSecret` provided to heroku-bouncer'); | ||
function setOptions(options) { | ||
if (!options.encryptionSecret) { | ||
throw new Error('No `encryptionSecret` provided to heroku-bouncer'); | ||
} | ||
if (!options.herokuOAuthID) { | ||
throw new Error('No `herokuOAuthID` provided to heroku-bouncer'); | ||
if (!options.oAuthClientID) { | ||
throw new Error('No `oAuthClientID` provided to heroku-bouncer'); | ||
} | ||
if (!options.herokuOAuthSecret) { | ||
throw new Error('No `herokuOAuthSecret` provided to heroku-bouncer'); | ||
if (!options.oAuthClientSecret) { | ||
throw new Error('No `oAuthClientSecret` provided to heroku-bouncer'); | ||
} | ||
if (!options.hasOwnProperty('ignoreRoutes')) { | ||
options.ignoreRoutes = []; | ||
if (options.herokaiOnlyHandler && typeof(options.herokaiOnlyHandler) !== 'function') { | ||
throw new Error('`herokaiOnlyHandler` must be a handler function'); | ||
} | ||
options.herokuAuthURL = options.herokuAuthURL || 'https://id.heroku.com'; | ||
options.herokaiOnlyRedirect = options.herokaiOnlyRedirect || 'https://www.heroku.com'; | ||
options.herokaiOnly = options.herokaiOnly || false; | ||
var defaults = { | ||
herokaiOnlyHandler: null, | ||
herokuAPIHost : null, | ||
ignoredRoutes : [], | ||
oAuthServerURL : 'https://id.heroku.com', | ||
oAuthScope : 'identity', | ||
sessionSyncNonce : null, | ||
}; | ||
for (var key in defaults) { | ||
if (defaults.hasOwnProperty(key)) { | ||
options[key] = options[key] || defaults[key]; | ||
} | ||
} | ||
} |
@@ -13,60 +13,48 @@ 'use strict'; | ||
/** | ||
* Create a middleware function for using Heroku OAuth. If the user is | ||
* authenticated, it will add an API token, email, name and ID to the user's | ||
* request and session. | ||
* Create a piece of middleware for using Heroku OAuth. If the user is | ||
* authenticated, it will add appropriate account information and a token for | ||
* making API requests to the session. | ||
* | ||
* @method main | ||
* @private | ||
* @param {Object} options options for configuring the middleware. See | ||
* {{#crossLink "Main/main"}}Main#main{{/crossLink}} for configuration | ||
* details. | ||
* @return {Function} a middleware function | ||
* @return {Function} a piece of middleware | ||
*/ | ||
module.exports = function(options) { | ||
var cipher = encryptor(options.herokuBouncerSecret); | ||
var cipher = encryptor(options.encryptionSecret); | ||
return function(req, res, next) { | ||
var currentSession = getCurrentSession(req, options.sessionSyncNonce); | ||
var i, route; | ||
var userSession = getUserSession(req, options.sessionSyncNonce); | ||
for (i = 0; i < options.ignoreRoutes.length; i++) { | ||
route = options.ignoreRoutes[i]; | ||
if (!userSession && isIgnoredRoute(req.path)) { | ||
return next(); | ||
} | ||
if (!currentSession && req.url.match(route)) { | ||
return next(); | ||
if (userSession) { | ||
var isHerokai = /@heroku\.com$/.test(userSession.user.email); | ||
if (options.herokaiOnlyHandler && !isHerokai) { | ||
return options.herokaiOnlyHandler(req, res, next); | ||
} | ||
} | ||
if (currentSession || isOAuthPath(req.path)) { | ||
if (currentSession) { | ||
var userSession = cipher.decrypt(currentSession); | ||
var isHerokai = /@heroku\.com$/.test(userSession.user.email); | ||
if (options.herokaiOnly === true && !isHerokai) { | ||
return reauthenticate(req, res, { | ||
redirectTo: options.herokaiOnlyRedirect, | ||
message : 'This app is limited to Herokai only.' | ||
}); | ||
} else if (typeof options.herokaiOnly === 'function' && !isHerokai) { | ||
return options.herokaiOnly(req, res, next); | ||
ensureValidToken(userSession, function(err) { | ||
if (err) { | ||
return reauthenticate(req, res); | ||
} | ||
ensureValidToken(userSession, function(err) { | ||
if (err) { | ||
return reauthenticate(req, res); | ||
} | ||
req.session.userSession = cipher.encrypt(userSession); | ||
req.session.userSession = cipher.encrypt(userSession); | ||
req['heroku-bouncer'] = { | ||
token: userSession.accessToken, | ||
email: userSession.user.email, | ||
name : userSession.user.name, | ||
id : userSession.user.id | ||
}; | ||
req['heroku-bouncer'] = { | ||
token: userSession.accessToken, | ||
email: userSession.user.email, | ||
name : userSession.user.name, | ||
id : userSession.user.id | ||
}; | ||
next(); | ||
}); | ||
} else { | ||
next(); | ||
} | ||
}); | ||
} else if (isOAuthPath(req.path)) { | ||
next(); | ||
} else { | ||
@@ -85,3 +73,3 @@ reauthenticate(req, res); | ||
request.post({ | ||
url : options.herokuAuthURL + '/oauth/token', | ||
url : options.oAuthServerURL + '/oauth/token', | ||
json: true, | ||
@@ -91,3 +79,3 @@ form: { | ||
refresh_token: userSession.refreshToken, | ||
client_secret: options.herokuOAuthSecret | ||
client_secret: options.oAuthClientSecret | ||
} | ||
@@ -115,21 +103,35 @@ }, function(err, res, body) { | ||
function reauthenticate(req, res, options) { | ||
var isJSON = /json/.test(req.get('accept')); | ||
function getUserSession(req, checkNonce) { | ||
var session = cipher.decrypt(req.session.userSession); | ||
options = options || {}; | ||
if (checkNonce) { | ||
return nonceMatch(req, checkNonce) ? session : null; | ||
} else { | ||
return (session && session.user && session.user.email) ? session : null; | ||
} | ||
} | ||
var redirectTo = options.redirectTo || '/auth/heroku'; | ||
var message = options.message || 'Please authenticate.'; | ||
function isIgnoredRoute(route) { | ||
var pattern; | ||
for (var i = 0; i < options.ignoredRoutes.length; i++) { | ||
pattern = options.ignoredRoutes[i]; | ||
if (pattern.test(route)) { | ||
return true; | ||
} | ||
} | ||
} | ||
function reauthenticate(req, res) { | ||
var isJSON = /json/.test(req.get('accept')); | ||
req.session.reset(); | ||
if (req.method.toLowerCase() === 'get' && !isJSON) { | ||
if (redirectTo === '/auth/heroku') { | ||
req.session.redirectPath = req.url; | ||
} | ||
res.redirect(redirectTo); | ||
req.session.redirectPath = req.url; | ||
res.redirect('/auth/heroku'); | ||
} else { | ||
res.statusCode = 401; | ||
res.json({ id: 'unauthorized', message: message }); | ||
res.json({ id: 'unauthorized', message: 'Please authenticate.' }); | ||
} | ||
@@ -139,12 +141,2 @@ } | ||
function getCurrentSession(req, checkNonce) { | ||
var session = req.session.userSession; | ||
if (checkNonce) { | ||
return nonceMatch(req, checkNonce) ? session : null; | ||
} else { | ||
return session; | ||
} | ||
} | ||
function nonceMatch(req, checkNonce) { | ||
@@ -151,0 +143,0 @@ return req.session.herokuBouncerSessionNonce === req.cookies[checkNonce]; |
@@ -22,3 +22,3 @@ 'use strict'; | ||
module.exports = function(options) { | ||
var cipher = encryptor(options.herokuBouncerSecret); | ||
var cipher = encryptor(options.encryptionSecret); | ||
var oauth = getOAuth(); | ||
@@ -35,3 +35,3 @@ var router = new express.Router(); | ||
res.redirect(oauth.getAuthorizeUrl({ response_type: 'code' })); | ||
res.redirect(oauth.getAuthorizeUrl({ response_type: 'code', scope: options.oAuthScope })); | ||
}); | ||
@@ -45,3 +45,3 @@ | ||
token: accessToken, | ||
host : options.hostname | ||
host : options.herokuAPIHost | ||
}); | ||
@@ -88,3 +88,3 @@ | ||
req.session.reset(); | ||
res.redirect(options.herokuAuthURL + '/logout'); | ||
res.redirect(options.oAuthServerURL + '/logout'); | ||
}); | ||
@@ -94,5 +94,5 @@ | ||
return new OAuth( | ||
options.herokuOAuthID, | ||
options.herokuOAuthSecret, | ||
options.herokuAuthURL, | ||
options.oAuthClientID, | ||
options.oAuthClientSecret, | ||
options.oAuthServerURL, | ||
'/oauth/authorize', | ||
@@ -99,0 +99,0 @@ '/oauth/token' |
{ | ||
"name": "heroku-bouncer", | ||
"version": "3.1.2", | ||
"version": "4.0.1", | ||
"description": "heroku bouncer middleware for express", | ||
@@ -5,0 +5,0 @@ "main": "index.js", |
# node-heroku-bouncer [![Build Status](https://travis-ci.org/jclem/node-heroku-bouncer.svg?branch=master)](https://travis-ci.org/jclem/node-heroku-bouncer) | ||
node-heroku-bouncer is an easy-to-use module for adding Heroku OAuth | ||
authentication to express 4 apps. | ||
authentication to Express 4 apps. | ||
@@ -12,43 +12,49 @@ ## Install | ||
## Requirements | ||
- Node 0.10.x | ||
- Express 4.x | ||
## Use | ||
node-heroku-bouncer assumes you've already added [cookie-parser][cookieParser] | ||
and [client-sessions][clientSessions] middlewares to your app. To set it up, | ||
pass it your OAuth client ID and secret and another secret used to encrypt your | ||
user's OAuth session data. | ||
Ensure your app is using the [cookie-parser][cookieParser] and | ||
[client-sessions][clientSessions] middlewares. This module is not guaranteed to | ||
work with any other session middleware. | ||
Use the `bouncer.middleware` object to set up middleware that will ensure that | ||
your users are logged in (and redirect otherwise), and the `bouncer.routes` | ||
object to add the OAuth-specific routes to your app: | ||
```javascript | ||
var express = require('express'); | ||
var app = express(); | ||
var express = require('express'); | ||
var cookieParser = require('cookie-parser'); | ||
var sessions = require('client-sessions'); | ||
var bouncer = require('heroku-bouncer'); | ||
var app = express(); | ||
app.use(require('cookie-parser')('your cookie secret')); | ||
app.use(require('client-sessions')({ | ||
app.use(cookieParser('your cookie secret')); | ||
// NOTE: These options are good general options for use in a Heroku app, but | ||
// carefully review your own environment's needs before just copying these. | ||
app.use(sessions({ | ||
cookieName : 'session', | ||
secret : 'your session secret', | ||
duration : 24 * 60 * 60 * 1000, | ||
activeDuration: 1000 * 60 * 5, | ||
cookie : { | ||
path : '/', | ||
ephemeral: false, | ||
httpOnly : true | ||
httpOnly : true, | ||
secure : false | ||
} | ||
})); | ||
var bouncer = require('heroku-bouncer')({ | ||
herokuOAuthID : 'client-id', | ||
herokuOAuthSecret : 'client-secret', | ||
herokuBouncerSecret: 'abcd1234abcd1234' | ||
}); | ||
app.use(bouncer({ | ||
oAuthClientID : 'client-id', | ||
oAuthClientSecret : 'client-secret', | ||
encryptionSecret : 'abcd1234abcd1234' | ||
})); | ||
app.use(bouncer.middleware); | ||
app.use(bouncer.router); | ||
app.get('/', function(req, res) { | ||
res.end('you are clearly logged in!'); | ||
res.end('You must be logged in.'); | ||
}); | ||
``` | ||
After requests pass through `bouncer.middleware`, they'll have a | ||
After requests pass through the bouncer middleware, they'll have the | ||
`heroku-bouncer` property on them: | ||
@@ -71,10 +77,10 @@ | ||
|---------|-----------|---------|-------------| | ||
| `herokuOAuthID` | Yes | n/a | The ID of your Heroku OAuth client | | ||
| `herokuOAuthSecret` | Yes | n/a | The secret of your Heroku OAuth client | | ||
| `herokuBouncerSecret` | Yes | n/a | A random string used to encrypt your user session data | | ||
| `encryptionSecret` | Yes | n/a | A random string used to encrypt your user session data | | ||
| `oAuthClientID` | Yes | n/a | The ID of your Heroku OAuth client | | ||
| `oAuthClientSecret` | Yes | n/a | The secret of your Heroku OAuth client | | ||
| `herokuAPIHost` | No | n/a | An optional override host to send Heroku API requests to | | ||
| `sessionSyncNonce` | No | `null` | The name of a nonce cookie to validate sessions against | | ||
| `ignoreRoutes` | No | `[]` | An array of regular expressions to match routes to be ignored | | ||
| `herokuAuthURL` | No | `"https://id.heroku.com"` | The location of the Heroku OAuth server | | ||
| `herokaiOnly` | No | `false` | Whether or not to restrict the app to Herokai only | | ||
| `herokaiOnlyRedirect` | No | `"https://www.heroku.com"` | Where to redirect non-Herokai to when using `herokaiOnly` | | ||
| `ignoredRoutes` | No | `[]` | An array of regular expressions to match routes to be ignored when there is no session active | | ||
| `oAuthServerURL` | No | `"https://id.heroku.com"` | The location of the Heroku OAuth server | | ||
| `herokaiOnlyHandler` | No | `null` | A route handler that will be called on requests by non-Herokai | | ||
@@ -81,0 +87,0 @@ ## Test |
@@ -138,2 +138,16 @@ // jshint -W030 | ||
}); | ||
context('and userSession does not contain user info', function() { | ||
beforeEach(function(){ | ||
herokuStubber.stubUser({ email: undefined }); | ||
}); | ||
it('forces to reauthenticate', function() { | ||
return withClient().spread(function(client, url) { | ||
return get(url, { followRedirect: false }); | ||
}).spread(function(res) { | ||
res.headers.location.should.eql('/auth/heroku'); | ||
}); | ||
}); | ||
}); | ||
}); | ||
@@ -241,7 +255,9 @@ | ||
context('and herokaiOnly is set to `true`', function() { | ||
context('and herokaiOnly is set', function() { | ||
var clientOptions; | ||
beforeEach(function() { | ||
clientOptions = { herokaiOnly: true }; | ||
clientOptions = { herokaiOnlyHandler: function(req, res) { | ||
res.end('You are not a Herokai.'); | ||
} }; | ||
}); | ||
@@ -264,7 +280,7 @@ | ||
context('and it is a non-JSON GET request', function() { | ||
it('redirects to the non-Herokai URL', function() { | ||
return withClient(clientOptions).spread(function(client, url) { | ||
return get(url + '/hello-world', { jar: true }); | ||
it('uses the custom request handler', function() { | ||
return authenticate(clientOptions).spread(function(client, url, jar) { | ||
return get(url, { jar: jar }); | ||
}).spread(function(res, body) { | ||
body.should.eql('herokai only'); | ||
body.should.eql('You are not a Herokai.'); | ||
}); | ||
@@ -275,29 +291,2 @@ }); | ||
}); | ||
context('and herokaiOnly is a function', function() { | ||
var clientOptions; | ||
beforeEach(function() { | ||
clientOptions = { herokaiOnly: function(req, res) { | ||
res.end('You are not a Herokai.'); | ||
} }; | ||
}); | ||
context('and the user is a Herokai', function() { | ||
it('performs the request like normal', function() { | ||
herokuStubber.stubUser({ email: 'user@heroku.com' }); | ||
return itBehavesLikeANormalRequest(clientOptions); | ||
}); | ||
}); | ||
context('and the user is not a Herokai', function() { | ||
it('uses the custom request handler', function() { | ||
return authenticate(clientOptions).spread(function(client, url, jar) { | ||
return get(url, { jar: jar }); | ||
}).spread(function(res, body) { | ||
body.should.eql('You are not a Herokai.'); | ||
}); | ||
}); | ||
}); | ||
}); | ||
}); | ||
@@ -304,0 +293,0 @@ |
@@ -24,4 +24,3 @@ 'use strict'; | ||
app.use(bouncer.middleware); | ||
app.use(bouncer.router); | ||
app.use(bouncer); | ||
@@ -28,0 +27,0 @@ app.use(function(req, res, next) { |
@@ -11,9 +11,7 @@ 'use strict'; | ||
var defaultOptions = { | ||
herokuOAuthID : 'client-id', | ||
herokuOAuthSecret : 'client-secret', | ||
herokuBouncerSecret: 'abcd1234abcd1234', | ||
herokuAuthURL : 'http://localhost:' + oAuthServer.address().port, | ||
herokaiOnlyRedirect: '/herokai-only', | ||
ignoreRoutes : [/^\/ignore/, /^\/herokai-only/], | ||
herokaiOnly : false | ||
oAuthClientID : 'client-id', | ||
oAuthClientSecret : 'client-secret', | ||
encryptionSecret : 'abcd1234abcd1234', | ||
oAuthServerURL : 'http://localhost:' + oAuthServer.address().port, | ||
ignoredRoutes : [/^\/ignore/, /^\/herokai-only/] | ||
}; | ||
@@ -20,0 +18,0 @@ |
@@ -6,22 +6,33 @@ // jshint -W068 | ||
describe('setup', function() { | ||
it('throws an error when not given a `herokuBouncerSecret`', function() { | ||
it('throws an error when not given a `encryptionSecret`', function() { | ||
(function() { | ||
require('../index')(); | ||
}).should.throw('No `herokuBouncerSecret` provided to heroku-bouncer'); | ||
}).should.throw('No `encryptionSecret` provided to heroku-bouncer'); | ||
}); | ||
it('throws an error when not given a `herokuOAuthID`', function() { | ||
it('throws an error when not given a `oAuthClientID`', function() { | ||
(function() { | ||
require('../index')({ herokuBouncerSecret: 'foo' }); | ||
}).should.throw('No `herokuOAuthID` provided to heroku-bouncer'); | ||
require('../index')({ encryptionSecret: 'foo' }); | ||
}).should.throw('No `oAuthClientID` provided to heroku-bouncer'); | ||
}); | ||
it('throws an error when not given a `herokuOAuthSecret`', function() { | ||
it('throws an error when not given a `oAuthClientSecret`', function() { | ||
(function() { | ||
require('../index')({ | ||
herokuBouncerSecret: 'foo', | ||
herokuOAuthID: '123' | ||
encryptionSecret: 'foo', | ||
oAuthClientID : '123' | ||
}); | ||
}).should.throw('No `herokuOAuthSecret` provided to heroku-bouncer'); | ||
}).should.throw('No `oAuthClientSecret` provided to heroku-bouncer'); | ||
}); | ||
it('throws an error if `herokaiOnlyHandler` is not a function', function() { | ||
(function() { | ||
require('../index')({ | ||
encryptionSecret : 'foo', | ||
oAuthClientID : '123', | ||
oAuthClientSecret : '123', | ||
herokaiOnlyHandler: true | ||
}); | ||
}).should.throw('`herokaiOnlyHandler` must be a handler function'); | ||
}); | ||
}); |
15
93
32741