Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,31 @@ Setting this to `false` can save storage space and comply with the EU cookie law
##### rolling (optional)
Forces the session identifier cookie to be set on every response. The expiration is reset to the original maxAge - effectively resetting the cookie lifetime. This is typically used in conjunction with short, non-session-length maxAge values to provide a quick expiration of the session data with reduced potential of session expiration occurring during ongoing server interactions. Defaults to true.

##### hooks (optional)
Lifecycle hooks can be used to connect session events to application logging, metrics, or tracing systems. Hooks may be synchronous or asynchronous. Errors thrown by a hook are passed to `onError` and do not interrupt the request.

```js
app.register(fastifySession, {
secret: process.env.SESSION_SECRET,
hooks: {
onCreate (session, request) {
request.log.info({ sessionId: session.sessionId }, 'session created')
},
onLoad (session, request) {
request.log.debug({ sessionId: session.sessionId }, 'session loaded')
},
onSave (session) {
metrics.increment('session.saved')
},
onError (error, context, request) {
request.log.error({ error, context }, 'session lifecycle error')
}
}
})
```

The available hooks are `onCreate`, `onLoad`, `onLoadMiss`, `onSave`, `onDestroy`, `onRegenerate`, `onExpire`, `onCookieSkipped`, and `onError`.

##### idGenerator(request) (optional)

Function used to generate new session IDs.
Expand Down
94 changes: 63 additions & 31 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ const idGenerator = require('./lib/idGenerator')()
const Store = require('./lib/store')
const Session = require('./lib/session')

function createHookEmitter (hooks = {}) {
function reportError (error, context, request) {
if (typeof hooks.onError !== 'function') return

try {
Promise.resolve(hooks.onError(error, context, request)).catch(() => {})
} catch {}
}

function emit (name, args, context, request) {
const hook = hooks[name]
if (typeof hook !== 'function') return

try {
Promise.resolve(hook(...args)).catch(error => {
reportError(error, { ...context, operation: `hook:${name}` }, request)
})
} catch (error) {
reportError(error, { ...context, operation: `hook:${name}` }, request)
}
}

return { emit, reportError }
}

