pas-external 0.8.0-beta.1

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! Async driver loop for the [`SvCore`] state machine.
//!
//! Wraps the existing ports (`SessionStore`, `PasAuthPort`,
//! `SvCachePort`, `TokenCipher`) and fulfills each [`SvStep`] request.
//! Translates `S::Error` (from re-fetch only — every other store error
//! is fail-closed per legacy behavior) to the caller, and folds every
//! other failure into [`SessionResolution::Expired`] via
//! [`fold_decision`].
//!
//! Behavioral invariant: this module preserves the *exact* legacy log
//! lines and severity from `SessionValidator::refresh_and_recheck`
//! (see commit history). The state machine carries
//! [`ExpiryCause`](super::core::ExpiryCause) variants one-for-one with
//! the legacy log call sites.

use std::sync::Arc;

use super::core::{
    CiphertextFeed, PasRefreshFeed, PersistFeed, RefetchFeed, SvCore, SvDecision, SvStep,
};
use super::super::session::SessionResolution;
use super::super::sv_cache::{CheckResult, SV_CACHE_TTL, SvCachePort};
use super::super::traits::SessionStore;
use crate::pas_port::{PasAuthPort, PasRefreshOutcome, pas_refresh};
use crate::session_liveness::TokenCipher;
use crate::types::SessionId;

/// Bundle of ports the driver needs.
pub(crate) struct SvDriverPorts<'a, S, P, B>
where
    S: SessionStore,
    P: PasAuthPort,
    B: SvCachePort,
{
    pub store: &'a Arc<S>,
    pub pas: &'a Arc<P>,
    pub cache: &'a Arc<B>,
    pub cipher: Option<&'a TokenCipher>,
}

/// Apply the SDK-owned key namespace + freshness decision to a raw
/// substrate lookup. Centralized here so each `SvCachePort`
/// implementation stays namespace- and TTL-agnostic.
fn classify_cache(token_sv: i64, cached: Option<i64>) -> CheckResult {
    match cached {
        Some(cached_sv) if token_sv >= cached_sv => CheckResult::Fresh,
        Some(_) => CheckResult::Stale,
        None => CheckResult::Unknown,
    }
}

fn cache_key(ppnum_id: &str) -> String {
    // Phase 6.1.D Finding G: the cache-key shape is owned by
    // `ppoppo-token` (engine SSOT). Reading via the engine's
    // [`sv_cache_key`] makes a future contract change a compile-time
    // ripple instead of silent drift.
    ppoppo_token::sv_cache_key(ppnum_id)
}

