import type pg from "pg"; import type { User } from "./users"; export type SessionValidationResult = | { session: Session; user: User } | { session: null; user: null }; export interface Session { token: string; userId: number; expiresAt: Date; } /** Generates a random string which can be used as a session token. */ function generateSessionToken(): string { const bytes = new Uint8Array(20); crypto.getRandomValues(bytes); const token = encodeBase32LowerCaseNoPadding(bytes); return token; } function encodeBase32LowerCaseNoPadding(input: Uint8Array): string { const alphabet = "abcdefghijklmnopqrstuvwxyz234567"; // Lowercase alphabet let output = ""; let bits = 0; let buffer = 0; for (const byte of input) { buffer = (buffer << 8) | byte; bits += 8; while (bits >= 5) { output += alphabet[(buffer >> (bits - 5)) & 0x1f]; bits -= 5; } } // Handle remaining bits (if any) - No padding, so just add if > 0. if (bits > 0) { output += alphabet[(buffer << (5 - bits)) & 0x1f]; } return output; } /** * The amount of milliseconds since last access, a session remains valid for when it is created. * * It is currently set to 30 days. */ const VALID_MILLISECONDS = 1000 * 60 * 60 * 24 * 30; /** Creates a new session for the user with the given `userId`. */ export async function createSession(dbConn: pg.PoolClient, userId: number): Promise { const token = generateSessionToken(); const session: Session = { token: token, userId, expiresAt: new Date(Date.now() + VALID_MILLISECONDS), }; await dbConn.query("INSERT INTO sessions(token, user_id, expires_at) VALUES ($1, $2, $3);", [ session.token, session.userId, session.expiresAt, ]); return session; } /** * Validates a session token. It happens in two parts: * * 1. Does a token exist? * 2. Is that token NOT expired? * * As a side effect, the session is touched (i.e. it's expiration is delayed) if validation suceeds. * This is to avoid unnecessary logouts, as session validation indicates that the session is still in use. * * @returns A session + user pair if the session is valid, or `null` for both otherwise. */ export async function validateSessionToken( dbConn: pg.PoolClient, token: string, ): Promise { // Step 1 const result = await dbConn.query( `SELECT * FROM sessions INNER JOIN users ON users.id = sessions.user_id WHERE token = $1;`, [token], ); console.debug(result); if (result.rowCount === 0) { return { session: null, user: null }; } const session: Session = { token, userId: result.rows[0].user_id, expiresAt: result.rows[0].expires_at, }; const user: User = { id: result.rows[0].id, // Luckily JOIN avoids collision... email: result.rows[0].email, firstName: result.rows[0].first_name, lastName: result.rows[0].last_name, role: result.rows[0].role, }; console.debug("Session with token %s: %o, %o", token, session, user); // Step 2. const now = Date.now(); if (now >= session.expiresAt.getTime()) { await invalidateSession(dbConn, session.token); return { session: null, user: null }; } // "Touch" the session, if it is about to expire. // We only do this a bit into the period to avoid superflous database writes. if (now >= session.expiresAt.getTime() - VALID_MILLISECONDS / 2) { session.expiresAt = new Date(session.expiresAt.getTime() + VALID_MILLISECONDS / 2); await dbConn.query("UPDATE sessions SET expires_at = ? WHERE id = ?;", [ session.expiresAt, session.token, ]); } return { session, user }; } /** Invalidates the session with token `sessionToken`. */ export async function invalidateSession( dbConn: pg.PoolClient, sessionToken: string, ): Promise { await dbConn.query("DELETE sessions WHERE token = ?;", [sessionToken]); } /** Invalidates all sessions for the user with the id `userId`. */ export async function invalidateAllSessions(dbConn: pg.PoolClient, userId: number): Promise { await dbConn.query("DELETE sessions WHERE user_id = ?", [userId]); }