pas-external 0.2.0

Ppoppo Accounts System (PAS) external SDK -- OAuth2 PKCE, PASETO verification, Axum middleware, session liveness
Documentation
//! SvAware session resolver — STANDARDS_AUTH_INVALIDATION §5.3.
//!
//! Wraps the base [`SessionResolver`] with an `sv`-version check
//! against a consumer-local cache. On miss or stale, fetches the
//! at-rest ciphertext via [`SessionStore::get_refresh_ciphertext`]
//! and routes it through the unified [`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) bounds cross-cluster propagation latency for
//! a PAS-side break-glass / lifecycle invalidation per spec §4.3.
//!
//! S-L6 fail-CLOSED: every PasFailure variant — including `Transient`
//! — and every [`CipherFailure`] surface 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::traits::{SessionStore, SvAware};
use crate::pas_port::{CipherFailure, PasAuthPort, PasRefreshOutcome, pas_refresh};
use crate::session_liveness::TokenCipher;
use crate::session_version::{SV_CACHE_KEY_PREFIX, SV_CACHE_TTL, SessionVersionCache};
use crate::types::SessionId;

/// Wrap a base [`SessionResolver`] with sv enforcement + refresh fallback.
pub struct SvAwareSessionResolver<S, C, P>
where
    S: SessionStore,
    C: SessionVersionCache,
    P: PasAuthPort,
{
    base: SessionResolver<S>,
    store: Arc<S>,
    pas: Arc<P>,
    cache: Arc<C>,
    /// 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, C: Clone, P: Clone` bounds.
impl<S, C, P> Clone for SvAwareSessionResolver<S, C, P>
where
    S: SessionStore,
    C: SessionVersionCache,
    P: PasAuthPort,
{
    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, C, P> SvAwareSessionResolver<S, C, P>
where
    S: SessionStore,
    C: SessionVersionCache,
    P: PasAuthPort,
{
    /// Construct directly. Most consumers should use
    /// [`super::PasAuth::resolver`] / [`super::PasAuth::resolver_with_cache`];
    /// 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<C>,
        cipher: Option<Arc<TokenCipher>>,
    ) -> Self {
        Self { base, store, pas, cache, cipher }
    }

    /// Resolve a session cookie into an authentication context, with
    /// `sv` enforcement.
    ///
    /// 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.
    ///
    /// # Errors
    ///
    /// Surfaces [`SessionStore::Error`] from the underlying store
    /// lookup. All other failure modes are flattened into `Expired`.
    pub async fn resolve(
        &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));
        };

        let cache_key = format!("{SV_CACHE_KEY_PREFIX}{}", session.ppnum_id());

        match self.cache.get(&cache_key).await {
            Some(cached_sv) if token_sv >= cached_sv => {
                Ok(SessionResolution::Authenticated(session))
            }
            _ => self.refresh_and_recheck(jar, &cache_key).await,
        }
    }

    async fn refresh_and_recheck(
        &self,
        jar: &PrivateCookieJar,
        cache_key: &str,
    ) -> Result<SessionResolution<S::AuthContext>, S::Error> {
        let Some(cookie) = jar.get(self.base.cookie_name()) else {
            return Ok(SessionResolution::Expired);
        };
        let session_id = SessionId(cookie.value().to_string());

        // 1. Fetch ciphertext from the consumer (no decrypt on the wire).
        let ct = match self.store.get_refresh_ciphertext(&session_id).await {
            Ok(Some(ct)) => ct,
            Ok(None) => {
                tracing::debug!(
                    session_id = %session_id,
                    "no refresh-token ciphertext available — surface as Expired"
                );
                return Ok(SessionResolution::Expired);
            }
            Err(e) => {
                tracing::warn!(
                    session_id = %session_id,
                    error = %e,
                    "ciphertext lookup failed — surface as Expired"
                );
                return Ok(SessionResolution::Expired);
            }
        };

        // 2. Soft misconfiguration check: ciphertext exists but no cipher.
        let cipher = match self.cipher.as_ref() {
            Some(c) => c.as_ref(),
            None => {
                tracing::error!(
                    session_id = %session_id,
                    "session ciphertext present but no TokenCipher configured \
                     (PasAuthConfig::with_refresh_token_cipher missing) — Expired"
                );
                return Ok(SessionResolution::Expired);
            }
        };

        // 3. Delegate decrypt + PAS /token to the deep core. S-L6 collapses
        //    every failure variant — including Transient — into Expired.
        let token_response = match pas_refresh(cipher, &*self.pas, &ct).await {
            Ok(PasRefreshOutcome::Refreshed { tokens }) => tokens,
            Ok(PasRefreshOutcome::Rejected { status, detail }) => {
                tracing::info!(
                    session_id = %session_id,
                    status = status,
                    detail = %detail,
                    "PAS refresh rejected — Expired (likely revoked)"
                );
                return Ok(SessionResolution::Expired);
            }
            Ok(PasRefreshOutcome::Transient { detail }) => {
                tracing::warn!(
                    session_id = %session_id,
                    detail = %detail,
                    "PAS refresh failed (transient) — Expired (S-L6 fail-closed)"
                );
                return Ok(SessionResolution::Expired);
            }
            Err(CipherFailure) => {
                // Match the S-L3 severity ladder — cipher failure is
                // operator-actionable (key rotation accident, DB tamper,
                // corruption) and must not be downgraded to warn just
                // because S-L6 fail-closes on it as a side-effect.
                tracing::error!(
                    session_id = %session_id,
                    "ciphertext decrypt failed — Expired (S-L6 fail-closed; stored ciphertext unrecoverable)"
                );
                return Ok(SessionResolution::Expired);
            }
        };

        // 4. Read fresh sv from /userinfo via the port.
        let new_sv = match self.pas.userinfo(&token_response.access_token).await {
            Ok(info) => match info.session_version {
                Some(sv) => sv,
                None => {
                    // Human session refreshed but userinfo omits sv.
                    // Anomalous (PAS regression, dev/prod skew, proxy
                    // mangling) — fail CLOSED.
                    tracing::error!(
                        session_id = %session_id,
                        "Human session refreshed but userinfo.session_version=None — Expired"
                    );
                    return Ok(SessionResolution::Expired);
                }
            },
            Err(e) => {
                use crate::pas_port::PasFailure;
                match &e {
                    PasFailure::Rejected { .. } => tracing::info!(
                        session_id = %session_id,
                        error = ?e,
                        "post-refresh userinfo rejected — Expired (revoked between /token and /userinfo)"
                    ),
                    PasFailure::ServerError { .. } | PasFailure::Transport { .. } => {
                        tracing::warn!(
                            session_id = %session_id,
                            error = ?e,
                            "post-refresh userinfo failed (transient) — Expired (S-L6 fail-closed)"
                        )
                    }
                }
                return Ok(SessionResolution::Expired);
            }
        };

        // 5. Persist new sv. Fail CLOSED on store error so cache + store
        //    cannot diverge.
        if let Err(e) = self.store.update_sv(&session_id, new_sv).await {
            tracing::warn!(
                session_id = %session_id,
                new_sv = new_sv,
                error = %e,
                "update_sv failed after refresh — Expired",
            );
            return Ok(SessionResolution::Expired);
        }
        self.cache.set(cache_key, new_sv, SV_CACHE_TTL).await;

        // 6. Re-fetch the session so the returned AuthContext carries
        //    the updated sv.
        match self.store.find(&session_id).await? {
            Some(refreshed) => Ok(SessionResolution::Authenticated(refreshed)),
            None => Ok(SessionResolution::Expired),
        }
    }
}