Skip to main content

webgates_sessions/
services.rs

1//! Session service layer.
2//!
3//! This module composes the framework-agnostic session domain, repository
4//! contracts, and token issuance primitives into cohesive workflows for:
5//!
6//! - issuing new sessions
7//! - renewing existing sessions
8//! - revoking sessions during logout
9//!
10//! The services in this module stay transport agnostic. HTTP adapters and other
11//! delivery layers remain responsible for cookie handling, header parsing, and
12//! response mutation.
13
14use std::time::SystemTime;
15
16use crate::config::{ConfigResult, SessionConfig};
17use crate::errors::Result;
18use crate::lease::{LeaseAcquisition, LeaseId, RenewalLease};
19use crate::logout::{LogoutOutcome, LogoutRequest, LogoutScope};
20use crate::renewal::{
21    AuthTokenState, RenewalDecision, RenewalOrchestrator, RenewalOutcome, RenewalRequest,
22    RenewalRequirement,
23};
24use crate::repository::{
25    CreateSession, RevokeSessionScope, RotateRefreshToken, RotateRefreshTokenOutcome,
26    SessionRepository,
27};
28use crate::session::{Session, SessionFamilyId};
29use crate::tokens::{
30    AuthTokenIssuer, IssuedSessionTokens, RefreshTokenGenerator, RefreshTokenHasher,
31    RefreshTokenPlaintext, TokenPairIssuer,
32};
33
34/// Result of issuing a brand-new session.
35///
36/// This value keeps the persisted session record together with the client-facing
37/// token material produced for it.
38///
39/// # Examples
40///
41/// ```
42/// use std::time::{Duration, SystemTime};
43/// use webgates_sessions::services::IssuedSession;
44/// use webgates_sessions::session::{Session, SessionFamilyId};
45/// use webgates_sessions::tokens::{
46///     AuthToken, IssuedSessionTokens, IssuedTokenPair, RefreshTokenHash,
47///     RefreshTokenPlaintext,
48/// };
49///
50/// let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
51/// let session = Session::new(
52///     SessionFamilyId::new(),
53///     "user-42",
54///     now,
55///     now + Duration::from_secs(3_600),
56/// );
57/// let tokens = IssuedSessionTokens::new(
58///     IssuedTokenPair::new(
59///         AuthToken::new("auth-token-value").unwrap(),
60///         RefreshTokenPlaintext::new("a".repeat(64)).unwrap(),
61///     ),
62///     RefreshTokenHash::new("abc123def456").unwrap(),
63/// );
64///
65/// let issued = IssuedSession::new(session.clone(), tokens);
66/// assert_eq!(issued.session, session);
67/// ```
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct IssuedSession {
70    /// Newly created session state.
71    pub session: Session,
72    /// Issued auth and refresh tokens plus the persisted refresh-token hash.
73    pub tokens: IssuedSessionTokens,
74}
75
76impl IssuedSession {
77    /// Creates a new issued-session result.
78    #[must_use]
79    pub fn new(session: Session, tokens: IssuedSessionTokens) -> Self {
80        Self { session, tokens }
81    }
82}
83
84/// Issues new session state and token pairs.
85///
86/// Use this service after successful authentication when you want to create a
87/// session-backed login result.
88///
89/// # Examples
90///
91/// ```
92/// use webgates_sessions::config::SessionConfig;
93/// use webgates_sessions::services::SessionIssuer;
94/// use webgates_sessions::tokens::{
95///     OpaqueRefreshTokenGenerator, RefreshTokenLength, Sha256RefreshTokenHasher,
96///     TokenPairIssuer, AuthToken, AuthTokenIssuer,
97/// };
98/// use webgates_sessions::session::Session;
99///
100/// #[derive(Debug, Clone, Copy)]
101/// struct NoopIssuer;
102///
103/// impl AuthTokenIssuer<Session> for NoopIssuer {
104///     type Error = webgates_sessions::errors::TokenError;
105///     fn issue_auth_token(
106///         &self,
107///         s: &Session,
108///     ) -> impl std::future::Future<Output = Result<AuthToken, Self::Error>> + Send {
109///         std::future::ready(AuthToken::new(format!("auth-{}", s.subject_id)))
110///     }
111/// }
112///
113/// # use webgates_sessions::repository::*;
114/// # use webgates_sessions::lease::{LeaseAcquisition, RenewalLease};
115/// # use webgates_sessions::session::*;
116/// # #[derive(Clone)] struct Noop;
117/// # impl SessionRepository for Noop {
118/// #     fn create_session(&self, _: CreateSession) -> impl std::future::Future<Output = RepositoryResult<()>> + Send { async { Ok(()) } }
119/// #     fn find_session_by_refresh_token_hash<'a>(&'a self, _: webgates_sessions::tokens::RefreshTokenHashRef<'a>) -> impl std::future::Future<Output = RepositoryResult<Option<SessionLookup>>> + Send + 'a { async { Ok(None) } }
120/// #     fn find_session(&self, _: SessionId) -> impl std::future::Future<Output = RepositoryResult<Option<SessionRecord>>> + Send { async { Ok(None) } }
121/// #     fn find_family(&self, _: SessionFamilyId) -> impl std::future::Future<Output = RepositoryResult<Option<SessionFamilyRecord>>> + Send { async { Ok(None) } }
122/// #     fn find_refresh_record(&self, _: SessionId) -> impl std::future::Future<Output = RepositoryResult<Option<SessionRefreshRecord>>> + Send { async { Ok(None) } }
123/// #     fn try_acquire_renewal_lease(&self, _: SessionId, l: RenewalLease) -> impl std::future::Future<Output = RepositoryResult<LeaseAcquisition>> + Send { async move { Ok(LeaseAcquisition::Acquired(l)) } }
124/// #     fn rotate_refresh_token(&self, _: RotateRefreshToken) -> impl std::future::Future<Output = RepositoryResult<RotateRefreshTokenOutcome>> + Send { async { Ok(RotateRefreshTokenOutcome::Rotated) } }
125/// #     fn revoke_session(&self, _: SessionId, _: RevokeSessionScope) -> impl std::future::Future<Output = RepositoryResult<()>> + Send { async { Ok(()) } }
126/// #     fn revoke_family(&self, _: SessionFamilyId) -> impl std::future::Future<Output = RepositoryResult<()>> + Send { async { Ok(()) } }
127/// #     fn touch_session(&self, _: SessionTouch) -> impl std::future::Future<Output = RepositoryResult<()>> + Send { async { Ok(()) } }
128/// # }
129///
130/// let length = RefreshTokenLength::new(64).unwrap();
131/// let token_pair_issuer = TokenPairIssuer::new(
132///     NoopIssuer,
133///     OpaqueRefreshTokenGenerator::new(length),
134///     Sha256RefreshTokenHasher,
135/// );
136///
137/// let issuer = SessionIssuer::new(
138///     SessionConfig::default(),
139///     Noop,
140///     token_pair_issuer,
141/// )
142/// .unwrap();
143/// ```
144#[derive(Debug, Clone)]
145pub struct SessionIssuer<R, A, G, H> {
146    config: SessionConfig,
147    repository: R,
148    token_pair_issuer: TokenPairIssuer<A, G, H>,
149}
150
151impl<R, A, G, H> SessionIssuer<R, A, G, H> {
152    /// Creates a new session issuer from validated configuration, a repository,
153    /// and a token-pair issuer.
154    ///
155    /// # Errors
156    ///
157    /// Returns a configuration error when `config` is internally inconsistent.
158    pub fn new(
159        config: SessionConfig,
160        repository: R,
161        token_pair_issuer: TokenPairIssuer<A, G, H>,
162    ) -> ConfigResult<Self> {
163        Ok(Self {
164            config: config.validate()?,
165            repository,
166            token_pair_issuer,
167        })
168    }
169}
170
171impl<R, A, G, H> SessionIssuer<R, A, G, H>
172where
173    R: SessionRepository,
174    A: AuthTokenIssuer<Session>,
175    G: RefreshTokenGenerator,
176    H: RefreshTokenHasher,
177{
178    /// Issues a new session for `subject_id`, persists it, and returns the
179    /// issued session plus tokens.
180    ///
181    /// # Errors
182    ///
183    /// Returns a session error when token issuance or persistence fails.
184    pub async fn issue_session(
185        &self,
186        subject_id: impl Into<String>,
187        now: SystemTime,
188    ) -> Result<IssuedSession> {
189        let session = Session::new(
190            SessionFamilyId::new(),
191            subject_id,
192            now,
193            now + self.config.refresh_token_ttl,
194        );
195        let tokens = self.token_pair_issuer.issue_for_subject(&session).await?;
196
197        self.repository
198            .create_session(CreateSession {
199                session: session.clone(),
200                refresh_token_hash: tokens.refresh_token_hash.clone(),
201            })
202            .await?;
203
204        Ok(IssuedSession::new(session, tokens))
205    }
206}
207
208/// Renews existing sessions using refresh-token rotation.
209///
210/// Use this service when a client presents a refresh token and you need to
211/// decide whether renewal is needed, coordinate leases, rotate token state, and
212/// produce replacement tokens.
213///
214/// # Examples
215///
216/// ```
217/// use webgates_sessions::config::SessionConfig;
218/// use webgates_sessions::services::SessionRenewer;
219/// use webgates_sessions::tokens::{
220///     OpaqueRefreshTokenGenerator, RefreshTokenLength, Sha256RefreshTokenHasher,
221///     TokenPairIssuer, AuthToken, AuthTokenIssuer,
222/// };
223/// use webgates_sessions::session::Session;
224///
225/// #[derive(Debug, Clone, Copy)]
226/// struct NoopIssuer;
227///
228/// impl AuthTokenIssuer<Session> for NoopIssuer {
229///     type Error = webgates_sessions::errors::TokenError;
230///     fn issue_auth_token(
231///         &self,
232///         s: &Session,
233///     ) -> impl std::future::Future<Output = Result<AuthToken, Self::Error>> + Send {
234///         std::future::ready(AuthToken::new(format!("auth-{}", s.subject_id)))
235///     }
236/// }
237///
238/// # use webgates_sessions::repository::*;
239/// # use webgates_sessions::lease::{LeaseAcquisition, RenewalLease};
240/// # use webgates_sessions::session::*;
241/// # #[derive(Clone)] struct Noop;
242/// # impl SessionRepository for Noop {
243/// #     fn create_session(&self, _: CreateSession) -> impl std::future::Future<Output = RepositoryResult<()>> + Send { async { Ok(()) } }
244/// #     fn find_session_by_refresh_token_hash<'a>(&'a self, _: webgates_sessions::tokens::RefreshTokenHashRef<'a>) -> impl std::future::Future<Output = RepositoryResult<Option<SessionLookup>>> + Send + 'a { async { Ok(None) } }
245/// #     fn find_session(&self, _: SessionId) -> impl std::future::Future<Output = RepositoryResult<Option<SessionRecord>>> + Send { async { Ok(None) } }
246/// #     fn find_family(&self, _: SessionFamilyId) -> impl std::future::Future<Output = RepositoryResult<Option<SessionFamilyRecord>>> + Send { async { Ok(None) } }
247/// #     fn find_refresh_record(&self, _: SessionId) -> impl std::future::Future<Output = RepositoryResult<Option<SessionRefreshRecord>>> + Send { async { Ok(None) } }
248/// #     fn try_acquire_renewal_lease(&self, _: SessionId, l: RenewalLease) -> impl std::future::Future<Output = RepositoryResult<LeaseAcquisition>> + Send { async move { Ok(LeaseAcquisition::Acquired(l)) } }
249/// #     fn rotate_refresh_token(&self, _: RotateRefreshToken) -> impl std::future::Future<Output = RepositoryResult<RotateRefreshTokenOutcome>> + Send { async { Ok(RotateRefreshTokenOutcome::Rotated) } }
250/// #     fn revoke_session(&self, _: SessionId, _: RevokeSessionScope) -> impl std::future::Future<Output = RepositoryResult<()>> + Send { async { Ok(()) } }
251/// #     fn revoke_family(&self, _: SessionFamilyId) -> impl std::future::Future<Output = RepositoryResult<()>> + Send { async { Ok(()) } }
252/// #     fn touch_session(&self, _: SessionTouch) -> impl std::future::Future<Output = RepositoryResult<()>> + Send { async { Ok(()) } }
253/// # }
254///
255/// let length = RefreshTokenLength::new(64).unwrap();
256/// let token_pair_issuer = TokenPairIssuer::new(
257///     NoopIssuer,
258///     OpaqueRefreshTokenGenerator::new(length),
259///     Sha256RefreshTokenHasher,
260/// );
261///
262/// let renewer = SessionRenewer::new(
263///     SessionConfig::default(),
264///     Noop,
265///     token_pair_issuer,
266/// )
267/// .unwrap();
268/// ```
269#[derive(Debug, Clone)]
270pub struct SessionRenewer<R, A, G, H> {
271    config: SessionConfig,
272    repository: R,
273    token_pair_issuer: TokenPairIssuer<A, G, H>,
274    orchestrator: RenewalOrchestrator,
275}
276
277impl<R, A, G, H> SessionRenewer<R, A, G, H> {
278    /// Creates a new session renewer.
279    ///
280    /// # Errors
281    ///
282    /// Returns a configuration error when `config` is internally inconsistent.
283    pub fn new(
284        config: SessionConfig,
285        repository: R,
286        token_pair_issuer: TokenPairIssuer<A, G, H>,
287    ) -> ConfigResult<Self> {
288        Ok(Self {
289            config: config.validate()?,
290            repository,
291            token_pair_issuer,
292            orchestrator: RenewalOrchestrator::new(),
293        })
294    }
295}
296
297impl<R, A, G, H> SessionRenewer<R, A, G, H>
298where
299    R: SessionRepository,
300    A: AuthTokenIssuer<Session>,
301    G: RefreshTokenGenerator,
302    H: RefreshTokenHasher,
303{
304    /// Attempts to renew a session using the presented refresh token.
305    ///
306    /// Valid auth tokens that are not yet near expiry short-circuit to
307    /// [`RenewalOutcome::NotNeeded`] without touching persistence.
308    ///
309    /// # Errors
310    ///
311    /// Returns a session error when hashing, repository access, token issuance,
312    /// or family revocation fails.
313    pub async fn renew_session(
314        &self,
315        auth_token_state: AuthTokenState,
316        requirement: RenewalRequirement,
317        refresh_token: &RefreshTokenPlaintext,
318        now: SystemTime,
319    ) -> Result<RenewalOutcome> {
320        let preliminary_decision = self
321            .orchestrator
322            .decide_for_state(auth_token_state, requirement);
323
324        match preliminary_decision {
325            RenewalDecision::NoRenewal => return Ok(RenewalOutcome::NotNeeded),
326            RenewalDecision::Reject => return Ok(RenewalOutcome::Rejected),
327            RenewalDecision::AttemptProactiveRenewal | RenewalDecision::RequireRenewal => {}
328        }
329
330        let presented_refresh_token_hash = self
331            .token_pair_issuer
332            .hash_refresh_token(refresh_token)
333            .await?;
334        let lookup = self
335            .repository
336            .find_session_by_refresh_token_hash(presented_refresh_token_hash.as_str())
337            .await?;
338
339        let Some(lookup) = lookup else {
340            return Ok(RenewalOutcome::Rejected);
341        };
342
343        if !lookup.is_active_at(now) {
344            return Ok(RenewalOutcome::Rejected);
345        }
346
347        let request = RenewalRequest::new(
348            lookup.session.session_id,
349            auth_token_state,
350            requirement,
351            now,
352        );
353
354        match self.orchestrator.decide(&request) {
355            RenewalDecision::NoRenewal => return Ok(RenewalOutcome::NotNeeded),
356            RenewalDecision::Reject => return Ok(RenewalOutcome::Rejected),
357            RenewalDecision::AttemptProactiveRenewal | RenewalDecision::RequireRenewal => {}
358        }
359
360        let proposed_lease = RenewalLease::from_ttl(
361            lookup.session.session_id,
362            LeaseId::new(),
363            now,
364            self.config.renewal_lease_ttl,
365        );
366
367        let acquisition = self
368            .repository
369            .try_acquire_renewal_lease(lookup.session.session_id, proposed_lease)
370            .await?;
371
372        let acquired_lease = match acquisition {
373            LeaseAcquisition::Acquired(lease) => lease,
374            LeaseAcquisition::HeldByOther { .. } | LeaseAcquisition::Unavailable => {
375                return Ok(RenewalOutcome::LeaseUnavailable { acquisition });
376            }
377        };
378
379        let mut next_session = lookup.session.clone().touched(now);
380        next_session.expires_at = now + self.config.refresh_token_ttl;
381
382        let issued = self
383            .token_pair_issuer
384            .issue_for_subject(&next_session)
385            .await?;
386        let rotate_outcome = self
387            .repository
388            .rotate_refresh_token(RotateRefreshToken {
389                session_id: next_session.session_id,
390                family: lookup.family.clone(),
391                lease: acquired_lease,
392                previous_refresh_token_hash: presented_refresh_token_hash,
393                next_refresh_token_hash: issued.refresh_token_hash.clone(),
394                next_session: next_session.clone(),
395            })
396            .await?;
397
398        match rotate_outcome {
399            RotateRefreshTokenOutcome::Rotated => Ok(RenewalOutcome::Renewed {
400                session: next_session,
401                tokens: issued.token_pair,
402            }),
403            RotateRefreshTokenOutcome::SessionMissing => Ok(RenewalOutcome::Rejected),
404            RotateRefreshTokenOutcome::LeaseUnavailable => Ok(RenewalOutcome::LeaseUnavailable {
405                acquisition: LeaseAcquisition::Unavailable,
406            }),
407            RotateRefreshTokenOutcome::RefreshTokenMismatch => {
408                self.repository
409                    .revoke_family(lookup.family.family_id)
410                    .await?;
411                Ok(RenewalOutcome::ReplayDetected {
412                    session_id: lookup.session.session_id,
413                })
414            }
415        }
416    }
417}
418
419/// Service that revokes session state during logout flows.
420///
421/// # Examples
422///
423/// ```
424/// use webgates_sessions::services::SessionRevoker;
425///
426/// # use webgates_sessions::repository::*;
427/// # use webgates_sessions::lease::{LeaseAcquisition, RenewalLease};
428/// # use webgates_sessions::session::*;
429/// # #[derive(Clone)] struct Noop;
430/// # impl SessionRepository for Noop {
431/// #     fn create_session(&self, _: CreateSession) -> impl std::future::Future<Output = RepositoryResult<()>> + Send { async { Ok(()) } }
432/// #     fn find_session_by_refresh_token_hash<'a>(&'a self, _: webgates_sessions::tokens::RefreshTokenHashRef<'a>) -> impl std::future::Future<Output = RepositoryResult<Option<SessionLookup>>> + Send + 'a { async { Ok(None) } }
433/// #     fn find_session(&self, _: SessionId) -> impl std::future::Future<Output = RepositoryResult<Option<SessionRecord>>> + Send { async { Ok(None) } }
434/// #     fn find_family(&self, _: SessionFamilyId) -> impl std::future::Future<Output = RepositoryResult<Option<SessionFamilyRecord>>> + Send { async { Ok(None) } }
435/// #     fn find_refresh_record(&self, _: SessionId) -> impl std::future::Future<Output = RepositoryResult<Option<SessionRefreshRecord>>> + Send { async { Ok(None) } }
436/// #     fn try_acquire_renewal_lease(&self, _: SessionId, l: RenewalLease) -> impl std::future::Future<Output = RepositoryResult<LeaseAcquisition>> + Send { async move { Ok(LeaseAcquisition::Acquired(l)) } }
437/// #     fn rotate_refresh_token(&self, _: RotateRefreshToken) -> impl std::future::Future<Output = RepositoryResult<RotateRefreshTokenOutcome>> + Send { async { Ok(RotateRefreshTokenOutcome::Rotated) } }
438/// #     fn revoke_session(&self, _: SessionId, _: RevokeSessionScope) -> impl std::future::Future<Output = RepositoryResult<()>> + Send { async { Ok(()) } }
439/// #     fn revoke_family(&self, _: SessionFamilyId) -> impl std::future::Future<Output = RepositoryResult<()>> + Send { async { Ok(()) } }
440/// #     fn touch_session(&self, _: SessionTouch) -> impl std::future::Future<Output = RepositoryResult<()>> + Send { async { Ok(()) } }
441/// # }
442///
443/// // Create a revoker from any SessionRepository implementation.
444/// let revoker = SessionRevoker::new(Noop);
445/// ```
446#[derive(Debug, Clone)]
447pub struct SessionRevoker<R> {
448    repository: R,
449}
450
451impl<R> SessionRevoker<R> {
452    /// Creates a new session revoker.
453    #[must_use]
454    pub fn new(repository: R) -> Self {
455        Self { repository }
456    }
457}
458
459impl<R> SessionRevoker<R>
460where
461    R: SessionRepository,
462{
463    /// Revokes the session state targeted by `request`.
464    ///
465    /// # Errors
466    ///
467    /// Returns a session error when the repository revocation operation fails.
468    pub async fn revoke_session(&self, request: LogoutRequest) -> Result<LogoutOutcome> {
469        match request.scope {
470            LogoutScope::CurrentSession => {
471                self.repository
472                    .revoke_session(request.session_id, RevokeSessionScope::CurrentSession)
473                    .await?;
474                Ok(LogoutOutcome::CurrentSessionRevoked)
475            }
476            LogoutScope::SessionFamily => {
477                self.repository.revoke_family(request.family_id).await?;
478                Ok(LogoutOutcome::SessionFamilyRevoked)
479            }
480        }
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use crate::lease::{LeaseAcquisition, LeaseId, LeaseTtl, RenewalLease};
488    use crate::repository::{CreateSession, RotateRefreshToken};
489    use crate::session::{
490        SessionFamilyRecord, SessionId, SessionLookup, SessionRefreshRecord, SessionTouch,
491    };
492    use crate::tokens::{
493        AuthToken, OpaqueRefreshTokenGenerator, RefreshTokenLength, Sha256RefreshTokenHasher,
494    };
495    use std::sync::Arc;
496    use std::time::Duration;
497    use tokio::sync::Mutex;
498
499    #[derive(Debug, Clone, Copy)]
500    struct StaticSessionAuthTokenIssuer;
501
502    impl AuthTokenIssuer<Session> for StaticSessionAuthTokenIssuer {
503        type Error = crate::errors::TokenError;
504
505        fn issue_auth_token(
506            &self,
507            session: &Session,
508        ) -> impl std::future::Future<Output = std::result::Result<AuthToken, Self::Error>> + Send
509        {
510            std::future::ready(AuthToken::new(format!("auth-{}", session.subject_id)))
511        }
512    }
513
514    #[derive(Debug, Clone)]
515    struct MockRepository {
516        state: Arc<Mutex<MockRepositoryState>>,
517    }
518
519    #[derive(Debug, Clone)]
520    struct MockRepositoryState {
521        created_sessions: Vec<CreateSession>,
522        lookup_result: Option<SessionLookup>,
523        lookup_calls: usize,
524        lease_acquisition: Option<LeaseAcquisition>,
525        rotate_outcome: RotateRefreshTokenOutcome,
526        rotate_inputs: Vec<RotateRefreshToken>,
527        revoked_sessions: Vec<(SessionId, RevokeSessionScope)>,
528        revoked_families: Vec<SessionFamilyId>,
529    }
530
531    impl Default for MockRepositoryState {
532        fn default() -> Self {
533            Self {
534                created_sessions: Vec::new(),
535                lookup_result: None,
536                lookup_calls: 0,
537                lease_acquisition: None,
538                rotate_outcome: RotateRefreshTokenOutcome::Rotated,
539                rotate_inputs: Vec::new(),
540                revoked_sessions: Vec::new(),
541                revoked_families: Vec::new(),
542            }
543        }
544    }
545
546    impl MockRepository {
547        fn new() -> Self {
548            Self {
549                state: Arc::new(Mutex::new(MockRepositoryState::default())),
550            }
551        }
552    }
553
554    impl SessionRepository for MockRepository {
555        fn create_session(
556            &self,
557            input: CreateSession,
558        ) -> impl std::future::Future<Output = crate::repository::RepositoryResult<()>> + Send
559        {
560            let state = Arc::clone(&self.state);
561
562            async move {
563                state.lock().await.created_sessions.push(input);
564                Ok(())
565            }
566        }
567
568        fn find_session_by_refresh_token_hash<'a>(
569            &'a self,
570            _refresh_token_hash: crate::tokens::RefreshTokenHashRef<'a>,
571        ) -> impl std::future::Future<
572            Output = crate::repository::RepositoryResult<Option<SessionLookup>>,
573        > + Send
574        + 'a {
575            let state = Arc::clone(&self.state);
576
577            async move {
578                let mut guard = state.lock().await;
579                guard.lookup_calls += 1;
580                Ok(guard.lookup_result.clone())
581            }
582        }
583
584        async fn find_session(
585            &self,
586            _session_id: SessionId,
587        ) -> crate::repository::RepositoryResult<Option<crate::session::SessionRecord>> {
588            Ok(None)
589        }
590
591        async fn find_family(
592            &self,
593            _family_id: SessionFamilyId,
594        ) -> crate::repository::RepositoryResult<Option<SessionFamilyRecord>> {
595            Ok(None)
596        }
597
598        async fn find_refresh_record(
599            &self,
600            _session_id: SessionId,
601        ) -> crate::repository::RepositoryResult<Option<SessionRefreshRecord>> {
602            Ok(None)
603        }
604
605        async fn try_acquire_renewal_lease(
606            &self,
607            _session_id: SessionId,
608            lease: RenewalLease,
609        ) -> crate::repository::RepositoryResult<LeaseAcquisition> {
610            let configured = self.state.lock().await.lease_acquisition;
611            Ok(configured.unwrap_or(LeaseAcquisition::Acquired(lease)))
612        }
613
614        async fn rotate_refresh_token(
615            &self,
616            input: RotateRefreshToken,
617        ) -> crate::repository::RepositoryResult<RotateRefreshTokenOutcome> {
618            let mut guard = self.state.lock().await;
619            guard.rotate_inputs.push(input);
620            Ok(guard.rotate_outcome)
621        }
622
623        async fn revoke_session(
624            &self,
625            session_id: SessionId,
626            scope: RevokeSessionScope,
627        ) -> crate::repository::RepositoryResult<()> {
628            self.state
629                .lock()
630                .await
631                .revoked_sessions
632                .push((session_id, scope));
633            Ok(())
634        }
635
636        async fn revoke_family(
637            &self,
638            family_id: SessionFamilyId,
639        ) -> crate::repository::RepositoryResult<()> {
640            self.state.lock().await.revoked_families.push(family_id);
641            Ok(())
642        }
643
644        async fn touch_session(
645            &self,
646            _touch: SessionTouch,
647        ) -> crate::repository::RepositoryResult<()> {
648            Ok(())
649        }
650    }
651
652    fn sample_time() -> SystemTime {
653        SystemTime::UNIX_EPOCH + Duration::from_secs(10_000)
654    }
655
656    fn valid_config() -> SessionConfig {
657        SessionConfig::default()
658    }
659
660    fn valid_refresh_token(value: &str) -> RefreshTokenPlaintext {
661        match RefreshTokenPlaintext::new(value) {
662            Ok(token) => token,
663            Err(error) => panic!("expected valid refresh token: {error}"),
664        }
665    }
666
667    fn token_pair_issuer() -> TokenPairIssuer<
668        StaticSessionAuthTokenIssuer,
669        OpaqueRefreshTokenGenerator,
670        Sha256RefreshTokenHasher,
671    > {
672        let generator = match RefreshTokenLength::new(48) {
673            Ok(length) => OpaqueRefreshTokenGenerator::new(length),
674            Err(error) => panic!("expected valid refresh-token length: {error}"),
675        };
676
677        TokenPairIssuer::new(
678            StaticSessionAuthTokenIssuer,
679            generator,
680            Sha256RefreshTokenHasher,
681        )
682    }
683
684    fn sample_lookup(now: SystemTime) -> SessionLookup {
685        let session = Session::new(
686            SessionFamilyId::new(),
687            "subject-123",
688            now - Duration::from_secs(30),
689            now + Duration::from_secs(3_600),
690        );
691        let family = SessionFamilyRecord::new(
692            session.family_id,
693            session.subject_id.clone(),
694            now - Duration::from_secs(30),
695        );
696        let refresh = SessionRefreshRecord::new(
697            session.session_id,
698            session.family_id,
699            now + Duration::from_secs(3_600),
700        );
701
702        SessionLookup::new(session, family, refresh)
703    }
704
705    fn sample_lease(session_id: SessionId, now: SystemTime) -> RenewalLease {
706        RenewalLease::from_ttl(
707            session_id,
708            LeaseId::new(),
709            now,
710            LeaseTtl::new(Duration::from_secs(30)),
711        )
712    }
713
714    #[tokio::test]
715    async fn session_issuer_persists_session_and_returns_tokens() {
716        let repository = MockRepository::new();
717        let issuer =
718            match SessionIssuer::new(valid_config(), repository.clone(), token_pair_issuer()) {
719                Ok(issuer) => issuer,
720                Err(error) => panic!("expected valid session issuer configuration: {error}"),
721            };
722        let now = sample_time();
723
724        let issued = match issuer.issue_session("subject-123", now).await {
725            Ok(issued) => issued,
726            Err(error) => panic!("expected successful session issuance: {error}"),
727        };
728
729        let guard = repository.state.lock().await;
730
731        assert_eq!(issued.session.subject_id, "subject-123");
732        assert_eq!(guard.created_sessions.len(), 1);
733        assert_eq!(guard.created_sessions[0].session, issued.session);
734        assert_eq!(
735            guard.created_sessions[0].refresh_token_hash,
736            issued.tokens.refresh_token_hash
737        );
738    }
739
740    #[tokio::test]
741    async fn renewer_returns_not_needed_without_repository_lookup_for_valid_tokens() {
742        let repository = MockRepository::new();
743        let renewer =
744            match SessionRenewer::new(valid_config(), repository.clone(), token_pair_issuer()) {
745                Ok(renewer) => renewer,
746                Err(error) => panic!("expected valid renewer configuration: {error}"),
747            };
748        let now = sample_time();
749        let refresh_token = valid_refresh_token("refresh-token-not-used");
750
751        let outcome = match renewer
752            .renew_session(
753                AuthTokenState::Valid {
754                    expires_in: Duration::from_secs(600),
755                },
756                RenewalRequirement::Proactive,
757                &refresh_token,
758                now,
759            )
760            .await
761        {
762            Ok(outcome) => outcome,
763            Err(error) => panic!("expected successful renewal evaluation: {error}"),
764        };
765
766        assert_eq!(outcome, RenewalOutcome::NotNeeded);
767        assert_eq!(repository.state.lock().await.lookup_calls, 0);
768    }
769
770    #[tokio::test]
771    async fn renewer_rotates_and_returns_tokens_when_renewal_succeeds() {
772        let repository = MockRepository::new();
773        let now = sample_time();
774        {
775            let mut guard = repository.state.lock().await;
776            let lookup = sample_lookup(now);
777            guard.lookup_result = Some(lookup.clone());
778            guard.lease_acquisition = Some(LeaseAcquisition::Acquired(sample_lease(
779                lookup.session.session_id,
780                now,
781            )));
782            guard.rotate_outcome = RotateRefreshTokenOutcome::Rotated;
783        }
784
785        let renewer =
786            match SessionRenewer::new(valid_config(), repository.clone(), token_pair_issuer()) {
787                Ok(renewer) => renewer,
788                Err(error) => panic!("expected valid renewer configuration: {error}"),
789            };
790        let refresh_token = valid_refresh_token("lookup-refresh-token");
791
792        let outcome = match renewer
793            .renew_session(
794                AuthTokenState::NearExpiry {
795                    expires_in: Duration::from_secs(30),
796                },
797                RenewalRequirement::Proactive,
798                &refresh_token,
799                now,
800            )
801            .await
802        {
803            Ok(outcome) => outcome,
804            Err(error) => panic!("expected successful renewal: {error}"),
805        };
806
807        let guard = repository.state.lock().await;
808
809        match outcome {
810            RenewalOutcome::Renewed { session, tokens } => {
811                assert_eq!(session.last_seen_at, Some(now));
812                assert_eq!(session.expires_at, now + valid_config().refresh_token_ttl);
813                assert_eq!(tokens.auth_token.as_str(), "auth-subject-123");
814                assert_eq!(guard.rotate_inputs.len(), 1);
815                assert_eq!(guard.rotate_inputs[0].next_session, session);
816            }
817            other => panic!("expected renewed outcome, got {other:?}"),
818        }
819    }
820
821    #[tokio::test]
822    async fn renewer_reports_lease_unavailable_when_another_actor_holds_it() {
823        let repository = MockRepository::new();
824        let now = sample_time();
825        let lookup = sample_lookup(now);
826        let active_lease = sample_lease(lookup.session.session_id, now);
827        {
828            let mut guard = repository.state.lock().await;
829            guard.lookup_result = Some(lookup.clone());
830            guard.lease_acquisition = Some(LeaseAcquisition::HeldByOther { active_lease });
831        }
832
833        let renewer =
834            match SessionRenewer::new(valid_config(), repository.clone(), token_pair_issuer()) {
835                Ok(renewer) => renewer,
836                Err(error) => panic!("expected valid renewer configuration: {error}"),
837            };
838        let refresh_token = valid_refresh_token("lookup-refresh-token");
839
840        let outcome = match renewer
841            .renew_session(
842                AuthTokenState::NearExpiry {
843                    expires_in: Duration::from_secs(15),
844                },
845                RenewalRequirement::Proactive,
846                &refresh_token,
847                now,
848            )
849            .await
850        {
851            Ok(outcome) => outcome,
852            Err(error) => panic!("expected renewal outcome: {error}"),
853        };
854
855        assert_eq!(
856            outcome,
857            RenewalOutcome::LeaseUnavailable {
858                acquisition: LeaseAcquisition::HeldByOther { active_lease },
859            }
860        );
861    }
862
863    #[tokio::test]
864    async fn renewer_revokes_family_on_refresh_token_replay() {
865        let repository = MockRepository::new();
866        let now = sample_time();
867        let lookup = sample_lookup(now);
868        {
869            let mut guard = repository.state.lock().await;
870            guard.lookup_result = Some(lookup.clone());
871            guard.lease_acquisition = Some(LeaseAcquisition::Acquired(sample_lease(
872                lookup.session.session_id,
873                now,
874            )));
875            guard.rotate_outcome = RotateRefreshTokenOutcome::RefreshTokenMismatch;
876        }
877
878        let renewer =
879            match SessionRenewer::new(valid_config(), repository.clone(), token_pair_issuer()) {
880                Ok(renewer) => renewer,
881                Err(error) => panic!("expected valid renewer configuration: {error}"),
882            };
883        let refresh_token = valid_refresh_token("lookup-refresh-token");
884
885        let outcome = match renewer
886            .renew_session(
887                AuthTokenState::Expired {
888                    expired_for: Duration::from_secs(5),
889                },
890                RenewalRequirement::Required,
891                &refresh_token,
892                now,
893            )
894            .await
895        {
896            Ok(outcome) => outcome,
897            Err(error) => panic!("expected replay outcome: {error}"),
898        };
899
900        assert_eq!(
901            outcome,
902            RenewalOutcome::ReplayDetected {
903                session_id: lookup.session.session_id,
904            }
905        );
906        assert_eq!(
907            repository.state.lock().await.revoked_families,
908            vec![lookup.family.family_id]
909        );
910    }
911
912    #[tokio::test]
913    async fn revoker_maps_logout_scopes_to_repository_operations() {
914        let repository = MockRepository::new();
915        let revoker = SessionRevoker::new(repository.clone());
916        let session = sample_lookup(sample_time()).session;
917
918        let current_outcome = match revoker
919            .revoke_session(LogoutRequest::new(
920                session.session_id,
921                session.family_id,
922                LogoutScope::CurrentSession,
923            ))
924            .await
925        {
926            Ok(outcome) => outcome,
927            Err(error) => panic!("expected successful current-session revocation: {error}"),
928        };
929
930        let family_outcome = match revoker
931            .revoke_session(LogoutRequest::new(
932                session.session_id,
933                session.family_id,
934                LogoutScope::SessionFamily,
935            ))
936            .await
937        {
938            Ok(outcome) => outcome,
939            Err(error) => panic!("expected successful family revocation: {error}"),
940        };
941
942        let guard = repository.state.lock().await;
943
944        assert_eq!(current_outcome, LogoutOutcome::CurrentSessionRevoked);
945        assert_eq!(family_outcome, LogoutOutcome::SessionFamilyRevoked);
946        assert_eq!(
947            guard.revoked_sessions,
948            vec![(session.session_id, RevokeSessionScope::CurrentSession)]
949        );
950        assert_eq!(guard.revoked_families, vec![session.family_id]);
951    }
952}