Skip to main content

webgates_sessions/
renewal.rs

1//! Renewal orchestration models for session auto-renewal.
2//!
3//! This module defines framework-agnostic renewal inputs, typed auth-token
4//! state, deterministic decision logic, and high-level renewal outcomes.
5
6use std::time::{Duration, SystemTime};
7
8use crate::lease::LeaseAcquisition;
9use crate::session::{Session, SessionId};
10use crate::tokens::IssuedTokenPair;
11
12/// Indicates whether a caller is attempting renewal proactively or because the
13/// current auth token can no longer be used.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum RenewalRequirement {
16    /// Attempt renewal opportunistically while the current auth token remains
17    /// valid.
18    Proactive,
19    /// Require a successful renewal before the request can proceed.
20    Required,
21}
22
23/// Describes the observed state of the current auth token.
24///
25/// This type is intentionally framework agnostic. HTTP adapters and other
26/// delivery layers can translate transport-specific validation results into
27/// this domain-level model before invoking session renewal logic.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum AuthTokenState {
30    /// The auth token is currently valid and does not need renewal.
31    Valid {
32        /// Remaining time until the token expires.
33        expires_in: Duration,
34    },
35    /// The auth token is still valid but has entered the proactive renewal
36    /// window.
37    NearExpiry {
38        /// Remaining time until the token expires.
39        expires_in: Duration,
40    },
41    /// The auth token has expired.
42    Expired {
43        /// Amount of time elapsed since expiry.
44        expired_for: Duration,
45    },
46    /// The auth token is malformed, revoked, or otherwise invalid.
47    Invalid,
48}
49
50impl AuthTokenState {
51    /// Returns `true` when the current token may still authorize the request.
52    #[must_use]
53    pub fn is_currently_usable(self) -> bool {
54        matches!(self, Self::Valid { .. } | Self::NearExpiry { .. })
55    }
56
57    /// Returns `true` when the token has entered the proactive renewal window.
58    #[must_use]
59    pub fn should_attempt_proactive_renewal(self) -> bool {
60        matches!(self, Self::NearExpiry { .. })
61    }
62
63    /// Returns `true` when successful renewal is required before a caller may
64    /// continue.
65    #[must_use]
66    pub fn requires_renewal(self) -> bool {
67        matches!(self, Self::Expired { .. })
68    }
69
70    /// Returns `true` when the token must be rejected without renewal.
71    #[must_use]
72    pub fn is_invalid(self) -> bool {
73        matches!(self, Self::Invalid)
74    }
75}
76
77/// Input passed into the renewal decision flow.
78///
79/// This request captures only the deterministic, framework-agnostic inputs
80/// needed to decide whether renewal should be attempted.
81///
82/// # Examples
83///
84/// ```
85/// use std::time::{Duration, SystemTime};
86/// use webgates_sessions::renewal::{
87///     AuthTokenState, RenewalDecision, RenewalOrchestrator, RenewalRequest,
88///     RenewalRequirement,
89/// };
90/// use webgates_sessions::session::SessionId;
91///
92/// let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
93/// let request = RenewalRequest::new(
94///     SessionId::new(),
95///     AuthTokenState::NearExpiry { expires_in: Duration::from_secs(60) },
96///     RenewalRequirement::Proactive,
97///     now,
98/// );
99///
100/// let decision = RenewalOrchestrator::new().decide(&request);
101/// assert_eq!(decision, RenewalDecision::AttemptProactiveRenewal);
102/// ```
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct RenewalRequest {
105    /// Session currently associated with the caller.
106    pub session_id: SessionId,
107    /// The current auth-token state observed by the caller.
108    pub auth_token_state: AuthTokenState,
109    /// Whether the renewal is opportunistic or mandatory.
110    pub requirement: RenewalRequirement,
111    /// Current wall-clock time used for deterministic decision making.
112    pub now: SystemTime,
113}
114
115impl RenewalRequest {
116    /// Creates a new renewal request.
117    #[must_use]
118    pub fn new(
119        session_id: SessionId,
120        auth_token_state: AuthTokenState,
121        requirement: RenewalRequirement,
122        now: SystemTime,
123    ) -> Self {
124        Self {
125            session_id,
126            auth_token_state,
127            requirement,
128            now,
129        }
130    }
131}
132
133/// High-level decision produced before repository-backed renewal work begins.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
135pub enum RenewalDecision {
136    /// No renewal attempt should be made.
137    NoRenewal,
138    /// Renewal may be attempted, but failure does not necessarily block the
139    /// caller.
140    AttemptProactiveRenewal,
141    /// Renewal must succeed before the caller can continue.
142    RequireRenewal,
143    /// The caller must be rejected immediately without attempting renewal.
144    Reject,
145}
146
147/// Context captured while a renewal attempt is in progress.
148///
149/// # Examples
150///
151/// ```
152/// use std::time::{Duration, SystemTime};
153/// use webgates_sessions::renewal::{
154///     AuthTokenState, RenewalAttempt, RenewalRequest, RenewalRequirement,
155/// };
156/// use webgates_sessions::session::{Session, SessionFamilyId, SessionId};
157///
158/// let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
159/// let session = Session::new(
160///     SessionFamilyId::new(),
161///     "user-42",
162///     now,
163///     now + Duration::from_secs(3_600),
164/// );
165/// let request = RenewalRequest::new(
166///     session.session_id,
167///     AuthTokenState::Expired { expired_for: Duration::from_secs(5) },
168///     RenewalRequirement::Required,
169///     now,
170/// );
171/// let attempt = RenewalAttempt::new(session.clone(), request);
172///
173/// assert_eq!(attempt.session_id(), session.session_id);
174/// ```
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct RenewalAttempt {
177    /// Session selected for renewal.
178    pub session: Session,
179    /// Original request that triggered the attempt.
180    pub request: RenewalRequest,
181}
182
183impl RenewalAttempt {
184    /// Creates a new renewal attempt context.
185    #[must_use]
186    pub fn new(session: Session, request: RenewalRequest) -> Self {
187        Self { session, request }
188    }
189
190    /// Returns the identifier of the session being renewed.
191    #[must_use]
192    pub fn session_id(&self) -> SessionId {
193        self.request.session_id
194    }
195}
196
197/// Outcome returned by the renewal orchestration layer.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub enum RenewalOutcome {
200    /// No renewal was necessary.
201    NotNeeded,
202    /// Renewal was attempted but could not proceed because another actor
203    /// currently owns the lease or renewal slot.
204    LeaseUnavailable {
205        /// Observed lease-acquisition result.
206        acquisition: LeaseAcquisition,
207    },
208    /// Renewal completed successfully and produced replacement tokens.
209    Renewed {
210        /// Session that remains active after renewal.
211        session: Session,
212        /// Newly issued auth and refresh tokens.
213        tokens: IssuedTokenPair,
214    },
215    /// Renewal detected refresh-token replay and requires broader revocation.
216    ReplayDetected {
217        /// Session associated with the detected replay.
218        session_id: SessionId,
219    },
220    /// Renewal cannot proceed because the request is not authorized.
221    Rejected,
222}
223
224impl RenewalOutcome {
225    /// Returns the newly issued token pair when renewal succeeded.
226    #[must_use]
227    pub fn issued_tokens(&self) -> Option<&IssuedTokenPair> {
228        match self {
229            Self::Renewed { tokens, .. } => Some(tokens),
230            Self::NotNeeded
231            | Self::LeaseUnavailable { .. }
232            | Self::ReplayDetected { .. }
233            | Self::Rejected => None,
234        }
235    }
236
237    /// Returns the renewed session when renewal succeeded.
238    #[must_use]
239    pub fn session(&self) -> Option<&Session> {
240        match self {
241            Self::Renewed { session, .. } => Some(session),
242            Self::NotNeeded
243            | Self::LeaseUnavailable { .. }
244            | Self::ReplayDetected { .. }
245            | Self::Rejected => None,
246        }
247    }
248}
249
250/// Stateless decision helper for renewal orchestration.
251///
252/// # Examples
253///
254/// ```
255/// use std::time::Duration;
256/// use webgates_sessions::renewal::{
257///     AuthTokenState, RenewalDecision, RenewalOrchestrator, RenewalRequirement,
258/// };
259///
260/// let orchestrator = RenewalOrchestrator::new();
261///
262/// assert_eq!(
263///     orchestrator.decide_for_state(
264///         AuthTokenState::Valid { expires_in: Duration::from_secs(300) },
265///         RenewalRequirement::Proactive,
266///     ),
267///     RenewalDecision::NoRenewal,
268/// );
269///
270/// assert_eq!(
271///     orchestrator.decide_for_state(
272///         AuthTokenState::Expired { expired_for: Duration::from_secs(10) },
273///         RenewalRequirement::Required,
274///     ),
275///     RenewalDecision::RequireRenewal,
276/// );
277/// ```
278#[derive(Debug, Default, Clone, Copy)]
279pub struct RenewalOrchestrator;
280
281impl RenewalOrchestrator {
282    /// Creates a new renewal orchestrator.
283    #[must_use]
284    pub fn new() -> Self {
285        Self
286    }
287
288    /// Decides whether a renewal attempt should be made from token state alone.
289    #[must_use]
290    pub fn decide_for_state(
291        &self,
292        auth_token_state: AuthTokenState,
293        requirement: RenewalRequirement,
294    ) -> RenewalDecision {
295        match (auth_token_state, requirement) {
296            (AuthTokenState::Invalid, _) => RenewalDecision::Reject,
297            (AuthTokenState::Expired { .. }, _) => RenewalDecision::RequireRenewal,
298            (AuthTokenState::NearExpiry { .. }, RenewalRequirement::Proactive) => {
299                RenewalDecision::AttemptProactiveRenewal
300            }
301            (AuthTokenState::NearExpiry { .. }, RenewalRequirement::Required) => {
302                RenewalDecision::RequireRenewal
303            }
304            (AuthTokenState::Valid { .. }, _) => RenewalDecision::NoRenewal,
305        }
306    }
307
308    /// Decides whether a renewal attempt should be made for the given request.
309    #[must_use]
310    pub fn decide(&self, request: &RenewalRequest) -> RenewalDecision {
311        self.decide_for_state(request.auth_token_state, request.requirement)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use uuid::Uuid;
319
320    fn session_id() -> SessionId {
321        SessionId::from_uuid(Uuid::now_v7())
322    }
323
324    #[test]
325    fn proactive_near_expiry_requests_attempt_renewal() {
326        let orchestrator = RenewalOrchestrator::new();
327        let request = RenewalRequest::new(
328            session_id(),
329            AuthTokenState::NearExpiry {
330                expires_in: Duration::from_secs(45),
331            },
332            RenewalRequirement::Proactive,
333            SystemTime::UNIX_EPOCH,
334        );
335
336        assert_eq!(
337            orchestrator.decide(&request),
338            RenewalDecision::AttemptProactiveRenewal
339        );
340    }
341
342    #[test]
343    fn required_near_expiry_requests_require_renewal() {
344        let orchestrator = RenewalOrchestrator::new();
345        let request = RenewalRequest::new(
346            session_id(),
347            AuthTokenState::NearExpiry {
348                expires_in: Duration::from_secs(10),
349            },
350            RenewalRequirement::Required,
351            SystemTime::UNIX_EPOCH,
352        );
353
354        assert_eq!(
355            orchestrator.decide(&request),
356            RenewalDecision::RequireRenewal
357        );
358    }
359
360    #[test]
361    fn expired_requests_require_renewal() {
362        let orchestrator = RenewalOrchestrator::new();
363        let request = RenewalRequest::new(
364            session_id(),
365            AuthTokenState::Expired {
366                expired_for: Duration::from_secs(30),
367            },
368            RenewalRequirement::Required,
369            SystemTime::UNIX_EPOCH,
370        );
371
372        assert_eq!(
373            orchestrator.decide(&request),
374            RenewalDecision::RequireRenewal
375        );
376    }
377
378    #[test]
379    fn valid_requests_do_not_trigger_renewal() {
380        let orchestrator = RenewalOrchestrator::new();
381        let request = RenewalRequest::new(
382            session_id(),
383            AuthTokenState::Valid {
384                expires_in: Duration::from_secs(600),
385            },
386            RenewalRequirement::Proactive,
387            SystemTime::UNIX_EPOCH,
388        );
389
390        assert_eq!(orchestrator.decide(&request), RenewalDecision::NoRenewal);
391    }
392
393    #[test]
394    fn invalid_requests_are_rejected() {
395        let orchestrator = RenewalOrchestrator::new();
396        let request = RenewalRequest::new(
397            session_id(),
398            AuthTokenState::Invalid,
399            RenewalRequirement::Proactive,
400            SystemTime::UNIX_EPOCH,
401        );
402
403        assert_eq!(orchestrator.decide(&request), RenewalDecision::Reject);
404    }
405
406    #[test]
407    fn auth_token_state_helpers_match_expected_behavior() {
408        let valid = AuthTokenState::Valid {
409            expires_in: Duration::from_secs(60),
410        };
411        let near_expiry = AuthTokenState::NearExpiry {
412            expires_in: Duration::from_secs(15),
413        };
414        let expired = AuthTokenState::Expired {
415            expired_for: Duration::from_secs(5),
416        };
417        let invalid = AuthTokenState::Invalid;
418
419        assert!(valid.is_currently_usable());
420        assert!(!valid.should_attempt_proactive_renewal());
421        assert!(!valid.requires_renewal());
422        assert!(!valid.is_invalid());
423
424        assert!(near_expiry.is_currently_usable());
425        assert!(near_expiry.should_attempt_proactive_renewal());
426        assert!(!near_expiry.requires_renewal());
427        assert!(!near_expiry.is_invalid());
428
429        assert!(!expired.is_currently_usable());
430        assert!(!expired.should_attempt_proactive_renewal());
431        assert!(expired.requires_renewal());
432        assert!(!expired.is_invalid());
433
434        assert!(!invalid.is_currently_usable());
435        assert!(!invalid.should_attempt_proactive_renewal());
436        assert!(!invalid.requires_renewal());
437        assert!(invalid.is_invalid());
438    }
439}