/** * @factory/auth/sessions — server-side session registry. * * A session is the server-side twin of a live refresh-token family: it tracks * device/userAgent/IP, last activity, and revocation. Storage-agnostic — the * API implements `SessionStore` over prisma Session. All logic here is pure * and unit-tested. */ import { randomUUID } from "node:crypto"; import { TOKEN_LIFETIMES } from "@factory/shared/constants"; export interface Session { id: string; userId: string; /** sha256 of the refresh token issued for this session. */ refreshTokenHash: string; familyId: string; userAgent?: string; ip?: string; createdAt: number; lastActiveAt: number; expiresAt: number; revokedAt?: number; } export interface SessionStore { put(session: Session): Promise; get(id: string): Promise; listByUser(userId: string): Promise; delete(id: string): Promise; /** * Optional: enumerate ALL sessions (across users). Required only for * `SessionManager.pruneExpired` (global housekeeping); stores that only * index by user may omit it, in which case `pruneExpired` is a no-op * (the API prunes via query instead). */ listAll?(): Promise; } export interface CreateSessionOptions { userId: string; refreshTokenHash: string; familyId: string; userAgent?: string; ip?: string; ttlMs?: number; nowMs?: number; } export class SessionManager { readonly store: SessionStore; constructor(store: SessionStore) { this.store = store; } async create(opts: CreateSessionOptions): Promise { const now = opts.nowMs ?? Date.now(); const ttl = opts.ttlMs ?? TOKEN_LIFETIMES.REFRESH_TOKEN_MS; const session: Session = { id: `sess_${randomUUID().replace(/-/g, "")}`, userId: opts.userId, refreshTokenHash: opts.refreshTokenHash, familyId: opts.familyId, createdAt: now, lastActiveAt: now, expiresAt: now + ttl }; if (opts.userAgent !== undefined) session.userAgent = opts.userAgent; if (opts.ip !== undefined) session.ip = opts.ip; await this.store.put(session); return session; } get(id: string): Promise { return this.store.get(id); } /** Update lastActiveAt (called on successful refresh rotation). */ async touch(id: string, nowMs: number = Date.now()): Promise { const s = await this.store.get(id); if (!s || s.revokedAt !== undefined) return null; const next: Session = { ...s, lastActiveAt: nowMs }; await this.store.put(next); return next; } async revoke(id: string, nowMs: number = Date.now()): Promise { const s = await this.store.get(id); if (!s || s.revokedAt !== undefined) return false; await this.store.put({ ...s, revokedAt: nowMs }); return true; } /** Revoke every non-revoked session for a user (password change / "log out everywhere"). */ async revokeAllForUser(userId: string, nowMs: number = Date.now()): Promise { const sessions = await this.store.listByUser(userId); let n = 0; for (const s of sessions) { if (s.revokedAt === undefined) { await this.store.put({ ...s, revokedAt: nowMs }); n += 1; } } return n; } /** Delete sessions that are revoked AND past expiry (housekeeping). */ async pruneExpired(nowMs: number = Date.now()): Promise { // SessionStore is minimal; the API prunes via query. Here we implement it // over the per-user listing is not possible without userId, so this helper // expects the store to expose `listAll` when available. const store = this.store as SessionStore & { listAll?: () => Promise }; if (typeof store.listAll !== "function") return 0; const all = await store.listAll(); let n = 0; for (const s of all) { if ((s.revokedAt !== undefined || s.expiresAt <= nowMs)) { await this.store.delete(s.id); n += 1; } } return n; } }