Skip to main content

pas_external/pas_port/
core.rs

1//! `pas_refresh` — the unified decrypt-and-classify pipeline.
2//!
3//! Replaces the v4 split between `session_liveness::attempt_liveness_refresh`
4//! (S-L3 fail-open) and the SDK-internal sv-aware refresh path (S-L6
5//! fail-closed; now driven by `middleware::sv::adapter`). Both call
6//! sites compose this primitive with their own policy `match` on the
7//! returned [`PasRefreshOutcome`].
8
9use super::port::{PasAuthPort, PasFailure};
10use crate::oauth::TokenResponse;
11use crate::session_liveness::{EncryptedRefreshToken, TokenCipher};
12
13/// Outcome of one PAS-refresh round-trip, before any S-L3 / S-L6
14/// policy is applied. Callers convert this into the policy-shaped
15/// outcome (`LivenessOutcome` for S-L3, `SessionResolution` for S-L6).
16#[derive(Debug)]
17#[must_use]
18#[non_exhaustive]
19pub enum PasRefreshOutcome {
20    /// PAS confirmed liveness. `tokens` is what to mint sessions from
21    /// (consumers re-encrypt `tokens.refresh_token` if `Some`; sv
22    /// callers also call [`PasAuthPort::userinfo`] to read `sv`).
23    Refreshed { tokens: TokenResponse },
24    /// PAS gave a definitive 4xx — the token is dead.
25    Rejected { status: u16, detail: String },
26    /// PAS unreachable, degraded, or transport blip / parse failure.
27    Transient { detail: String },
28}
29
30/// Marker: the stored ciphertext could not be decrypted. Both call
31/// sites drop the session — for opposite reasons (S-L3:
32/// `Revoked{CipherFailure}`; S-L6: `Expired`) — but the local
33/// handling is identical.
34#[derive(Debug)]
35pub struct CipherFailure;
36
37/// Decrypt → call PAS `/token` → translate `PasFailure` into
38/// `PasRefreshOutcome`. Plaintext lifetime is local to this function;
39/// it is never returned and never logged.
40///
41/// Takes [`EncryptedRefreshToken`] (newtype) rather than `&str` so the
42/// type system blocks accidentally passing plaintext at the call site.
43/// This is the only place in the crate that materializes plaintext on
44/// the read path.
45///
46/// # Errors
47///
48/// Returns [`CipherFailure`] iff the ciphertext cannot be decrypted
49/// with the supplied cipher. The PAS round-trip is *not* attempted in
50/// that case.
51pub async fn pas_refresh<P: PasAuthPort>(
52    cipher: &TokenCipher,
53    port: &P,
54    ct: &EncryptedRefreshToken,
55) -> Result<PasRefreshOutcome, CipherFailure> {
56    let plaintext = cipher.decrypt(ct.as_str()).map_err(|_| CipherFailure)?;
57
58    Ok(match port.refresh(&plaintext).await {
59        Ok(tokens) => PasRefreshOutcome::Refreshed { tokens },
60        Err(PasFailure::Rejected { status, detail }) => {
61            PasRefreshOutcome::Rejected { status, detail }
62        }
63        Err(PasFailure::ServerError { detail, .. }) | Err(PasFailure::Transport { detail }) => {
64            PasRefreshOutcome::Transient { detail }
65        }
66    })
67}