/// Drive the SvCore state machine to a terminal decision and fold it
/// into a [`SessionResolution`]. Surfaces `S::Error` only from the final
/// re-fetch step (matching legacy semantics where every other store
/// error fail-closes).
///
/// `session` is the already-resolved base [`SessionStore::AuthContext`];
/// it is returned unchanged on the cache-fresh path. On the refresh
/// path the driver re-fetches a new context from the store after
/// `update_sv`.
pub(crate) async fn drive<S, P, B>(
    ports: SvDriverPorts<'_, S, P, B>,
    session_id: SessionId,
    ppnum_id: String,
    token_sv: i64,
    base_session: S::AuthContext,
) -> Result<SessionResolution<S::AuthContext>, S::Error>
where
    S: SessionStore,
    P: PasAuthPort,
    B: SvCachePort,
{
    // The state machine carries `ciphertext` and `access_token` across
    // step boundaries via SvStep payloads — driver-local Option
    // bookkeeping is unnecessary and would weaken the invariant that
    // those values are only handled inside the refresh path.
    let mut refreshed_ctx: Option<S::AuthContext> = None;

    let (mut core, mut step) = SvCore::start();

    loop {
        step = match step {
            SvStep::QueryCache => {
                let cached = ports.cache.load(&cache_key(&ppnum_id)).await;
                let result = classify_cache(token_sv, cached);
                // Only log on Stale/Unknown — Fresh is the hot path and
                // would 10x the log volume. Operators key on this line
                // to spot break-glass convergence vs cold-cache patterns.
                if !matches!(result, CheckResult::Fresh) {
                    tracing::debug!(
                        session_id = %session_id,
                        sv_cache_outcome = ?result,
                        "sv refresh entered (cache outcome that triggered the path)"
                    );
                }
                core.feed_check(result)
            }

            SvStep::LoadCiphertext => {
                let ct_outcome = match ports.store.get_refresh_ciphertext(&session_id).await {
                    Ok(Some(ct)) => match ports.cipher {
                        Some(_) => CiphertextFeed::Available { ciphertext: ct },
                        None => {
                            // Operator-actionable: ciphertext was persisted
                            // (full OAuth flow ran) but the consumer forgot
                            // to wire `PasAuthConfig::with_refresh_token_cipher`.
                            // Without this log, every Human session expires
                            // silently with no clue why.
                            tracing::error!(
                                session_id = %session_id,
                                "session ciphertext present but no TokenCipher configured \
                                 (PasAuthConfig::with_refresh_token_cipher missing) — Expired"
                            );
                            CiphertextFeed::NoCipherConfigured
                        }
                    },
                    Ok(None) => {
                        tracing::debug!(
                            session_id = %session_id,
                            "no refresh-token ciphertext available — surface as Expired"
                        );
                        CiphertextFeed::Absent
                    }
                    Err(e) => {
                        tracing::warn!(
                            session_id = %session_id,
                            error = %e,
                            "ciphertext lookup failed — surface as Expired"
                        );
                        CiphertextFeed::LookupFailed
                    }
                };
                core.feed_ciphertext(ct_outcome)
            }

            SvStep::PasRefresh { ciphertext } => {
                // Cipher presence was verified at LoadCiphertext —
                // SvCore only emits PasRefresh after CiphertextFeed::Available,
                // which is itself only constructed when `ports.cipher.is_some()`.
                // The `else` branch below is therefore structurally dead;
                // we keep it as a defense-in-depth fail-closed. If a future
                // refactor breaks the invariant the error log makes it
                // immediately visible instead of silently expiring sessions.
                let Some(cipher) = ports.cipher else {
                    tracing::error!(
                        session_id = %session_id,
                        "internal invariant violated: PasRefresh reached with cipher=None — Expired"
                    );
                    return Ok(SessionResolution::Expired);
                };

                let feed = match pas_refresh(cipher, &**ports.pas, &ciphertext).await {
                    Ok(PasRefreshOutcome::Refreshed { tokens }) => {
                        // Trust-extract sv from the just-issued access_token
                        // (TLS-to-PAS trust boundary; same boundary as the
                        // legacy `/userinfo` snapshot path). `None` here on
                        // a Human session is anomalous — feed_pas_refresh
                        // expires with `AccessTokenMissingSv`. Logged at
                        // error severity to mirror the prior MissingSv
                        // call site.
                        let new_sv = crate::token::jwt::peek_session_version(
                            &tokens.access_token,
                        );
                        if new_sv.is_none() {
                            tracing::error!(
                                session_id = %session_id,
                                "PAS refreshed access_token but `sv` claim \
                                 absent on a Human session — Expired"
                            );
                        }
                        PasRefreshFeed::Refreshed { new_sv }
                    }
                    Ok(PasRefreshOutcome::Rejected { status, detail }) => {
                        tracing::info!(
                            session_id = %session_id,
                            status = status,
                            detail = %detail,
                            "PAS refresh rejected — Expired (likely revoked)"
                        );
                        PasRefreshFeed::Rejected
                    }
                    Ok(PasRefreshOutcome::Transient { detail }) => {
                        tracing::warn!(
                            session_id = %session_id,
                            detail = %detail,
                            "PAS refresh failed (transient) — Expired (S-L6 fail-closed)"
                        );
                        PasRefreshFeed::Transient
                    }
                    Err(_cipher_failure) => {
                        tracing::error!(
                            session_id = %session_id,
                            "ciphertext decrypt failed — Expired (S-L6 fail-closed; \
                             stored ciphertext unrecoverable)"
                        );
                        PasRefreshFeed::CipherFailed
                    }
                };
                core.feed_pas_refresh(feed)
            }

            SvStep::PersistSv { new_sv } => {
                let feed = match ports.store.update_sv(&session_id, new_sv).await {
                    Ok(()) => PersistFeed::Ok,
                    Err(e) => {
                        tracing::warn!(
                            session_id = %session_id,
                            new_sv = new_sv,
                            error = %e,
                            "update_sv failed after refresh — Expired",
                        );
                        PersistFeed::Failed
                    }
                };
                core.feed_persist(feed)
            }

            SvStep::RecordCache { sv } => {
                ports.cache.store(&cache_key(&ppnum_id), sv, SV_CACHE_TTL).await;
                core.feed_record()
            }

            SvStep::ReFetch => {
                // The only path that surfaces S::Error to the caller —
                // matches legacy line 275 (`store.find(...).await?`).
                let feed = match ports.store.find(&session_id).await? {
                    Some(ctx) => {
                        refreshed_ctx = Some(ctx);
                        RefetchFeed::Found
                    }
                    None => RefetchFeed::Missing,
                };
                core.feed_refetch(feed)
            }

            SvStep::Done(decision) => {
                return Ok(fold_decision(&session_id, decision, base_session, refreshed_ctx));
            }
        };
    }
}

fn fold_decision<A>(
    session_id: &SessionId,
    decision: SvDecision,
    base_session: A,
    refreshed: Option<A>,
) -> SessionResolution<A> {
    match decision {
        SvDecision::FreshFromCache => SessionResolution::Authenticated(base_session),
        SvDecision::Refreshed => match refreshed {
            Some(ctx) => SessionResolution::Authenticated(ctx),
            None => {
                // Structurally unreachable today: SvCore emits Refreshed
                // only via feed_refetch(RefetchFeed::Found), which the
                // driver pairs with `refreshed_ctx = Some(ctx)`. Logged
                // as defense-in-depth: a future SvCore edit that emits
                // Refreshed elsewhere would otherwise fail-close silently.
                tracing::error!(
                    session_id = %session_id,
                    "internal invariant violated: SvCore emitted Refreshed without refreshed_ctx — Expired"
                );
                SessionResolution::Expired
            }
        },
        SvDecision::Expired(_cause) => SessionResolution::Expired,
    }
}

#[cfg(test)]
mod tests {
    use super::{CheckResult, cache_key, classify_cache};

    #[test]
    fn cache_key_uses_canonical_namespace() {
        // Pins SV_CACHE_KEY_PREFIX. Sister crates (`ppoppo-token`,
        // PCS chat-auth) hold their own copy of this prefix; if it
        // ever moves, all three must update in lockstep.
        assert_eq!(cache_key("01HXYZ"), "sv:01HXYZ");
    }

    #[test]
    fn classify_fresh_when_token_sv_ge_cached() {
        assert_eq!(classify_cache(5, Some(5)), CheckResult::Fresh);
        assert_eq!(classify_cache(6, Some(5)), CheckResult::Fresh);
    }

    #[test]
    fn classify_stale_when_token_sv_lt_cached() {
        assert_eq!(classify_cache(9, Some(10)), CheckResult::Stale);
    }

    #[test]
    fn classify_unknown_on_miss() {
        assert_eq!(classify_cache(1, None), CheckResult::Unknown);
    }
}