Skip to main content

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}