farmcontrol-api/src/services/misc/sessionStore.js

128 lines
3.6 KiB
JavaScript

/**
* Redis-backed session store.
* Sessions are created after Keycloak authentication. We generate our own session tokens
* and use Redis as the source of truth. Keycloak tokens are stored for refresh.
*/
import crypto from 'crypto';
import log4js from 'log4js';
import config from '../../config.js';
import { redisServer } from '../../database/redis.js';
const logger = log4js.getLogger('SessionStore');
logger.level = config.server.logLevel;
const SESSION_KEY_PREFIX = 'session:';
/**
* Generate a cryptographically secure session token
*/
function generateSessionToken() {
return crypto.randomBytes(32).toString('hex');
}
/**
* Get TTL in seconds from expiresAt timestamp
*/
function getTtlSeconds(expiresAt) {
const now = Date.now();
const ttlMs = expiresAt - now;
return Math.max(Math.ceil(ttlMs / 1000), 60); // minimum 60 seconds
}
/**
* Create a new session in Redis after Keycloak authentication
* @param {Object} params
* @param {Object} params.user - User object (from createOrUpdateUser)
* @param {Object} params.keycloakTokens - { access_token, refresh_token, id_token, expires_at }
* @returns {{ sessionToken: string, expiresAt: number }}
*/
export async function createSession({ user, keycloakTokens }) {
const sessionToken = generateSessionToken();
const expiresAt = keycloakTokens.expires_at;
const sessionData = {
sessionToken,
user: userToSessionUser(user),
keycloakTokens: {
access_token: keycloakTokens.access_token,
refresh_token: keycloakTokens.refresh_token,
id_token: keycloakTokens.id_token,
expires_at: keycloakTokens.expires_at,
},
expiresAt,
};
const key = SESSION_KEY_PREFIX + sessionToken;
const ttlSeconds = getTtlSeconds(expiresAt);
await redisServer.setKey(key, sessionData, ttlSeconds);
logger.debug(`Created session for user ${user.username}, expires in ${ttlSeconds}s`);
return { sessionToken, expiresAt };
}
/**
* Get session by token. Returns null if not found or expired.
*/
export async function getSession(sessionToken) {
if (!sessionToken) return null;
const key = SESSION_KEY_PREFIX + sessionToken;
const session = await redisServer.getKey(key);
if (!session) return null;
if (session.expiresAt && session.expiresAt <= Date.now()) {
await redisServer.deleteKey(key);
return null;
}
return session;
}
/**
* Update session with new Keycloak tokens (after refresh)
*/
export async function updateSessionKeycloakTokens(sessionToken, keycloakTokens) {
const session = await getSession(sessionToken);
if (!session) return null;
const updatedSession = {
...session,
keycloakTokens: {
access_token: keycloakTokens.access_token,
refresh_token: keycloakTokens.refresh_token,
id_token: keycloakTokens.id_token || session.keycloakTokens?.id_token,
expires_at: keycloakTokens.expires_at,
},
expiresAt: keycloakTokens.expires_at,
};
const key = SESSION_KEY_PREFIX + sessionToken;
const ttlSeconds = getTtlSeconds(keycloakTokens.expires_at);
await redisServer.setKey(key, updatedSession, ttlSeconds);
return updatedSession;
}
/**
* Delete a session (logout)
*/
export async function deleteSession(sessionToken) {
if (!sessionToken) return;
const key = SESSION_KEY_PREFIX + sessionToken;
await redisServer.deleteKey(key);
logger.debug(`Deleted session for token ${sessionToken.substring(0, 12)}...`);
}
/**
* Normalize user object for session storage (ensure _id is serializable for Redis)
*/
function userToSessionUser(user) {
if (!user) return null;
const u = { ...user };
if (u._id && typeof u._id === 'object' && u._id.toString) {
u._id = u._id;
}
return JSON.parse(JSON.stringify(u));
}