UNPKG

@mgcrea/fastify-session

Version:

Session plugin for fastify written in TypeScript supporting both stateless and stateful sessions

508 lines (495 loc) 16 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var src_exports = {}; __export(src_exports, { CRYPTO_SPLIT_CHAR: () => CRYPTO_SPLIT_CHAR, ErrorWithCode: () => ErrorWithCode, HMAC: () => HMAC, Hmac: () => Hmac, MemoryStore: () => MemoryStore, Session: () => Session, SessionCrypto: () => SessionCrypto, SessionStore: () => SessionStore, createError: () => createError, default: () => src_default }); module.exports = __toCommonJS(src_exports); var import_fastify_plugin = __toESM(require("fastify-plugin"), 1); // src/crypto/Hmac.ts var import_crypto = __toESM(require("crypto"), 1); // src/utils/buffer.ts var asBuffer = (maybeBuffer, encoding = "ascii") => Buffer.isBuffer(maybeBuffer) ? maybeBuffer : Buffer.from(maybeBuffer, encoding); // src/utils/crypto.ts var CRYPTO_SPLIT_CHAR = "."; // src/utils/error.ts var ErrorWithCode = class extends Error { code; constructor(code, message) { super(`${code}: ${message}`); Object.setPrototypeOf(this, new.target.prototype); this.code = code; } }; var createError = (code, message) => new ErrorWithCode(code, message); // src/crypto/Hmac.ts var Hmac = class { protocol = "/hmac"; stateless = false; algorithm; encoding; constructor(encoding = "base64", algorithm = "sha256") { this.encoding = encoding; this.algorithm = algorithm; } deriveSecretKeys(key, secret) { if (key) { return sanitizeSecretKeys(key, this.encoding); } else if (secret) { return [asBuffer(secret)]; } throw createError("SecretKeyDerivation", "Failed to derive keys from options"); } sealMessage(message, secretKey) { return message.toString(this.encoding) + CRYPTO_SPLIT_CHAR + import_crypto.default.createHmac(this.algorithm, secretKey).update(message).digest(this.encoding).replace(/=+$/, ""); } unsealMessage(message, secretKeys) { const splitCharIndex = message.lastIndexOf(CRYPTO_SPLIT_CHAR); if (splitCharIndex === -1) { throw createError("MalformedMessageError", "The message is malformed"); } const cleartext = Buffer.from(message.slice(0, splitCharIndex), this.encoding); let rotated = false; const success = secretKeys.some((secretKey, index) => { const signedBuffer = Buffer.from(this.sealMessage(cleartext, secretKey)); const messageBuffer = Buffer.alloc(signedBuffer.length); messageBuffer.write(message); const verified = import_crypto.default.timingSafeEqual(signedBuffer, messageBuffer); rotated = verified && index > 0; return verified; }); if (!success) { throw createError("VerifyError", "Unable to verify"); } return { buffer: cleartext, rotated }; } }; var HMAC = new Hmac(); var sanitizeSecretKeys = (key, encoding = "base64") => { const secretKeys = Array.isArray(key) ? key.map((v) => asBuffer(v, encoding)) : [asBuffer(key, encoding)]; return secretKeys; }; // src/crypto/SessionCrypto.ts var SessionCrypto = class { }; // src/session/Session.ts var import_nanoid = require("nanoid"); var import_node_assert = __toESM(require("assert"), 1); // src/store/MemoryStore.ts var import_events = require("events"); var MemoryStore = class extends import_events.EventEmitter { store; prefix; constructor({ store = /* @__PURE__ */ new Map(), prefix = "sess:" } = {}) { super(); this.store = store; this.prefix = prefix; } getKey(sessionId) { return `${this.prefix}${sessionId}`; } async set(sessionId, session, expiry = null) { this.store.set(this.getKey(sessionId), [session, expiry]); } async get(sessionId) { const result = this.store.get(this.getKey(sessionId)) ?? null; if (!result) { return null; } const [session, expiry] = result; if (expiry && expiry <= Date.now()) { return null; } return [session, expiry]; } async destroy(sessionId) { this.store.delete(this.getKey(sessionId)); } async touch(sessionId, expiry = null) { const sessionData = await this.get(sessionId); if (!sessionData) { return; } const [session] = sessionData; await this.set(sessionId, session, expiry); } async all() { return [...this.store.entries()].reduce((soFar, [k, v]) => { soFar[k] = v[0]; return soFar; }, {}); } }; var MEMORY_STORE = new MemoryStore(); // src/store/SessionStore.ts var SessionStore = class { }; // src/session/Session.ts var kSessionData = Symbol("kSessionData"); var kCookieOptions = Symbol("kCookieOptions"); var Session = class _Session { // Session metadata id; created = false; rotated = false; changed = false; deleted = false; saved = false; skipped = false; // Private instance fields #sessionData; #cookieOptions; #expiry = null; // expiration timestamp in ms // Private static fields static #secretKeys; static #sessionCrypto; static #sessionStore; static #globalCookieOptions; static #configured = false; /** * This method is used to setup the Session class with global options. */ static configure({ secretKeys, crypto: crypto2 = HMAC, store = MEMORY_STORE, cookieOptions = {} }) { _Session.#secretKeys = secretKeys; _Session.#sessionCrypto = crypto2; _Session.#sessionStore = store; _Session.#globalCookieOptions = cookieOptions; _Session.#configured = true; } /** * Private constructor - instances should be created via the static `create` method */ constructor(data, options = {}) { const { id = (0, import_nanoid.nanoid)(), ...cookieOptions } = options; this.#sessionData = data ?? {}; this.#cookieOptions = { ..._Session.#globalCookieOptions, ...cookieOptions }; this.id = id; this.created = !data; } /** * Create a new session instance. * Checks if the class is configured before creating a session. */ static async create(data, options = {}) { if (!_Session.#configured) { throw createError( "MissingConfiguration", "Session is not configured. Please call Session.configure before creating a Session instance." ); } const session = new _Session(data, options); await session.touch(); return session; } /** * Decoding */ /** * Create a Session from a serialized cookie. * Determines the type of the session (stateful/stateless) and delegates to the appropriate method. */ static async fromCookie(cookie) { const { buffer: cleartext, rotated } = _Session.#sessionCrypto.unsealMessage(cookie, _Session.#secretKeys); if (_Session.#sessionCrypto.stateless) { return await _Session.fromStatelessCookie(cleartext.toString(), rotated); } return await _Session.fromStatefulCookie(cleartext.toString(), rotated); } /** * Create a Session from a stateless cookie. The session data is all stored in the cookie. */ static async fromStatelessCookie(payload, rotated) { let data; try { data = JSON.parse(payload); } catch (error) { throw createError( "InvalidData", `Failed to parse session data from cookie. Original error: ${String(error)}` ); } const session = await _Session.create(data, { id: data.id }); session.rotated = rotated; return session; } /** * Create a Session from a stateful cookie. The cookie only contains the session id. * The rest of the session data is retrieved from the session store. */ static async fromStatefulCookie(sessionId, rotated) { (0, import_node_assert.default)(_Session.#sessionStore); const result = await _Session.#sessionStore.get(sessionId); if (!result) { throw createError("SessionNotFound", "did not found a matching session in the store"); } const [data, expiry] = result; if (expiry && expiry <= Date.now()) { throw createError("ExpiredSession", "the store returned an expired session"); } const session = await _Session.create(data, { id: sessionId, expires: expiry ? new Date(expiry) : void 0 }); session.rotated = rotated; return session; } /** * Encoding */ /** * Serialize the Session into a cookie. * The format of the cookie depends on whether the session is stateful or stateless. */ // eslint-disable-next-line @typescript-eslint/require-await async toCookie() { const buffer = Buffer.from( _Session.#sessionCrypto.stateless ? JSON.stringify({ ...this.#sessionData, id: this.id }) : this.id ); if (!_Session.#secretKeys[0]) { throw createError("MissingSecretKey", "Missing secret key for session encryption"); } return _Session.#sessionCrypto.sealMessage(buffer, _Session.#secretKeys[0]); } /** * Refresh the expiration time of the session. Only applicable for stateful sessions. */ async touch() { if (_Session.#sessionCrypto.stateless) { return; } const { maxAge = _Session.#globalCookieOptions.maxAge, expires = _Session.#globalCookieOptions.expires } = this.#cookieOptions; if (maxAge) { const expiry = Date.now() + maxAge * 1e3; this.#expiry = expires ? Math.max(expires.getTime(), expiry) : expiry; } else if (expires) { this.#expiry = expires.getTime(); } (0, import_node_assert.default)(_Session.#sessionStore); if (!this.created && _Session.#sessionStore.touch) { await _Session.#sessionStore.touch(this.id, this.#expiry); } } /** * Delete the session. */ async destroy() { this.deleted = true; if (_Session.#sessionCrypto.stateless) { return; } if (this.created && !this.saved) { return; } (0, import_node_assert.default)(_Session.#sessionStore); await _Session.#sessionStore.destroy(this.id); } /** * Save the session data to the session store. Only applicable for stateful sessions. */ async save() { if (_Session.#sessionCrypto.stateless) { return; } this.saved = true; (0, import_node_assert.default)(_Session.#sessionStore); await _Session.#sessionStore.set(this.id, this.#sessionData, this.#expiry); } get data() { return this.#sessionData; } get expiry() { return this.#expiry; } /** * Get a value from the session data. */ get(key) { return this.#sessionData[key]; } set(key, value) { this.#sessionData[key] = value; this.changed = true; } get options() { return this.#cookieOptions; } /** * Update the session cookie options. */ setOptions(options) { Object.assign(this.#cookieOptions, options); } /** * Check if the session data is empty. */ isEmpty() { return Object.keys(this.#sessionData).length === 0; } }; // src/plugin.ts var DEFAULT_COOKIE_NAME = "Session"; var DEFAULT_COOKIE_PATH = "/"; var plugin = async (fastify, options = {}) => { const { key, secret, salt, cookieName = DEFAULT_COOKIE_NAME, cookie: cookieOptions = {}, store, crypto: crypto2 = HMAC, saveUninitialized = true, logBindings = { plugin: "fastify-session" } } = options; if (!key && !secret) { throw new Error("key or secret must specified"); } if (!crypto2) { throw new Error("invalid crypto specified"); } if (!cookieOptions.path) { cookieOptions.path = DEFAULT_COOKIE_PATH; } const secretKeys = crypto2.deriveSecretKeys(key, secret, salt); Session.configure({ cookieOptions, secretKeys, store, crypto: crypto2 }); fastify.decorateRequest("session", null); async function destroySession() { if (!this.session) { return; } await this.session.destroy(); } fastify.decorateRequest("destroySession", destroySession); fastify.addHook("onRequest", async (request) => { const { cookies, log } = request; const bindings = { ...logBindings, hook: "onRequest" }; const cookie = cookies[cookieName]; if (!cookie) { request.session = await Session.create(); log.debug( { ...bindings, sessionId: request.session.id }, "There was no cookie, created an empty session" ); return; } try { log.debug(bindings, "Found an existing cookie, attempting to decode session ..."); request.session = await Session.fromCookie(cookie); log.debug({ ...bindings, sessionId: request.session.id }, "Session successfully decoded"); return; } catch (err) { request.session = await Session.create(); log.warn( { ...bindings, err, sessionId: request.session.id }, `Failed to decode existing cookie, created an empty session` ); return; } }); fastify.addHook("onSend", async (request, reply) => { const { session, log } = request; const bindings = { ...logBindings, hook: "onSend" }; if (!session) { log.debug(bindings, "There was no session, leaving it as is"); return; } else if (session.deleted) { reply.setCookie(cookieName, "", { ...session.options, expires: /* @__PURE__ */ new Date(0), maxAge: 0 }); log.debug( { ...bindings, sessionId: session.id }, `Deleted ${session.created ? "newly created" : "existing"} session` ); return; } else if (!saveUninitialized && session.isEmpty()) { log.debug( { ...bindings, sessionId: session.id }, "Created session is empty and won't be saved, leaving it as is" ); return; } else if (!session.changed && !session.created && !session.rotated) { log.debug( { ...bindings, sessionId: session.id }, "The existing session was not changed, leaving it as is" ); return; } else if (session.skipped) { log.debug({ ...bindings, sessionId: session.id }, "Skipped session"); return; } if (session.created || session.changed) { log.debug( { ...bindings, sessionId: session.id }, `About to save a ${session.created ? "created" : "changed"} session, saving ...` ); await session.save(); log.debug( { ...bindings, sessionId: session.id }, `${session.created ? "Created" : "Changed"} session successfully saved` ); } reply.setCookie(cookieName, await session.toCookie(), session.options); }); }; // src/index.ts var src_default = (0, import_fastify_plugin.default)(plugin, { fastify: ">=4.x", name: "fastify-session" }); // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { CRYPTO_SPLIT_CHAR, ErrorWithCode, HMAC, Hmac, MemoryStore, Session, SessionCrypto, SessionStore, createError }); //# sourceMappingURL=index.cjs.map