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 check (RFC_2026-05-04_jwt-full-adoption Phase 5).
//!
//! Per STANDARDS_JWT_DETAILS_MITIGATION §E: "Even with valid signature,
//! non-expired, non-replayed, verifier checks `user_sessions` row for
//! `(sub, sid)`. Row deletion = revocation. This makes the system
//! **stateful by design** — the OVERVIEW §6 note 'stateless 환상 폐기'
//! lives here."
//!
//! ── Short-circuit conditions ────────────────────────────────────────────
//!
//! Two `None`-paths short-circuit (admit without consulting substrate):
//!
//! 1. `cfg.session = None` — port not wired (legacy / sibling-test
//!    config). Same opt-in pattern as `check_replay`.
//! 2. `claims.sid = None` — token is not session-bound. AI-agent /
//!    machine flows mint without `sid`; their authority is bounded by
//!    the access-token TTL (1h) + sv epoch revocation, not session-row
//!    revocation. Pre-Phase-5 tokens (no `sid` yet) also fall here —
//!    gradual rollout (R6 admit) until issuance starts emitting `sid`.
//!
//! ── Why this is distinct from `check_epoch` (sv-port) ──────────────────
//!
//! M36 kicks ONE device while siblings stay alive (LogoutSession
//! primitive — STANDARDS_AUTH_INVALIDATION §2.1). sv-port's
//! `EpochRevocation` is account-wide (LogoutAll / break-glass — kicks
//! every prior token for a subject). The two axes are intentionally
//! orthogonal: deleting a session row requires knowing *which* row,
//! while sv bump is a single per-account counter.
//!
//! ── Fail-closed mapping ─────────────────────────────────────────────────
//!
//! `is_active(...) = Ok(true)`  → admit (continue).
//! `is_active(...) = Ok(false)` → `AuthError::SessionRevoked` (security signal).
//! `is_active(...) = Err(_)`    → `AuthError::SessionLookupUnavailable` (ops signal).

use crate::access_token::session_revocation::SessionRevocationError;
use crate::access_token::{AuthError, Claims, VerifyConfig};

pub(crate) async fn run(claims: &Claims, cfg: &VerifyConfig) -> Result<(), AuthError> {
    let Some(port) = cfg.session.as_ref() else {
        return Ok(()); // Port not wired — gate disabled.
    };
    let Some(sid) = claims.sid.as_deref() else {
        return Ok(()); // Token has no session binding — gate skipped.
    };

    // M37 observability — same event shape as check_replay; ops
    // dashboards aggregate by `port` and `outcome` for the SLA view.
    match port.is_active(&claims.sub, sid).await {
        Ok(true) => {
            tracing::trace!(
                target: "ppoppo_token::revocation",
                port = "session",
                outcome = "admit",
                sub = %claims.sub,
                "revocation.checked",
            );
            Ok(())
        }
        Ok(false) => {
            tracing::warn!(
                target: "ppoppo_token::revocation",
                port = "session",
                outcome = "reject",
                reason = "revoked",
                sub = %claims.sub,
                "revocation.checked",
            );
            Err(AuthError::SessionRevoked)
        }
        Err(SessionRevocationError::Transient(detail)) => {
            tracing::warn!(
                target: "ppoppo_token::revocation",
                port = "session",
                outcome = "transient",
                sub = %claims.sub,
                detail = %detail,
                "revocation.checked",
            );
            Err(AuthError::SessionLookupUnavailable)
        }
    }
}