ppoppo-token 0.2.0

JWT (RFC 9068, EdDSA) issuance + verification engine for the Ppoppo ecosystem. Single deep module with a small interface (issue, verify) hiding RFC 8725 mitigations M01-M45, JWKS handling, and substrate ports (epoch, session, replay).
Documentation
//! M36 — session-row liveness port (RFC_2026-05-04_jwt-full-adoption Phase 5).
//!
//! The *textbook* revocation gate: even with valid signature +
//! non-expired + non-replayed, the verifier checks `user_sessions(sub,
//! sid)` and refuses if the row is gone. STANDARDS_JWT_DETAILS_MITIGATION
//! §E M36 — "Row deletion = revocation. This makes the system **stateful
//! by design** — the OVERVIEW §6 note 'stateless 환상 폐기' lives here."
//!
//! ── Why a third axis (vs sv epoch + jti replay) ─────────────────────────
//!
//! sv (`EpochRevocation`) bumps account-wide on break-glass / LogoutAll
//! — it cannot kick a single device while leaving siblings alive. M35
//! (`ReplayDefense`) defends against re-presenting the same token —
//! it does not invalidate other tokens. M36 (`SessionRevocation`) is
//! the per-session axis: "this device's session row was deleted, this
//! token must die, all the user's other sessions stay alive". Single
//! "Sign out this device" UX maps to LogoutSession primitive in
//! STANDARDS_AUTH_INVALIDATION §2.1, which deletes only the target
//! row.
//!
//! ── Failure-mode contract (fail-closed) ────────────────────────────────
//!
//! `is_active` returns `Ok(true)` for live sessions, `Ok(false)` for
//! deleted/missing rows (= revoked), `Err(Transient)` for substrate
//! failure. Engine maps `Ok(false)` → `AuthError::SessionRevoked`
//! and `Err(Transient)` → `AuthError::SessionLookupUnavailable`. As
//! with `ReplayDefense`, the two are distinct audit signals — admitting
//! on substrate failure would let revoked sessions persist past their
//! invalidation window.
//!
//! ── Phase 10 split (RFC §6.11) ──────────────────────────────────────────
//!
//! Moves to `access_token::SessionRevocation` in Phase 10 D1. id_token
//! is not a bearer credential — it has no session row to check.

/// Per-session liveness check.
///
/// `sub` is the subject claim (ppnum_id ULID); `sid` is the session
/// row id from `Claims.sid`. When `Claims.sid` is `None`, the engine
/// skips this gate (legacy / non-session-bound tokens admit) — this
/// trait is never called with an empty `sid`.
#[async_trait::async_trait]
pub trait SessionRevocation: std::fmt::Debug + Send + Sync {
    async fn is_active(
        &self,
        sub: &str,
        sid: &str,
    ) -> Result<bool, SessionRevocationError>;
}

#[derive(Debug, thiserror::Error)]
pub enum SessionRevocationError {
    /// Substrate (e.g. `user_sessions` SELECT) transient failure.
    /// Engine maps to `AuthError::SessionLookupUnavailable`.
    #[error("session lookup transient failure: {0}")]
    Transient(String),
}