const fs = require("node:fs/promises"); const path = require("node:path"); const { createCsrfToken, decryptJson, decryptText, deriveMasterKey, deriveTokenKey, encryptJson, encryptText, generateToken, hashSessionSecret, verifySessionSecret, } = require("./crypto-utils"); class SessionStore { constructor({ storageDir, encryptionKey, sessionTtlMs }) { this.storageDir = storageDir; this.filePath = path.join(storageDir, "sessions.enc"); this.masterKey = deriveMasterKey(encryptionKey); this.sessionTtlMs = sessionTtlMs; this.queue = Promise.resolve(); } async init() { await fs.mkdir(this.storageDir, { recursive: true }); } async createSession({ accessToken, user }) { const now = Date.now(); const sessionId = generateToken(18); const sessionSecret = generateToken(32); const expiresAt = now + this.sessionTtlMs; const { hash, salt } = hashSessionSecret(sessionSecret); const tokenKey = deriveTokenKey(this.masterKey, sessionSecret, sessionId); const encryptedAccessToken = encryptText(accessToken, tokenKey, `access-token:${sessionId}`); const cookieValue = encryptJson( { sessionId, sessionSecret, expiresAt }, this.masterKey, "session-cookie", ); await this.withState(async (state) => { state.sessions[sessionId] = { sessionId, secretHash: hash, secretSalt: salt, encryptedAccessToken, user, createdAt: now, expiresAt, }; return { save: true }; }); return { id: sessionId, cookieValue, expiresAt, user, }; } async validateSession(cookieValue) { let cookiePayload; try { cookiePayload = decryptJson(cookieValue, this.masterKey, "session-cookie"); } catch (error) { return null; } const { sessionId, sessionSecret, expiresAt } = cookiePayload || {}; if (!sessionId || !sessionSecret || !expiresAt || Date.now() > expiresAt) { return null; } return this.withState(async (state) => { const record = state.sessions[sessionId]; if (!record) { return { result: null }; } if (record.expiresAt <= Date.now()) { delete state.sessions[sessionId]; return { save: true, result: null }; } if (!verifySessionSecret(sessionSecret, record.secretHash, record.secretSalt)) { return { result: null }; } return { result: { id: sessionId, secret: sessionSecret, user: record.user, expiresAt: record.expiresAt, }, }; }); } async getAccessToken(sessionId, sessionSecret) { return this.withState(async (state) => { const record = state.sessions[sessionId]; if (!record) { return { result: null }; } const tokenKey = deriveTokenKey(this.masterKey, sessionSecret, sessionId); const accessToken = decryptText( record.encryptedAccessToken, tokenKey, `access-token:${sessionId}`, ); return { result: accessToken }; }); } async revokeSession(sessionId) { return this.withState(async (state) => { if (state.sessions[sessionId]) { delete state.sessions[sessionId]; return { save: true }; } return { save: false }; }); } async updateUserSummary(sessionId, user) { return this.withState(async (state) => { const record = state.sessions[sessionId]; if (!record) { return { save: false }; } record.user = user; return { save: true }; }); } getCsrfToken(sessionId, sessionSecret) { return createCsrfToken(this.masterKey, sessionId, sessionSecret); } async withState(handler) { const task = async () => { const state = await this.readState(); this.pruneExpiredSessions(state); const result = await handler(state); if (result && result.save) { await this.writeState(state); } return result ? result.result ?? null : null; }; const run = this.queue.then(task, task); this.queue = run.then( () => undefined, () => undefined, ); return run; } async readState() { try { const encrypted = await fs.readFile(this.filePath, "utf8"); const parsed = decryptJson(encrypted, this.masterKey, "session-bucket"); return { version: parsed.version || 1, sessions: parsed.sessions || {}, }; } catch (error) { if (error && error.code === "ENOENT") { return { version: 1, sessions: {} }; } throw error; } } async writeState(state) { const encrypted = encryptJson( { version: 1, sessions: state.sessions, }, this.masterKey, "session-bucket", ); const tempPath = `${this.filePath}.tmp`; await fs.writeFile(tempPath, encrypted, "utf8"); await fs.rename(tempPath, this.filePath); } pruneExpiredSessions(state) { const now = Date.now(); for (const [sessionId, record] of Object.entries(state.sessions)) { if (!record || record.expiresAt <= now) { delete state.sessions[sessionId]; } } } } module.exports = { SessionStore, };