feat(identity): add persistent Postgres-backed session store

Replaces the in-memory studentSessions/adminSessions Maps (Phase 2B — gone on every
restart) with a sessions table. Browser holds an opaque CSPRNG bearer token; only its
SHA-256 hash is ever stored server-side, same principle as password hashing. Each session
has an absolute expiry (fixed at creation) and a sliding idle expiry (pushed forward on
every authenticated request, capped at the absolute one) — one atomic UPDATE...RETURNING
per resolve, no read-then-write race. Student and instructor sessions stay fully separate
token namespaces, matching Phase 2B's cookie-name separation.

cookies.js centralizes Set-Cookie construction so the Secure attribute (COOKIE_SECURE=1,
for real HTTPS deployments) is applied consistently instead of hand-built per call site.
This commit is contained in:
2026-08-18 13:35:54 -05:00
parent 08ff5315de
commit 52c17c1907
3 changed files with 160 additions and 52 deletions
+35
View File
@@ -0,0 +1,35 @@
-- sessions — replaces Phase 2B's in-memory studentSessions/adminSessions Maps. A Platform API
-- restart no longer logs everyone out, and sessions are individually revocable/auditable.
--
-- Only a SHA-256 hash of the opaque bearer token is stored (token_hash) — never the raw token
-- itself, so a database read (backup, dump, compromised replica) can't be turned into working
-- session cookies.
--
-- expires_at is the absolute ceiling (set once, at creation). idle_expires_at slides forward on
-- every authenticated request (see src/sessions.js resolve*Session), capped at expires_at —
-- whichever of the two is hit first ends the session. session_type distinguishes student vs.
-- instructor sessions; the CHECK below keeps a session row from ever being ambiguous about
-- which kind it is, so a bug elsewhere can't accidentally treat one as the other.
CREATE TABLE IF NOT EXISTS sessions (
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_type TEXT NOT NULL CHECK (session_type IN ('student', 'instructor')),
student_id TEXT REFERENCES students(student_id),
instructor_id UUID REFERENCES instructors(instructor_id),
token_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
idle_expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
metadata JSONB,
CHECK (
(session_type = 'student' AND student_id IS NOT NULL AND instructor_id IS NULL) OR
(session_type = 'instructor' AND instructor_id IS NOT NULL AND student_id IS NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_instructor ON sessions(instructor_id) WHERE instructor_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_student ON sessions(student_id) WHERE student_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expires_at);
+21
View File
@@ -0,0 +1,21 @@
// Cookie construction, centralized so the Secure attribute is set consistently everywhere
// instead of hand-built per call site. Secure is opt-in via COOKIE_SECURE=1 — off by default
// because local dev and the current single-VM pilot deployment run over plain HTTP; anything
// served over real HTTPS should set COOKIE_SECURE=1 (see docs/PHASE3A... "production HTTPS
// expectations").
const SECURE = process.env.COOKIE_SECURE === '1';
function buildCookie(name, value, { maxAgeSec, httpOnly = true } = {}) {
const parts = [`${name}=${value}`, 'Path=/', 'SameSite=Lax'];
if (httpOnly) parts.push('HttpOnly');
if (SECURE) parts.push('Secure');
parts.push(`Max-Age=${maxAgeSec != null ? maxAgeSec : 0}`);
return parts.join('; ');
}
function clearCookie(name) {
return buildCookie(name, '', { maxAgeSec: 0 });
}
module.exports = { buildCookie, clearCookie };
+104 -52
View File
@@ -1,67 +1,119 @@
// Server-controlled session store — replaces Phase 2A's pilot ?sid= mechanism (client-supplied,
// trivially forgeable) with opaque, server-issued tokens the browser can't fabricate or guess.
// In-memory, single-process: fine for a pilot admin/classroom tool (see
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Known limitations" — a Platform API restart logs
// everyone out, which is an acceptable tradeoff at this scale, not something silently ignored).
// Persistent session store (Phase 3A) — replaces Phase 2B's in-memory studentSessions/
// adminSessions Maps. Sessions live in Postgres (the `sessions` table), so a Platform API
// restart no longer logs everyone out, and individual sessions can be revoked/audited.
//
// Two separate, non-overlapping session kinds (student / admin) so a student session can never
// be mistaken for an admin one regardless of any bug elsewhere — they use different cookie
// names AND different token namespaces.
// The browser only ever holds an opaque, unguessable bearer token (32 random bytes,
// base64url). The server stores only a SHA-256 hash of it (token_hash) — a database dump
// can't be replayed as working session cookies, same principle as password hashing.
//
// Two independent lifecycles, matching Phase 2B's separation: student sessions and instructor
// sessions never share a token namespace or a cookie name, so a bug can't confuse one for the
// other. Each has an absolute expiry (expires_at, fixed at creation) and a sliding idle expiry
// (idle_expires_at, pushed forward on every authenticated use, never past expires_at) —
// whichever is hit first ends the session.
const crypto = require('crypto');
const STUDENT_TTL_MS = 8 * 60 * 60 * 1000; // 8h — a work/class session
const ADMIN_TTL_MS = 4 * 60 * 60 * 1000; // 4h — shorter, this is a privileged session
const studentSessions = new Map(); // token -> { student_id, expiresAt }
const adminSessions = new Map(); // token -> { expiresAt }
const STUDENT_TTL_MS = Number(process.env.STUDENT_SESSION_TTL_HOURS || 8) * 60 * 60 * 1000;
const STUDENT_IDLE_TTL_MS = Number(process.env.STUDENT_SESSION_IDLE_HOURS || 2) * 60 * 60 * 1000;
const INSTRUCTOR_TTL_MS = Number(process.env.INSTRUCTOR_SESSION_TTL_HOURS || 12) * 60 * 60 * 1000;
const INSTRUCTOR_IDLE_TTL_MS = Number(process.env.INSTRUCTOR_SESSION_IDLE_HOURS || 2) * 60 * 60 * 1000;
function makeToken() {
return crypto.randomBytes(32).toString('base64url');
}
function createStudentSession(studentId) {
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
async function createSession(pool, { type, studentId, instructorId, ttlMs, idleTtlMs, metadata }) {
const token = makeToken();
studentSessions.set(token, { student_id: studentId, expiresAt: Date.now() + STUDENT_TTL_MS });
return token;
}
function resolveStudentSession(token) {
if (!token) return null;
const s = studentSessions.get(token);
if (!s || s.expiresAt < Date.now()) { studentSessions.delete(token); return null; }
return s.student_id;
}
function destroyStudentSession(token) {
studentSessions.delete(token);
}
function createAdminSession() {
const token = makeToken();
adminSessions.set(token, { expiresAt: Date.now() + ADMIN_TTL_MS });
return token;
}
function resolveAdminSession(token) {
if (!token) return false;
const s = adminSessions.get(token);
if (!s || s.expiresAt < Date.now()) { adminSessions.delete(token); return false; }
return true;
}
function destroyAdminSession(token) {
adminSessions.delete(token);
}
// Periodic sweep so long-idle sessions don't accumulate forever in memory.
setInterval(() => {
const now = Date.now();
for (const [token, s] of studentSessions) if (s.expiresAt < now) studentSessions.delete(token);
for (const [token, s] of adminSessions) if (s.expiresAt < now) adminSessions.delete(token);
}, 10 * 60 * 1000).unref();
const expiresAt = new Date(now + ttlMs);
const idleExpiresAt = new Date(Math.min(now + idleTtlMs, expiresAt.getTime()));
await pool.query(
`INSERT INTO sessions (session_type, student_id, instructor_id, token_hash, expires_at, idle_expires_at, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[type, studentId || null, instructorId || null, hashToken(token), expiresAt, idleExpiresAt, metadata ? JSON.stringify(metadata) : null]
);
return token;
}
// Validates + slides the idle window forward in one round trip. Returns null on any failure
// (missing, expired, idle-timed-out, or revoked) without distinguishing why — the caller
// doesn't need to know, and not distinguishing avoids leaking session state to a client.
async function resolveSession(pool, token, type, idleTtlMs) {
if (!token) return null;
const { rows } = await pool.query(
`UPDATE sessions
SET last_seen_at = now(), idle_expires_at = LEAST(now() + $3::interval, expires_at)
WHERE token_hash = $1 AND session_type = $2 AND revoked_at IS NULL
AND expires_at > now() AND idle_expires_at > now()
RETURNING student_id, instructor_id`,
[hashToken(token), type, `${idleTtlMs} milliseconds`]
);
return rows[0] || null;
}
async function revokeSession(pool, token) {
if (!token) return;
await pool.query(`UPDATE sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`, [hashToken(token)]);
}
// ── Student sessions ─────────────────────────────────────────────────────────────────────
function createStudentSession(pool, studentId) {
return createSession(pool, { type: 'student', studentId, ttlMs: STUDENT_TTL_MS, idleTtlMs: STUDENT_IDLE_TTL_MS });
}
async function resolveStudentSession(pool, token) {
const row = await resolveSession(pool, token, 'student', STUDENT_IDLE_TTL_MS);
return row ? row.student_id : null;
}
const destroyStudentSession = revokeSession;
// ── Instructor sessions ──────────────────────────────────────────────────────────────────
function createInstructorSession(pool, instructorId) {
return createSession(pool, { type: 'instructor', instructorId, ttlMs: INSTRUCTOR_TTL_MS, idleTtlMs: INSTRUCTOR_IDLE_TTL_MS });
}
async function resolveInstructorSession(pool, token) {
const row = await resolveSession(pool, token, 'instructor', INSTRUCTOR_IDLE_TTL_MS);
return row ? row.instructor_id : null;
}
const destroyInstructorSession = revokeSession;
// Revokes every live session for an instructor — used for the owner-facing "revoke all
// sessions" control, and automatically after a password change (except the session that just
// changed the password, so the user isn't immediately logged out of their own action).
async function revokeAllInstructorSessions(pool, instructorId, exceptToken) {
const exceptHash = exceptToken ? hashToken(exceptToken) : null;
await pool.query(
`UPDATE sessions SET revoked_at = now()
WHERE instructor_id = $1 AND revoked_at IS NULL AND ($2::text IS NULL OR token_hash != $2)`,
[instructorId, exceptHash]
);
}
async function listInstructorSessions(pool, instructorId) {
const { rows } = await pool.query(
`SELECT session_id, created_at, last_seen_at, expires_at, revoked_at
FROM sessions WHERE instructor_id = $1 ORDER BY created_at DESC LIMIT 20`,
[instructorId]
);
return rows;
}
// Periodic cleanup of long-dead rows so the table doesn't grow forever — cosmetic/storage only,
// not a security control (expired/revoked rows already fail resolveSession's WHERE clause).
function startSessionSweeper(pool) {
setInterval(() => {
pool.query(`DELETE FROM sessions WHERE expires_at < now() - interval '7 days'`).catch((e) => {
console.error('[session sweep] failed:', e.message);
});
}, 60 * 60 * 1000).unref();
}
module.exports = {
createStudentSession, resolveStudentSession, destroyStudentSession,
createAdminSession, resolveAdminSession, destroyAdminSession,
createInstructorSession, resolveInstructorSession, destroyInstructorSession,
revokeAllInstructorSessions, listInstructorSessions, startSessionSweeper,
};