"""Opaque-token session storage for the FastAPI auth layer. Schema lives in :mod:`src.stage1_inventory.schema` (``sessions`` table, see BACKEND_BUILD.md §6.1). This module owns the four operations the auth layer needs against it: - :func:`create_session` — INSERT a fresh row at login; returns the token the caller puts in the ``Set-Cookie`` header. - :func:`lookup_session` — token → username, bumping ``last_seen`` so an active user's session stays warm. Returns ``None`` for unknown or expired tokens (and lazily deletes expired ones on hit). - :func:`invalidate_session` — DELETE on logout. Idempotent: missing rows are not an error. - :func:`purge_expired` — bulk-delete expired rows; used by tests and a background sweep we'll wire in Stage 7. Tokens are 256-bit URL-safe randoms from :func:`secrets.token_urlsafe`, and only their SHA-256 hash is persisted (see :func:`_hash_token`) so a read of the ``sessions`` table never yields usable cookies. The cookie that carries the raw token is ``HttpOnly`` so JS cannot read it, and ``SameSite=Lax`` so cross-site form posts can't replay it; CSRF on mutating requests is enforced separately via the ``X-Requested-With`` header (see :mod:`src.api.middleware.csrf` and CONTRACT.md §3). """ from __future__ import annotations import hashlib import secrets import threading import time from datetime import datetime, timedelta, timezone from src.stage1_inventory.db import connect def _hash_token(token: str) -> str: """SHA-256 of the opaque cookie token, hex-encoded. The DB stores only this hash, never the raw token — so a read of the ``sessions`` table (a backup leak, a SQL-read compromise) does not hand an attacker usable session cookies. The cookie still carries the raw 256-bit token; we hash on the way in and on every lookup. (Plain SHA-256 is correct here — the token already has 256 bits of entropy, so no salt/KDF is needed, unlike low-entropy passwords.) """ return hashlib.sha256(token.encode("utf-8")).hexdigest() # 30-day window — same as the cookie's Max-Age in router.py. SESSION_TTL = timedelta(days=30) # ---------- In-process session cache -------------------------------------- # current_user resolves the cookie on EVERY authenticated request, and the DB # path is a SELECT + an UPDATE(last_seen) + commit — two cross-region Turso # round-trips per request. On the single-replica cloud Space we can safely # cache token -> username for a short window: a logout drops the entry # immediately, and the DB ``expires_at`` is days out so a 30s cache can't # serve a genuinely-expired token. This removes ~2 round-trips from the hot # path of every navigation. The last_seen bump is thereby throttled to at # most once per window per token, which is fine for "is anyone using this". _SESSION_CACHE_TTL = 30.0 # seconds _session_cache: dict[str, tuple[str, float]] = {} # token -> (username, expires_monotonic) _session_cache_lock = threading.Lock() def _cache_get(token: str) -> str | None: now = time.monotonic() with _session_cache_lock: hit = _session_cache.get(token) if hit is not None and hit[1] > now: return hit[0] if hit is not None: _session_cache.pop(token, None) return None def _cache_put(token: str, username: str) -> None: with _session_cache_lock: _session_cache[token] = (username, time.monotonic() + _SESSION_CACHE_TTL) def _cache_drop(token: str) -> None: with _session_cache_lock: _session_cache.pop(token, None) def _now() -> datetime: return datetime.now(timezone.utc) def _isoformat(ts: datetime) -> str: """Match the format SQLite's ``datetime('now')`` writes: ``YYYY-MM-DD HH:MM:SS``. Comparing strings is fine because the format is sortable; we keep both sides on the same shape so ``WHERE expires_at > datetime('now')`` works regardless of which side wrote the row. """ return ts.strftime("%Y-%m-%d %H:%M:%S") def create_session(username: str) -> str: """Insert a fresh session row, return the opaque cookie token. The caller (auth router) is responsible for putting the token in a ``Set-Cookie`` header. We don't issue more than one session per login request, so duplicate-token collision is effectively zero (``token_urlsafe(32)`` = 256 bits of entropy); we still wrap the INSERT defensively in case of a clock-skew replay race. """ if not username: raise ValueError("create_session requires a non-empty username") token = secrets.token_urlsafe(32) expires = _isoformat(_now() + SESSION_TTL) with connect() as conn: conn.execute( "INSERT INTO sessions (token, username, expires_at) VALUES (?, ?, ?)", (_hash_token(token), username, expires), ) conn.commit() return token # raw token to the cookie; only its hash is persisted def lookup_session(token: str) -> str | None: """Resolve a token to its username; bumps ``last_seen``. Returns ``None`` for unknown tokens *and* for tokens whose row has aged past ``expires_at``. Expired rows are lazily deleted on hit so the table doesn't bloat without a background sweeper. """ if not token: return None # Fast path: a recently-validated token skips both Turso round-trips. cached = _cache_get(token) if cached is not None: return cached now_str = _isoformat(_now()) token_hash = _hash_token(token) # the DB stores only the hash with connect() as conn: cur = conn.execute( "SELECT username, expires_at FROM sessions WHERE token = ?", (token_hash,), ) row = cur.fetchone() if row is None: return None if row["expires_at"] <= now_str: # Lazy GC — caller experiences a 401 just like an unknown # token, and the row is gone before the next login. conn.execute("DELETE FROM sessions WHERE token = ?", (token_hash,)) conn.commit() _cache_drop(token) return None # Bump last_seen so admins can answer "is anyone using this?" later; # the cookie itself is fixed-TTL so this doesn't extend the session. conn.execute( "UPDATE sessions SET last_seen = ? WHERE token = ?", (now_str, token_hash), ) conn.commit() _cache_put(token, row["username"]) return row["username"] def invalidate_session(token: str) -> None: """Remove the row for ``token``. Idempotent — missing rows are fine.""" if not token: return _cache_drop(token) # drop before the DB delete so no window serves a logged-out token with connect() as conn: conn.execute("DELETE FROM sessions WHERE token = ?", (_hash_token(token),)) conn.commit() def purge_expired() -> int: """Delete every row whose ``expires_at`` has passed. Returns the count. Not called automatically yet; exposed so tests can force a clean slate and so a scheduled sweep can be wired in a later stage. """ now_str = _isoformat(_now()) with connect() as conn: cur = conn.execute("DELETE FROM sessions WHERE expires_at <= ?", (now_str,)) conn.commit() return cur.rowcount or 0