pas-external 0.1.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, refreshes the
//! PAS access token via [`PasAuthPort`] to pick up the current `sv`,
//! updates both the session store and the cache, 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`
//! — surfaces as `Expired`. Break-glass propagation must not be
//! bypassable by a PAS hiccup.

use std::sync::Arc;

use axum_extra::extract::PrivateCookieJar;

use super::session::{SessionResolution, SessionResolver};
use super::traits::{RefreshTokenResolver, SessionStore, SvAware};
use crate::pas_port::PasAuthPort;
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, R, C, P>
where
    S: SessionStore,
    R: RefreshTokenResolver,
    C: SessionVersionCache,
    P: PasAuthPort,
{
    base: SessionResolver<S>,
    store: Arc<S>,
    refresh_resolver: Arc<R>,
    pas: Arc<P>,
    cache: Arc<C>,
}

// Manual Clone — derive would add `S: Clone, R: Clone, C: Clone, P: Clone` bounds.
impl<S, R, C, P> Clone for SvAwareSessionResolver<S, R, C, P>
where
    S: SessionStore,
    R: RefreshTokenResolver,
    C: SessionVersionCache,
    P: PasAuthPort,
{
    fn clone(&self) -> Self {
        Self {
            base: self.base.clone(),
            store: Arc::clone(&self.store),
            refresh_resolver: Arc::clone(&self.refresh_resolver),
            pas: Arc::clone(&self.pas),
            cache: Arc::clone(&self.cache),
        }
    }
}

impl<S, R, C, P> SvAwareSessionResolver<S, R, C, P>
where
    S: SessionStore,
    R: RefreshTokenResolver,
    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.
    pub fn new(
        base: SessionResolver<S>,
        store: Arc<S>,
        refresh_resolver: Arc<R>,
        pas: Arc<P>,
        cache: Arc<C>,
    ) -> Self {
        Self { base, store, refresh_resolver, pas, cache }
    }

    /// 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.
    ///
    /// # 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 decrypted refresh_token from the consumer.
        let refresh_token = match self
            .refresh_resolver
            .resolve_refresh_token(&session_id)
            .await
        {
            Ok(Some(rt)) => rt,
            Ok(None) => {
                tracing::debug!(
                    session_id = %session_id,
                    "no refresh_token available — surface as Expired"
                );
                return Ok(SessionResolution::Expired);
            }
            Err(e) => {
                tracing::warn!(
                    session_id = %session_id,
                    error = %e,
                    "refresh_token lookup failed — surface as Expired"
                );
                return Ok(SessionResolution::Expired);
            }
        };

        // 2. Call PAS /token via the port. Fail-CLOSED on every PasFailure.
        let token_response = match self.pas.refresh(&refresh_token).await {
            Ok(t) => t,
            Err(e) => {
                // Split the log level by variant so PAS outages
                // (ServerError / Transport) surface as warnings while
                // routine token revocation (Rejected) stays at info.
                use crate::pas_port::PasFailure;
                match &e {
                    PasFailure::Rejected { .. } => tracing::info!(
                        session_id = %session_id,
                        error = ?e,
                        "PAS refresh rejected — Expired (likely revoked)"
                    ),
                    PasFailure::ServerError { .. } | PasFailure::Transport { .. } => {
                        tracing::warn!(
                            session_id = %session_id,
                            error = ?e,
                            "PAS refresh failed (transient) — Expired (S-L6 fail-closed)"
                        )
                    }
                }
                return Ok(SessionResolution::Expired);
            }
        };

        // 3. 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) => {
                // Split log level the same way as the refresh branch.
                // A `Rejected` here means the access_token PAS just
                // issued was revoked between /token and /userinfo —
                // routine, info. A `ServerError` / `Transport` is a
                // PAS outage during S-L6 enforcement — warn.
                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);
            }
        };

        // 4. 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;

        // 5. 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),
        }
    }
}