hf / src /session-store.js
incognitolm
update
3666265
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,
};