ppoppo_token/session_revocation.rs
1//! M36 — session-row liveness port (RFC_2026-05-04_jwt-full-adoption Phase 5).
2//!
3//! The *textbook* revocation gate: even with valid signature +
4//! non-expired + non-replayed, the verifier checks `user_sessions(sub,
5//! sid)` and refuses if the row is gone. STANDARDS_JWT_DETAILS_MITIGATION
6//! §E M36 — "Row deletion = revocation. This makes the system **stateful
7//! by design** — the OVERVIEW §6 note 'stateless 환상 폐기' lives here."
8//!
9//! ── Why a third axis (vs sv epoch + jti replay) ─────────────────────────
10//!
11//! sv (`EpochRevocation`) bumps account-wide on break-glass / LogoutAll
12//! — it cannot kick a single device while leaving siblings alive. M35
13//! (`ReplayDefense`) defends against re-presenting the same token —
14//! it does not invalidate other tokens. M36 (`SessionRevocation`) is
15//! the per-session axis: "this device's session row was deleted, this
16//! token must die, all the user's other sessions stay alive". Single
17//! "Sign out this device" UX maps to LogoutSession primitive in
18//! STANDARDS_AUTH_INVALIDATION §2.1, which deletes only the target
19//! row.
20//!
21//! ── Failure-mode contract (fail-closed) ────────────────────────────────
22//!
23//! `is_active` returns `Ok(true)` for live sessions, `Ok(false)` for
24//! deleted/missing rows (= revoked), `Err(Transient)` for substrate
25//! failure. Engine maps `Ok(false)` → `AuthError::SessionRevoked`
26//! and `Err(Transient)` → `AuthError::SessionLookupUnavailable`. As
27//! with `ReplayDefense`, the two are distinct audit signals — admitting
28//! on substrate failure would let revoked sessions persist past their
29//! invalidation window.
30//!
31//! ── Phase 10 split (RFC §6.11) ──────────────────────────────────────────
32//!
33//! Moves to `access_token::SessionRevocation` in Phase 10 D1. id_token
34//! is not a bearer credential — it has no session row to check.
35
36/// Per-session liveness check.
37///
38/// `sub` is the subject claim (ppnum_id ULID); `sid` is the session
39/// row id from `Claims.sid`. When `Claims.sid` is `None`, the engine
40/// skips this gate (legacy / non-session-bound tokens admit) — this
41/// trait is never called with an empty `sid`.
42#[async_trait::async_trait]
43pub trait SessionRevocation: std::fmt::Debug + Send + Sync {
44 async fn is_active(
45 &self,
46 sub: &str,
47 sid: &str,
48 ) -> Result<bool, SessionRevocationError>;
49}
50
51#[derive(Debug, thiserror::Error)]
52pub enum SessionRevocationError {
53 /// Substrate (e.g. `user_sessions` SELECT) transient failure.
54 /// Engine maps to `AuthError::SessionLookupUnavailable`.
55 #[error("session lookup transient failure: {0}")]
56 Transient(String),
57}