function fastifySession (fastify, options, next) {
const error = checkOptions(options)
if (error) {
Expand All @@ -17,6 +42,7 @@ function fastifySession (fastify, options, next) {
const cookieSigner = options.signer
const cookieName = options.cookieName
const cookiePrefix = options.cookiePrefix
const hookEmitter = createHookEmitter(options.hooks)
const hasCookiePrefix = typeof cookiePrefix === 'string' && cookiePrefix.length !== 0
const cookiePrefixLength = hasCookiePrefix && cookiePrefix.length

Expand All @@ -36,48 +62,48 @@ function fastifySession (fastify, options, next) {
fastify.addHook('onSend', onSend(options))
next()

function createSession (request, options) {
const session = new Session(
sessionStore,
request,
options.idGenerator,
options.cookie,
cookieSigner,
{ hookEmitter }
)
request.session = session
hookEmitter.emit('onCreate', [session, request], { operation: 'create' }, request)
return session
}

function decryptSession (sessionId, options, request, done) {
const cookieOpts = options.cookie
const idGenerator = options.idGenerator

const unsignedCookie = cookieSigner.unsign(sessionId)
if (unsignedCookie.valid === false) {
request.session = new Session(
sessionStore,
request,
idGenerator,
cookieOpts,
cookieSigner
)
hookEmitter.emit('onLoadMiss', [sessionId, request], { operation: 'load' }, request)
createSession(request, options)
done()
return
}
const decryptedSessionId = unsignedCookie.value
sessionStore.get(decryptedSessionId, (err, session) => {
if (err) {
if (err.code === 'ENOENT') {
request.session = new Session(
sessionStore,
request,
idGenerator,
cookieOpts,
cookieSigner
)
hookEmitter.emit('onLoadMiss', [decryptedSessionId, request], { operation: 'load', sessionId: decryptedSessionId }, request)
createSession(request, options)
done()
} else {
hookEmitter.reportError(err, { operation: 'load', sessionId: decryptedSessionId }, request)
done(err)
}
return
}

if (!session) {
request.session = new Session(
sessionStore,
request,
idGenerator,
cookieOpts,
cookieSigner
)
hookEmitter.emit('onLoadMiss', [decryptedSessionId, request], { operation: 'load', sessionId: decryptedSessionId }, request)
createSession(request, options)
done()
return
}
Expand All @@ -88,13 +114,17 @@ function fastifySession (fastify, options, next) {
idGenerator,
cookieOpts,
cookieSigner,
session,
decryptedSessionId
{
prevSession: session,
sessionId: decryptedSessionId,
hookEmitter
}
)

const expiration = restoredSession.cookie.originalExpires || restoredSession.cookie.expires

if (expiration && expiration.getTime() <= Date.now()) {
hookEmitter.emit('onExpire', [decryptedSessionId, request], { operation: 'expire', sessionId: decryptedSessionId }, request)
restoredSession.destroy(err => {
if (err) {
done(err)
Expand All @@ -107,6 +137,7 @@ function fastifySession (fastify, options, next) {
}

request.session = restoredSession
hookEmitter.emit('onLoad', [restoredSession, request], { operation: 'load', sessionId: decryptedSessionId }, request)
done()
})
}
Expand All @@ -125,7 +156,6 @@ function fastifySession (fastify, options, next) {

function onRequest (options) {
const cookieOpts = options.cookie
const idGenerator = options.idGenerator

return function handleSession (request, _reply, done) {
request.session = {}
Expand All @@ -138,13 +168,7 @@ function fastifySession (fastify, options, next) {

const cookieSessionId = getCookieSessionId(request)
if (!cookieSessionId) {
request.session = new Session(
sessionStore,
request,
idGenerator,
cookieOpts,
cookieSigner
)
createSession(request, options)
done()
} else {
decryptSession(cookieSessionId, options, request, done)
Expand Down Expand Up @@ -181,6 +205,13 @@ function fastifySession (fastify, options, next) {
// we need to remove extra properties to align the same with `express-session`
session.cookie.toJSON()
)
} else {
hookEmitter.emit(
'onCookieSkipped',
[isInsecureConnection ? 'insecure-connection' : 'not-needed', request],
{ operation: 'cookie' },
request
)
}

done()
Expand Down Expand Up @@ -231,6 +262,7 @@ function fastifySession (fastify, options, next) {
? new (require('@fastify/cookie').Signer)(options.secret, opts.algorithm)
: options.secret
opts.cookiePrefix = option(options, 'cookiePrefix', '')
opts.hooks = options.hooks || {}
return opts
}

Expand Down
76 changes: 69 additions & 7 deletions lib/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const sessionIdKey = Symbol('sessionId')
const sessionStoreKey = Symbol('sessionStore')
const encryptedSessionIdKey = Symbol('encryptedSessionId')
const savedKey = Symbol('saved')
const hookEmitterKey = Symbol('hookEmitter')

module.exports = class Session {
constructor (
Expand All @@ -25,8 +26,11 @@ module.exports = class Session {
idGenerator,
cookieOpts,
cookieSigner,
prevSession,
sessionId = idGenerator(request)
{
prevSession,
sessionId = idGenerator(request),
hookEmitter
} = {}
) {
const previousCookie = prevSession?.cookie && typeof prevSession.cookie.toJSON === 'function'
? prevSession.cookie.toJSON()
Expand Down Expand Up @@ -56,6 +60,7 @@ module.exports = class Session {
this[cookieOptsKey] = sessionCookieOpts
this[cookieSignerKey] = cookieSigner
this[requestKey] = request
this[hookEmitterKey] = hookEmitter
this[sessionIdKey] = sessionId
this[encryptedSessionIdKey] = (
prevSession &&
Expand Down Expand Up @@ -102,7 +107,8 @@ module.exports = class Session {
this[requestKey],
this[generateId],
this[cookieOptsKey],
this[cookieSignerKey]
this[cookieSignerKey],
{ hookEmitter: this[hookEmitterKey] }
)

if (Array.isArray(keys)) {
Expand All @@ -115,6 +121,17 @@ module.exports = class Session {
this[sessionStoreKey].set(session.sessionId, session, error => {
this[requestKey].session = session

if (!error) {
this[hookEmitterKey]?.emit(
'onRegenerate',
[this[sessionIdKey], session.sessionId, this[requestKey]],
{ operation: 'regenerate', sessionId: this[sessionIdKey] },
this[requestKey]
)
} else {
this[hookEmitterKey]?.reportError(error, { operation: 'regenerate', sessionId: this[sessionIdKey] }, this[requestKey])
}

callback(error)
})
} else {
Expand All @@ -123,8 +140,15 @@ module.exports = class Session {
this[requestKey].session = session

if (error) {
this[hookEmitterKey]?.reportError(error, { operation: 'regenerate', sessionId: this[sessionIdKey] }, this[requestKey])
reject(error)
} else {
this[hookEmitterKey]?.emit(
'onRegenerate',
[this[sessionIdKey], session.sessionId, this[requestKey]],
{ operation: 'regenerate', sessionId: this[sessionIdKey] },
this[requestKey]
)
resolve()
}
})
Expand All @@ -145,6 +169,17 @@ module.exports = class Session {
this[sessionStoreKey].destroy(this[sessionIdKey], error => {
this[requestKey].session = null

if (error) {
this[hookEmitterKey]?.reportError(error, { operation: 'destroy', sessionId: this[sessionIdKey] }, this[requestKey])
} else {
this[hookEmitterKey]?.emit(
'onDestroy',
[this[sessionIdKey], this[requestKey]],
{ operation: 'destroy', sessionId: this[sessionIdKey] },
this[requestKey]
)
}

callback(error)
})
} else {
Expand All @@ -153,8 +188,15 @@ module.exports = class Session {
this[requestKey].session = null

if (error) {
this[hookEmitterKey]?.reportError(error, { operation: 'destroy', sessionId: this[sessionIdKey] }, this[requestKey])
reject(error)
} else {
this[hookEmitterKey]?.emit(
'onDestroy',
[this[sessionIdKey], this[requestKey]],
{ operation: 'destroy', sessionId: this[sessionIdKey] },
this[requestKey]
)
resolve()
}
})
Expand All @@ -170,8 +212,11 @@ module.exports = class Session {
this[generateId],
this[cookieOptsKey],
this[cookieSignerKey],
session,
this[sessionIdKey]
{
prevSession: session,
sessionId: this[sessionIdKey],
hookEmitter: this[hookEmitterKey]
}
)

callback(error)
Expand All @@ -185,8 +230,11 @@ module.exports = class Session {
this[generateId],
this[cookieOptsKey],
this[cookieSignerKey],
session,
this[sessionIdKey]
{
prevSession: session,
sessionId: this[sessionIdKey],
hookEmitter: this[hookEmitterKey]
}
)

if (error) {
Expand All @@ -203,21 +251,35 @@ module.exports = class Session {
if (callback) {
this[sessionStoreKey].set(this[sessionIdKey], this, error => {
if (error) {
this[hookEmitterKey]?.reportError(error, { operation: 'save', sessionId: this[sessionIdKey] }, this[requestKey])
callback(error)
} else {
this[savedKey] = true
this[persistedHash] = this[hash]()
this[hookEmitterKey]?.emit(
'onSave',
[this, this[requestKey]],
{ operation: 'save', sessionId: this[sessionIdKey] },
this[requestKey]
)
callback()
}
})
} else {
return new Promise((resolve, reject) => {
this[sessionStoreKey].set(this[sessionIdKey], this, error => {
if (error) {
this[hookEmitterKey]?.reportError(error, { operation: 'save', sessionId: this[sessionIdKey] }, this[requestKey])
reject(error)
} else {
this[savedKey] = true
this[persistedHash] = this[hash]()
this[hookEmitterKey]?.emit(
'onSave',
[this, this[requestKey]],
{ operation: 'save', sessionId: this[sessionIdKey] },
this[requestKey]
)
resolve()
}
})
Expand Down
Loading
Loading