pas-external 0.7.1

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! Synchronous, IO-free session-validation state machine.
//!
//! Mirrors the decision tree formerly inlined in
//! [`SessionValidator`](super::super::validator::SessionValidator) (the
//! pre-0.4.0 `refresh_and_recheck` async fn — see git history),
//! but as a sync state machine driven by typed `feed_*` inputs. The
//! async driver in [`super::adapter`] performs the actual IO; this
//! module only chooses the next step and folds outcomes into a
//! terminal [`SvDecision`].
//!
//! # Invariants enforced structurally
//!
//! 1. **Fail-CLOSED.** Every failing variant on every `feed_*` input is
//!    folded into [`SvDecision::Expired`]. The store-layer `S::Error`
//!    surfaces from the driver before reaching the state machine — the
//!    state machine itself only handles success/typed-failure inputs.
//! 2. **`update_sv` before `policy.record`.** The only path from
//!    [`SvStep::PersistSv`] is through `feed_persist`, which on success
//!    yields [`SvStep::RecordCache`]. The only path to
//!    [`SvDecision::Refreshed`] passes through both. No ordering
//!    discipline is required of the driver — the type sequence enforces
//!    it.

use crate::session_liveness::EncryptedRefreshToken;

use super::super::sv_cache::CheckResult;

/// Next IO action the driver must perform, or [`SvStep::Done`] when the
/// state machine has reached a terminal decision.
#[derive(Debug)]
#[must_use]
pub(crate) enum SvStep {
    /// Driver: call `policy.check(ppnum_id, token_sv)` and feed the
    /// result via [`SvCore::feed_check`].
    QueryCache,
    /// Driver: call `store.get_refresh_ciphertext(session_id)` and feed
    /// the classified outcome via [`SvCore::feed_ciphertext`].
    LoadCiphertext,
    /// Driver: call `pas_refresh(cipher, port, &ciphertext)` and feed
    /// the classified outcome via [`SvCore::feed_pas_refresh`]. The
    /// state machine carries the ciphertext so the driver does not have
    /// to thread it locally between steps. After PAS returns a fresh
    /// access_token, the driver trust-extracts the `sv` claim
    /// (`token::jwt::peek_session_version`) and feeds it via
    /// [`PasRefreshFeed::Refreshed`] — no separate `/userinfo`
    /// round-trip (Phase 10.13: F6 single-SSOT cleanup).
    PasRefresh { ciphertext: EncryptedRefreshToken },
    /// Driver: call `store.update_sv(session_id, new_sv)` and feed the
    /// success/failure flag via [`SvCore::feed_persist`].
    PersistSv { new_sv: i64 },
    /// Driver: call `policy.record(ppnum_id, sv)` (infallible) and call
    /// [`SvCore::feed_record`].
    RecordCache { sv: i64 },
    /// Driver: call `store.find(session_id)` and feed the classified
    /// outcome via [`SvCore::feed_refetch`]. The driver `?`s the
    /// underlying `S::Error` *before* feeding — `S::Error` never reaches
    /// the state machine.
    ReFetch,
    /// Terminal — driver folds this into a [`SessionResolution`](super::super::session::SessionResolution).
    Done(SvDecision),
}

/// Terminal verdict produced by the state machine.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SvDecision {
    /// Cache said `Fresh`. The base session's `AuthContext` is admitted
    /// unchanged.
    FreshFromCache,
    /// Refreshed against PAS; the driver must return the re-fetched
    /// `AuthContext` produced during [`SvStep::ReFetch`].
    Refreshed,
    /// Fail-closed. `cause` is preserved for telemetry.
    Expired(ExpiryCause),
}

