Skip to main content

webgates_sessions/
repository.rs

1//! Session repository contracts.
2//!
3//! This module defines the framework-agnostic persistence boundary used by
4//! `webgates-sessions` services.
5//!
6//! If you implement session storage for a new backend, this is the main module
7//! you will work with.
8
9use crate::lease::LeaseAcquisition;
10use crate::lease::RenewalLease;
11use crate::session::SessionFamilyId;
12use crate::session::SessionFamilyRecord;
13use crate::session::SessionId;
14use crate::session::SessionLookup;
15use crate::session::SessionRecord;
16use crate::session::SessionRefreshRecord;
17use crate::session::SessionTouch;
18use crate::tokens::RefreshTokenHash;
19use crate::tokens::RefreshTokenHashRef;
20
21/// Result type for session persistence operations.
22pub type RepositoryResult<T> = std::result::Result<T, RepositoryError>;
23
24/// Repository error used by the session contract surface.
25#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
26pub enum RepositoryError {
27    /// The requested session record could not be found.
28    #[error("session not found")]
29    SessionNotFound,
30
31    /// The requested session family record could not be found.
32    #[error("session family not found")]
33    SessionFamilyNotFound,
34
35    /// The repository rejected a concurrent update.
36    #[error("concurrent session update detected")]
37    Conflict,
38
39    /// Stored session state was invalid or internally inconsistent.
40    #[error("invalid persisted session state")]
41    InvalidState,
42
43    /// The backend returned a safe, caller-facing error summary.
44    #[error("{message}")]
45    Backend {
46        /// Safe backend failure summary.
47        message: String,
48    },
49}
50
51impl RepositoryError {
52    /// Creates a backend error with a safe, caller-facing message.
53    #[must_use]
54    pub fn backend(message: impl Into<String>) -> Self {
55        Self::Backend {
56            message: message.into(),
57        }
58    }
59}
60
61/// Input required to persist a newly issued session.
62///
63/// # Examples
64///
65/// ```
66/// use std::time::{Duration, SystemTime};
67/// use webgates_sessions::repository::CreateSession;
68/// use webgates_sessions::session::{Session, SessionFamilyId};
69/// use webgates_sessions::tokens::RefreshTokenHash;
70///
71/// let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
72/// let session = Session::new(
73///     SessionFamilyId::new(),
74///     "user-42",
75///     now,
76///     now + Duration::from_secs(3_600),
77/// );
78/// let hash = RefreshTokenHash::new("abc123def456").unwrap();
79///
80/// let input = CreateSession {
81///     session: session.clone(),
82///     refresh_token_hash: hash.clone(),
83/// };
84///
85/// assert_eq!(input.session, session);
86/// assert_eq!(input.refresh_token_hash, hash);
87/// ```
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct CreateSession {
90    /// Full session record to persist.
91    pub session: SessionRecord,
92    /// Active refresh-token hash associated with the session.
93    pub refresh_token_hash: RefreshTokenHash,
94}
95
96/// Input required to atomically rotate a refresh token.
97///
98/// # Examples
99///
100/// ```
101/// use std::time::{Duration, SystemTime};
102/// use webgates_sessions::lease::{LeaseId, LeaseTtl, RenewalLease};
103/// use webgates_sessions::repository::RotateRefreshToken;
104/// use webgates_sessions::session::{Session, SessionFamilyId, SessionFamilyRecord};
105/// use webgates_sessions::tokens::RefreshTokenHash;
106///
107/// let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
108/// let family_id = SessionFamilyId::new();
109/// let session = Session::new(
110///     family_id,
111///     "user-42",
112///     now,
113///     now + Duration::from_secs(3_600),
114/// );
115/// let family = SessionFamilyRecord::new(family_id, "user-42", now);
116/// let lease = RenewalLease::from_ttl(
117///     session.session_id,
118///     LeaseId::new(),
119///     now,
120///     LeaseTtl::new(Duration::from_secs(30)),
121/// );
122///
123/// let input = RotateRefreshToken {
124///     session_id: session.session_id,
125///     family,
126///     lease,
127///     previous_refresh_token_hash: RefreshTokenHash::new("prev-hash-abc").unwrap(),
128///     next_refresh_token_hash: RefreshTokenHash::new("next-hash-xyz").unwrap(),
129///     next_session: session.clone().touched(now),
130/// };
131///
132/// assert_eq!(input.session_id, session.session_id);
133/// ```
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct RotateRefreshToken {
136    /// Session that is being renewed.
137    pub session_id: SessionId,
138    /// Session family that owns the session.
139    pub family: SessionFamilyRecord,
140    /// Lease expected to authorize this rotation.
141    pub lease: RenewalLease,
142    /// Previously active refresh-token hash.
143    pub previous_refresh_token_hash: RefreshTokenHash,
144    /// Newly issued refresh-token hash to persist.
145    pub next_refresh_token_hash: RefreshTokenHash,
146    /// Updated session record that should become current after rotation.
147    pub next_session: SessionRecord,
148}
149
150/// Result of attempting an atomic refresh-token rotation.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum RotateRefreshTokenOutcome {
153    /// Rotation succeeded and the new refresh token is now active.
154    Rotated,
155    /// The session no longer exists or is no longer active.
156    SessionMissing,
157    /// The lease was missing, expired, or owned by another renewal attempt.
158    LeaseUnavailable,
159    /// The previous refresh token no longer matched the persisted active token.
160    ///
161    /// Callers should treat this as potential replay and apply family
162    /// revocation rules as appropriate.
163    RefreshTokenMismatch,
164}
165
166/// Scope used when revoking session state.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum RevokeSessionScope {
169    /// Revoke only the current session.
170    CurrentSession,
171    /// Revoke every session in the same family.
172    SessionFamily,
173}
174
175/// Repository boundary for session persistence and lease coordination.
176///
177/// Implementations are expected to keep lease acquisition, token rotation, and
178/// revocation semantics correct under concurrent access.
179///
180/// # Contract
181///
182/// Implementations should keep refresh-token rotation atomic, treat `Ok(None)`
183/// as an expected not-found result, reserve `Err(..)` for backend or state
184/// failures, and avoid leaking backend-specific details in error messages.
185pub trait SessionRepository: Send + Sync {
186    /// Creates a new persisted session and stores its active refresh-token hash.
187    fn create_session(
188        &self,
189        input: CreateSession,
190    ) -> impl std::future::Future<Output = RepositoryResult<()>> + Send;
191
192    /// Looks up session state by the presented refresh-token hash.
193    fn find_session_by_refresh_token_hash<'a>(
194        &'a self,
195        refresh_token_hash: RefreshTokenHashRef<'a>,
196    ) -> impl std::future::Future<Output = RepositoryResult<Option<SessionLookup>>> + Send + 'a;
197
198    /// Loads the current session record by identifier.
199    fn find_session(
200        &self,
201        session_id: SessionId,
202    ) -> impl std::future::Future<Output = RepositoryResult<Option<SessionRecord>>> + Send;
203
204    /// Loads summary information for a session family.
205    fn find_family(
206        &self,
207        family_id: SessionFamilyId,
208    ) -> impl std::future::Future<Output = RepositoryResult<Option<SessionFamilyRecord>>> + Send;
209
210    /// Loads the current refresh-token record for a session.
211    fn find_refresh_record(
212        &self,
213        session_id: SessionId,
214    ) -> impl std::future::Future<Output = RepositoryResult<Option<SessionRefreshRecord>>> + Send;
215
216    /// Attempts to acquire or observe the renewal lease for a session.
217    fn try_acquire_renewal_lease(
218        &self,
219        session_id: SessionId,
220        lease: RenewalLease,
221    ) -> impl std::future::Future<Output = RepositoryResult<LeaseAcquisition>> + Send;
222
223    /// Atomically rotates the active refresh token for an existing session.
224    fn rotate_refresh_token(
225        &self,
226        input: RotateRefreshToken,
227    ) -> impl std::future::Future<Output = RepositoryResult<RotateRefreshTokenOutcome>> + Send;
228
229    /// Revokes the specified session or its full family.
230    fn revoke_session(
231        &self,
232        session_id: SessionId,
233        scope: RevokeSessionScope,
234    ) -> impl std::future::Future<Output = RepositoryResult<()>> + Send;
235
236    /// Revokes every session that belongs to the provided family.
237    fn revoke_family(
238        &self,
239        family_id: SessionFamilyId,
240    ) -> impl std::future::Future<Output = RepositoryResult<()>> + Send;
241
242    /// Records session activity such as a later `last_seen_at` update.
243    fn touch_session(
244        &self,
245        touch: SessionTouch,
246    ) -> impl std::future::Future<Output = RepositoryResult<()>> + Send;
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::lease::{LeaseAcquisition, LeaseId, LeaseTtl, RenewalLease};
253    use crate::session::{Session, SessionFamilyRecord};
254    use std::time::{Duration, SystemTime};
255
256    #[derive(Debug, Clone, Copy, Default)]
257    struct ContractRepository;
258
259    impl SessionRepository for ContractRepository {
260        async fn create_session(&self, _input: CreateSession) -> RepositoryResult<()> {
261            Ok(())
262        }
263
264        async fn find_session_by_refresh_token_hash<'a>(
265            &'a self,
266            _refresh_token_hash: RefreshTokenHashRef<'a>,
267        ) -> RepositoryResult<Option<SessionLookup>> {
268            Ok(None)
269        }
270
271        async fn find_session(
272            &self,
273            _session_id: SessionId,
274        ) -> RepositoryResult<Option<SessionRecord>> {
275            Ok(None)
276        }
277
278        async fn find_family(
279            &self,
280            _family_id: SessionFamilyId,
281        ) -> RepositoryResult<Option<SessionFamilyRecord>> {
282            Ok(None)
283        }
284
285        async fn find_refresh_record(
286            &self,
287            _session_id: SessionId,
288        ) -> RepositoryResult<Option<SessionRefreshRecord>> {
289            Ok(None)
290        }
291
292        async fn try_acquire_renewal_lease(
293            &self,
294            _session_id: SessionId,
295            lease: RenewalLease,
296        ) -> RepositoryResult<LeaseAcquisition> {
297            Ok(LeaseAcquisition::Acquired(lease))
298        }
299
300        async fn rotate_refresh_token(
301            &self,
302            _input: RotateRefreshToken,
303        ) -> RepositoryResult<RotateRefreshTokenOutcome> {
304            Ok(RotateRefreshTokenOutcome::Rotated)
305        }
306
307        async fn revoke_session(
308            &self,
309            _session_id: SessionId,
310            _scope: RevokeSessionScope,
311        ) -> RepositoryResult<()> {
312            Ok(())
313        }
314
315        async fn revoke_family(&self, _family_id: SessionFamilyId) -> RepositoryResult<()> {
316            Ok(())
317        }
318
319        async fn touch_session(&self, _touch: SessionTouch) -> RepositoryResult<()> {
320            Ok(())
321        }
322    }
323
324    fn assert_session_repository<T: SessionRepository>(_repository: &T) {}
325
326    fn sample_time() -> SystemTime {
327        SystemTime::UNIX_EPOCH + Duration::from_secs(1_000)
328    }
329
330    fn sample_hash(value: &str) -> RefreshTokenHash {
331        match RefreshTokenHash::new(value) {
332            Ok(hash) => hash,
333            Err(error) => panic!("expected valid refresh-token hash: {error}"),
334        }
335    }
336
337    fn sample_session() -> SessionRecord {
338        let now = sample_time();
339
340        Session::new(
341            SessionFamilyId::new(),
342            "subject-123",
343            now,
344            now + Duration::from_secs(3_600),
345        )
346    }
347
348    fn sample_lease(session_id: SessionId) -> RenewalLease {
349        RenewalLease::from_ttl(
350            session_id,
351            LeaseId::new(),
352            sample_time(),
353            LeaseTtl::new(Duration::from_secs(30)),
354        )
355    }
356
357    #[test]
358    fn backend_error_constructor_keeps_message() {
359        let error = RepositoryError::backend("safe backend summary");
360
361        assert_eq!(
362            error,
363            RepositoryError::Backend {
364                message: String::from("safe backend summary"),
365            }
366        );
367    }
368
369    #[tokio::test]
370    async fn repository_trait_contracts_are_callable() {
371        let repository = ContractRepository;
372        assert_session_repository(&repository);
373
374        let session = sample_session();
375        let family =
376            SessionFamilyRecord::new(session.family_id, session.subject_id.clone(), sample_time());
377        let lease = sample_lease(session.session_id);
378        let create_input = CreateSession {
379            session: session.clone(),
380            refresh_token_hash: sample_hash("active-refresh-hash"),
381        };
382        let rotate_input = RotateRefreshToken {
383            session_id: session.session_id,
384            family,
385            lease,
386            previous_refresh_token_hash: sample_hash("previous-refresh-hash"),
387            next_refresh_token_hash: sample_hash("next-refresh-hash"),
388            next_session: session
389                .clone()
390                .touched(sample_time() + Duration::from_secs(10)),
391        };
392        let touch = SessionTouch::new(session.session_id, sample_time() + Duration::from_secs(20));
393
394        assert_eq!(repository.create_session(create_input).await, Ok(()));
395        assert!(matches!(
396            repository
397                .find_session_by_refresh_token_hash("lookup-refresh-hash")
398                .await,
399            Ok(None)
400        ));
401        assert!(matches!(
402            repository.find_session(session.session_id).await,
403            Ok(None)
404        ));
405        assert!(matches!(
406            repository.find_family(session.family_id).await,
407            Ok(None)
408        ));
409        assert!(matches!(
410            repository.find_refresh_record(session.session_id).await,
411            Ok(None)
412        ));
413        assert_eq!(
414            repository
415                .try_acquire_renewal_lease(session.session_id, lease)
416                .await,
417            Ok(LeaseAcquisition::Acquired(lease))
418        );
419        assert_eq!(
420            repository.rotate_refresh_token(rotate_input).await,
421            Ok(RotateRefreshTokenOutcome::Rotated)
422        );
423        assert_eq!(
424            repository
425                .revoke_session(session.session_id, RevokeSessionScope::CurrentSession)
426                .await,
427            Ok(())
428        );
429        assert_eq!(repository.revoke_family(session.family_id).await, Ok(()));
430        assert_eq!(repository.touch_session(touch).await, Ok(()));
431    }
432}