ppoppo-token 0.5.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
//! sv-port — per-account `session_version` epoch revocation check
//! (RFC_2026-05-04_jwt-full-adoption Phase 5 commit 5.5).
//!
//! Account-wide axis distinct from `check_session` (per-row liveness):
//! a `LogoutAll` / break-glass increments the substrate's per-account
//! counter, invalidating every prior token within the cache TTL window.
//! STANDARDS_AUTH_PPOPPO §17.7 + STANDARDS_AUTH_INVALIDATION §2.3.
//!
//! ── sv lives on the wire, hidden from `Claims` ──────────────────────────
//!
//! Phase 4 Decision 1 keeps `sv` HIDDEN (`engine/check_domain.rs` ALLOWED
//! list contains `"sv"` so M45 admits it, but the value is not surfaced).
//! Surfacing would push enforcement responsibility onto every caller —
//! against the deep-module rule. Engine reads sv from the raw payload at
//! the one site that needs it (here) and hands the comparison off to the
//! port.
//!
//! ── Short-circuit conditions ────────────────────────────────────────────
//!
//! 1. `cfg.epoch` is a named non-`Enforced` stance (`EnforcedElsewhere` /
//!    `Unenforced` — RFC_202607150428 Q3 typed stance; the silent
//!    `Option::None` slot is retired). This is the ONLY short-circuit: a
//!    non-`Enforced` validator never reaches the payload read. Unlike the
//!    old `None`, the stance is declared, greppable, and boot-logged.
//!
//! There is no longer a "missing `sv` ⇒ admit" short-circuit. Post-S4 every
//! PAS mint stamps `sv`, so a *wired* validator that receives a token without
//! one refuses it (**R6 legacy-admit retired**, RFC_202607150428 S6) — see the
//! failure-mode contract below. STANDARDS_AUTH_PPOPPO §4.2.1 R6.
//!
//! ── Why `sv` not `Claims.sv` ────────────────────────────────────────────
//!
//! `Claims.sv` does not exist (Phase 4 design: HIDDEN). Engine reads the
//! raw payload via `parse_payload_json` exactly as `check_domain` does
//! for other hidden claims. The single re-parse here is symmetric with
//! the existing pattern (`check_domain` already re-parses for M40/M41/...).
//!
//! ── Failure-mode contract (fail-closed) ────────────────────────────────
//!
//! - `payload.sv` absent (wired validator)   → `AuthError::SessionVersionStale`
//!   (`reason = "unstamped"` in the trace — a mint forgot to stamp, or a
//!   pre-cutover legacy token; refuse it, S6).
//! - `current(sub) = Ok(c)` and `token.sv >= c` → admit.
//! - `current(sub) = Ok(c)` and `token.sv < c`  → `AuthError::SessionVersionStale`
//!   (`reason = "stale"` — break-glass / LogoutAll just kicked this token).
//! - `current(sub) = Err(_)`                     → `AuthError::SessionVersionLookupUnavailable`
//!   (infrastructure signal — substrate down; admit-on-failure would let
//!   stale tokens slip through during the outage window).
//!
//! ── M37 observability ──────────────────────────────────────────────────
//!
//! Same `revocation.checked` event shape as the replay/session ports so
//! ops dashboards aggregate by `port = "epoch"`.

use crate::access_token::epoch_revocation::EpochRevocationError;
use crate::access_token::{AuthError, Claims, VerifyConfig};
use crate::engine::raw::parse_payload_json;

pub(crate) async fn run(
    token: &str,
    claims: &Claims,
    cfg: &VerifyConfig,
) -> Result<(), AuthError> {
    let Some(port) = cfg.epoch.port() else {
        return Ok(()); // Named non-Enforced stance — gate declared off.
    };

    // Re-parse the payload to read `sv` (HIDDEN claim — not on `Claims`).
    // Symmetric with `check_domain`'s payload reads; the cost is one
    // serde_json::from_str per verify (~µs).
    let payload = parse_payload_json(token)?;
    let Some(token_sv) = payload.get("sv").and_then(|v| v.as_i64()) else {
        // R6 legacy-admit RETIRED (RFC_202607150428 S6). Post-S4 every PAS mint
        // stamps `sv`, so a token reaching a *wired* validator without one is a
        // pre-cutover legacy token (bounded by the 1h access-token TTL) or a
        // mint path that forgot to stamp — refuse it. Reuses
        // `SessionVersionStale` (the caller's remediation is identical:
        // re-authenticate) but logs `reason = "unstamped"` so "a mint forgot to
        // stamp" is not conflated with an ordinary revocation in observability.
        tracing::warn!(
            target: "ppoppo_token::revocation",
            port = "epoch",
            outcome = "reject",
            reason = "unstamped",
            sub = %claims.sub,
            "revocation.checked",
        );
        return Err(AuthError::SessionVersionStale);
    };

    match port.current(&claims.sub).await {
        Ok(current) if token_sv >= current => {
            tracing::trace!(
                target: "ppoppo_token::revocation",
                port = "epoch",
                outcome = "admit",
                sub = %claims.sub,
                token_sv,
                current_sv = current,
                "revocation.checked",
            );
            Ok(())
        }
        Ok(current) => {
            tracing::warn!(
                target: "ppoppo_token::revocation",
                port = "epoch",
                outcome = "reject",
                reason = "stale",
                sub = %claims.sub,
                token_sv,
                current_sv = current,
                "revocation.checked",
            );
            Err(AuthError::SessionVersionStale)
        }
        Err(EpochRevocationError::Transient(detail)) => {
            tracing::warn!(
                target: "ppoppo_token::revocation",
                port = "epoch",
                outcome = "transient",
                sub = %claims.sub,
                detail = %detail,
                "revocation.checked",
            );
            Err(AuthError::SessionVersionLookupUnavailable)
        }
    }
}