Skip to main content

webgates_sessions/
session.rs

1//! Session domain types.
2//!
3//! This module contains the framework-agnostic session model used by issuance,
4//! renewal, revocation, and repository orchestration.
5
6use std::time::SystemTime;
7
8use uuid::Uuid;
9
10/// Unique identifier for a single session record.
11///
12/// # Examples
13///
14/// ```
15/// use webgates_sessions::session::SessionId;
16///
17/// let id = SessionId::new();
18/// let uuid = id.into_uuid();
19/// let restored = SessionId::from_uuid(uuid);
20/// assert_eq!(restored.into_uuid(), uuid);
21/// ```
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub struct SessionId(Uuid);
24
25impl SessionId {
26    /// Creates a new session identifier.
27    #[must_use]
28    pub fn new() -> Self {
29        Self(Uuid::now_v7())
30    }
31
32    /// Creates a session identifier from an existing UUID.
33    #[must_use]
34    pub fn from_uuid(value: Uuid) -> Self {
35        Self(value)
36    }
37
38    /// Returns the underlying UUID value.
39    #[must_use]
40    pub fn into_uuid(self) -> Uuid {
41        self.0
42    }
43}
44
45impl Default for SessionId {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51/// Unique identifier for a session family.
52///
53/// A session family groups related sessions so higher-level logic can revoke
54/// them together when replay or broader logout behavior requires it.
55///
56/// # Examples
57///
58/// ```
59/// use webgates_sessions::session::SessionFamilyId;
60///
61/// let family_id = SessionFamilyId::new();
62/// let uuid = family_id.into_uuid();
63/// let restored = SessionFamilyId::from_uuid(uuid);
64/// assert_eq!(restored.into_uuid(), uuid);
65/// ```
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub struct SessionFamilyId(Uuid);
68
69impl SessionFamilyId {
70    /// Creates a new session-family identifier.
71    #[must_use]
72    pub fn new() -> Self {
73        Self(Uuid::now_v7())
74    }
75
76    /// Creates a session-family identifier from an existing UUID.
77    #[must_use]
78    pub fn from_uuid(value: Uuid) -> Self {
79        Self(value)
80    }
81
82    /// Returns the underlying UUID value.
83    #[must_use]
84    pub fn into_uuid(self) -> Uuid {
85        self.0
86    }
87}
88
89impl Default for SessionFamilyId {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95/// Persisted session state tracked by the session layer.
96///
97/// This is the canonical framework-agnostic session record used by repository
98/// contracts and higher-level renewal services.
99///
100/// # Examples
101///
102/// ```
103/// use std::time::{Duration, SystemTime};
104/// use webgates_sessions::session::{Session, SessionFamilyId};
105///
106/// let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
107/// let session = Session::new(
108///     SessionFamilyId::new(),
109///     "user-42",
110///     now,
111///     now + Duration::from_secs(3_600),
112/// );
113///
114/// assert_eq!(session.subject_id, "user-42");
115/// assert!(session.is_active_at(now));
116/// assert!(!session.is_expired_at(now));
117///
118/// let revoked = session.revoked();
119/// assert!(!revoked.is_active_at(now));
120/// ```
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct Session {
123    /// Stable session identifier.
124    pub session_id: SessionId,
125    /// Owning session family identifier.
126    pub family_id: SessionFamilyId,
127    /// Stable subject identifier that owns the session.
128    pub subject_id: String,
129    /// Creation timestamp for the session.
130    pub created_at: SystemTime,
131    /// Expiration timestamp for the session.
132    pub expires_at: SystemTime,
133    /// Last observed activity timestamp for the session.
134    pub last_seen_at: Option<SystemTime>,
135    /// Whether the session is currently revoked.
136    pub revoked: bool,
137}
138
139impl Session {
140    /// Creates a new active session record.
141    #[must_use]
142    pub fn new(
143        family_id: SessionFamilyId,
144        subject_id: impl Into<String>,
145        created_at: SystemTime,
146        expires_at: SystemTime,
147    ) -> Self {
148        Self {
149            session_id: SessionId::new(),
150            family_id,
151            subject_id: subject_id.into(),
152            created_at,
153            expires_at,
154            last_seen_at: None,
155            revoked: false,
156        }
157    }
158
159    /// Returns a copy of the session with an updated `last_seen_at` value.
160    #[must_use]
161    pub fn touched(mut self, last_seen_at: SystemTime) -> Self {
162        self.last_seen_at = Some(last_seen_at);
163        self
164    }
165
166    /// Returns a copy of the session marked as revoked.
167    #[must_use]
168    pub fn revoked(mut self) -> Self {
169        self.revoked = true;
170        self
171    }
172
173    /// Returns `true` when the session is active at `now`.
174    #[must_use]
175    pub fn is_active_at(&self, now: SystemTime) -> bool {
176        !self.revoked && self.expires_at > now
177    }
178
179    /// Returns `true` when the session has expired at `now`.
180    #[must_use]
181    pub fn is_expired_at(&self, now: SystemTime) -> bool {
182        self.expires_at <= now
183    }
184}
185
186/// Canonical repository-facing session record.
187///
188/// Repository interfaces use this alias when they operate on persisted session
189/// records.
190pub type SessionRecord = Session;
191
192/// Repository-facing summary of a session family.
193///
194/// This view captures the minimum metadata needed for family-wide revocation and
195/// replay handling.
196///
197/// # Examples
198///
199/// ```
200/// use std::time::{Duration, SystemTime};
201/// use webgates_sessions::session::{SessionFamilyId, SessionFamilyRecord};
202///
203/// let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
204/// let family = SessionFamilyRecord::new(SessionFamilyId::new(), "user-42", now);
205///
206/// assert!(family.is_active());
207///
208/// let revoked = family.revoked();
209/// assert!(!revoked.is_active());
210/// ```
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct SessionFamilyRecord {
213    /// Stable family identifier.
214    pub family_id: SessionFamilyId,
215    /// Stable subject identifier that owns the family.
216    pub subject_id: String,
217    /// Creation timestamp for the family.
218    pub created_at: SystemTime,
219    /// Whether the full family has been revoked.
220    pub revoked: bool,
221}
222
223impl SessionFamilyRecord {
224    /// Creates a new active session-family record.
225    #[must_use]
226    pub fn new(
227        family_id: SessionFamilyId,
228        subject_id: impl Into<String>,
229        created_at: SystemTime,
230    ) -> Self {
231        Self {
232            family_id,
233            subject_id: subject_id.into(),
234            created_at,
235            revoked: false,
236        }
237    }
238
239    /// Returns a copy of the family marked as revoked.
240    #[must_use]
241    pub fn revoked(mut self) -> Self {
242        self.revoked = true;
243        self
244    }
245
246    /// Returns `true` when the family is still active.
247    #[must_use]
248    pub fn is_active(&self) -> bool {
249        !self.revoked
250    }
251}
252
253/// Repository-facing record for the currently active refresh token of a session.
254///
255/// # Examples
256///
257/// ```
258/// use std::time::{Duration, SystemTime};
259/// use webgates_sessions::session::{SessionFamilyId, SessionId, SessionRefreshRecord};
260///
261/// let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
262/// let expires_at = now + Duration::from_secs(3_600);
263/// let refresh = SessionRefreshRecord::new(SessionId::new(), SessionFamilyId::new(), expires_at);
264///
265/// assert!(refresh.is_active_at(now));
266/// assert!(!refresh.is_expired_at(now));
267///
268/// let revoked = refresh.revoked();
269/// assert!(!revoked.is_active_at(now));
270/// ```
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct SessionRefreshRecord {
273    /// Session that owns the refresh token.
274    pub session_id: SessionId,
275    /// Session family that owns the refresh token.
276    pub family_id: SessionFamilyId,
277    /// Timestamp when the current refresh token expires.
278    pub expires_at: SystemTime,
279    /// Whether the refresh token is currently revoked.
280    pub revoked: bool,
281}
282
283impl SessionRefreshRecord {
284    /// Creates a new active refresh-token record.
285    #[must_use]
286    pub fn new(session_id: SessionId, family_id: SessionFamilyId, expires_at: SystemTime) -> Self {
287        Self {
288            session_id,
289            family_id,
290            expires_at,
291            revoked: false,
292        }
293    }
294
295    /// Returns a copy of the refresh-token record marked as revoked.
296    #[must_use]
297    pub fn revoked(mut self) -> Self {
298        self.revoked = true;
299        self
300    }
301
302    /// Returns `true` when the refresh token is active at `now`.
303    #[must_use]
304    pub fn is_active_at(&self, now: SystemTime) -> bool {
305        !self.revoked && self.expires_at > now
306    }
307
308    /// Returns `true` when the refresh token has expired at `now`.
309    #[must_use]
310    pub fn is_expired_at(&self, now: SystemTime) -> bool {
311        self.expires_at <= now
312    }
313}
314
315/// Combined repository lookup result used when locating session state by a
316/// refresh token hash.
317///
318/// # Examples
319///
320/// ```
321/// use std::time::{Duration, SystemTime};
322/// use webgates_sessions::session::{
323///     Session, SessionFamilyId, SessionFamilyRecord, SessionLookup, SessionRefreshRecord,
324/// };
325///
326/// let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
327/// let family = SessionFamilyRecord::new(SessionFamilyId::new(), "user-42", now);
328/// let session = Session::new(
329///     family.family_id,
330///     "user-42",
331///     now,
332///     now + Duration::from_secs(3_600),
333/// );
334/// let refresh = SessionRefreshRecord::new(
335///     session.session_id,
336///     family.family_id,
337///     now + Duration::from_secs(3_600),
338/// );
339///
340/// let lookup = SessionLookup::new(session, family, refresh);
341/// assert!(lookup.is_active_at(now));
342/// ```
343#[derive(Debug, Clone, PartialEq, Eq)]
344pub struct SessionLookup {
345    /// Session matched by the lookup.
346    pub session: SessionRecord,
347    /// Session family that owns the matched session.
348    pub family: SessionFamilyRecord,
349    /// Current refresh-token record for the matched session.
350    pub refresh: SessionRefreshRecord,
351}
352
353impl SessionLookup {
354    /// Creates a new combined session lookup result.
355    #[must_use]
356    pub fn new(
357        session: SessionRecord,
358        family: SessionFamilyRecord,
359        refresh: SessionRefreshRecord,
360    ) -> Self {
361        Self {
362            session,
363            family,
364            refresh,
365        }
366    }
367
368    /// Returns `true` when all looked-up state is currently active at `now`.
369    #[must_use]
370    pub fn is_active_at(&self, now: SystemTime) -> bool {
371        self.session.is_active_at(now) && self.family.is_active() && self.refresh.is_active_at(now)
372    }
373}
374
375/// Input used to record session activity updates.
376///
377/// # Examples
378///
379/// ```
380/// use std::time::{Duration, SystemTime};
381/// use webgates_sessions::session::{SessionId, SessionTouch};
382///
383/// let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
384/// let touch = SessionTouch::new(SessionId::new(), now);
385///
386/// assert_eq!(touch.last_seen_at, now);
387/// ```
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub struct SessionTouch {
390    /// Session to update.
391    pub session_id: SessionId,
392    /// New activity timestamp to persist.
393    pub last_seen_at: SystemTime,
394}
395
396impl SessionTouch {
397    /// Creates a new session-touch input.
398    #[must_use]
399    pub fn new(session_id: SessionId, last_seen_at: SystemTime) -> Self {
400        Self {
401            session_id,
402            last_seen_at,
403        }
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::{
410        Session, SessionFamilyId, SessionFamilyRecord, SessionLookup, SessionRefreshRecord,
411        SessionTouch,
412    };
413    use std::time::{Duration, SystemTime};
414
415    #[test]
416    fn new_session_is_active_and_not_revoked() {
417        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
418        let session = Session::new(
419            SessionFamilyId::new(),
420            "user-123",
421            now,
422            now + Duration::from_secs(60),
423        );
424
425        assert!(!session.revoked);
426        assert!(session.last_seen_at.is_none());
427        assert!(session.is_active_at(now));
428        assert!(!session.is_expired_at(now));
429    }
430
431    #[test]
432    fn touched_session_updates_last_seen() {
433        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
434        let touched_at = now + Duration::from_secs(10);
435        let session = Session::new(
436            SessionFamilyId::new(),
437            "user-123",
438            now,
439            now + Duration::from_secs(60),
440        )
441        .touched(touched_at);
442
443        assert_eq!(session.last_seen_at, Some(touched_at));
444    }
445
446    #[test]
447    fn revoked_session_is_not_active() {
448        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
449        let session = Session::new(
450            SessionFamilyId::new(),
451            "user-123",
452            now,
453            now + Duration::from_secs(60),
454        )
455        .revoked();
456
457        assert!(session.revoked);
458        assert!(!session.is_active_at(now));
459    }
460
461    #[test]
462    fn expired_session_reports_expired() {
463        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
464        let session = Session::new(
465            SessionFamilyId::new(),
466            "user-123",
467            now,
468            now + Duration::from_secs(1),
469        );
470
471        assert!(session.is_expired_at(now + Duration::from_secs(1)));
472        assert!(!session.is_active_at(now + Duration::from_secs(1)));
473    }
474
475    #[test]
476    fn family_record_reports_activity_state() {
477        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
478        let active_family = SessionFamilyRecord::new(SessionFamilyId::new(), "user-123", now);
479        let revoked_family = active_family.clone().revoked();
480
481        assert!(active_family.is_active());
482        assert!(!revoked_family.is_active());
483    }
484
485    #[test]
486    fn refresh_record_reports_activity_state() {
487        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
488        let refresh = SessionRefreshRecord::new(
489            super::SessionId::new(),
490            SessionFamilyId::new(),
491            now + Duration::from_secs(60),
492        );
493        let revoked = refresh.clone().revoked();
494
495        assert!(refresh.is_active_at(now));
496        assert!(!refresh.is_expired_at(now));
497        assert!(!revoked.is_active_at(now));
498    }
499
500    #[test]
501    fn lookup_is_active_only_when_all_components_are_active() {
502        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
503        let family = SessionFamilyRecord::new(SessionFamilyId::new(), "user-123", now);
504        let session = Session::new(
505            family.family_id,
506            "user-123",
507            now,
508            now + Duration::from_secs(60),
509        );
510        let refresh = SessionRefreshRecord::new(
511            session.session_id,
512            family.family_id,
513            now + Duration::from_secs(60),
514        );
515
516        let lookup = SessionLookup::new(session.clone(), family.clone(), refresh.clone());
517        let revoked_lookup = SessionLookup::new(session.revoked(), family, refresh);
518
519        assert!(lookup.is_active_at(now));
520        assert!(!revoked_lookup.is_active_at(now));
521    }
522
523    #[test]
524    fn session_touch_captures_target_and_timestamp() {
525        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
526        let touch = SessionTouch::new(super::SessionId::new(), now);
527
528        assert_eq!(touch.last_seen_at, now);
529    }
530}