pas-external 0.8.0-beta.1

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! Session liveness verification against PAS — S-L3 fail-open path.
//!
//! Consumers who treat PAS as the single source of truth for session
//! validity call [`attempt_liveness_refresh`] periodically (e.g.,
//! every 15 minutes since a session was last verified). The helper
//! handles the decrypt → call PAS → re-encrypt-if-rotated sequence
//! and returns a [`LivenessOutcome`] that cleanly splits "keep
//! trusting the cache", "drop the session", and "PAS is shaky, serve
//! cache for now".
//!
//! # Why three outcomes
//!
//! A two-state "alive / dead" classification is too coarse: a transient
//! PAS outage (5xx, network blip) would force-logout every active user
//! every 15 minutes. The S-L3 fail-open invariant in
//! `STANDARDS_SESSION_LIVENESS.md` prohibits that.
//!
//! # Cause variants
//!
//! Both [`LivenessFailure`] variants carry a cause ([`RevokeCause`] /
//! [`TransientCause`]) so consumers can log *why* without the SDK
//! emitting its own tracing events.
//!
//! # What the SDK does NOT do
//!
//! - It does not persist `last_verified_at` or `revoked_at`.
//! - It does not decide when to run the check (consumer's stale-gate).
//! - It does not log (consumer logs with its own correlation IDs).

use std::time::Duration;

use super::cipher::{EncryptedRefreshToken, TokenCipher};
use crate::pas_port::{CipherFailure, PasAuthPort, PasRefreshOutcome, pas_refresh};

const DEFAULT_TRANSIENT_RETRY_AFTER: Duration = Duration::from_secs(2);

/// Why a session was revoked.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RevokeCause {
    /// The stored ciphertext could not be decrypted. Local issue
    /// (key rotation accident, DB tamper). Operators investigate.
    CipherFailure,
    /// PAS returned a permanent OAuth error (4xx). User logged out
    /// elsewhere, admin revocation, refresh_token expired. Routine.
    PasRejected,
}

/// Why a liveness attempt was classified transient.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TransientCause {
    /// PAS is unreachable: a 5xx response, an HTTP transport failure
    /// (timeout, TLS, DNS, connect), or a 2xx body that failed to
    /// parse (CDN/proxy interference). All three categories are
    /// indistinguishable at the policy level (S-L3 serves cache for
    /// every one of them) — the variant name is a deliberate
    /// simplification carried over from v3 where each had a separate
    /// cause variant; v5.0 merged them.
    ///
    /// Detail strings carrying the underlying
    /// [`PasFailure::Transport`][crate::pas_port::PasFailure::Transport]
    /// or
    /// [`PasFailure::ServerError`][crate::pas_port::PasFailure::ServerError]
    /// message are not exposed here — consumers needing that level of
    /// diagnostic should implement
    /// [`PasAuthPort`][crate::pas_port::PasAuthPort] and log inside the
    /// adapter.
    PasServerError,
    /// Re-encrypting a rotated refresh_token failed after PAS already
    /// confirmed liveness. Local infrastructure issue; the previously
    /// stored ciphertext remains valid.
    CipherEncryptFailed,
}

/// A liveness attempt that did not confirm freshness.
#[derive(Debug)]
#[must_use]
pub enum LivenessFailure {
    /// PAS rejected the refresh_token, or the SDK cannot recover it.
    /// Mark the session revoked and drop the auth context.
    Revoked { cause: RevokeCause },
    /// PAS is temporarily unreachable or local infra blipped. Serve
    /// the cached session. `retry_after` is a back-off hint.
    Transient {
        retry_after: Option<Duration>,
        cause: TransientCause,
    },
}

