From 8c3f0108d8a7c5317bf526dbb1dfe98e1a046c4f Mon Sep 17 00:00:00 2001 From: Zac Tredger Date: Thu, 12 Jan 2023 14:28:09 +0000 Subject: [PATCH 1/8] deps: Bump dev dependencies --- package.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 19fec15..24c0695 100644 --- a/package.json +++ b/package.json @@ -22,16 +22,16 @@ }, "devDependencies": { "connect": "3.7.0", - "eslint": "7.32.0", - "eslint-config-standard": "14.1.1", - "eslint-plugin-import": "2.25.3", - "eslint-plugin-markdown": "2.2.1", + "eslint": "8.30.0", + "eslint-config-standard": "17.0.0", + "eslint-plugin-import": "2.26.0", + "eslint-plugin-markdown": "3.0.0", "eslint-plugin-node": "11.1.0", - "eslint-plugin-promise": "5.2.0", + "eslint-plugin-promise": "6.1.1", "eslint-plugin-standard": "4.1.0", - "mocha": "9.1.3", + "mocha": "10.2.0", "nyc": "15.1.0", - "supertest": "6.1.6" + "supertest": "6.3.0" }, "files": [ "HISTORY.md", From 6b6beb0965919d2533fbb37a6ac03b1084152fc7 Mon Sep 17 00:00:00 2001 From: Zac Tredger Date: Thu, 12 Jan 2023 14:28:26 +0000 Subject: [PATCH 2/8] Enable setting multiple cookie-sessions Introduces a new option, 'sessionName', which is the key under `Express.Request.sessions` where the session can be accessed. 'session' is the default sessionName, and sessions with sessionName 'session' are still accessible at `Express.Request.session`. This API was chosen to avoid breaking existing applications. Rejected alternative APIs: - The `name` property acts as the session accessor on `req.sessions`. Either obviating the need for the `sessionName` option, or perhaps allowing the `sessionName` to default to `name`. This would be a breaking change in all apps that provide a non-default value for `name`. I considered working around this by always making the first cookie-session created the one accessible at `req.session`, but this felt hacky and potentially confusing. - Each session is accessed by adding its sessionName as a property on `req`. I feared this would pollute the namespace, risking name collisions with other modules. --- README.md | 140 ++++++++++++++++++++++++++----- index.js | 82 ++++++++++++------ test/test.js | 232 +++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 369 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 3126dec..673d09e 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,16 @@ the loaded session. This session is either a new session if no valid session was provided in the request, or a loaded session from the request. The middleware will automatically add a `Set-Cookie` header to the response if the -contents of `req.session` were altered. _Note_ that no `Set-Cookie` header will be +contents of the session were altered. _Note_ that no `Set-Cookie` header will be in the response (and thus no session created for a specific user) unless there are contents in the session, so be sure to add something to `req.session` as soon as you have identifying information to store for the session. +If the session contents change rarely, you may wish to intervene to prolong +sessions, as described [below](#extending-the-session-expiration). + +This middleware can be used multiple times to create several cookie-sessions. +But note that their [`names`](#name), as well as their +[`sessionNames`](#sessionname), must be distinct. #### Options @@ -68,6 +74,27 @@ Cookie session accepts these properties in the options object. ##### name The name of the cookie to set, defaults to `session`. +If you are using multiple cookie-sessions, give each a unique `name`. + +##### sessionName + +The name of the session. +Defaults to `"session"`. + +To avoid confusion, it is sensible to choose the same value as [`name`](#name). + +Sessions with the default name will be accessible at `req.session`, and their +[options](#reqsessionoptions) will be accessible at `req.sessionOptions`. + +The session data will always be accessible on the `req.sessions` object, at the +property matching your sessionName. +e.g. for sessionName `"foo"`, you can access the session at `req.sessions.foo`. + +Similarly, the [options](#reqsessionoptions) for each session will always be +accessible on the `req.sessionsOptions` object. +E.g. `"foo"`'s options will be at `req.sessionsOptions.foo`. + +To create multiple cookie-sessions, give each a unique `sessionName`. ##### keys @@ -99,29 +126,26 @@ The options can also contain any of the following (for the full list, see - `signed`: a boolean indicating whether the cookie is to be signed (`true` by default). - `overwrite`: a boolean indicating whether to overwrite previously set cookies of the same name (`true` by default). -### req.session - -Represents the session for the given request. +### Accessing sessions -#### .isChanged +Session data can always be accessed via the property on[ +`req.sessions`](#reqsessions) matching their [sessionName](#sessionname). +Sessions with the default [sessionName](#sessionname), `"session"`, can also be +accessed at [`req.session`](#reqsession). -Is `true` if the session has been changed during the request. +#### req.session -#### .isNew +The session data for the session with [sessionName](#sessionname) "session" (the +default). `undefined` if there is no session with [sessionName](#sessionname) +"session". -Is `true` if the session is new. - -#### .isPopulated - -Determine if the session has been populated with data or is empty. +#### req.sessions -### req.sessionOptions +Provides access to the data for all sessions, keyed by their +[sessionName](#sessionname). +E.g. for sessionName `"foo"`, you can access the session at `req.sessions.foo`. -Represents the session options for the current request. These options are a -shallow clone of what was provided at middleware construction and can be -altered to change cookie setting behavior on a per-request basis. - -### Destroying a session +#### Destroying a session To destroy a session simply set it to `null`: @@ -129,7 +153,7 @@ To destroy a session simply set it to `null`: req.session = null ``` -### Saving a session +#### Saving a session Since the entire contents of the session is kept in a client-side cookie, the session is "saved" by writing a cookie out in a `Set-Cookie` response header. @@ -137,6 +161,46 @@ This is done automatically if there has been a change made to the session when the Node.js response headers are being written to the client and the session was not destroyed. +#### Session properties + +Sessions always have the following properties, in addition to any you define: + +##### .isChanged + +Is `true` if the session has been changed during the request. + +##### .isNew + +Is `true` if the session is new. + +##### .isPopulated + +Determine if the session has been populated with data or is empty. + +#### Session options + +These options inherit from the options provided at middleware construction and +can be altered to change cookie setting behavior on a per-request basis. + +The options for each session can always be accessed via the property on +[`req.sessionsOptions`](#reqsessionsoptions) matching their +[sessionName](#sessionname). +Sessions with the default [sessionName](#sessionname), `"session"`, can also be +accessed at [`req.sessionOptions`](#reqsession). + +##### req.sessionOptions + +The session options for the session with [sessionName](#sessionname) `"session"` +(the default). +`undefined` if there is no session with [sessionName](#sessionname) `"session"`. + +##### req.sessionsOptions + +Provides access to the options for all sessions, keyed by their +[sessionName](#sessionname). +E.g. for sessionName `"foo"`, you can access the session options at +`req.sessionsOptions.foo`. + ## Examples ### Simple view counter example @@ -238,6 +302,44 @@ app.use(cookieSession({ // ... your logic here ... ``` +### Setting multiple cookies +This example sets a session cookie that the server can trust, and an insecure +session cookie that is just used to make some metadata available to the client. +```js +var cookieSession = require('cookie-session') +var express = require('express') + +var app = express() + +// One cookie not available to client-side JS +app.use(cookieSession({ + // name & sessionName default to 'session' + secret: 'topSecret' +})) + +// Another cookie. This one can be accessed by client-side JS. +app.use(cookieSession({ + name: 'insecureSession', + sessionName: 'insecureSession', + httpOnly: false, + signed: false +})) + +app.get('/', function (req, res, next) { + // Set secure session data + req.session.signedStuff ||= "shibboleth" + // Update insecure session data. (This is just an FYI for the client. The + // server must not trust it to authenticate users!) + const tomorrow = new Date(Date.now() + 24 * 60 * 60e3) + req.sessions.insecureSession.sessionExpiry = tomorrow + next() +}) + +// ... your logic here ... + +app.listen(3000) +``` + ## Usage Limitations ### Max Cookie Size diff --git a/index.js b/index.js index 9ece996..a7db7b9 100644 --- a/index.js +++ b/index.js @@ -31,6 +31,9 @@ module.exports = cookieSession * @param {boolean} [options.httpOnly=true] * @param {array} [options.keys] * @param {string} [options.name=session] Name of the cookie to use + * @param {string} [options.sessionName=session] The key on `request.sessions` + * through which the session can be accessed. Sessions with sessionName + * 'session' (the default) will always be accessible at `req.session`. * @param {boolean} [options.overwrite=true] * @param {string} [options.secret] * @param {boolean} [options.signed=true] @@ -39,23 +42,24 @@ module.exports = cookieSession */ function cookieSession (options) { - var opts = options || {} + var opts = Object.create(options || {}) - // cookie name - var name = opts.name || 'session' + // defaults + opts.name ??= 'session' + opts.sessionName ??= 'session' + opts.overwrite ??= true + opts.httpOnly ??= true + opts.signed ??= true // secrets var keys = opts.keys - if (!keys && opts.secret) keys = [opts.secret] - - // defaults - if (opts.overwrite == null) opts.overwrite = true - if (opts.httpOnly == null) opts.httpOnly = true - if (opts.signed == null) opts.signed = true + if ((!keys || !keys.length) && opts.secret) keys = [opts.secret] - if (!keys && opts.signed) throw new Error('.keys required.') + if (!keys && opts.signed) { + throw new Error("If '.signed' is true, '.keys' or '.secret' are required.") + } - debug('session options %j', opts) + debug('%s session options %j', opts.name, opts) return function _cookieSession (req, res, next) { var cookies = new Cookies(req, res, { @@ -64,10 +68,34 @@ function cookieSession (options) { var sess // for overriding - req.sessionOptions = Object.create(opts) + var overrideOptions = Object.create(opts) + + // If the name is session, add classic accessors for backwards compatibility + if (opts.sessionName === 'session') { + // for overriding + req.sessionOptions = overrideOptions + + // define req.session getter / setter + Object.defineProperty(req, 'session', { + configurable: true, + enumerable: true, + get: getSession, + set: setSession + }) + } - // define req.session getter / setter - Object.defineProperty(req, 'session', { + if (!('sessions' in req)) { + req.sessions = {} + } + if (!('sessionsOptions' in req)) { + req.sessionsOptions = {} + } + + // for overriding + req.sessionsOptions[opts.sessionName] = overrideOptions + + // define req.sessions[sessionName] getter / setter + Object.defineProperty(req.sessions, opts.sessionName, { configurable: true, enumerable: true, get: getSession, @@ -86,12 +114,14 @@ function cookieSession (options) { } // get session - if ((sess = tryGetSession(cookies, name, req.sessionOptions))) { + if ((sess = tryGetSession( + cookies, opts.name, req.sessionsOptions[opts.sessionName] + ))) { return sess } // create session - debug('new session') + debug('new %s session', opts.name) return (sess = Session.create()) } @@ -120,26 +150,30 @@ function cookieSession (options) { try { if (sess === false) { // remove - debug('remove %s', name) - cookies.set(name, '', req.sessionOptions) + debug('remove %s', opts.name) + cookies.set(opts.name, '', req.sessionsOptions[opts.sessionName]) } else if ((!sess.isNew || sess.isPopulated) && sess.isChanged) { // save populated or non-new changed session - debug('save %s', name) - cookies.set(name, Session.serialize(sess), req.sessionOptions) + debug('save %s', opts.name) + cookies.set( + opts.name, + Session.serialize(sess), + req.sessionsOptions[opts.sessionName] + ) } } catch (e) { - debug('error saving session %s', e.message) + debug('error saving session %s: %s', opts.name, e.message) } }) next() } -}; +} /** * Session model. * - * @param {Context} ctx + * @param {SessionContext} ctx * @param {Object} obj * @private */ @@ -278,7 +312,7 @@ function tryGetSession (cookies, name, opts) { return undefined } - debug('parse %s', str) + debug('parsing %s session: %s', name, str) try { return Session.deserialize(str) diff --git a/test/test.js b/test/test.js index 2d4fa81..1bbbc2d 100644 --- a/test/test.js +++ b/test/test.js @@ -1,3 +1,4 @@ +'use strict' process.env.NODE_ENV = 'test' @@ -10,7 +11,7 @@ describe('Cookie Session', function () { describe('"httpOnly" option', function () { it('should default to "true"', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session.message = 'hi' res.end(String(req.sessionOptions.httpOnly)) }) @@ -23,7 +24,7 @@ describe('Cookie Session', function () { it('should use given "false"', function (done) { var app = App({ httpOnly: false }) - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session.message = 'hi' res.end(String(req.sessionOptions.httpOnly)) }) @@ -38,7 +39,7 @@ describe('Cookie Session', function () { describe('"overwrite" option', function () { it('should default to "true"', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { res.setHeader('Set-Cookie', [ 'session=foo; path=/fake', 'foo=bar' @@ -55,7 +56,7 @@ describe('Cookie Session', function () { it('should use given "false"', function (done) { var app = App({ overwrite: false }) - app.use(function (req, res, next) { + app.use(function (req, res, _next) { res.setHeader('Set-Cookie', [ 'session=foo; path=/fake', 'foo=bar' @@ -73,9 +74,9 @@ describe('Cookie Session', function () { }) describe('when options.name = my.session', function () { - it('should use my.session for cookie name', function (done) { + it("should use 'my.session' for cookie name, but 'session' for session name", function (done) { var app = App({ name: 'my.session' }) - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session.message = 'hi' res.end() }) @@ -87,6 +88,123 @@ describe('Cookie Session', function () { }) }) + describe('when options.sessionName = my.session', function () { + it("the default-named cookie should be accessible at req.session", function (done) { + var app = App( + {}, // default name 'session' should be used + { + name: "secondary", + sessionName: "secondary", + } + ) + app.use(function (req, res, next) { + req.sessions.session.number = 1 + req.sessions.secondary.number = 2 + next() + }) + app.use(function (req, res, _next) { + res.end(String(req.session.number)) + }) + + request(app) + .get('/') + .expect(200, '1', done) + }) + + it("the session should be at req.sessions['my.session'] but not req.session", function (done) { + var app = App({ sessionName: 'my.session' }) + app.use(function (req, res, _next) { + req.sessions['my.session'].message = 'hi' + res.end(String(req.session)) + }) + + request(app) + .get('/') + .expect(shouldHaveCookie('session')) + .expect(200, 'undefined', done) + }) + + it("should use 'my.session' as the session name, but 'session' as the cookie name", function (done) { + var app = App({ sessionName: 'my.session' }) + app.use(function (req, res, _next) { + req.sessions['my.session'].message = 'hi' + res.end() + }) + + request(app) + .get('/') + .expect(shouldHaveCookie('session')) + .expect(200, done) + }) + }) + + describe('when two cookies are used with different options', function () { + it("should set the 'httpOnly' but not the 'jsAlso' cookie-session as httpOnly", function (done) { + var app = App({ + name: 'httpOnly', + sessionName: 'httpOnly', + }, { + name: 'jsAlso', + sessionName: 'jsAlso', + httpOnly: false, + }) + app.use(function (req, res, _next) { + req.sessions.httpOnly.message = 'httpOnly' + req.sessions.jsAlso.message = 'jsAlso' + res.end() + }) + + request(app) + .get('/') + .expect(shouldHaveCookieWithParameter('httpOnly', 'httpOnly')) + .expect(shouldHaveCookieWithoutParameter('jsAlso', 'httpOnly')) + .expect(200, done) + }) + }) + + describe('when multiple cookieSessions are required', function () { + var app + it("multiple configs to be passed to cookieSession in an array", function () { + app = connect() + app.use(session([ + { + signed: false, + }, { + signed: false, + name: 'secondary', + sessionName: 'secondary', + } + ])) + }) + it("cookieSession can be called multiple times", function () { + app = connect() + app.use(session({ + signed: false, + })) + app.use(session({ + signed: false, + name: 'secondary', + sessionName: 'secondary', + })) + }) + afterEach(function (done) { + app.use(function (req, _res, next) { + req.session.name = req.sessionOptions.name + req.sessions.secondary.name = req.sessionsOptions.secondary.name + next() + }) + app.use('/', function (req, res, _next) { + res.end(req.session.name + req.sessions.secondary.name) + }) + + request(app) + .get('/') + .expect(shouldHaveCookie('session')) + .expect(shouldHaveCookie('secondary')) + .expect(200, 'sessionsecondary', done) + }) + }) + describe('when options.signed = true', function () { describe('when options.keys are set', function () { it('should work', function (done) { @@ -94,7 +212,7 @@ describe('Cookie Session', function () { app.use(session({ keys: ['a', 'b'] })) - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session.message = 'hi' res.end() }) @@ -111,7 +229,7 @@ describe('Cookie Session', function () { app.use(session({ secret: 'a' })) - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session.message = 'hi' res.end() }) @@ -138,7 +256,7 @@ describe('Cookie Session', function () { app.use(session({ signed: false })) - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session.message = 'hi' res.end() }) @@ -154,7 +272,7 @@ describe('Cookie Session', function () { describe('when connection not secured', function () { it('should not Set-Cookie', function (done) { var app = App({ secure: true }) - app.use(function (req, res, next) { + app.use(function (req, res, _next) { process.nextTick(function () { req.session.message = 'hello!' res.end('greetings') @@ -172,7 +290,7 @@ describe('Cookie Session', function () { describe('when the session contains a ;', function () { it('should still work', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { if (req.method === 'POST') { req.session.string = ';' res.statusCode = 204 @@ -198,7 +316,7 @@ describe('Cookie Session', function () { describe('when the session is invalid', function () { it('should create new session', function (done) { var app = App({ name: 'my.session', signed: false }) - app.use(function (req, res, next) { + app.use(function (req, res, _next) { res.end(String(req.session.isNew)) }) @@ -213,7 +331,7 @@ describe('Cookie Session', function () { describe('when not accessed', function () { it('should not Set-Cookie', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { res.end('greetings') }) @@ -224,10 +342,10 @@ describe('Cookie Session', function () { }) }) - describe('when accessed and not populated', function (done) { + describe('when accessed and not populated', function () { it('should not Set-Cookie', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { var sess = req.session res.end(JSON.stringify(sess)) }) @@ -239,10 +357,10 @@ describe('Cookie Session', function () { }) }) - describe('when populated', function (done) { + describe('when populated', function () { it('should Set-Cookie', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session.message = 'hello' res.end() }) @@ -260,7 +378,7 @@ describe('Cookie Session', function () { before(function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session.message = 'hello' res.end() }) @@ -278,7 +396,7 @@ describe('Cookie Session', function () { describe('when not accessed', function () { it('should not Set-Cookie', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { res.end('aklsjdfklasjdf') }) @@ -293,7 +411,7 @@ describe('Cookie Session', function () { describe('when accessed but not changed', function () { it('should be the same session', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { assert.strictEqual(req.session.message, 'hello') res.end('aklsjdfkljasdf') }) @@ -306,7 +424,7 @@ describe('Cookie Session', function () { it('should not Set-Cookie', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { assert.strictEqual(req.session.message, 'hello') res.end('aklsjdfkljasdf') }) @@ -322,7 +440,7 @@ describe('Cookie Session', function () { describe('when accessed and changed', function () { it('should Set-Cookie', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session.money = '$$$' res.end('klajsdlkfjadsf') }) @@ -340,7 +458,7 @@ describe('Cookie Session', function () { describe('null', function () { it('should expire the session', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session = null res.end('lkajsdf') }) @@ -353,7 +471,7 @@ describe('Cookie Session', function () { it('should no longer return a session', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session = null res.end(JSON.stringify(req.session)) }) @@ -368,7 +486,7 @@ describe('Cookie Session', function () { describe('{}', function () { it('should not Set-Cookie', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session = {} res.end('hello, world') }) @@ -383,7 +501,7 @@ describe('Cookie Session', function () { describe('{a: b}', function () { it('should create a session', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session = { message: 'hello' } res.end('klajsdfasdf') }) @@ -398,7 +516,7 @@ describe('Cookie Session', function () { describe('anything else', function () { it('should throw', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session = 'aklsdjfasdf' }) @@ -413,7 +531,7 @@ describe('Cookie Session', function () { describe('.isPopulated', function () { it('should be false on new session', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { res.end(String(req.session.isPopulated)) }) @@ -424,7 +542,7 @@ describe('Cookie Session', function () { it('should be true after adding property', function (done) { var app = App() - app.use(function (req, res, next) { + app.use(function (req, res, _next) { req.session.message = 'hello!' res.end(String(req.session.isPopulated)) }) @@ -436,21 +554,43 @@ describe('Cookie Session', function () { }) }) - describe('req.sessionOptions', function () { - it('should be the session options', function (done) { - var app = App({ name: 'my.session' }) - app.use(function (req, res, next) { + describe('session options', function () { + it('should be at sessionOptions by default', function (done) { + var app = App() + app.use(function (req, res, _next) { res.end(String(req.sessionOptions.name)) }) request(app) .get('/') - .expect(200, 'my.session', done) + .expect(200, 'session', done) + }) + + it('should also be in sessionsOptions[sessionName]', function (done) { + var app = App() + app.use(function (req, res, _next) { + res.end(String(req.sessionsOptions.session.name)) + }) + + request(app) + .get('/') + .expect(200, 'session', done) + }) + + it('should not be at sessionOptions for non-default sessionName', function (done) { + var app = App({ sessionName: 'foo' }) + app.use(function (req, res, _next) { + res.end(String(req.sessionOptions)) + }) + + request(app) + .get('/') + .expect(200, 'undefined', done) }) it('should alter the cookie setting', function (done) { - var app = App({ maxAge: 3600000, name: 'my.session' }) - app.use(function (req, res, next) { + var app = App({ maxAge: 3600000 }) + app.use(function (req, res, _next) { if (req.url === '/max') { req.sessionOptions.maxAge = 6500000 } @@ -461,23 +601,31 @@ describe('Cookie Session', function () { request(app) .get('/') - .expect(shouldHaveCookieWithTTLBetween('my.session', 0, 3600000)) + .expect(shouldHaveCookieWithTTLBetween('session', 0, 3600000)) .expect(200, function (err) { if (err) return done(err) request(app) .get('/max') - .expect(shouldHaveCookieWithTTLBetween('my.session', 5000000, Infinity)) + .expect(shouldHaveCookieWithTTLBetween('session', 5000000, Infinity)) .expect(200, done) }) }) }) }) -function App (options) { - var opts = Object.create(options || null) - opts.keys = ['a', 'b'] +/** + * Connect to an app using cookie-sessions with passed configurations + * @param {...object} configurations + * @return {app} + */ +function App (configurations) { var app = connect() - app.use(session(opts)) + var configs = arguments.length ? Array.prototype.slice.call(arguments) : [{}] + configs.forEach(function addKeyBasedSession(configuration) { + var config = Object.create(configuration) + config.keys = ['a', 'b'] + app.use(session(config)) + }) return app } From 17aa1eef089f76730641091c1c40e6bc825c1f5c Mon Sep 17 00:00:00 2001 From: Zac Tredger Date: Thu, 12 Jan 2023 14:15:25 +0000 Subject: [PATCH 3/8] Call cookieSession with multiple options objects Users can now pass several, or an array of, options objects to `cookieSession`. One middleware function will always be returned that creates all the sessions. This means that users using multiple cookie-sessions in their app never need to call `cookieSession` multiple times. `cookieSession` now also checks that the sessions have unique names and sessionNames to avoid tricky overwriting bugs. I rejected an alternative approach, in which `cookieSession` returns an array of middleware functions. You can pass Express an array of middleware exactly like passing a single functions, so it wouldn't break any user's apps in those cases. But we can't assume that all users are passing the function straight to Express. --- README.md | 11 +++--- index.js | 96 +++++++++++++++++++++++++++++++++++++++++----------- package.json | 1 + test/test.js | 27 ++++++++++++--- 4 files changed, 104 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 673d09e..a55392e 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,8 @@ you have identifying information to store for the session. If the session contents change rarely, you may wish to intervene to prolong sessions, as described [below](#extending-the-session-expiration). -This middleware can be used multiple times to create several cookie-sessions. +You can create multiple cookie-sessions by passing multiple [options](#options) +objects to `cookieSession`. But note that their [`names`](#name), as well as their [`sessionNames`](#sessionname), must be distinct. @@ -311,14 +312,12 @@ var express = require('express') var app = express() -// One cookie not available to client-side JS app.use(cookieSession({ + // One cookie not available to client-side JS // name & sessionName default to 'session' secret: 'topSecret' -})) - -// Another cookie. This one can be accessed by client-side JS. -app.use(cookieSession({ +}, { + // Another cookie. This one can be accessed by client-side JS. name: 'insecureSession', sessionName: 'insecureSession', httpOnly: false, diff --git a/index.js b/index.js index a7db7b9..eee670d 100644 --- a/index.js +++ b/index.js @@ -16,6 +16,7 @@ var Buffer = require('safe-buffer').Buffer var debug = require('debug')('cookie-session') var Cookies = require('cookies') var onHeaders = require('on-headers') +var flatten = require('array-flatten') /** * Module exports. @@ -24,32 +25,66 @@ var onHeaders = require('on-headers') module.exports = cookieSession +var defaultConfiguration = { + name: 'session', + sessionName: 'session', + overwrite: true, + httpOnly: true, + signed: true, +} + /** - * Create a new cookie session middleware. + * @typedef Configuration * - * @param {object} [options] - * @param {boolean} [options.httpOnly=true] - * @param {array} [options.keys] - * @param {string} [options.name=session] Name of the cookie to use - * @param {string} [options.sessionName=session] The key on `request.sessions` + * @type {object} + * @property {boolean} [httpOnly=true] + * @property {array} [keys] + * @property {string} [name=session] - Name of the cookie to use + * @property {string} [sessionName=session] - The key on `request.sessions` * through which the session can be accessed. Sessions with sessionName * 'session' (the default) will always be accessible at `req.session`. - * @param {boolean} [options.overwrite=true] - * @param {string} [options.secret] - * @param {boolean} [options.signed=true] - * @return {function} middleware - * @public + * @property {boolean} [overwrite=true] + * @property {string} [secret] + * @property {boolean} [signed=true] */ -function cookieSession (options) { - var opts = Object.create(options || {}) +/** + * Create a new cookie session middleware. + * + * @param {...Configuration|Configuration[]} configurations + * @return {function} middleware function + * @public + */ +function cookieSession (configurations) { + var configs = flatten(Array.prototype.slice.call(arguments), null) + switch (configs.length) { + case 0: + configs = [{}] + break + case 1: + break + default: + ['name', 'sessionName'].forEach(function throwIfDupName(nameKind) { + var names = Object.create(null) + configs.forEach(function checkUniqueness(c) { + var name = c[nameKind] || 'null' + if (name in names) { + throw new Error('Each configuration must have a unique ' + nameKind) + } else { + names[name] = true + } + }) + }) + } + return compose(configs.map(createCookieSessionMiddleware)) +} - // defaults - opts.name ??= 'session' - opts.sessionName ??= 'session' - opts.overwrite ??= true - opts.httpOnly ??= true - opts.signed ??= true +function createCookieSessionMiddleware(config) { + // Options inherit from config but with defaults applied + var opts = Object.create(config) + Object.keys(defaultConfiguration).forEach(function applyDefaults(k) { + if (opts[k] == null) opts[k] = defaultConfiguration[k] + }) // secrets var keys = opts.keys @@ -61,7 +96,7 @@ function cookieSession (options) { debug('%s session options %j', opts.name, opts) - return function _cookieSession (req, res, next) { + return function cookieSessionMiddleware (req, res, next) { var cookies = new Cookies(req, res, { keys: keys }) @@ -170,6 +205,27 @@ function cookieSession (options) { } } +/** + * Compose a non-empty array of middleware into one function + * @param {function[]} middlewares - middleware functions + * @return {function} middleware function + */ +function compose(middlewares) { + var head = middlewares[0] + var tail = middlewares.slice(1) + + if (!tail.length) { + return head; + } + + return function(req, res, next) { + head(req, res, function(err) { + if (err) return next(err); + compose(tail)(req, res, next); + }); + }; +} + /** * Session model. * diff --git a/package.json b/package.json index 24c0695..70ed385 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ ], "repository": "expressjs/cookie-session", "dependencies": { + "array-flatten": "1.1.1", "cookies": "0.8.0", "debug": "3.2.7", "on-headers": "~1.0.2", diff --git a/test/test.js b/test/test.js index 1bbbc2d..ea922b9 100644 --- a/test/test.js +++ b/test/test.js @@ -162,6 +162,21 @@ describe('Cookie Session', function () { }) }) + describe('when two cookies are configured with the same name', function () { + it("should error if the cookies have the same name", function (done) { + assert.throws(function() { + App({}, {sessionName: 'secondary'}) + }) + done() + }) + it("should error if the cookies have the same SessionName", function (done) { + assert.throws(function() { + App({}, {name: 'secondary'}) + }) + done() + }) + }) + describe('when multiple cookieSessions are required', function () { var app it("multiple configs to be passed to cookieSession in an array", function () { @@ -615,17 +630,19 @@ describe('Cookie Session', function () { /** * Connect to an app using cookie-sessions with passed configurations - * @param {...object} configurations + * @param {...Configuration} configurations * @return {app} */ function App (configurations) { var app = connect() var configs = arguments.length ? Array.prototype.slice.call(arguments) : [{}] - configs.forEach(function addKeyBasedSession(configuration) { - var config = Object.create(configuration) - config.keys = ['a', 'b'] - app.use(session(config)) + var keyedConfigs = configs.map(function addKeys(configuration) { + return Object.create(configuration, { keys: { + value: ['a', 'b'], + enumerable: true, + }}) }) + app.use(session(keyedConfigs)) return app } From dc6e3c652f20a03a103b49e1f4fc5a31873c64b0 Mon Sep 17 00:00:00 2001 From: Zac Tredger Date: Sun, 15 Jan 2023 10:19:09 +0000 Subject: [PATCH 4/8] Namespace debug messages with sessionNames --- index.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/index.js b/index.js index eee670d..65aa4ae 100644 --- a/index.js +++ b/index.js @@ -13,7 +13,7 @@ */ var Buffer = require('safe-buffer').Buffer -var debug = require('debug')('cookie-session') +var debug = require('debug') var Cookies = require('cookies') var onHeaders = require('on-headers') var flatten = require('array-flatten') @@ -94,7 +94,9 @@ function createCookieSessionMiddleware(config) { throw new Error("If '.signed' is true, '.keys' or '.secret' are required.") } - debug('%s session options %j', opts.name, opts) + // Debug namespace + var sessionDebug = debug('cookie-session:' + opts.name) + sessionDebug('Session options %j', opts) return function cookieSessionMiddleware (req, res, next) { var cookies = new Cookies(req, res, { @@ -150,13 +152,13 @@ function createCookieSessionMiddleware(config) { // get session if ((sess = tryGetSession( - cookies, opts.name, req.sessionsOptions[opts.sessionName] + cookies, opts.name, req.sessionsOptions[opts.sessionName], sessionDebug ))) { return sess } // create session - debug('new %s session', opts.name) + sessionDebug('session created') return (sess = Session.create()) } @@ -185,11 +187,11 @@ function createCookieSessionMiddleware(config) { try { if (sess === false) { // remove - debug('remove %s', opts.name) + sessionDebug('session removed') cookies.set(opts.name, '', req.sessionsOptions[opts.sessionName]) } else if ((!sess.isNew || sess.isPopulated) && sess.isChanged) { // save populated or non-new changed session - debug('save %s', opts.name) + sessionDebug('save %s', opts.name) cookies.set( opts.name, Session.serialize(sess), @@ -197,7 +199,7 @@ function createCookieSessionMiddleware(config) { ) } } catch (e) { - debug('error saving session %s: %s', opts.name, e.message) + sessionDebug('error saving session: %s', e.message) } }) @@ -361,14 +363,14 @@ function encode (body) { * @private */ -function tryGetSession (cookies, name, opts) { +function tryGetSession (cookies, name, opts, log) { var str = cookies.get(name, opts) if (!str) { return undefined } - debug('parsing %s session: %s', name, str) + log('parsing session: %s', str) try { return Session.deserialize(str) From 057020d368ce9e5e58e621ad4ebd67d8566ac821 Mon Sep 17 00:00:00 2001 From: Zac Tredger Date: Mon, 16 Jan 2023 11:04:34 +0000 Subject: [PATCH 5/8] build: avoid deprecated set-output command https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/ --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b575c5..6266cc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,7 +143,7 @@ jobs: echo "node@$(node -v)" echo "npm@$(npm -v)" npm -s ls ||: - (npm -s ls --depth=0 ||:) | awk -F'[ @]' 'NR>1 && $2 { print "::set-output name=" $2 "::" $3 }' + (npm -s ls --depth=0 ||:) | awk -F'[ @]' 'NR>1 && $2 { print "$2=$3" >> $GITHUB_OUTPUT }' - name: Run tests shell: bash From 3ada7c186d333b04ec8a4af1dbfec55b8184990a Mon Sep 17 00:00:00 2001 From: Zac Tredger Date: Mon, 16 Jan 2023 17:15:34 +0000 Subject: [PATCH 6/8] build: supertest@3.4.2 for Node versions 6 and 7 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6266cc6..217a329 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,11 +61,11 @@ jobs: - name: Node.js 6.x node-version: "6.17" - npm-i: mocha@6.2.2 nyc@14.1.1 + npm-i: mocha@6.2.2 nyc@14.1.1 supertest@3.4.2 - name: Node.js 7.x node-version: "7.10" - npm-i: mocha@6.2.2 nyc@14.1.1 + npm-i: mocha@6.2.2 nyc@14.1.1 supertest@3.4.2 - name: Node.js 8.x node-version: "8.17" From 06024fa8e0bd39b3a4f0fe32ed7bebbc620dd85b Mon Sep 17 00:00:00 2001 From: Zac Tredger Date: Mon, 16 Jan 2023 11:03:37 +0000 Subject: [PATCH 7/8] build: Use Node16-based actions Bumps actions/checkout from v2 to v3 Uses latests Ubuntu version for tests Because actions using Node12 are being deprecated: https://github.blog/changelog/2022-09-22-github-actions-all-actions-will-begin-running-on-node16-instead-of-node12/ --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 217a329..e62425b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: matrix: name: @@ -102,7 +102,7 @@ jobs: node-version: "17.2" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Install Node.js ${{ matrix.node-version }} shell: bash -eo pipefail -l {0} From e75939b11a45abde829c3f203201f4b4e1281d4f Mon Sep 17 00:00:00 2001 From: Zac Tredger Date: Thu, 19 Jan 2023 13:00:35 +0000 Subject: [PATCH 8/8] Update broken browser-cookie-limits link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a55392e..f8a8bbb 100644 --- a/README.md +++ b/README.md @@ -352,7 +352,7 @@ recommends that a browser **SHOULD** allow > the cookie's name, value, and attributes) In practice this limit differs slightly across browsers. See a list of -[browser limits here](http://browsercookielimits.squawky.net/). As a rule +[browser limits here](http://browsercookielimits.iain.guru). As a rule of thumb **don't exceed 4093 bytes per domain**. If your session object is large enough to exceed a browser limit when encoded,