/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/auth/session.mjs * Purpose : Cookie sessions (httpOnly/Secure/Lax) + API bearer tokens * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { createHash, randomBytes } from 'node:crypto'; import { config } from '../config.mjs'; import { getDb, logActivity } from '../db/db.mjs'; export const SESSION_COOKIE = 'spbdrive_sid'; const sha256 = (s) => createHash('sha256').update(s).digest('hex'); /** * Create a session and return the raw cookie value (only the hash is stored). * @param {{remember?: boolean, ip?: string, ua?: string}} [opts] * @returns {{token: string, expiresAt: number, maxAgeSec: number}} */ export function createSession({ remember = false, ip = '', ua = '' } = {}) { const token = randomBytes(32).toString('base64url'); const now = Date.now(); const ttl = remember ? config.rememberTtlMs : config.sessionTtlMs; const expiresAt = now + ttl; getDb() .prepare( `INSERT INTO sessions (token_hash, created, last_seen, expires_at, remember, ip, ua) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run(sha256(token), now, now, expiresAt, remember ? 1 : 0, ip, ua.slice(0, 300)); logActivity('auth.login', { ip }); return { token, expiresAt, maxAgeSec: Math.floor(ttl / 1000) }; } /** * Validate a session cookie value; slides the expiry forward on use. * @returns {object|null} session row or null */ export function getSession(token) { if (!token) return null; const db = getDb(); const row = db.prepare('SELECT * FROM sessions WHERE token_hash = ?').get(sha256(token)); if (!row) return null; const now = Date.now(); if (row.expires_at < now) { db.prepare('DELETE FROM sessions WHERE id = ?').run(row.id); return null; } // Sliding renewal, throttled to once a minute to avoid write churn. if (now - row.last_seen > 60_000) { const ttl = row.remember ? config.rememberTtlMs : config.sessionTtlMs; db.prepare('UPDATE sessions SET last_seen = ?, expires_at = ? WHERE id = ?').run( now, now + ttl, row.id, ); } return row; } /** Destroy one session by raw cookie value. */ export function destroySession(token) { if (!token) return; getDb().prepare('DELETE FROM sessions WHERE token_hash = ?').run(sha256(token)); } /** List active sessions (for the Settings page). */ export function listSessions() { return getDb() .prepare('SELECT id, created, last_seen, expires_at, remember, ip, ua FROM sessions WHERE expires_at > ? ORDER BY last_seen DESC') .all(Date.now()); } /** Revoke a session by row id; returns true if one was deleted. */ export function revokeSession(id) { return getDb().prepare('DELETE FROM sessions WHERE id = ?').run(id).changes > 0; } /** Revoke every session (logout everywhere). */ export function revokeAllSessions() { getDb().prepare('DELETE FROM sessions').run(); logActivity('auth.logout_everywhere'); } // ── API tokens (used by the spbdrive CLI) ─────────────────────────────── /** * Create a named API token; the raw value is shown once and stored hashed. * @returns {{id: number, token: string}} */ export function createApiToken(name) { const token = `spbd_${randomBytes(24).toString('base64url')}`; const info = getDb() .prepare('INSERT INTO api_tokens (name, token_hash, created) VALUES (?, ?, ?)') .run(String(name || 'token').slice(0, 80), sha256(token), Date.now()); logActivity('auth.api_token_created', { detail: name }); return { id: Number(info.lastInsertRowid), token }; } /** Validate a Bearer token; returns the token row or null. */ export function getApiToken(token) { if (!token) return null; const db = getDb(); const row = db.prepare('SELECT * FROM api_tokens WHERE token_hash = ?').get(sha256(token)); if (row) db.prepare('UPDATE api_tokens SET last_used = ? WHERE id = ?').run(Date.now(), row.id); return row; } /** List API tokens (names + metadata only). */ export function listApiTokens() { return getDb() .prepare('SELECT id, name, created, last_used FROM api_tokens ORDER BY created DESC') .all(); } /** Delete an API token by id. */ export function revokeApiToken(id) { return getDb().prepare('DELETE FROM api_tokens WHERE id = ?').run(id).changes > 0; } /** Purge expired sessions (daily maintenance). */ export function pruneSessions() { return getDb().prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now()).changes; }