/// Why a session is being expired. One arm per distinct telemetry path
/// in the legacy `refresh_and_recheck` log set — the driver maps
/// `ExpiryCause` → `tracing::{info,warn,error}!` to preserve severity.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub(crate) enum ExpiryCause {
    /// `store.get_refresh_ciphertext` returned `Ok(None)` — DEV_AUTH
    /// session, or write-side never persisted ciphertext.
    RefreshCiphertextAbsent,
    /// `store.get_refresh_ciphertext` returned `Err(_)` — store failure.
    /// Driver fail-closes (warn) rather than surfacing.
    RefreshLoadFailed,
    /// Ciphertext present but no `TokenCipher` configured — soft
    /// misconfiguration. Operator-actionable (error level).
    CipherMissing,
    /// `pas_refresh` returned `Err(CipherFailure)` — stored ciphertext
    /// could not be decrypted. Operator-actionable (error level).
    CipherFailed,
    /// PAS refused the refresh — definitive 4xx. Likely revoked.
    PasRefreshRejected,
    /// PAS unreachable / 5xx / parse failure on `/token`. S-L6
    /// fail-closed (warn level).
    PasRefreshTransient,
    /// PAS returned a fresh access_token but the `sv` claim was absent
    /// or malformed on a Human session — anomalous (the access_token
    /// shape contract requires `sv` for Human entities). Operator-
    /// actionable (error level).
    AccessTokenMissingSv,
    /// `store.update_sv` returned `Err(_)`. Fail-closed (warn level) —
    /// must not let cache + store diverge.
    PersistFailed,
    /// `store.find` re-fetch returned `Ok(None)`. Session vanished
    /// between `update_sv` and re-fetch (race with logout, GC).
    ReFetchMissing,
}

/// Driver feeds this after [`SvStep::LoadCiphertext`].
#[derive(Debug)]
pub(crate) enum CiphertextFeed {
    /// Ciphertext was retrieved AND a `TokenCipher` is configured.
    /// The driver passes the ciphertext through; the state machine
    /// hands it back to the driver via [`SvStep::PasRefresh`].
    Available { ciphertext: EncryptedRefreshToken },
    /// `store.get_refresh_ciphertext` returned `Ok(None)`.
    Absent,
    /// `store.get_refresh_ciphertext` returned `Err(_)`.
    LookupFailed,
    /// Ciphertext was retrieved but `TokenCipher` is `None`.
    NoCipherConfigured,
}

/// Driver feeds this after [`SvStep::PasRefresh`]. Mirrors
/// [`PasRefreshOutcome`](crate::pas_port::PasRefreshOutcome) plus the
/// `CipherFailure` arm.
#[derive(Debug)]
pub(crate) enum PasRefreshFeed {
    /// Tokens received and `sv` claim trust-extracted from the new
    /// access_token. `None` on a Human session is anomalous — the
    /// driver classifies via this variant and the state machine
    /// fail-closes via `ExpiryCause::AccessTokenMissingSv`.
    Refreshed { new_sv: Option<i64> },
    /// Definitive 4xx from PAS (revoked / expired).
    Rejected,
    /// 5xx / transport / parse failure.
    Transient,
    /// Decrypt failed before the network call.
    CipherFailed,
}

/// Driver feeds this after [`SvStep::PersistSv`].
#[derive(Debug)]
pub(crate) enum PersistFeed {
    Ok,
    Failed,
}

/// Driver feeds this after [`SvStep::ReFetch`]. The driver `?`s the
/// underlying `S::Error` before feeding.
#[derive(Debug)]
pub(crate) enum RefetchFeed {
    Found,
    Missing,
}

// ---------------------------------------------------------------------
// State machine
// ---------------------------------------------------------------------

/// Sync state-machine encoding the SvAware refresh-and-recheck decision
/// tree. See the module-level docs for invariants.
pub(crate) struct SvCore {
    state: State,
}

#[derive(Debug)]
enum State {
    /// Started — first step is `QueryCache`.
    AwaitingCheck,
    /// `feed_check` returned Stale|Unknown — next is `LoadCiphertext`.
    AwaitingCiphertext,
    /// Ciphertext available + cipher configured — next is `PasRefresh`.
    AwaitingPasRefresh,
    /// `pas_refresh` returned Refreshed with sv extracted — next is
    /// `PersistSv`.
    AwaitingPersist { new_sv: i64 },
    /// `update_sv` succeeded — next is `RecordCache`. The
    /// `update_sv`-before-`record` invariant is encoded by the fact
    /// that this state is only reachable via [`SvCore::feed_persist`]
    /// returning `Ok`.
    AwaitingRecord,
    /// `policy.record` complete — next is `ReFetch`.
    AwaitingRefetch,
    /// Terminal — further `feed_*` calls are a programmer error.
    Done,
}

