ppoppo-token 0.3.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
//! M35 — jti replay defense port (RFC_2026-05-04_jwt-full-adoption Phase 5).
//!
//! `ReplayDefense` is an *engine-facing* port (deep-module discipline):
//! the engine sees one method (`check_and_record`); concrete adapters
//! own the substrate-specific composition (KVRocks `SET NX EX`, Postgres
//! unique-constraint + retention sweeper, etc).
//!
//! ── Atomicity contract (TOCTOU defense) ─────────────────────────────────
//!
//! `check_and_record` is intentionally a *single* atomic primitive rather
//! than the split pair `check + record`. A split shape leaves a window
//! where two simultaneous verifies of the same jti both observe "not
//! seen" before either records. The atomic primitive maps directly to
//! the substrate's compare-and-set / set-if-absent operation, eliminating
//! the race.
//!
//! ── Failure-mode contract (fail-closed) ────────────────────────────────
//!
//! Adapters MUST NOT translate substrate transient failures into "admit"
//! — a KVRocks outage during a replay attack would otherwise let every
//! replay through. The error variants below distinguish the two
//! conditions so the engine maps each to its own `AuthError` and audit
//! signal.
//!
//! ── Phase 10 split (RFC §6.11) ──────────────────────────────────────────
//!
//! This trait moves to `access_token::ReplayDefense` in Phase 10 D1.
//! id_token does not import — id tokens are not bearers (see
//! STANDARDS_JWT_DETAILS_MITIGATION_PPOPPO line 225 "M35-M38 inheritance"
//! exclusion).

use std::time::Duration;

/// Atomic check-and-record over a per-token uniqueness key.
///
/// `jti_hash` is the SHA-256 prefix of the raw token (per
/// STANDARDS_JWT_DETAILS_MITIGATION_PPOPPO §E M35), already hashed by
/// the engine before this call so adapters never see jti-equivalent
/// secret material in their substrate logs.
///
/// `ttl` is `claims.exp - now` at verify time — the replay window is
/// bounded by the token's own admissibility window, not a fixed cache
/// TTL. Adapters set the substrate key's expiry to this value so a key
/// outliving its token is impossible.
#[async_trait::async_trait]
pub trait ReplayDefense: std::fmt::Debug + Send + Sync {
    async fn check_and_record(
        &self,
        jti_hash: &str,
        ttl: Duration,
    ) -> Result<(), ReplayDefenseError>;
}

/// Failure modes from a `ReplayDefense` substrate call.
///
/// Two variants — adapters MUST NOT collapse them. `Replayed` is an
/// *attack signal* (someone presented the same jti twice within TTL);
/// `Transient` is an *infrastructure signal* (substrate unreachable).
/// Audit log routing differs (security incident vs ops alert), and
/// the engine maps each to its own `AuthError` variant.
#[derive(Debug, thiserror::Error)]
pub enum ReplayDefenseError {
    /// The jti hash was already recorded within TTL — replay detected.
    /// Engine maps to `AuthError::JtiReplayed`.
    #[error("replay detected for jti hash")]
    Replayed,

    /// Substrate transient failure — engine fails closed and maps to
    /// `AuthError::ReplayCacheUnavailable`. The string carries adapter
    /// context for ops triage (NOT for the audit log; the log keys off
    /// the variant, not the payload).
    #[error("replay cache transient failure: {0}")]
    Transient(String),
}