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}