/// Outcome of a single PAS liveness round-trip.
#[derive(Debug)]
#[must_use]
pub enum LivenessOutcome {
    /// Session reconfirmed against PAS. If `rotated_ciphertext` is
    /// `Some(ct)`, persist that as the new ciphertext (PAS rotated).
    /// If `None`, the existing ciphertext remains valid. Always
    /// update `last_verified_at`.
    Fresh { rotated_ciphertext: Option<String> },
    /// See [`LivenessFailure`].
    Failed(LivenessFailure),
}

/// Run one liveness round-trip against PAS.
///
/// Flow:
///
/// 1. Run [`pas_refresh`] (decrypt → `/token` → typed outcome).
/// 2. On `Refreshed`, if PAS rotated the token, re-encrypt it and
///    return as `rotated_ciphertext`. If PAS did not rotate, return
///    `None`.
/// 3. On `Rejected`, return `LivenessOutcome::Failed(Revoked{PasRejected})`.
/// 4. On `Transient` (5xx / transport / parse), return
///    `LivenessOutcome::Failed(Transient{PasServerError})`.
/// 5. On the underlying `CipherFailure`, return
///    `LivenessOutcome::Failed(Revoked{CipherFailure})`.
pub async fn attempt_liveness_refresh<P: PasAuthPort>(
    cipher: &TokenCipher,
    port: &P,
    ct: &EncryptedRefreshToken,
) -> LivenessOutcome {
    match pas_refresh(cipher, port, ct).await {
        Err(CipherFailure) => {
            LivenessOutcome::Failed(LivenessFailure::Revoked { cause: RevokeCause::CipherFailure })
        }
        Ok(PasRefreshOutcome::Refreshed { tokens }) => match tokens.refresh_token.as_deref() {
            Some(new_rt) => match cipher.encrypt(new_rt) {
                Ok(ct) => LivenessOutcome::Fresh { rotated_ciphertext: Some(ct) },
                Err(_) => LivenessOutcome::Failed(LivenessFailure::Transient {
                    retry_after: Some(DEFAULT_TRANSIENT_RETRY_AFTER),
                    cause: TransientCause::CipherEncryptFailed,
                }),
            },
            None => LivenessOutcome::Fresh { rotated_ciphertext: None },
        },
        Ok(PasRefreshOutcome::Rejected { .. }) => {
            LivenessOutcome::Failed(LivenessFailure::Revoked { cause: RevokeCause::PasRejected })
        }
        Ok(PasRefreshOutcome::Transient { .. }) => {
            LivenessOutcome::Failed(LivenessFailure::Transient {
                retry_after: Some(DEFAULT_TRANSIENT_RETRY_AFTER),
                cause: TransientCause::PasServerError,
            })
        }
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    //! Pins the S-L1 consequence (cipher failure is terminal for the
    //! session). Behavior of the PAS-status classifier is now covered
    //! by the boundary tests in `tests/liveness_boundary.rs`.

    use super::*;
    use crate::pas_port::MemoryPasAuth;
    use base64::{Engine, engine::general_purpose::STANDARD};

    #[tokio::test]
    async fn decrypt_failure_short_circuits_to_revoked_cipher_failure() {
        // S-L1: a ciphertext we cannot decrypt is unrecoverable.
        // attempt_liveness_refresh must never reach the PAS call.
        let key_b64 = STANDARD.encode([0u8; 32]);
        let cipher = TokenCipher::from_base64_key(&key_b64).unwrap();
        // Valid base64 that passes the length check but is not a
        // valid AEAD ciphertext under this key.
        let garbage_ct = EncryptedRefreshToken::from_stored(STANDARD.encode([0u8; 64]));

        // Empty MemoryPasAuth — registering no expectations means a
        // call to refresh() would panic. The test asserts the function
        // never reaches the network call.
        let port = MemoryPasAuth::new();

        let outcome = attempt_liveness_refresh(&cipher, &port, &garbage_ct).await;
        assert!(matches!(
            outcome,
            LivenessOutcome::Failed(LivenessFailure::Revoked { cause: RevokeCause::CipherFailure })
        ));
    }
}