diff --git a/README.md b/README.md index ebea7cd..230e0b6 100644 --- a/README.md +++ b/README.md @@ -20,15 +20,14 @@ npm i @fastify/session ```js const fastify = require('fastify'); const fastifySession = require('@fastify/session'); -const fastifyCookie = require('fastify-cookie'); +const fastifyCookie = require('@fastify/cookie'); const app = fastify(); -app.register(fastifyCookie); -app.register(fastifySession, {secret: 'a secret with minimum length of 32 characters'}); +app.register(fastifyCookie, {secret: 'a secret with minimum length of 32 characters'}); +app.register(fastifySession); ``` Store data in the session by adding it to the `session` decorator at the `request`: ```js -app.register(fastifySession, {secret: 'a secret with minimum length of 32 characters'}); app.addHook('preHandler', (request, reply, next) => { request.session.user = {name: 'max'}; next(); @@ -37,7 +36,6 @@ app.addHook('preHandler', (request, reply, next) => { **NOTE**: For all unencrypted (HTTP) connections, you need to set the `secure` cookie option to `false`. See below for all cookie options and their details. The `session` object has methods that allow you to get, save, reload and delete sessions. ```js -app.register(fastifySession, {secret: 'a secret with minimum length of 32 characters'}); app.addHook('preHandler', (request, reply, next) => { request.session.destroy(next); }) @@ -51,14 +49,6 @@ app.addHook('preHandler', (request, reply, next) => { ### session(fastify, options, next) The session plugin accepts the following options. It decorates the request with the `sessionStore` and a `session` object. The session data is stored server-side using the configured session store. #### options -##### secret (required) -The secret used to sign the cookie. Must be an array of strings or a string with a length of 32 or greater. - -If an array, the first secret is used to sign new cookies and is the first to be checked for incoming cookies. -Further secrets in the array are used to check incoming cookies in the order specified. - -Note that the rest of the application may manipulate the array during its life cycle. This can be done by storing the array in a separate variable that is later used with mutating methods like unshift(), pop(), splice(), etc. -This can be used to rotate the signing secret at regular intervals. A secret should remain somewhere in the array as long as there are active sessions with cookies signed by it. Secrets management is left up to the rest of the application. ##### cookieName (optional) The name of the session cookie. Defaults to `sessionId`. ##### cookiePrefix (optional) diff --git a/lib/fastifySession.js b/lib/fastifySession.js index ecabc40..36178c7 100644 --- a/lib/fastifySession.js +++ b/lib/fastifySession.js @@ -4,14 +4,8 @@ const fp = require('fastify-plugin') const idGenerator = require('./idGenerator')() const Store = require('./store') const Session = require('./session') -const cookieSignature = require('cookie-signature') function session (fastify, options, next) { - const error = checkOptions(options) - if (error) { - return next(error) - } - options = ensureDefaults(options) // Decorator function takes cookieOpts so we can customize on per-session basis. @@ -30,36 +24,31 @@ function session (fastify, options, next) { fastify.addHook('onRequest', onRequest(options)) fastify.addHook('onSend', onSend(options)) next() -} -function decryptSession (sessionId, options, request, done) { - const cookieOpts = options.cookie - const idGenerator = options.idGenerator - const secrets = options.secret - const secretsLength = secrets.length - const secret = secrets[0] - - let decryptedSessionId = false - for (let i = 0; i < secretsLength; ++i) { - decryptedSessionId = cookieSignature.unsign(sessionId, secrets[i]) - if (decryptedSessionId !== false) { - break + function decryptSession (sessionId, options, request, done) { + const cookieOpts = options.cookie + const idGenerator = options.idGenerator + + const unsignedCookie = request.unsignCookie(sessionId) + if (unsignedCookie.valid === false) { + request.session = new Session(request, idGenerator, cookieOpts) + done() + return } - } - if (decryptedSessionId === false) { - newSession(secret, request, cookieOpts, idGenerator, done) - } else { + const decryptedSessionId = unsignedCookie.value options.store.get(decryptedSessionId, (err, session) => { if (err) { if (err.code === 'ENOENT') { - newSession(secret, request, cookieOpts, idGenerator, done) + request.session = new Session(request, idGenerator, cookieOpts) + done() } else { done(err) } return } if (!session) { - newSession(secret, request, cookieOpts, idGenerator, done) + request.session = new Session(request, idGenerator, cookieOpts) + done() return } if (session.cookie?.expires && session.cookie.expires <= Date.now()) { @@ -67,7 +56,6 @@ function decryptSession (sessionId, options, request, done) { request, idGenerator, cookieOpts, - secret, session ) @@ -86,7 +74,6 @@ function decryptSession (sessionId, options, request, done) { request, idGenerator, cookieOpts, - secret, session ) } else { @@ -94,151 +81,124 @@ function decryptSession (sessionId, options, request, done) { request, idGenerator, cookieOpts, - secret, session ) } done() }) } -} -function onRequest (options) { - const unsignSignedCookie = options.unsignSignedCookie - const cookieOpts = options.cookie - const cookieName = options.cookieName - const idGenerator = options.idGenerator - const cookiePrefix = options.cookiePrefix - const hasCookiePrefix = typeof cookiePrefix === 'string' && cookiePrefix.length !== 0 - const cookiePrefixLength = hasCookiePrefix && cookiePrefix.length + function onRequest (options) { + const cookieOpts = options.cookie + const cookieName = options.cookieName + const idGenerator = options.idGenerator + const cookiePrefix = options.cookiePrefix + const hasCookiePrefix = typeof cookiePrefix === 'string' && cookiePrefix.length !== 0 + const cookiePrefixLength = hasCookiePrefix && cookiePrefix.length - return function handleSession (request, reply, done) { - request.session = {} + return function handleSession (request, reply, done) { + request.session = {} - const url = request.raw.url - if (url.indexOf(cookieOpts.path || '/') !== 0) { - done() - return - } - let sessionId = request.cookies[cookieName] - if (sessionId && hasCookiePrefix && sessionId.startsWith(cookiePrefix)) { - sessionId = sessionId.slice(cookiePrefixLength) - } - const secret = options.secret[0] - if (!sessionId) { - newSession(secret, request, cookieOpts, idGenerator, done) - } else { - let sessionToDecrypt = sessionId - - if (unsignSignedCookie) { - const unsignedCookie = reply.unsignCookie(sessionId) - if (unsignedCookie.valid) { - sessionToDecrypt = unsignedCookie.value - } + const url = request.raw.url + if (url.indexOf(cookieOpts.path || '/') !== 0) { + done() + return + } + let sessionId = request.cookies[cookieName] + if (sessionId && hasCookiePrefix && sessionId.startsWith(cookiePrefix)) { + sessionId = sessionId.slice(cookiePrefixLength) + } + if (!sessionId) { + request.session = new Session(request, idGenerator, cookieOpts) + done() + } else { + decryptSession(sessionId, options, request, done) } - - decryptSession(sessionToDecrypt, options, request, done) } } -} - -function onSend (options) { - const cookieOpts = options.cookie - const cookieName = options.cookieName - const cookiePrefix = options.cookiePrefix - const saveUninitialized = options.saveUninitialized - const hasCookiePrefix = typeof cookiePrefix === 'string' && cookiePrefix.length !== 0 - return function saveSession (request, reply, payload, done) { - const session = request.session - if (!session || !session.sessionId) { - done() - return - } + function onSend (options) { + const cookieOpts = options.cookie + const cookieName = options.cookieName + const cookiePrefix = options.cookiePrefix + const saveUninitialized = options.saveUninitialized + const hasCookiePrefix = typeof cookiePrefix === 'string' && cookiePrefix.length !== 0 + + return function saveSession (request, reply, payload, done) { + const session = request.session + if (!session || !session.sessionId) { + done() + return + } - let encryptedSessionId = session.encryptedSessionId - if (encryptedSessionId && hasCookiePrefix) { - encryptedSessionId = `${cookiePrefix}${encryptedSessionId}` - } + let encryptedSessionId = session.encryptedSessionId + if (encryptedSessionId && hasCookiePrefix) { + encryptedSessionId = `${cookiePrefix}${encryptedSessionId}` + } - if (!shouldSaveSession(request, cookieOpts, saveUninitialized)) { + if (!shouldSaveSession(request, cookieOpts, saveUninitialized)) { // if a session cookie is set, but has a different ID, clear it - if (request.cookies[cookieName] && request.cookies[cookieName] !== encryptedSessionId) { - reply.clearCookie(cookieName) - } - done() - return - } - session.save((err) => { - if (err) { - done(err) + if (request.cookies[cookieName] && request.cookies[cookieName] !== encryptedSessionId) { + reply.clearCookie(cookieName) + } + done() return } - reply.setCookie( - cookieName, - encryptedSessionId, - session.cookie.options(isConnectionSecure(request)) - ) - done() - }) + session.save((err) => { + if (err) { + done(err) + return + } + reply.setCookie( + cookieName, + encryptedSessionId, + session.cookie.options(isConnectionSecure(request)) + ) + done() + }) + } } -} -function newSession (secret, request, cookieOpts, idGenerator, done) { - request.session = new Session(request, idGenerator, cookieOpts, secret) - done() -} - -function checkOptions (options) { - if (!options.secret) { - return new Error('the secret option is required!') - } - if (typeof options.secret === 'string' && options.secret.length < 32) { - return new Error('the secret must have length 32 or greater') + function ensureDefaults (options) { + options.store = options.store || new Store() + options.idGenerator = options.idGenerator || idGenerator + options.cookieName = options.cookieName || 'sessionId' + options.cookie = options.cookie || {} + options.cookie.secure = option(options.cookie, 'secure', true) + options.rolling = option(options, 'rolling', true) + options.saveUninitialized = option(options, 'saveUninitialized', true) + options.cookiePrefix = option(options, 'cookiePrefix', '') + return options } - if (Array.isArray(options.secret) && options.secret.length === 0) { - return new Error('at least one secret is required') - } -} -function ensureDefaults (options) { - options.store = options.store || new Store() - options.idGenerator = options.idGenerator || idGenerator - options.cookieName = options.cookieName || 'sessionId' - options.unsignSignedCookie = options.unsignSignedCookie || false - options.cookie = options.cookie || {} - options.cookie.secure = option(options.cookie, 'secure', true) - options.rolling = option(options, 'rolling', true) - options.saveUninitialized = option(options, 'saveUninitialized', true) - options.secret = Array.isArray(options.secret) ? options.secret : [options.secret] - options.cookiePrefix = option(options, 'cookiePrefix', '') - return options -} - -function isConnectionSecure (request) { - return ( - request.raw.socket?.encrypted === true || + function isConnectionSecure (request) { + return ( + request.raw.socket?.encrypted === true || request.headers['x-forwarded-proto'] === 'https' - ) -} - -function shouldSaveSession (request, cookieOpts, saveUninitialized) { - if (!saveUninitialized && !request.session.isModified()) { - return false + ) } - if (cookieOpts.secure !== true || cookieOpts.secure === 'auto') { - return true + + function shouldSaveSession (request, cookieOpts, saveUninitialized) { + if (!saveUninitialized && !request.session.isModified()) { + return false + } + if (cookieOpts.secure !== true || cookieOpts.secure === 'auto') { + return true + } + return isConnectionSecure(request) } - return isConnectionSecure(request) -} -function option (options, key, def) { - return options[key] === undefined ? def : options[key] + function option (options, key, def) { + return options[key] === undefined ? def : options[key] + } } module.exports = fp(session, { fastify: '4.x', name: '@fastify/session', + decorators: { + request: ['signCookie', 'unsignCookie'] + }, dependencies: [ '@fastify/cookie' ] diff --git a/lib/session.js b/lib/session.js index dd33b6b..9de9157 100644 --- a/lib/session.js +++ b/lib/session.js @@ -3,14 +3,11 @@ const crypto = require('crypto') const Cookie = require('./cookie') -const cookieSignature = require('cookie-signature') const { configure: configureStringifier } = require('safe-stable-stringify') const stringify = configureStringifier({ bigint: false }) const maxAge = Symbol('maxAge') -const secretKey = Symbol('secretKey') -const sign = Symbol('sign') const generateId = Symbol('generateId') const requestKey = Symbol('request') const cookieOptsKey = Symbol('cookieOpts') @@ -18,11 +15,10 @@ const originalHash = Symbol('originalHash') const hash = Symbol('hash') module.exports = class Session { - constructor (request, idGenerator, cookieOpts, secret, prevSession) { + constructor (request, idGenerator, cookieOpts, prevSession) { this[generateId] = idGenerator this[cookieOptsKey] = cookieOpts this[maxAge] = cookieOpts.maxAge - this[secretKey] = secret this[requestKey] = request this.cookie = new Cookie(cookieOpts) @@ -36,7 +32,7 @@ module.exports = class Session { this.touch() if (!this.sessionId) { this.sessionId = this[generateId](this[requestKey]) - this.encryptedSessionId = this[sign]() + this.encryptedSessionId = request.signCookie(this.sessionId) } this[originalHash] = this[hash]() } @@ -49,7 +45,7 @@ module.exports = class Session { regenerate (callback) { if (callback) { - const session = new Session(this[requestKey], this[generateId], this[cookieOptsKey], this[secretKey]) + const session = new Session(this[requestKey], this[generateId], this[cookieOptsKey]) this[requestKey].sessionStore.set(session.sessionId, session, error => { this[requestKey].session = session @@ -58,7 +54,7 @@ module.exports = class Session { }) } else { return new Promise((resolve, reject) => { - const session = new Session(this[requestKey], this[generateId], this[cookieOptsKey], this[secretKey]) + const session = new Session(this[requestKey], this[generateId], this[cookieOptsKey]) this[requestKey].sessionStore.set(session.sessionId, session, error => { this[requestKey].session = session @@ -106,14 +102,14 @@ module.exports = class Session { reload (callback) { if (callback) { this[requestKey].sessionStore.get(this.sessionId, (error, session) => { - this[requestKey].session = new Session(this[requestKey], this[generateId], this[cookieOptsKey], this[secretKey], session) + this[requestKey].session = new Session(this[requestKey], this[generateId], this[cookieOptsKey], session) callback(error) }) } else { return new Promise((resolve, reject) => { this[requestKey].sessionStore.get(this.sessionId, (error, session) => { - this[requestKey].session = new Session(this[requestKey], this[generateId], this[cookieOptsKey], this[secretKey], session) + this[requestKey].session = new Session(this[requestKey], this[generateId], this[cookieOptsKey], session) if (error) { reject(error) @@ -143,10 +139,6 @@ module.exports = class Session { } } - [sign] () { - return cookieSignature.sign(this.sessionId, this[secretKey]) - } - [hash] () { const sess = this const str = stringify(sess, function (key, val) { @@ -168,8 +160,8 @@ module.exports = class Session { return this[originalHash] !== this[hash]() } - static restore (request, idGenerator, cookieOpts, secret, prevSession) { - const restoredSession = new Session(request, idGenerator, cookieOpts, secret, prevSession) + static restore (request, idGenerator, cookieOpts, prevSession) { + const restoredSession = new Session(request, idGenerator, cookieOpts, prevSession) const restoredCookie = new Cookie(cookieOpts) restoredCookie.expires = new Date(prevSession.cookie.expires) restoredSession.cookie = restoredCookie diff --git a/package.json b/package.json index 2b94c0a..f9b6bb6 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "author": "Denis Fäcke", "license": "MIT", "dependencies": { - "cookie-signature": "^1.1.0", "fastify-plugin": "^4.0.0", "safe-stable-stringify": "^2.3.1" }, @@ -28,7 +27,7 @@ "url": "git+https://github.com/fastify/session.git" }, "devDependencies": { - "@fastify/cookie": "^7.0.0", + "@fastify/cookie": "^8.0.0", "@types/node": "^18.0.0", "connect-redis": "^6.1.3", "cronometro": "^1.1.0", diff --git a/test/base.test.js b/test/base.test.js index d0803b0..038d40f 100644 --- a/test/base.test.js +++ b/test/base.test.js @@ -2,11 +2,11 @@ const test = require('tap').test const fastifyPlugin = require('fastify-plugin') -const { DEFAULT_OPTIONS, DEFAULT_COOKIE, DEFAULT_SESSION_ID, DEFAULT_SECRET, DEFAULT_ENCRYPTED_SESSION_ID, buildFastify } = require('./util') +const { DEFAULT_COOKIE_OPTIONS, DEFAULT_COOKIE, DEFAULT_SESSION_ID, DEFAULT_SECRET, DEFAULT_ENCRYPTED_SESSION_ID, buildFastify } = require('./util') test('should not set session cookie on post without params', async (t) => { t.plan(3) - const fastify = await buildFastify((request, reply) => reply.send(200), DEFAULT_OPTIONS) + const fastify = await buildFastify((request, reply) => reply.send(200), DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -24,7 +24,7 @@ test('should set session cookie', async (t) => { const fastify = await buildFastify((request, reply) => { request.session.test = {} reply.send(200) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response1 = await fastify.inject({ @@ -112,7 +112,7 @@ test('should set session cookie using the default cookie name', async (t) => { request.session.test = {} reply.send(200) } - const fastify = await buildFastify(handler, DEFAULT_OPTIONS, plugin) + const fastify = await buildFastify(handler, DEFAULT_COOKIE_OPTIONS, plugin) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -231,7 +231,7 @@ test('should set new session cookie if expired', async (t) => { request.session.test = {} reply.send(200) } - const fastify = await buildFastify(handler, DEFAULT_OPTIONS, plugin) + const fastify = await buildFastify(handler, DEFAULT_COOKIE_OPTIONS, plugin) t.teardown(() => fastify.close()) const response = await fastify.inject({ diff --git a/test/cookie.test.js b/test/cookie.test.js index 701d9c9..d2e5a1c 100644 --- a/test/cookie.test.js +++ b/test/cookie.test.js @@ -4,7 +4,7 @@ const test = require('tap').test const Fastify = require('fastify') const fastifyCookie = require('@fastify/cookie') const fastifySession = require('../lib/fastifySession') -const { DEFAULT_OPTIONS, DEFAULT_SECRET, buildFastify } = require('./util') +const { DEFAULT_COOKIE_OPTIONS, DEFAULT_SECRET, buildFastify } = require('./util') test('should set session cookie', async (t) => { t.plan(2) @@ -13,8 +13,8 @@ test('should set session cookie', async (t) => { fastify.addHook('onRequest', async (request, reply) => { request.raw.socket.encrypted = true }) - fastify.register(fastifyCookie) - fastify.register(fastifySession, DEFAULT_OPTIONS) + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) + fastify.register(fastifySession) fastify.get('/', (request, reply) => { request.session.test = {} reply.send(200) @@ -36,8 +36,8 @@ test('should not set session cookie is request is not secure', async (t) => { fastify.addHook('onRequest', async (request, reply) => { request.raw.socket.encrypted = false }) - fastify.register(fastifyCookie) - fastify.register(fastifySession, DEFAULT_OPTIONS) + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) + fastify.register(fastifySession) fastify.get('/', (request, reply) => reply.send(200)) await fastify.listen({ port: 0 }) t.teardown(() => { fastify.close() }) @@ -56,8 +56,8 @@ test('should not set session cookie is request is not secure and x-forwarded-pro fastify.addHook('onRequest', async (request, reply) => { request.raw.socket.encrypted = false }) - fastify.register(fastifyCookie) - fastify.register(fastifySession, DEFAULT_OPTIONS) + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) + fastify.register(fastifySession) fastify.get('/', (request, reply) => reply.send(200)) await fastify.listen({ port: 0 }) t.teardown(() => { fastify.close() }) @@ -77,8 +77,8 @@ test('should set session cookie is request is not secure and x-forwarded-proto = fastify.addHook('onRequest', async (request, reply) => { request.raw.socket.encrypted = false }) - fastify.register(fastifyCookie) - fastify.register(fastifySession, DEFAULT_OPTIONS) + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) + fastify.register(fastifySession) fastify.get('/', (request, reply) => { request.session.test = {} reply.send(200) @@ -182,12 +182,11 @@ test('should set session another path in cookie', async (t) => { t.plan(2) const fastify = Fastify() - const options = { + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) + fastify.register(fastifySession, { secret: DEFAULT_SECRET, cookie: { path: '/a/test/path' } - } - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) + }) fastify.get('/a/test/path', (request, reply) => { request.session.test = {} reply.send(200) @@ -294,9 +293,8 @@ test('should set session cookie secureAuto', async (t) => { fastify.addHook('onRequest', async (request, reply) => { request.raw.socket.encrypted = false }) - fastify.register(fastifyCookie) + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) fastify.register(fastifySession, { - secret: DEFAULT_SECRET, cookie: { secure: 'auto' } }) fastify.get('/', (request, reply) => { @@ -320,9 +318,8 @@ test('should set session cookie secureAuto change SameSite', async (t) => { fastify.addHook('onRequest', async (request, reply) => { request.raw.socket.encrypted = false }) - fastify.register(fastifyCookie) + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) fastify.register(fastifySession, { - secret: DEFAULT_SECRET, cookie: { secure: 'auto', sameSite: 'none' } }) fastify.get('/', (request, reply) => { @@ -346,9 +343,8 @@ test('should set session cookie secureAuto keep SameSite when secured', async (t fastify.addHook('onRequest', async (request, reply) => { request.raw.socket.encrypted = true }) - fastify.register(fastifyCookie) + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) fastify.register(fastifySession, { - secret: DEFAULT_SECRET, cookie: { secure: 'auto', sameSite: 'none' } }) fastify.get('/', (request, reply) => { @@ -372,9 +368,8 @@ test('should set session secure cookie secureAuto http encrypted', async (t) => fastify.addHook('onRequest', async (request, reply) => { request.raw.socket.encrypted = true }) - fastify.register(fastifyCookie) + fastify.register(fastifyCookie, { secret: DEFAULT_SECRET }) fastify.register(fastifySession, { - secret: DEFAULT_SECRET, cookie: { secure: 'auto' } }) fastify.get('/', (request, reply) => { diff --git a/test/secret.test.js b/test/secret.test.js deleted file mode 100644 index 4507fb8..0000000 --- a/test/secret.test.js +++ /dev/null @@ -1,68 +0,0 @@ -'use strict' - -const test = require('tap').test -const Fastify = require('fastify') -const fastifyCookie = require('@fastify/cookie') -const fastifySession = require('..') -const { DEFAULT_SECRET } = require('./util') - -test('register should fail if no secret is specified', async t => { - t.plan(1) - const fastify = Fastify() - - const options = {} - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) - - await t.rejects(fastify.ready(), 'the secret option is required!') -}) - -test('register should succeed if valid secret is specified', async t => { - t.plan(1) - const fastify = Fastify() - - const options = { secret: DEFAULT_SECRET } - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) - await t.resolves(fastify.ready()) -}) - -test('register should fail if the secret is too short', async t => { - t.plan(1) - const fastify = Fastify() - - const options = { secret: 'geheim' } - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) - await t.rejects(fastify.ready(), 'the secret must have length 32 or greater') -}) - -test('register should succeed if secret is short, but in an array', async t => { - t.plan(1) - const fastify = Fastify() - - const options = { secret: ['geheim'] } - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) - await t.resolves(fastify.ready()) -}) - -test('register should succeed if multiple secrets are present', async t => { - t.plan(1) - const fastify = Fastify() - - const options = { secret: ['geheim', 'test'] } - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) - await t.resolves(fastify.ready()) -}) - -test('register should fail if no secret is present in array', async t => { - t.plan(1) - const fastify = Fastify() - - const options = { secret: [] } - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) - await t.rejects(fastify.ready(), 'at least one secret is required') -}) diff --git a/test/session.test.js b/test/session.test.js index e96a81d..5830515 100644 --- a/test/session.test.js +++ b/test/session.test.js @@ -5,15 +5,14 @@ const Fastify = require('fastify') const fastifyCookie = require('@fastify/cookie') const sinon = require('sinon') const fastifySession = require('..') -const cookieSignature = require('cookie-signature') -const { buildFastify, DEFAULT_OPTIONS, DEFAULT_COOKIE, DEFAULT_SESSION_ID, DEFAULT_SECRET, DEFAULT_COOKIE_VALUE } = require('./util') +const { buildFastify, DEFAULT_COOKIE_OPTIONS, DEFAULT_COOKIE, DEFAULT_SESSION_ID, DEFAULT_COOKIE_VALUE } = require('./util') test('should add session object to request', async (t) => { t.plan(2) const fastify = await buildFastify((request, reply) => { t.ok(request.session) reply.send(200) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -31,7 +30,7 @@ test('should destroy the session', async (t) => { t.equal(request.session, null) reply.send(200) }) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -46,7 +45,7 @@ test('should add session.encryptedSessionId object to request', async (t) => { const fastify = await buildFastify((request, reply) => { t.ok(request.session.encryptedSessionId) reply.send(200) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -61,7 +60,7 @@ test('should add session.cookie object to request', async (t) => { const fastify = await buildFastify((request, reply) => { t.ok(request.session.cookie) reply.send(200) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -76,7 +75,7 @@ test('should add session.sessionId object to request', async (t) => { const fastify = await buildFastify((request, reply) => { t.ok(request.session.sessionId) reply.send(200) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -92,7 +91,7 @@ test('should allow get/set methods for fetching/updating session values', async request.session.set('foo', 'bar') t.equal(request.session.get('foo'), 'bar') reply.send(200) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -105,17 +104,15 @@ test('should allow get/set methods for fetching/updating session values', async test('should use custom sessionId generator if available (without request)', async (t) => { t.plan(2) const fastify = await buildFastify((request, reply) => { - t.equal(request.session.sessionId.startsWith('custom-'), false) + t.equal(request.session.sessionId.startsWith('custom-'), true) reply.send(200) }, { idGenerator: () => { - return `custom-${ - new Date().getTime() - }-${ - Math.random().toString().slice(2) - }` + return `custom-${new Date().getTime() + }-${Math.random().toString().slice(2) + }` }, - ...DEFAULT_OPTIONS + ...DEFAULT_COOKIE_OPTIONS }) t.teardown(() => fastify.close()) @@ -130,12 +127,10 @@ test('should keep user data in session throughout the time', async (t) => { t.plan(3) const fastify = Fastify() - const options = { - secret: DEFAULT_SECRET, + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) + fastify.register(fastifySession, { cookie: { secure: false } - } - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) + }) fastify.get('/', (request, reply) => { request.session.foo = 'bar' reply.send(200) @@ -165,13 +160,11 @@ test('should generate new sessionId', async (t) => { t.plan(3) const fastify = Fastify() - const options = { - secret: DEFAULT_SECRET, - cookie: { secure: false } - } let oldSessionId - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) + fastify.register(fastifySession, { + cookie: { secure: false } + }) fastify.get('/', (request, reply) => { oldSessionId = request.session.sessionId request.session.regenerate(error => { @@ -207,9 +200,8 @@ test('should decorate the server with decryptSession', async t => { t.plan(2) const fastify = Fastify() - const options = { secret: DEFAULT_SECRET } - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) + fastify.register(fastifySession) t.teardown(() => fastify.close()) t.ok(await fastify.ready()) @@ -220,12 +212,8 @@ test('should decryptSession with custom request object', async (t) => { t.plan(4) const fastify = Fastify() - const options = { - secret: DEFAULT_SECRET - } - - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) + fastify.register(fastifySession) fastify.addHook('onRequest', (request, reply, done) => { request.sessionStore.set(DEFAULT_SESSION_ID, { testData: 'this is a test', @@ -246,7 +234,10 @@ test('should decryptSession with custom request object', async (t) => { t.equal(response.statusCode, 200) const { sessionId } = fastify.parseCookie(DEFAULT_COOKIE) - const requestObj = {} + const requestObj = { + signCookie: fastify.signCookie, + unsignCookie: fastify.unsignCookie + } fastify.decryptSession(sessionId, requestObj, () => { t.equal(requestObj.session.cookie.maxAge, null) t.equal(requestObj.session.sessionId, DEFAULT_SESSION_ID) @@ -258,12 +249,8 @@ test('should decryptSession with custom cookie options', async (t) => { t.plan(2) const fastify = Fastify() - const options = { - secret: DEFAULT_SECRET - } - - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) + fastify.register(fastifySession) fastify.get('/', (request, reply) => { reply.send(200) @@ -277,7 +264,10 @@ test('should decryptSession with custom cookie options', async (t) => { t.equal(response.statusCode, 200) const { sessionId } = fastify.parseCookie(DEFAULT_COOKIE) - const requestObj = {} + const requestObj = { + signCookie: fastify.signCookie, + unsignCookie: fastify.unsignCookie + } fastify.decryptSession(sessionId, requestObj, { maxAge: 86400 }, () => { t.equal(requestObj.session.cookie.maxAge, 86400) }) @@ -294,14 +284,12 @@ test('should bubble up errors with destroy call if session expired', async (t) = destroy (id, cb) { cb(new Error('No can do')) } } - const options = { - secret: DEFAULT_SECRET, + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) + fastify.register(fastifySession, { store, cookie: { secure: false } } - - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) + ) fastify.get('/', (request, reply) => { reply.send(200) @@ -322,13 +310,11 @@ test('should not reset session cookie expiration if rolling is false', async (t) const fastify = Fastify() - const options = { - secret: DEFAULT_SECRET, + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) + fastify.register(fastifySession, { rolling: false, cookie: { secure: false, maxAge: 10000 } - } - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) + }) fastify.addHook('onRequest', (request, reply, done) => { reply.send(request.session.expires) done() @@ -358,13 +344,11 @@ test('should update the expires property of the session using Session#touch() ev const fastify = Fastify() - const options = { - secret: DEFAULT_SECRET, + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) + fastify.register(fastifySession, { rolling: false, cookie: { secure: false, maxAge: 10000 } - } - fastify.register(fastifyCookie) - fastify.register(fastifySession, options) + }) fastify.addHook('onRequest', (request, reply, done) => { request.session.touch() reply.send(request.session.cookie.expires) @@ -394,9 +378,8 @@ test('should update the expires property of the session using Session#touch() ev test('should use custom sessionId generator if available (with request)', async (t) => { const fastify = Fastify() - fastify.register(fastifyCookie) + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) fastify.register(fastifySession, { - secret: DEFAULT_SECRET, cookie: { secure: false, maxAge: 10000 }, idGenerator: (request) => { if (request.session?.returningVisitor) return `returningVisitor-${new Date().getTime()}` @@ -445,24 +428,19 @@ test('should use custom sessionId generator if available (with request)', async test('should use custom sessionId generator if available (with request and rolling false)', async (t) => { const fastify = Fastify() - fastify.register(fastifyCookie) + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) fastify.register(fastifySession, { - secret: DEFAULT_SECRET, rolling: false, cookie: { secure: false, maxAge: 10000 }, idGenerator: (request) => { if (request.session?.returningVisitor) { - return `returningVisitor-${ - new Date().getTime() - }-${ - Math.random().toString().slice(2) - }` + return `returningVisitor-${new Date().getTime() + }-${Math.random().toString().slice(2) + }` } - return `custom-${ - new Date().getTime() - }-${ - Math.random().toString().slice(2) - }` + return `custom-${new Date().getTime() + }-${Math.random().toString().slice(2) + }` } }) t.teardown(() => fastify.close()) @@ -518,7 +496,7 @@ test('should reload the session', async (t) => { reply.send(200) }) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -548,7 +526,7 @@ test('should save the session', async (t) => { reply.send(200) }) }) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -564,7 +542,7 @@ test('destroy supports promises', async t => { await t.resolves(request.session.destroy()) reply.send(200) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -581,7 +559,7 @@ test('destroy supports rejecting promises', async t => { reply.send(200) }, { - ...DEFAULT_OPTIONS, + ...DEFAULT_COOKIE_OPTIONS, store: { set (id, data, cb) { cb(null) }, get (id, cb) { cb(null) }, @@ -604,7 +582,7 @@ test('regenerate supports promises', async t => { await t.resolves(request.session.regenerate()) reply.send(200) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -621,7 +599,7 @@ test('regenerate supports rejecting promises', async t => { reply.send(200) }, { - ...DEFAULT_OPTIONS, + ...DEFAULT_COOKIE_OPTIONS, store: { set (id, data, cb) { cb(new Error('no can do')) }, get (id, cb) { cb(null) }, @@ -644,7 +622,7 @@ test('reload supports promises', async t => { await t.resolves(request.session.reload()) reply.send(200) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -661,7 +639,7 @@ test('reload supports rejecting promises', async t => { reply.send(200) }, { - ...DEFAULT_OPTIONS, + ...DEFAULT_COOKIE_OPTIONS, store: { set (id, data, cb) { cb(null) }, get (id, cb) { cb(new Error('no can do')) }, @@ -684,7 +662,7 @@ test('save supports promises', async t => { await t.resolves(request.session.save()) reply.send(200) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -701,7 +679,7 @@ test('save supports rejecting promises', async t => { reply.send(200) }, { - ...DEFAULT_OPTIONS, + ...DEFAULT_COOKIE_OPTIONS, store: { set (id, data, cb) { cb(new Error('no can do')) }, get (id, cb) { cb(null) }, @@ -722,7 +700,7 @@ test("clears cookie if not backed by a session, and there's nothing to save", as t.plan(2) const fastify = await buildFastify((request, reply) => { reply.send(200) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -738,7 +716,7 @@ test('does not clear cookie if no session cookie in request', async t => { t.plan(2) const fastify = await buildFastify((request, reply) => { reply.send(200) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -756,10 +734,9 @@ test('only save session when it changes', async t => { const store = new Map() const fastify = Fastify() - fastify.register(fastifyCookie) + fastify.register(fastifyCookie, DEFAULT_COOKIE_OPTIONS) fastify.register(fastifySession, { - ...DEFAULT_OPTIONS, saveUninitialized: false, cookie: { secure: false }, store: { @@ -805,8 +782,6 @@ test('when unsignSignedCookie is true sessions should still be managed correctly const store = new Map() const cookieSignKey = 'some-key' const options = { - ...DEFAULT_OPTIONS, - unsignSignedCookie: true, cookie: { secure: false, signed: false }, store: { set (id, data, cb) { @@ -821,51 +796,42 @@ test('when unsignSignedCookie is true sessions should still be managed correctly } } - const runTestScenario = async (cookieSigned) => { - options.cookie.signed = cookieSigned - - let encryptedSessionId = null - - const fastify = Fastify() - fastify.register(fastifyCookie, { secret: cookieSignKey }) - fastify.register(fastifySession, options) - fastify.get('/', (request, reply) => { - encryptedSessionId = encryptedSessionId || request.session.encryptedSessionId - reply.send(200) - }) + let encryptedSessionId = null - const { - statusCode: statusCode1, - headers: { - 'set-cookie': cookie1 - } - } = await fastify.inject('/') - t.ok(cookie1) - t.equal(statusCode1, 200) - - const { sessionId: sessionId1 } = fastify.parseCookie(cookie1) - t.equal(sessionId1, encryptedSessionId) - - const sessionId = cookieSigned - ? cookieSignature.sign(sessionId1, cookieSignKey) - : sessionId1 - const cookie = `sessionId=${sessionId};` - const { - statusCode: statusCode2, - headers: { - 'set-cookie': cookie2 - } - } = await fastify.inject({ - path: '/', - headers: { cookie } - }) - t.equal(statusCode2, 200) - t.ok(cookie2) + const fastify = Fastify() + fastify.register(fastifyCookie, { secret: cookieSignKey }) + fastify.register(fastifySession, options) + fastify.get('/', (request, reply) => { + encryptedSessionId = encryptedSessionId || request.session.encryptedSessionId + reply.send(200) + }) - const { sessionId: sessionId2 } = fastify.parseCookie(cookie2) - t.equal(sessionId2, encryptedSessionId) - } + const { + statusCode: statusCode1, + headers: { + 'set-cookie': cookie1 + } + } = await fastify.inject('/') + t.ok(cookie1) + t.equal(statusCode1, 200) + + const { sessionId: sessionId1 } = fastify.parseCookie(cookie1) + t.equal(sessionId1, encryptedSessionId) + + const sessionId = sessionId1 + const cookie = `sessionId=${sessionId};` + const { + statusCode: statusCode2, + headers: { + 'set-cookie': cookie2 + } + } = await fastify.inject({ + path: '/', + headers: { cookie } + }) + t.equal(statusCode2, 200) + t.ok(cookie2) - await runTestScenario(false) - await runTestScenario(true) + const { sessionId: sessionId2 } = fastify.parseCookie(cookie2) + t.equal(sessionId2, encryptedSessionId) }) diff --git a/test/store.test.js b/test/store.test.js index 97f517a..8f6c911 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -2,7 +2,7 @@ const test = require('tap').test const fastifyPlugin = require('fastify-plugin') -const { buildFastify, DEFAULT_OPTIONS, DEFAULT_COOKIE, DEFAULT_SECRET, DEFAULT_SESSION_ID } = require('./util') +const { buildFastify, DEFAULT_COOKIE_OPTIONS, DEFAULT_COOKIE, DEFAULT_SECRET, DEFAULT_SESSION_ID } = require('./util') const { Store } = require('..') test('should decorate request with sessionStore', async (t) => { @@ -11,7 +11,7 @@ test('should decorate request with sessionStore', async (t) => { const fastify = await buildFastify((request, reply) => { t.ok(request.sessionStore) reply.send(200) - }, DEFAULT_OPTIONS) + }, DEFAULT_COOKIE_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ diff --git a/test/util.js b/test/util.js index ae53565..c857ef7 100644 --- a/test/util.js +++ b/test/util.js @@ -5,19 +5,23 @@ const fastifyCookie = require('@fastify/cookie') const fastifySession = require('../lib/fastifySession') const DEFAULT_SECRET = 'cNaoPYAwF60HZJzkcNaoPYAwF60HZJzk' -const DEFAULT_OPTIONS = { secret: DEFAULT_SECRET } +const DEFAULT_COOKIE_OPTIONS = { secret: DEFAULT_SECRET } const DEFAULT_SESSION_ID = 'Qk_XT2K7-clT-x1tVvoY6tIQ83iP72KN' const DEFAULT_ENCRYPTED_SESSION_ID = `${DEFAULT_SESSION_ID}.B7fUDYXU9fXF9pNuL3qm4NVmSduLJ6kzCOPh5JhHGoE` const DEFAULT_COOKIE_VALUE = `sessionId=${DEFAULT_ENCRYPTED_SESSION_ID};` const DEFAULT_COOKIE = `${DEFAULT_COOKIE_VALUE}; Path=/; HttpOnly; Secure` -async function buildFastify (handler, sessionOptions, plugin) { +async function buildFastify (handler, options, plugin) { + const { + secret, + ...fastifySessionOpts + } = options const fastify = Fastify() - await fastify.register(fastifyCookie) + await fastify.register(fastifyCookie, { secret }) if (plugin) { await fastify.register(plugin) } - await fastify.register(fastifySession, sessionOptions) + await fastify.register(fastifySession, fastifySessionOpts) fastify.get('/', handler) await fastify.listen({ port: 0 }) @@ -27,7 +31,7 @@ async function buildFastify (handler, sessionOptions, plugin) { module.exports = { buildFastify, DEFAULT_SECRET, - DEFAULT_OPTIONS, + DEFAULT_COOKIE_OPTIONS, DEFAULT_SESSION_ID, DEFAULT_ENCRYPTED_SESSION_ID, DEFAULT_COOKIE_VALUE, diff --git a/types/types.d.ts b/types/types.d.ts index 99c84c7..3641dc9 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -73,29 +73,9 @@ declare namespace FastifySessionPlugin { } interface Options { - /** - * The secret used to sign the cookie. - * - * Must be an array of strings, or a string with length 32 or greater. If an array, the first secret is used to - * sign new cookies, and is the first one to be checked for incoming cookies. - * Further secrets in the array are used to check incoming cookies, in the order specified. - * - * Note that the array may be manipulated by the rest of the application during its life cycle. - * This can be done by storing the array in a separate variable that is later manipulated with mutating methods - * like unshift(), pop(), splice(), etc. - * This can be used to rotate the signing secret at regular intervals. - * A secret should remain somewhere in the array as long as there are active sessions with cookies signed by it. - * Secrets management is left up to the rest of the application. - */ - secret: string | string[]; - /** The name of the session cookie. Defaults to `sessionId`. */ cookieName?: string; - /** If the cookie plugin is already signing the cookie this must be enabled. - * Otherwise it has no effect on the request whatsoever */ - unsignSignedCookie?: boolean; - /** * The options object used to generate the `Set-Cookie` header of the session cookie. * diff --git a/types/types.test-d.ts b/types/types.test-d.ts index ca80d0c..970f2ec 100644 --- a/types/types.test-d.ts +++ b/types/types.test-d.ts @@ -29,39 +29,29 @@ const secret = 'ABCDEFGHIJKLNMOPQRSTUVWXYZ012345'; const app: FastifyInstance = fastify(); app.register(plugin); -app.register(plugin, { secret: 'DizIzSecret' }); -app.register(plugin, { secret: 'DizIzSecret', rolling: true }); +app.register(plugin, { rolling: true }); app.register(plugin, { - secret, rolling: false, cookie: { secure: false } }); app.register(plugin, { - secret, cookie: { secure: false } }); app.register(plugin, { - secret, store: new EmptyStore() }); app.register(plugin, { - secret, idGenerator: () => Date.now() + '' }); app.register(plugin, { - secret, - unsignSignedCookie: true -}); -app.register(plugin, { - secret, idGenerator: (request) => `${request == undefined ? 'null' : request.ip}-${Date.now()}` }); -expectError(app.register(plugin, {})); +app.register(plugin, {}) expectError(app.register(plugin, { secret, unsignSignedCookie: 'not-a-boolean'