pas-external 0.8.0-beta.1

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! Session validator — STANDARDS_AUTH_INVALIDATION §5.3.
//!
//! [`SessionValidator`] wraps the base [`SessionResolver`] with an
//! `sv`-version check against a consumer-local cache (an
//! [`SvCachePort`](super::sv_cache::SvCachePort), defaulting to
//! [`MemorySvBackend`]). On a stale/unknown cache outcome, fetches the
//! at-rest ciphertext via [`SessionStore::get_refresh_ciphertext`] and
//! routes it through the unified [`pas_refresh`](crate::pas_port::pas_refresh)
//! deep core (decrypt → call PAS → classify), then refreshes the access
//! token's `sv` via [`PasAuthPort::userinfo`], persists the new `sv`,
//! and re-checks.
//!
//! The cache TTL (60 s, spec-fixed) bounds cross-cluster propagation
//! latency for a PAS-side break-glass / lifecycle invalidation per
//! spec §4.3.
//!
//! S-L6 fail-CLOSED: every refresh-path failure — PAS rejected /
//! transient, ciphertext decrypt failure, `update_sv` failure — surfaces
//! as `Expired`. Break-glass propagation must not be bypassable by a PAS
//! hiccup or a local cipher misconfiguration.

use std::sync::Arc;

use axum_extra::extract::PrivateCookieJar;

use super::session::{SessionResolution, SessionResolver};
use super::sv::adapter::{SvDriverPorts, drive};
use super::sv_cache::{MemorySvBackend, SvCachePort};
use super::traits::{SessionStore, SvAware};
use crate::pas_port::PasAuthPort;
use crate::session_liveness::TokenCipher;
use crate::types::SessionId;

/// Wrap a base [`SessionResolver`] with sv enforcement + refresh fallback.
///
/// `B` defaults to [`MemorySvBackend`] so single-pod consumers never
/// type the parameter. Multi-pod consumers swap in a KVRocks/Redis
/// backend via [`super::PasAuth::session_validator_with_backend`].
pub struct SessionValidator<S, P, B = MemorySvBackend>
where
    S: SessionStore,
    P: PasAuthPort,
    B: SvCachePort,
{
    base: SessionResolver<S>,
    store: Arc<S>,
    pas: Arc<P>,
    cache: Arc<B>,
    /// Cipher used by [`pas_refresh`] to decrypt the at-rest ciphertext
    /// returned by [`SessionStore::get_refresh_ciphertext`]. `None` is a
    /// soft misconfiguration: if a session has stored ciphertext but no
    /// cipher was configured via
    /// [`PasAuthConfig::with_refresh_token_cipher`](super::PasAuthConfig::with_refresh_token_cipher),
    /// the resolver fails closed (logs error, returns `Expired`).
    cipher: Option<Arc<TokenCipher>>,
}

// Manual Clone — derive would add `S: Clone, P: Clone, B: Clone` bounds.
impl<S, P, B> Clone for SessionValidator<S, P, B>
where
    S: SessionStore,
    P: PasAuthPort,
    B: SvCachePort,
{
    fn clone(&self) -> Self {
        Self {
            base: self.base.clone(),
            store: Arc::clone(&self.store),
            pas: Arc::clone(&self.pas),
            cache: Arc::clone(&self.cache),
            cipher: self.cipher.as_ref().map(Arc::clone),
        }
    }
}

impl<S, P, B> SessionValidator<S, P, B>
where
    S: SessionStore,
    P: PasAuthPort,
    B: SvCachePort,
{
    /// Construct directly. Most consumers should use
    /// [`super::PasAuth::session_validator`] /
    /// [`super::PasAuth::session_validator_with_backend`]; the public
    /// constructor exists so SDK boundary tests can substitute
    /// `MemoryPasAuth` without going through the full `PasAuth`
    /// builder.
    ///
    /// `cipher` is `Option<Arc<TokenCipher>>`: pass `None` only when the
    /// consumer never persists refresh tokens (DEV_AUTH-only, no OAuth
    /// flow). When `None`, the refresh path always surfaces `Expired`.
    pub fn new(
        base: SessionResolver<S>,
        store: Arc<S>,
        pas: Arc<P>,
        cache: Arc<B>,
        cipher: Option<Arc<TokenCipher>>,
    ) -> Self {
        Self { base, store, pas, cache, cipher }
    }

    /// Validate a session cookie: resolve the base session, enforce
    /// the PASETO `sv` claim against the cache, and refresh against
    /// PAS if needed.
    ///
    /// Failure handling is **fail-CLOSED** for every step beyond the
    /// base resolver. Break-glass invalidation must not be bypassable
    /// by a PAS 5xx, a transport blip, or a local cipher failure.
    ///
    /// The orchestration is delegated to an SDK-internal sync state
    /// machine driven by an async loop in `middleware::sv::adapter`;
    /// this method handles only the pre-conditions (base resolution,
    /// sv-claim absence, session-id extraction).
    ///
    /// # Errors
    ///
    /// Surfaces [`SessionStore::Error`] from the underlying store
    /// lookup. All other failure modes are flattened into `Expired`.
    pub async fn validate(
        &self,
        jar: &PrivateCookieJar,
    ) -> Result<SessionResolution<S::AuthContext>, S::Error> {
        let resolution = self.base.resolve(jar).await?;
        let session = match resolution {
            SessionResolution::Authenticated(s) => s,
            other => return Ok(other),
        };

        // sv claim absent → admit (spec §4.2.1: AI agent / DEV_AUTH).
        let Some(token_sv) = session.sv() else {
            return Ok(SessionResolution::Authenticated(session));
        };

        // Re-extract session_id for the driver. The cookie was already
        // validated by `base.resolve`; if it has vanished here, treat
        // as a benign race and fail closed.
        let Some(cookie) = jar.get(self.base.cookie_name()) else {
            return Ok(SessionResolution::Expired);
        };
        let session_id = SessionId(cookie.value().to_string());
        let ppnum_id = session.ppnum_id().to_string();

        let ports = SvDriverPorts {
            store: &self.store,
            pas: &self.pas,
            cache: &self.cache,
            cipher: self.cipher.as_ref().map(Arc::as_ref),
        };
        drive::<S, P, B>(ports, session_id, ppnum_id, token_sv, session).await
    }
}