connect-mongo
Version:
MongoDB session store for Express and Connect
433 lines (429 loc) • 15.3 kB
JavaScript
import assert from "node:assert/strict";
import * as session from "express-session";
import { MongoClient } from "mongodb";
import Debug from "debug";
import util from "node:util";
import { webcrypto } from "node:crypto";
import kruptein from "kruptein";
//#region src/lib/cryptoAdapters.ts
const defaultCryptoOptions = {
secret: false,
algorithm: "aes-256-gcm",
hashing: "sha512",
encodeas: "base64",
key_size: 32,
iv_size: 16,
at_size: 16
};
const createKrupteinAdapter = (options) => {
const merged = {
...defaultCryptoOptions,
...options
};
if (!merged.secret) throw new Error("createKrupteinAdapter requires a non-empty secret");
const instance = kruptein(merged);
const encrypt = util.promisify(instance.set).bind(instance);
const decrypt = util.promisify(instance.get).bind(instance);
return {
async encrypt(plaintext) {
const ciphertext = await encrypt(merged.secret, plaintext);
return String(ciphertext);
},
async decrypt(ciphertext) {
const plaintext = await decrypt(merged.secret, ciphertext);
if (typeof plaintext === "string") return plaintext;
return JSON.stringify(plaintext);
}
};
};
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const toUint8Array = (input) => {
if (typeof input === "string") return encoder.encode(input);
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
if (input instanceof ArrayBuffer) return new Uint8Array(input);
throw new TypeError("Unsupported secret type for Web Crypto adapter");
};
const encodeBytes = (bytes, encoding) => {
switch (encoding) {
case "hex": return Buffer.from(bytes).toString("hex");
case "base64url": return Buffer.from(bytes).toString("base64url");
case "base64":
default: return Buffer.from(bytes).toString("base64");
}
};
const decodeBytes = (payload, encoding) => {
switch (encoding) {
case "hex": return new Uint8Array(Buffer.from(payload, "hex"));
case "base64url": return new Uint8Array(Buffer.from(payload, "base64url"));
case "base64":
default: return new Uint8Array(Buffer.from(payload, "base64"));
}
};
const createWebCryptoAdapter = ({ secret, ivLength, encoding = "base64", algorithm = "AES-GCM", salt, iterations = 31e4 }) => {
if (!secret) throw new Error("createWebCryptoAdapter requires a secret");
const { subtle } = webcrypto;
if (!subtle?.decrypt || !webcrypto?.getRandomValues) throw new Error("Web Crypto API is not available in this runtime");
const resolvedIvLength = ivLength ?? (algorithm === "AES-GCM" ? 12 : 16);
const resolvedSalt = salt ?? encoder.encode("connect-mongo:webcrypto-default-salt");
const deriveKey = async () => {
const secretBytes = toUint8Array(secret);
const baseKey = await subtle.importKey("raw", secretBytes, "PBKDF2", false, ["deriveKey"]);
const saltBytes = typeof resolvedSalt === "string" ? encoder.encode(resolvedSalt) : toUint8Array(resolvedSalt);
return subtle.deriveKey({
name: "PBKDF2",
salt: saltBytes,
iterations,
hash: "SHA-256"
}, baseKey, {
name: algorithm,
length: 256
}, false, ["encrypt", "decrypt"]);
};
const keyPromise = deriveKey();
return {
async encrypt(plaintext) {
const key = await keyPromise;
const iv = webcrypto.getRandomValues(new Uint8Array(resolvedIvLength));
const data = encoder.encode(plaintext);
const encrypted = await subtle.encrypt({
name: algorithm,
iv
}, key, data);
const cipherBytes = new Uint8Array(encrypted);
const combined = new Uint8Array(resolvedIvLength + cipherBytes.byteLength);
combined.set(iv, 0);
combined.set(cipherBytes, resolvedIvLength);
return encodeBytes(combined, encoding);
},
async decrypt(ciphertext) {
const key = await keyPromise;
const combined = decodeBytes(ciphertext, encoding);
const iv = combined.slice(0, resolvedIvLength);
const data = combined.slice(resolvedIvLength);
const decrypted = await subtle.decrypt({
name: algorithm,
iv
}, key, data);
return decoder.decode(decrypted);
}
};
};
//#endregion
//#region src/lib/MongoStore.ts
const debug = Debug("connect-mongo");
const noop = () => {};
const unit = (a) => a;
function defaultSerializeFunction(currentSession) {
const result = { cookie: currentSession.cookie };
Object.entries(currentSession).forEach(([key, value]) => {
if (key === "cookie" && value && typeof value.toJSON === "function") result.cookie = value.toJSON();
else result[key] = value;
});
return result;
}
function computeTransformFunctions(options) {
if (options.serialize || options.unserialize) return {
serialize: options.serialize || defaultSerializeFunction,
unserialize: options.unserialize || unit
};
if (options.stringify === false) return {
serialize: defaultSerializeFunction,
unserialize: unit
};
return {
serialize: (value) => JSON.stringify(value),
unserialize: (payload) => JSON.parse(payload)
};
}
function computeExpires(session$1, fallbackTtlSeconds) {
const cookie = session$1?.cookie;
if (cookie?.expires) return new Date(cookie.expires);
const now = Date.now();
return new Date(now + fallbackTtlSeconds * 1e3);
}
var MongoStore = class MongoStore extends session.Store {
clientP;
cryptoAdapter = null;
timer;
collectionP;
options;
transformFunctions;
constructor({ collectionName = "sessions", ttl = 1209600, mongoOptions = {}, autoRemove = "native", autoRemoveInterval = 10, touchAfter = 0, stringify = true, timestamps = false, crypto, cryptoAdapter, ...required }) {
super();
debug("create MongoStore instance");
const options = {
collectionName,
ttl,
mongoOptions,
autoRemove,
autoRemoveInterval,
touchAfter,
stringify,
timestamps,
crypto,
cryptoAdapter: cryptoAdapter ?? null,
...required
};
assert(options.mongoUrl || options.clientPromise || options.client, "You must provide either mongoUrl|clientPromise|client in options");
assert(options.createAutoRemoveIdx === null || options.createAutoRemoveIdx === void 0, "options.createAutoRemoveIdx has been reverted to autoRemove and autoRemoveInterval");
assert(!options.autoRemoveInterval || options.autoRemoveInterval <= 71582, "autoRemoveInterval is too large. options.autoRemoveInterval is in minutes but not seconds nor mills");
if (crypto !== void 0 && cryptoAdapter !== void 0) throw new Error("Provide either the legacy crypto option or cryptoAdapter, not both");
const legacyCryptoRequested = crypto !== void 0 && crypto.secret !== false;
if (options.cryptoAdapter) this.cryptoAdapter = options.cryptoAdapter;
else if (legacyCryptoRequested) this.cryptoAdapter = createKrupteinAdapter(options.crypto);
options.cryptoAdapter = this.cryptoAdapter;
this.transformFunctions = computeTransformFunctions(options);
let _clientP;
if (options.mongoUrl) _clientP = MongoClient.connect(options.mongoUrl, options.mongoOptions);
else if (options.clientPromise) _clientP = options.clientPromise;
else if (options.client) _clientP = Promise.resolve(options.client);
else throw new Error("Cannot init client. Please provide correct options");
assert(!!_clientP, "Client is null|undefined");
this.clientP = _clientP;
this.options = options;
this.collectionP = _clientP.then(async (con) => {
const collection = con.db(options.dbName).collection(options.collectionName);
await this.setAutoRemove(collection);
return collection;
});
}
static create(options) {
return new MongoStore(options);
}
setAutoRemove(collection) {
const removeQuery = () => ({ expires: { $lt: /* @__PURE__ */ new Date() } });
switch (this.options.autoRemove) {
case "native":
debug("Creating MongoDB TTL index");
return collection.createIndex({ expires: 1 }, {
background: true,
expireAfterSeconds: 0
});
case "interval": {
debug("create Timer to remove expired sessions");
const runIntervalRemove = () => collection.deleteMany(removeQuery(), { writeConcern: { w: 0 } }).catch((err) => {
debug("autoRemove interval cleanup failed: %s", err?.message ?? err);
});
this.timer = setInterval(() => {
runIntervalRemove();
}, this.options.autoRemoveInterval * 1e3 * 60);
this.timer.unref();
return Promise.resolve();
}
case "disabled":
default: return Promise.resolve();
}
}
computeStorageId(sessionId) {
if (this.options.transformId && typeof this.options.transformId === "function") return this.options.transformId(sessionId);
return sessionId;
}
/**
* Normalize payload before encryption so decrypt can restore the original
* serialized session value.
*/
serializeForCrypto(payload) {
if (typeof payload === "string") return payload;
try {
return JSON.stringify(payload);
} catch (error) {
debug("Falling back to string serialization for crypto payload: %O", error);
return String(payload);
}
}
parseDecryptedPayload(plaintext) {
let parsed;
try {
parsed = JSON.parse(plaintext);
} catch {
parsed = plaintext;
}
if (this.options.stringify === false) {
if (typeof parsed === "string") try {
return JSON.parse(parsed);
} catch {
return parsed;
}
return parsed;
}
if (!this.options.serialize && !this.options.unserialize) return typeof parsed === "string" ? parsed : JSON.stringify(parsed);
return parsed;
}
/**
* Decrypt given session data
* @param session session data to be decrypt. Mutate the input session.
*/
async decryptSession(sessionDoc) {
if (this.cryptoAdapter && sessionDoc && typeof sessionDoc.session === "string") {
const plaintext = await this.cryptoAdapter.decrypt(sessionDoc.session);
sessionDoc.session = this.parseDecryptedPayload(plaintext);
}
}
/**
* Get a session from the store given a session ID (sid)
* @param sid session ID
*/
get(sid, callback) {
(async () => {
try {
debug(`MongoStore#get=${sid}`);
const sessionDoc = await (await this.collectionP).findOne({
_id: this.computeStorageId(sid),
$or: [{ expires: { $exists: false } }, { expires: { $gt: /* @__PURE__ */ new Date() } }]
});
if (this.cryptoAdapter && sessionDoc) try {
await this.decryptSession(sessionDoc);
} catch (error) {
callback(error);
return;
}
let result;
if (sessionDoc) {
result = this.transformFunctions.unserialize(sessionDoc.session);
if (this.options.touchAfter > 0 && sessionDoc.lastModified) result.lastModified = sessionDoc.lastModified;
}
this.emit("get", sid);
callback(null, result ?? null);
} catch (error) {
callback(error);
}
})();
}
/**
* Upsert a session into the store given a session ID (sid) and session (session) object.
* @param sid session ID
* @param session session object
*/
set(sid, session$1, callback = noop) {
(async () => {
try {
debug(`MongoStore#set=${sid}`);
if (this.options.touchAfter > 0 && session$1?.lastModified) delete session$1.lastModified;
const s = {
_id: this.computeStorageId(sid),
session: this.transformFunctions.serialize(session$1)
};
s.expires = computeExpires(session$1, this.options.ttl);
if (this.options.touchAfter > 0) s.lastModified = /* @__PURE__ */ new Date();
if (this.cryptoAdapter) {
const plaintext = this.serializeForCrypto(s.session);
s.session = await this.cryptoAdapter.encrypt(plaintext);
}
const collection = await this.collectionP;
const update = { $set: s };
if (this.options.timestamps) {
update.$setOnInsert = { createdAt: /* @__PURE__ */ new Date() };
update.$currentDate = { updatedAt: true };
}
if ((await collection.updateOne({ _id: s._id }, update, {
upsert: true,
writeConcern: this.options.writeOperationOptions
})).upsertedCount > 0) this.emit("create", sid);
else this.emit("update", sid);
this.emit("set", sid);
} catch (error) {
return callback(error);
}
return callback(null);
})();
}
touch(sid, session$1, callback = noop) {
(async () => {
try {
debug(`MongoStore#touch=${sid}`);
const updateFields = {};
const touchAfter = this.options.touchAfter * 1e3;
const lastModified = session$1.lastModified ? session$1.lastModified.getTime() : 0;
const currentDate = /* @__PURE__ */ new Date();
if (touchAfter > 0 && lastModified > 0) {
if (currentDate.getTime() - lastModified < touchAfter) {
debug(`Skip touching session=${sid}`);
return callback(null);
}
updateFields.lastModified = currentDate;
}
updateFields.expires = computeExpires(session$1, this.options.ttl);
const collection = await this.collectionP;
const updateQuery = { $set: updateFields };
if (this.options.timestamps) updateQuery.$currentDate = { updatedAt: true };
if ((await collection.updateOne({ _id: this.computeStorageId(sid) }, updateQuery, { writeConcern: this.options.writeOperationOptions })).matchedCount === 0) return callback(/* @__PURE__ */ new Error("Unable to find the session to touch"));
else {
this.emit("touch", sid, session$1);
return callback(null);
}
} catch (error) {
return callback(error);
}
})();
}
/**
* Get all sessions in the store as an array
*/
all(callback) {
(async () => {
try {
debug("MongoStore#all()");
const sessions = (await this.collectionP).find({ $or: [{ expires: { $exists: false } }, { expires: { $gt: /* @__PURE__ */ new Date() } }] });
const results = [];
for await (const sessionDoc of sessions) {
if (this.cryptoAdapter && sessionDoc) await this.decryptSession(sessionDoc);
results.push(this.transformFunctions.unserialize(sessionDoc.session));
}
this.emit("all", results);
callback(null, results);
} catch (error) {
callback(error);
}
})();
}
/**
* Destroy/delete a session from the store given a session ID (sid)
* @param sid session ID
*/
destroy(sid, callback = noop) {
debug(`MongoStore#destroy=${sid}`);
this.collectionP.then((colleciton) => colleciton.deleteOne({ _id: this.computeStorageId(sid) }, { writeConcern: this.options.writeOperationOptions })).then(() => {
this.emit("destroy", sid);
callback(null);
}).catch((err) => callback(err));
}
/**
* Get the count of all sessions in the store
*/
length(callback) {
debug("MongoStore#length()");
this.collectionP.then((collection) => collection.countDocuments()).then((c) => callback(null, c)).catch((err) => callback(err, 0));
}
/**
* Delete all sessions from the store.
*/
clear(callback = noop) {
debug("MongoStore#clear()");
this.collectionP.then((collection) => collection.deleteMany({}, { writeConcern: this.options.writeOperationOptions })).then(() => callback(null)).catch((err) => {
const message = err?.message ?? "";
if (err?.code === 26 || /ns not found/i.test(message)) {
callback(null);
return;
}
callback(err);
});
}
/**
* Close database connection
*/
close() {
debug("MongoStore#close()");
if (this.timer) {
clearInterval(this.timer);
this.timer = void 0;
}
return this.clientP.then((c) => c.close());
}
};
//#endregion
//#region src/index.ts
var src_default = MongoStore;
//#endregion
export { MongoStore, createKrupteinAdapter, createWebCryptoAdapter, src_default as default };
//# sourceMappingURL=index.mjs.map