Spaces:
Running
Running
| 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, | |
| }; | |