pas_external/session_liveness/liveness.rs
1//! Session liveness verification against PAS — S-L3 fail-open path.
2//!
3//! Consumers who treat PAS as the single source of truth for session
4//! validity call [`attempt_liveness_refresh`] periodically (e.g.,
5//! every 15 minutes since a session was last verified). The helper
6//! handles the decrypt → call PAS → re-encrypt-if-rotated sequence
7//! and returns a [`LivenessOutcome`] that cleanly splits "keep
8//! trusting the cache", "drop the session", and "PAS is shaky, serve
9//! cache for now".
10//!
11//! # Why three outcomes
12//!
13//! A two-state "alive / dead" classification is too coarse: a transient
14//! PAS outage (5xx, network blip) would force-logout every active user
15//! every 15 minutes. The S-L3 fail-open invariant in
16//! `STANDARDS_SESSION_LIVENESS.md` prohibits that.
17//!
18//! # Cause variants
19//!
20//! Both [`LivenessFailure`] variants carry a cause ([`RevokeCause`] /
21//! [`TransientCause`]) so consumers can log *why* without the SDK
22//! emitting its own tracing events.
23//!
24//! # What the SDK does NOT do
25//!
26//! - It does not persist `last_verified_at` or `revoked_at`.
27//! - It does not decide when to run the check (consumer's stale-gate).
28//! - It does not log (consumer logs with its own correlation IDs).
29
30use std::time::Duration;
31
32use super::cipher::{EncryptedRefreshToken, TokenCipher};
33use crate::pas_port::{CipherFailure, PasAuthPort, PasRefreshOutcome, pas_refresh};
34
35const DEFAULT_TRANSIENT_RETRY_AFTER: Duration = Duration::from_secs(2);
36
37/// Why a session was revoked.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum RevokeCause {
41 /// The stored ciphertext could not be decrypted. Local issue
42 /// (key rotation accident, DB tamper). Operators investigate.
43 CipherFailure,
44 /// PAS returned a permanent OAuth error (4xx). User logged out
45 /// elsewhere, admin revocation, refresh_token expired. Routine.
46 PasRejected,
47}
48
49/// Why a liveness attempt was classified transient.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51#[non_exhaustive]
52pub enum TransientCause {
53 /// PAS is unreachable: a 5xx response, an HTTP transport failure
54 /// (timeout, TLS, DNS, connect), or a 2xx body that failed to
55 /// parse (CDN/proxy interference). All three categories are
56 /// indistinguishable at the policy level (S-L3 serves cache for
57 /// every one of them) — the variant name is a deliberate
58 /// simplification carried over from v3 where each had a separate
59 /// cause variant; v5.0 merged them.
60 ///
61 /// Detail strings carrying the underlying
62 /// [`PasFailure::Transport`][crate::pas_port::PasFailure::Transport]
63 /// or
64 /// [`PasFailure::ServerError`][crate::pas_port::PasFailure::ServerError]
65 /// message are not exposed here — consumers needing that level of
66 /// diagnostic should implement
67 /// [`PasAuthPort`][crate::pas_port::PasAuthPort] and log inside the
68 /// adapter.
69 PasServerError,
70 /// Re-encrypting a rotated refresh_token failed after PAS already
71 /// confirmed liveness. Local infrastructure issue; the previously
72 /// stored ciphertext remains valid.
73 CipherEncryptFailed,
74}
75
76/// A liveness attempt that did not confirm freshness.
77#[derive(Debug)]
78#[must_use]
79pub enum LivenessFailure {
80 /// PAS rejected the refresh_token, or the SDK cannot recover it.
81 /// Mark the session revoked and drop the auth context.
82 Revoked { cause: RevokeCause },
83 /// PAS is temporarily unreachable or local infra blipped. Serve
84 /// the cached session. `retry_after` is a back-off hint.
85 Transient {
86 retry_after: Option<Duration>,
87 cause: TransientCause,
88 },
89}
90
91/// Outcome of a single PAS liveness round-trip.
92#[derive(Debug)]
93#[must_use]
94pub enum LivenessOutcome {
95 /// Session reconfirmed against PAS. If `rotated_ciphertext` is
96 /// `Some(ct)`, persist that as the new ciphertext (PAS rotated).
97 /// If `None`, the existing ciphertext remains valid. Always
98 /// update `last_verified_at`.
99 Fresh { rotated_ciphertext: Option<String> },
100 /// See [`LivenessFailure`].
101 Failed(LivenessFailure),
102}
103
104/// Run one liveness round-trip against PAS.
105///
106/// Flow:
107///
108/// 1. Run [`pas_refresh`] (decrypt → `/token` → typed outcome).
109/// 2. On `Refreshed`, if PAS rotated the token, re-encrypt it and
110/// return as `rotated_ciphertext`. If PAS did not rotate, return
111/// `None`.
112/// 3. On `Rejected`, return `LivenessOutcome::Failed(Revoked{PasRejected})`.
113/// 4. On `Transient` (5xx / transport / parse), return
114/// `LivenessOutcome::Failed(Transient{PasServerError})`.
115/// 5. On the underlying `CipherFailure`, return
116/// `LivenessOutcome::Failed(Revoked{CipherFailure})`.
117pub async fn attempt_liveness_refresh<P: PasAuthPort>(
118 cipher: &TokenCipher,
119 port: &P,
120 ct: &EncryptedRefreshToken,
121) -> LivenessOutcome {
122 match pas_refresh(cipher, port, ct).await {
123 Err(CipherFailure) => {
124 LivenessOutcome::Failed(LivenessFailure::Revoked { cause: RevokeCause::CipherFailure })
125 }
126 Ok(PasRefreshOutcome::Refreshed { tokens }) => match tokens.refresh_token.as_deref() {
127 Some(new_rt) => match cipher.encrypt(new_rt) {
128 Ok(ct) => LivenessOutcome::Fresh { rotated_ciphertext: Some(ct) },
129 Err(_) => LivenessOutcome::Failed(LivenessFailure::Transient {
130 retry_after: Some(DEFAULT_TRANSIENT_RETRY_AFTER),
131 cause: TransientCause::CipherEncryptFailed,
132 }),
133 },
134 None => LivenessOutcome::Fresh { rotated_ciphertext: None },
135 },
136 Ok(PasRefreshOutcome::Rejected { .. }) => {
137 LivenessOutcome::Failed(LivenessFailure::Revoked { cause: RevokeCause::PasRejected })
138 }
139 Ok(PasRefreshOutcome::Transient { .. }) => {
140 LivenessOutcome::Failed(LivenessFailure::Transient {
141 retry_after: Some(DEFAULT_TRANSIENT_RETRY_AFTER),
142 cause: TransientCause::PasServerError,
143 })
144 }
145 }
146}
147
148#[cfg(test)]
149#[allow(clippy::unwrap_used)]
150mod tests {
151 //! Pins the S-L1 consequence (cipher failure is terminal for the
152 //! session). Behavior of the PAS-status classifier is now covered
153 //! by the boundary tests in `tests/liveness_boundary.rs`.
154
155 use std::assert_matches;
156
157 use super::*;
158 use crate::pas_port::MemoryPasAuth;
159 use base64::{Engine, engine::general_purpose::STANDARD};
160
161 #[tokio::test]
162 async fn decrypt_failure_short_circuits_to_revoked_cipher_failure() {
163 // S-L1: a ciphertext we cannot decrypt is unrecoverable.
164 // attempt_liveness_refresh must never reach the PAS call.
165 let key_b64 = STANDARD.encode([0u8; 32]);
166 let cipher = TokenCipher::from_base64_key(&key_b64).unwrap();
167 // Valid base64 that passes the length check but is not a
168 // valid AEAD ciphertext under this key.
169 let garbage_ct = EncryptedRefreshToken::from_stored(STANDARD.encode([0u8; 64]));
170
171 // Empty MemoryPasAuth — registering no expectations means a
172 // call to refresh() would panic. The test asserts the function
173 // never reaches the network call.
174 let port = MemoryPasAuth::new();
175
176 let outcome = attempt_liveness_refresh(&cipher, &port, &garbage_ct).await;
177 assert_matches!(
178 outcome,
179 LivenessOutcome::Failed(LivenessFailure::Revoked { cause: RevokeCause::CipherFailure })
180 );
181 }
182}