pas-external 0.12.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! `pas_refresh` — the unified decrypt-and-classify pipeline.
//!
//! Replaces the v4 split between `session_liveness::attempt_liveness_refresh`
//! (S-L3 fail-open) and the SDK-internal sv-aware refresh path (S-L6
//! fail-closed; now driven by `middleware::sv::adapter`). Both call
//! sites compose this primitive with their own policy `match` on the
//! returned [`PasRefreshOutcome`].

use super::port::{PasAuthPort, PasFailure};
use crate::oauth::TokenResponse;
use crate::session_liveness::{EncryptedRefreshToken, TokenCipher};

/// Outcome of one PAS-refresh round-trip, before any S-L3 / S-L6
/// policy is applied. Callers convert this into the policy-shaped
/// outcome (`LivenessOutcome` for S-L3, `SessionResolution` for S-L6).
#[derive(Debug)]
#[must_use]
#[non_exhaustive]
pub enum PasRefreshOutcome {
    /// PAS confirmed liveness. `tokens` is what to mint sessions from
    /// (consumers re-encrypt `tokens.refresh_token` if `Some`; sv
    /// callers also call [`PasAuthPort::userinfo`] to read `sv`).
    Refreshed { tokens: TokenResponse },
    /// PAS gave a definitive 4xx — the token is dead.
    Rejected { status: u16, detail: String },
    /// PAS unreachable, degraded, or transport blip / parse failure.
    Transient { detail: String },
}

/// Marker: the stored ciphertext could not be decrypted. Both call
/// sites drop the session — for opposite reasons (S-L3:
/// `Revoked{CipherFailure}`; S-L6: `Expired`) — but the local
/// handling is identical.
#[derive(Debug)]
pub struct CipherFailure;

/// Decrypt → call PAS `/token` → translate `PasFailure` into
/// `PasRefreshOutcome`. Plaintext lifetime is local to this function;
/// it is never returned and never logged.
///
/// Takes [`EncryptedRefreshToken`] (newtype) rather than `&str` so the
/// type system blocks accidentally passing plaintext at the call site.
/// This is the only place in the crate that materializes plaintext on
/// the read path.
///
/// # Errors
///
/// Returns [`CipherFailure`] iff the ciphertext cannot be decrypted
/// with the supplied cipher. The PAS round-trip is *not* attempted in
/// that case.
pub async fn pas_refresh<P: PasAuthPort>(
    cipher: &TokenCipher,
    port: &P,
    ct: &EncryptedRefreshToken,
) -> Result<PasRefreshOutcome, CipherFailure> {
    let plaintext = cipher.decrypt(ct.as_str()).map_err(|_| CipherFailure)?;

    Ok(match port.refresh(&plaintext).await {
        Ok(tokens) => PasRefreshOutcome::Refreshed { tokens },
        Err(PasFailure::Rejected { status, detail }) => {
            PasRefreshOutcome::Rejected { status, detail }
        }
        Err(PasFailure::ServerError { detail, .. }) | Err(PasFailure::Transport { detail }) => {
            PasRefreshOutcome::Transient { detail }
        }
    })
}