impl SvCore {
    /// Construct and emit the first step.
    pub(crate) fn start() -> (Self, SvStep) {
        (Self { state: State::AwaitingCheck }, SvStep::QueryCache)
    }

    /// Drive the state machine after [`SvStep::QueryCache`].
    pub(crate) fn feed_check(&mut self, result: CheckResult) -> SvStep {
        debug_assert!(matches!(self.state, State::AwaitingCheck));
        match result {
            CheckResult::Fresh => self.terminate(SvDecision::FreshFromCache),
            CheckResult::Stale | CheckResult::Unknown => {
                self.state = State::AwaitingCiphertext;
                SvStep::LoadCiphertext
            }
        }
    }

    /// Drive the state machine after [`SvStep::LoadCiphertext`].
    pub(crate) fn feed_ciphertext(&mut self, result: CiphertextFeed) -> SvStep {
        debug_assert!(matches!(self.state, State::AwaitingCiphertext));
        match result {
            CiphertextFeed::Available { ciphertext } => {
                self.state = State::AwaitingPasRefresh;
                SvStep::PasRefresh { ciphertext }
            }
            CiphertextFeed::Absent => self.expire(ExpiryCause::RefreshCiphertextAbsent),
            CiphertextFeed::LookupFailed => self.expire(ExpiryCause::RefreshLoadFailed),
            CiphertextFeed::NoCipherConfigured => self.expire(ExpiryCause::CipherMissing),
        }
    }

    /// Drive the state machine after [`SvStep::PasRefresh`].
    pub(crate) fn feed_pas_refresh(&mut self, result: PasRefreshFeed) -> SvStep {
        debug_assert!(matches!(self.state, State::AwaitingPasRefresh));
        match result {
            PasRefreshFeed::Refreshed { new_sv: Some(new_sv) } => {
                self.state = State::AwaitingPersist { new_sv };
                SvStep::PersistSv { new_sv }
            }
            PasRefreshFeed::Refreshed { new_sv: None } => {
                self.expire(ExpiryCause::AccessTokenMissingSv)
            }
            PasRefreshFeed::Rejected => self.expire(ExpiryCause::PasRefreshRejected),
            PasRefreshFeed::Transient => self.expire(ExpiryCause::PasRefreshTransient),
            PasRefreshFeed::CipherFailed => self.expire(ExpiryCause::CipherFailed),
        }
    }

    /// Drive the state machine after [`SvStep::PersistSv`].
    pub(crate) fn feed_persist(&mut self, result: PersistFeed) -> SvStep {
        let new_sv = match self.state {
            State::AwaitingPersist { new_sv } => new_sv,
            _ => {
                debug_assert!(false, "feed_persist called in unexpected state");
                return self.expire(ExpiryCause::PersistFailed);
            }
        };
        match result {
            PersistFeed::Ok => {
                self.state = State::AwaitingRecord;
                SvStep::RecordCache { sv: new_sv }
            }
            PersistFeed::Failed => self.expire(ExpiryCause::PersistFailed),
        }
    }

    /// Drive the state machine after [`SvStep::RecordCache`]. Infallible
    /// — `policy.record` is best-effort.
    pub(crate) fn feed_record(&mut self) -> SvStep {
        debug_assert!(matches!(self.state, State::AwaitingRecord));
        self.state = State::AwaitingRefetch;
        SvStep::ReFetch
    }

    /// Drive the state machine after [`SvStep::ReFetch`].
    pub(crate) fn feed_refetch(&mut self, result: RefetchFeed) -> SvStep {
        debug_assert!(matches!(self.state, State::AwaitingRefetch));
        match result {
            RefetchFeed::Found => self.terminate(SvDecision::Refreshed),
            RefetchFeed::Missing => self.expire(ExpiryCause::ReFetchMissing),
        }
    }

    fn expire(&mut self, cause: ExpiryCause) -> SvStep {
        self.terminate(SvDecision::Expired(cause))
    }

    fn terminate(&mut self, decision: SvDecision) -> SvStep {
        self.state = State::Done;
        SvStep::Done(decision)
    }
}