Skip to content

Commit 7c6da9c

Browse files
gzurbachdougwilson
authored andcommitted
Support an array in secret option for key rotation
closes #127 closes #135
1 parent f08a763 commit 7c6da9c

File tree

4 files changed

+123
-15
lines changed

4 files changed

+123
-15
lines changed

HISTORY.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
unreleased
2+
==========
3+
4+
* Support an array in `secret` option for key rotation
5+
16
1.10.4 / 2015-03-15
27
===================
38

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,10 @@ it to be saved.
134134

135135
**Required option**
136136

137-
This is the secret used to sign the session ID cookie.
137+
This is the secret used to sign the session ID cookie. This can be either a string
138+
for a single secret, or an array of multiple secrets. If an array of secrets is
139+
provided, only the first element will be used to sign the session ID cookie, while
140+
all the elements will be considered when verifying the signature in requests.
138141

139142
##### store
140143

index.js

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ var defer = typeof setImmediate === 'function'
7474
* @param {Boolean} [options.resave] Resave unmodified sessions back to the store
7575
* @param {Boolean} [options.rolling] Enable/disable rolling session expiration
7676
* @param {Boolean} [options.saveUninitialized] Save uninitialized sessions to the store
77-
* @param {String} [options.secret] Secret for signing session ID
77+
* @param {String|Array} [options.secret] Secret for signing session ID
7878
* @param {Object} [options.store=MemoryStore] Session store
7979
* @param {String} [options.unset]
8080
* @return {Function} middleware
@@ -116,6 +116,19 @@ function session(options){
116116
// TODO: switch to "destroy" on next major
117117
var unsetDestroy = options.unset === 'destroy';
118118

119+
if (Array.isArray(options.secret) && options.secret.length === 0) {
120+
throw new TypeError('secret option array must contain one or more strings');
121+
}
122+
123+
if (options.secret && !Array.isArray(options.secret)) {
124+
options.secret = [options.secret];
125+
}
126+
127+
if (!options.secret) {
128+
deprecate('req.secret; provide secret option');
129+
options.secret = undefined;
130+
}
131+
119132
// notify user that this store is not
120133
// meant for a production environment
121134
if ('production' == env && store instanceof MemoryStore) {
@@ -133,10 +146,6 @@ function session(options){
133146
store.on('disconnect', function(){ storeReady = false; });
134147
store.on('connect', function(){ storeReady = true; });
135148

136-
if (!options.secret) {
137-
deprecate('req.secret; provide secret option');
138-
}
139-
140149
return function session(req, res, next) {
141150
// self-awareness
142151
if (req.session) return next();
@@ -149,12 +158,15 @@ function session(options){
149158
var originalPath = parseUrl.original(req).pathname;
150159
if (0 != originalPath.indexOf(cookie.path || '/')) return next();
151160

161+
// ensure a secret is available or bail
162+
if (!options.secret && !req.secret) {
163+
next(new Error('secret option required for sessions'));
164+
return;
165+
}
166+
152167
// backwards compatibility for signed cookies
153168
// req.secret is passed from the cookie parser middleware
154-
var secret = options.secret || req.secret;
155-
156-
// ensure secret is available or bail
157-
if (!secret) next(new Error('`secret` option required for sessions'));
169+
var secrets = options.secret || [req.secret];
158170

159171
var originalHash;
160172
var originalId;
@@ -164,7 +176,7 @@ function session(options){
164176
req.sessionStore = store;
165177

166178
// get the session ID from the cookie
167-
var cookieId = req.sessionID = getcookie(req, name, secret);
179+
var cookieId = req.sessionID = getcookie(req, name, secrets);
168180

169181
// set-cookie
170182
onHeaders(res, function(){
@@ -185,7 +197,7 @@ function session(options){
185197
return;
186198
}
187199

188-
setcookie(res, name, req.sessionID, secret, cookie.data);
200+
setcookie(res, name, req.sessionID, secrets[0], cookie.data);
189201
});
190202

191203
// proxy end() to commit the session
@@ -441,7 +453,7 @@ function generateSessionId(sess) {
441453
* @private
442454
*/
443455

444-
function getcookie(req, name, secret) {
456+
function getcookie(req, name, secrets) {
445457
var header = req.headers.cookie;
446458
var raw;
447459
var val;
@@ -454,7 +466,7 @@ function getcookie(req, name, secret) {
454466

455467
if (raw) {
456468
if (raw.substr(0, 2) === 's:') {
457-
val = signature.unsign(raw.slice(2), secret);
469+
val = unsigncookie(raw.slice(2), secrets);
458470

459471
if (val === false) {
460472
debug('cookie signature invalid');
@@ -481,7 +493,7 @@ function getcookie(req, name, secret) {
481493

482494
if (raw) {
483495
if (raw.substr(0, 2) === 's:') {
484-
val = signature.unsign(raw.slice(2), secret);
496+
val = unsigncookie(raw.slice(2), secrets);
485497

486498
if (val) {
487499
deprecate('cookie should be available in req.headers.cookie');
@@ -573,3 +585,23 @@ function setcookie(res, name, val, secret, options) {
573585

574586
res.setHeader('set-cookie', header)
575587
}
588+
589+
/**
590+
* Verify and decode the given `val` with `secrets`.
591+
*
592+
* @param {String} val
593+
* @param {Array} secrets
594+
* @returns {String|Boolean}
595+
* @private
596+
*/
597+
function unsigncookie(val, secrets) {
598+
for (var i = 0; i < secrets.length; i++) {
599+
var result = signature.unsign(val, secrets[i]);
600+
601+
if (result !== false) {
602+
return result;
603+
}
604+
}
605+
606+
return false;
607+
}

test/session.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,74 @@ describe('session()', function(){
10101010
})
10111011
});
10121012

1013+
describe('secret option', function () {
1014+
it('should reject empty arrays', function () {
1015+
assert.throws(createServer.bind(null, { secret: [] }), /secret option array/);
1016+
})
1017+
1018+
describe('when an array', function () {
1019+
it('should sign cookies', function (done) {
1020+
var server = createServer({ secret: ['keyboard cat', 'nyan cat'] }, function (req, res) {
1021+
req.session.user = 'bob';
1022+
res.end(req.session.user);
1023+
});
1024+
1025+
request(server)
1026+
.get('/')
1027+
.expect(shouldSetCookie('connect.sid'))
1028+
.expect(200, 'bob', done);
1029+
})
1030+
1031+
it('should sign cookies with first element', function (done) {
1032+
var store = new session.MemoryStore();
1033+
1034+
var server1 = createServer({ secret: ['keyboard cat', 'nyan cat'], store: store }, function (req, res) {
1035+
req.session.user = 'bob';
1036+
res.end(req.session.user);
1037+
});
1038+
1039+
var server2 = createServer({ secret: 'nyan cat', store: store }, function (req, res) {
1040+
res.end(String(req.session.user));
1041+
});
1042+
1043+
request(server1)
1044+
.get('/')
1045+
.expect(shouldSetCookie('connect.sid'))
1046+
.expect(200, 'bob', function (err, res) {
1047+
if (err) return done(err);
1048+
request(server2)
1049+
.get('/')
1050+
.set('Cookie', cookie(res))
1051+
.expect(200, 'undefined', done);
1052+
});
1053+
});
1054+
1055+
it('should read cookies using all elements', function (done) {
1056+
var store = new session.MemoryStore();
1057+
1058+
var server1 = createServer({ secret: 'nyan cat', store: store }, function (req, res) {
1059+
req.session.user = 'bob';
1060+
res.end(req.session.user);
1061+
});
1062+
1063+
var server2 = createServer({ secret: ['keyboard cat', 'nyan cat'], store: store }, function (req, res) {
1064+
res.end(String(req.session.user));
1065+
});
1066+
1067+
request(server1)
1068+
.get('/')
1069+
.expect(shouldSetCookie('connect.sid'))
1070+
.expect(200, 'bob', function (err, res) {
1071+
if (err) return done(err);
1072+
request(server2)
1073+
.get('/')
1074+
.set('Cookie', cookie(res))
1075+
.expect(200, 'bob', done);
1076+
});
1077+
});
1078+
})
1079+
})
1080+
10131081
describe('unset option', function () {
10141082
it('should reject unknown values', function(){
10151083
assert.throws(session.bind(null, { unset: 'bogus!' }), /unset.*must/)

0 commit comments

Comments
 (0)