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}