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}