Skip to main content

alibi_core/session/
mod.rs

1use crate::AdapterRecord;
2pub mod cookie_cache;
3mod request;
4use crate::config::AuthConfig;
5use crate::entity::{AuthSession, AuthUser};
6use crate::error::AuthResult;
7use crate::schema::AuthSchema;
8use crate::store::AuthStore;
9use crate::types::CreateSession;
10use chrono::Utc;
11pub use request::SessionRequest;
12use std::sync::Arc;
13
14/// Insert into request extensions to suppress automatic renewal for a server
15/// render or an application-controlled request, like Source request-local
16/// `setShouldSkipSessionRefresh`. It is never accepted from client input.
17#[derive(Clone, Copy, Debug)]
18pub struct SessionRefreshSuppressed;
19
20/// Controls whether a persistent session read may write to its store.
21#[derive(Debug, Clone, Copy)]
22pub struct SessionReadOptions {
23    pub allow_refresh: bool,
24    pub cleanup_expired: bool,
25}
26
27impl Default for SessionReadOptions {
28    fn default() -> Self {
29        Self {
30            allow_refresh: true,
31            cleanup_expired: true,
32        }
33    }
34}
35
36/// The persisted result and refresh state of a session read.
37pub struct SessionRead<T> {
38    pub session: Option<T>,
39    pub needs_refresh: bool,
40    pub refreshed: bool,
41}
42
43impl<T> SessionRead<T> {
44    const fn absent() -> Self {
45        Self {
46            session: None,
47            needs_refresh: false,
48            refreshed: false,
49        }
50    }
51}
52
53/// Session manager handles session creation, validation, and cleanup
54pub struct SessionManager<S: AuthSchema> {
55    config: Arc<AuthConfig>,
56    database: Arc<dyn AuthStore<S>>,
57}
58
59impl<S: AuthSchema> Clone for SessionManager<S> {
60    fn clone(&self) -> Self {
61        Self {
62            config: Arc::clone(&self.config),
63            database: Arc::clone(&self.database),
64        }
65    }
66}
67
68impl<S: AuthSchema> SessionManager<S> {
69    #[must_use]
70    pub fn new(config: Arc<AuthConfig>, database: Arc<dyn AuthStore<S>>) -> Self {
71        Self { config, database }
72    }
73
74    /// Create a new session for a user
75    ///
76    /// # Errors
77    ///
78    /// Propagates errors from session hooks or persistence.
79    pub async fn create_session(
80        &self,
81        user: &impl AuthUser,
82        ip_address: Option<String>,
83        user_agent: Option<String>,
84    ) -> AuthResult<S::Session> {
85        self.database
86            .create_session(self.new_session(user, ip_address, user_agent))
87            .await
88    }
89
90    /// Create a real session and retain its declared adapter output.
91    ///
92    /// # Errors
93    /// Propagates errors from persistence or configured callbacks.
94    pub async fn create_session_record(
95        &self,
96        user: &impl AuthUser,
97        ip_address: Option<String>,
98        user_agent: Option<String>,
99    ) -> AuthResult<AdapterRecord<S::Session>> {
100        self.database
101            .create_session_record(self.new_session(user, ip_address, user_agent))
102            .await
103    }
104
105    fn new_session(
106        &self,
107        user: &impl AuthUser,
108        ip_address: Option<String>,
109        user_agent: Option<String>,
110    ) -> CreateSession {
111        CreateSession {
112            additional_fields: crate::field_policy::FieldValues::default(),
113            token: None,
114            active_team_id: None,
115            user_id: user.id().to_string(),
116            expires_at: Utc::now() + self.config.session.expires_in,
117            ip_address,
118            user_agent,
119            impersonated_by: None,
120            active_organization_id: None,
121        }
122    }
123
124    /// Read and refresh a session according to the configured expiry window.
125    ///
126    /// # Errors
127    ///
128    /// Propagates errors from the session store.
129    pub async fn get_session(&self, token: &str) -> AuthResult<Option<S::Session>> {
130        Ok(self
131            .read_session(token, SessionReadOptions::default())
132            .await?
133            .session)
134    }
135
136    /// Read persisted session state with explicit control over side effects.
137    /// Deferred browser reads leave expired rows in place until a later write.
138    ///
139    /// # Errors
140    ///
141    /// Propagates errors from the session store.
142    pub async fn read_session(
143        &self,
144        token: &str,
145        options: SessionReadOptions,
146    ) -> AuthResult<SessionRead<S::Session>> {
147        let Some(session) = self.database.get_session(token).await? else {
148            return Ok(SessionRead::absent());
149        };
150        self.read_loaded_session(session, options).await
151    }
152
153    /// Apply the session lifecycle to a snapshot already read from this store.
154    /// Session handlers use this to retain their original hook context without
155    /// looking up the session a second time. Validation and refresh still run.
156    /// The supplied snapshot must come from this manager's store, never client input.
157    ///
158    /// # Errors
159    ///
160    /// Propagates errors from the session store.
161    pub async fn read_loaded_session(
162        &self,
163        session: S::Session,
164        options: SessionReadOptions,
165    ) -> AuthResult<SessionRead<S::Session>> {
166        self.read_loaded(session, options, |token, expires_at| async move {
167            self.database.refresh_session(&token, expires_at).await
168        })
169        .await
170    }
171
172    /// Apply the same physical expiry/refresh lifecycle to retained adapter output.
173    /// The record must originate from this manager's actual initialized store.
174    pub async fn read_loaded_session_record(
175        &self,
176        session: AdapterRecord<S::Session>,
177        options: SessionReadOptions,
178    ) -> AuthResult<SessionRead<AdapterRecord<S::Session>>> {
179        self.read_loaded(session, options, |token, expires_at| async move {
180            self.database
181                .refresh_session_record(&token, expires_at)
182                .await
183        })
184        .await
185    }
186
187    async fn read_loaded<T, F, Fut>(
188        &self,
189        session: T,
190        options: SessionReadOptions,
191        refresh: F,
192    ) -> AuthResult<SessionRead<T>>
193    where
194        T: AuthSession,
195        F: FnOnce(String, chrono::DateTime<chrono::Utc>) -> Fut,
196        Fut: std::future::Future<Output = AuthResult<Option<T>>>,
197    {
198        let token = session.token();
199        let now = Utc::now();
200        if session.expires_at() < now {
201            if options.cleanup_expired {
202                self.database.delete_session(token).await?;
203            }
204            return Ok(SessionRead::absent());
205        }
206        let needs_refresh = !self.config.session.disable_session_refresh
207            && self.config.session.update_age.is_none_or(|age| {
208                session.expires_at() - self.config.session.expires_in + age <= now
209            });
210        if needs_refresh && options.allow_refresh {
211            let refreshed_session =
212                refresh(token.to_owned(), now + self.config.session.expires_in).await?;
213            let refreshed = refreshed_session.is_some();
214            return Ok(SessionRead {
215                session: refreshed_session,
216                needs_refresh,
217                refreshed,
218            });
219        }
220        Ok(SessionRead {
221            session: Some(session),
222            needs_refresh,
223            refreshed: false,
224        })
225    }
226
227    /// Whether signed browser preferences or the query suppress refresh.
228    /// Query values use the upstream Boolean coercion: any nonempty string,
229    /// including `false`, disables refreshing.
230    #[must_use]
231    pub fn request_disables_refresh(&self, request: &impl SessionRequest) -> bool {
232        request.session_query_truthy("disableRefresh") || self.has_dont_remember_cookie(request)
233    }
234
235    /// The independently signed browser session preference.
236    #[must_use]
237    pub fn has_dont_remember_cookie(&self, request: &impl SessionRequest) -> bool {
238        let name = crate::utils::cookie_utils::related_cookie_name(&self.config, "dont_remember");
239        request
240            .session_headers()
241            .get("cookie")
242            .is_some_and(|header| {
243                cookie::Cookie::split_parse(header)
244                    .flatten()
245                    .find(|cookie| cookie.name() == name)
246                    .and_then(|cookie| {
247                        crate::utils::cookie_utils::verify_cookie_value(
248                            cookie.value(),
249                            self.config.current_secret(),
250                        )
251                    })
252                    .is_some_and(|value| !value.is_empty())
253            })
254    }
255
256    /// Delete a session
257    ///
258    /// # Errors
259    ///
260    /// Propagates errors from the session store.
261    pub async fn delete_session(&self, token: &str) -> AuthResult<()> {
262        self.database.delete_session(token).await
263    }
264
265    /// Delete all sessions for a user
266    ///
267    /// # Errors
268    ///
269    /// Propagates errors from the session store.
270    pub async fn delete_user_sessions(&self, user_id: impl AsRef<str>) -> AuthResult<()> {
271        self.database.delete_user_sessions(user_id.as_ref()).await
272    }
273
274    /// Get all active sessions for a user
275    ///
276    /// # Errors
277    ///
278    /// Propagates errors from the session store.
279    pub async fn list_user_sessions(
280        &self,
281        user_id: impl AsRef<str>,
282    ) -> AuthResult<Vec<S::Session>> {
283        let sessions = self.database.get_user_sessions(user_id.as_ref()).await?;
284        let now = Utc::now();
285        Ok(sessions
286            .into_iter()
287            .filter(|session| session.expires_at() > now)
288            .collect())
289    }
290
291    /// Revoke a specific session by token
292    ///
293    /// # Errors
294    ///
295    /// Propagates errors from the session store.
296    pub async fn revoke_session(&self, token: &str) -> AuthResult<bool> {
297        if self.get_session(token).await?.is_none() {
298            return Ok(false);
299        }
300        self.delete_session(token).await?;
301        Ok(true)
302    }
303
304    /// Revoke all sessions for a user
305    ///
306    /// # Errors
307    ///
308    /// Propagates errors from the session store.
309    pub async fn revoke_all_user_sessions(&self, user_id: impl AsRef<str>) -> AuthResult<usize> {
310        let user_id = user_id.as_ref();
311        let count = self.list_user_sessions(user_id).await?.len();
312        self.delete_user_sessions(user_id).await?;
313        Ok(count)
314    }
315
316    /// Revoke all sessions for a user except the current one
317    ///
318    /// # Errors
319    ///
320    /// Propagates errors from the session store.
321    pub async fn revoke_other_user_sessions(
322        &self,
323        user_id: impl AsRef<str>,
324        current_token: &str,
325    ) -> AuthResult<usize> {
326        let sessions = self.list_user_sessions(user_id).await?;
327        let mut count = 0;
328
329        for session in sessions {
330            if session.token() != current_token {
331                self.delete_session(session.token()).await?;
332                count += 1;
333            }
334        }
335
336        Ok(count)
337    }
338
339    /// Check whether a session is "fresh" (created recently enough for
340    /// sensitive operations like password change or account deletion).
341    ///
342    /// A positive freshness window requires creation time within that window.
343    /// `None` and zero disable the restriction, matching upstream `freshAge: 0`.
344    pub fn is_session_fresh(&self, session: &impl AuthSession) -> bool {
345        match self.config.session.fresh_age {
346            Some(fresh_age) if fresh_age != chrono::Duration::zero() => {
347                session.created_at() + fresh_age > Utc::now()
348            }
349            _ => true,
350        }
351    }
352
353    /// Validate session token format
354    #[must_use]
355    pub fn validate_token_format(&self, token: &str) -> bool {
356        token.len() == 32 && token.bytes().all(|byte| byte.is_ascii_alphanumeric())
357    }
358
359    /// Extract a verified session token from the configured cookie.
360    ///
361    /// The core runtime does not authenticate bearer headers. The separate
362    /// bearer plugin may establish a signed cookie before this parser runs.
363    #[must_use]
364    pub fn extract_session_token(&self, req: &impl SessionRequest) -> Option<String> {
365        let header = req.session_headers().get("cookie")?;
366        header
367            .split(';')
368            .find_map(|pair| {
369                let (name, value) = pair.split_once('=')?;
370                (crate::utils::javascript::trim(name) == self.config.session.cookie_name)
371                    .then(|| crate::utils::javascript::trim(value))
372            })
373            .map(|value| {
374                // Better Call unwraps the first quoted value before verifying;
375                // later duplicates cannot recover an invalid first credential.
376                if value.starts_with('"') {
377                    value.get(1..value.len().saturating_sub(1)).unwrap_or("")
378                } else {
379                    value
380                }
381            })
382            .and_then(|value| {
383                crate::utils::cookie_utils::verify_cookie_value(value, self.config.current_secret())
384            })
385            .filter(|token| !token.is_empty())
386    }
387}
388
389impl<T> std::fmt::Debug for SessionRead<T> {
390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391        f.debug_struct("SessionRead").finish_non_exhaustive()
392    }
393}
394
395impl<S: AuthSchema> std::fmt::Debug for SessionManager<S> {
396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        f.debug_struct("SessionManager").finish_non_exhaustive()
398    }
399}
400
401// LCOV_EXCL_START
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use crate::entity::AuthSession;
406    use crate::test_store::{BundledSchema, test_config, test_database};
407    use crate::types::AuthRequest;
408    use crate::types::HttpMethod;
409    use crate::wire::SessionView;
410    use chrono::Duration;
411
412    fn test_manager() -> SessionManager<BundledSchema> {
413        let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
414        SessionManager::new(test_config(), runtime.block_on(test_database()))
415    }
416
417    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
418    #[test]
419    fn valid_token_format() {
420        let mgr = test_manager();
421        let token = "abcdefghijklmnopqrstuvwxyz123456";
422        assert!(mgr.validate_token_format(token));
423    }
424
425    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
426    #[test]
427    fn invalid_token_non_alphanumeric() {
428        let mgr = test_manager();
429        assert!(!mgr.validate_token_format("abcdefghijklmnopqrstuvwxy_123456"));
430    }
431
432    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
433    #[test]
434    fn invalid_token_too_short() {
435        let mgr = test_manager();
436        assert!(!mgr.validate_token_format("session_short"));
437    }
438
439    // Pinned Better Call sessions require a signed cookie; bearer authentication
440    // is supplied by the separate bearer plugin, not core session parsing.
441    #[test]
442    fn extract_rejects_bare_bearer() {
443        let mgr = test_manager();
444        let mut req = AuthRequest::new(HttpMethod::Get, "/test");
445        _ = req
446            .headers
447            .insert("authorization".into(), "Bearer my-token".into());
448        assert_eq!(mgr.extract_session_token(&req), None);
449    }
450
451    #[test]
452    fn extract_cookie_checks_signature_and_first_duplicate() {
453        let mgr = test_manager();
454        let signed = crate::utils::cookie_utils::sign_cookie_value("tok123", &mgr.config.secret);
455        let foreign =
456            crate::utils::cookie_utils::sign_cookie_value("tok123", "another-server-secret");
457        for (cookie, expected) in [
458            (
459                format!("better-auth.session_token={signed}; other=val"),
460                Some("tok123"),
461            ),
462            ("better-auth.session_token=tok123".to_owned(), None),
463            (format!("better-auth.session_token={foreign}"), None),
464            (
465                format!("better-auth.session_token={signed}; better-auth.session_token=invalid"),
466                Some("tok123"),
467            ),
468            (
469                format!("better-auth.session_token=invalid; better-auth.session_token={signed}"),
470                None,
471            ),
472        ] {
473            let mut req = AuthRequest::new(HttpMethod::Get, "/test");
474            _ = req.headers.insert("cookie".into(), cookie);
475            assert_eq!(mgr.extract_session_token(&req).as_deref(), expected);
476        }
477    }
478
479    #[test]
480    fn extract_ignores_bearer_when_signed_cookie_exists() {
481        let mgr = test_manager();
482        let mut req = AuthRequest::new(HttpMethod::Get, "/test");
483        _ = req
484            .headers
485            .insert("authorization".into(), "Bearer bearer-tok".into());
486        let signed =
487            crate::utils::cookie_utils::sign_cookie_value("cookie-tok", &mgr.config.secret);
488        _ = req.headers.insert(
489            "cookie".into(),
490            format!("better-auth.session_token={signed}"),
491        );
492        assert_eq!(mgr.extract_session_token(&req), Some("cookie-tok".into()));
493    }
494
495    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
496    #[test]
497    fn extract_returns_none_without_auth() {
498        let mgr = test_manager();
499        let req = AuthRequest::new(HttpMethod::Get, "/test");
500        assert_eq!(mgr.extract_session_token(&req), None);
501    }
502
503    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
504    #[test]
505    fn extract_skips_empty_cookie_value() {
506        let mgr = test_manager();
507        let mut req = AuthRequest::new(HttpMethod::Get, "/test");
508        _ = req
509            .headers
510            .insert("cookie".into(), "better-auth.session_token=".into());
511        assert_eq!(mgr.extract_session_token(&req), None);
512    }
513
514    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
515    #[test]
516    fn session_fresh_when_within_window() {
517        let mut config = AuthConfig::new("test-secret-min-32-chars-1234567");
518        config.session.fresh_age = Some(Duration::minutes(10));
519        let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
520        let mgr = SessionManager::new(Arc::new(config), runtime.block_on(test_database()));
521
522        // A session created "now" is fresh within a 10-minute window.
523        let session = SessionView {
524            omitted_fields: std::collections::BTreeSet::default(),
525            active_team_id: None,
526            extension_fields: std::collections::BTreeMap::default(),
527            id: "s1".into(),
528            expires_at: Utc::now() + Duration::hours(1),
529            token: "tok".into(),
530            created_at: Utc::now(),
531            updated_at: Utc::now(),
532            ip_address: None,
533            user_agent: None,
534            user_id: "u1".into(),
535            impersonated_by: None,
536            active_organization_id: None,
537        };
538        assert!(mgr.is_session_fresh(&session));
539    }
540
541    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
542    #[test]
543    fn session_not_fresh_when_old() {
544        let mut config = AuthConfig::new("test-secret-min-32-chars-1234567");
545        config.session.fresh_age = Some(Duration::minutes(10));
546        let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
547        let mgr = SessionManager::new(Arc::new(config), runtime.block_on(test_database()));
548
549        let session = SessionView {
550            omitted_fields: std::collections::BTreeSet::default(),
551            active_team_id: None,
552            extension_fields: std::collections::BTreeMap::default(),
553            id: "s1".into(),
554            expires_at: Utc::now() + Duration::hours(1),
555            token: "tok".into(),
556            created_at: Utc::now() - Duration::minutes(20),
557            updated_at: Utc::now(),
558            ip_address: None,
559            user_agent: None,
560            user_id: "u1".into(),
561            impersonated_by: None,
562            active_organization_id: None,
563        };
564        assert!(!mgr.is_session_fresh(&session));
565    }
566
567    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
568    #[test]
569    fn disabled_freshness_allows_an_old_session() {
570        let mut config = (*test_config()).clone();
571        config.session.fresh_age = None;
572        let mgr = SessionManager::new(Arc::new(config), test_manager().database);
573        let session = SessionView {
574            omitted_fields: std::collections::BTreeSet::default(),
575            active_team_id: None,
576            extension_fields: std::collections::BTreeMap::default(),
577            id: "s1".into(),
578            expires_at: Utc::now() + Duration::hours(1),
579            token: "tok".into(),
580            created_at: Utc::now() - Duration::days(30),
581            updated_at: Utc::now(),
582            ip_address: None,
583            user_agent: None,
584            user_id: "u1".into(),
585            impersonated_by: None,
586            active_organization_id: None,
587        };
588        assert!(mgr.is_session_fresh(&session));
589    }
590
591    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
592    #[tokio::test]
593    async fn create_and_get_session() {
594        let db = test_database().await;
595        let mgr = SessionManager::new(test_config(), Arc::clone(&db));
596
597        // Create a user first
598        let user = db
599            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
600            .await
601            .unwrap();
602
603        let session = mgr.create_session(&user, None, None).await.unwrap();
604        let token = session.token().to_owned();
605
606        let retrieved = mgr.get_session(&token).await.unwrap();
607        assert!(retrieved.is_some());
608    }
609
610    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
611    #[tokio::test]
612    async fn refresh_returns_the_persisted_expiry() {
613        let db = test_database().await;
614        let mut config = AuthConfig::new("test-secret-min-32-chars-1234567");
615        // Refresh on every access so a single `get_session` exercises the path.
616        config.session.update_age = None;
617        let mgr = SessionManager::new(Arc::new(config), Arc::clone(&db));
618
619        let user = db
620            .create_user(crate::types::CreateUser::new().with_email("refresh@test.com"))
621            .await
622            .unwrap();
623        let session = mgr.create_session(&user, None, None).await.unwrap();
624        let token = session.token().to_owned();
625
626        // Move the stored expiry back so the refresh is observable.
627        let stale = session.expires_at() - Duration::minutes(30);
628        db.update_session_expiry(&token, stale).await.unwrap();
629
630        let returned = mgr
631            .get_session(&token)
632            .await
633            .unwrap()
634            .expect("session should still be live");
635        let stored = db
636            .get_session(&token)
637            .await
638            .unwrap()
639            .expect("session should still be stored");
640
641        assert!(
642            returned.expires_at() > stale,
643            "refresh should have extended the expiry"
644        );
645        assert_eq!(
646            returned.expires_at(),
647            stored.expires_at(),
648            "returned session must reflect the persisted expiry, not the pre-refresh value"
649        );
650    }
651
652    #[test]
653    fn refresh_preferences_verify_the_first_cookie_and_use_javascript_truthiness() {
654        let manager = test_manager();
655        let name =
656            crate::utils::cookie_utils::related_cookie_name(&manager.config, "dont_remember");
657        let valid = crate::utils::cookie_utils::sign_cookie_value("true", &manager.config.secret);
658        let empty = crate::utils::cookie_utils::sign_cookie_value("", &manager.config.secret);
659        let wrong = crate::utils::cookie_utils::sign_cookie_value("true", "foreign-secret");
660        for (header, expected) in [
661            (format!("{name}={valid}"), true),
662            (format!("{name}={empty}"), false),
663            (format!("{name}={wrong}"), false),
664            (format!("{name}=invalid; {name}={valid}"), false),
665            (format!("{name}={valid}; {name}=invalid"), true),
666        ] {
667            let mut request = AuthRequest::new(HttpMethod::Get, "/get-session");
668            _ = request.headers.insert("cookie".into(), header);
669            assert_eq!(manager.request_disables_refresh(&request), expected);
670        }
671        for (value, expected) in [("", false), ("false", true), ("0", true), ("true", true)] {
672            let mut request = AuthRequest::new(HttpMethod::Get, "/get-session");
673            _ = request.query.insert("disableRefresh".into(), value.into());
674            assert_eq!(manager.request_disables_refresh(&request), expected);
675        }
676    }
677
678    #[tokio::test]
679    async fn recent_sessions_refresh_at_the_expiry_based_half_second_boundary() {
680        let db = test_database().await;
681        let config = test_config();
682        let manager = SessionManager::new(Arc::clone(&config), Arc::clone(&db));
683        let user = db
684            .create_user(crate::types::CreateUser::new().with_email("half-second@test.com"))
685            .await
686            .unwrap();
687        for difference in [Duration::milliseconds(500), Duration::milliseconds(-500)] {
688            let session = manager.create_session(&user, None, None).await.unwrap();
689            let expiry = Utc::now() + config.session.expires_in
690                - config.session.update_age.unwrap()
691                + difference;
692            db.update_session_expiry(session.token(), expiry)
693                .await
694                .unwrap();
695            let returned = manager.get_session(session.token()).await.unwrap().unwrap();
696            let stored = db.get_session(session.token()).await.unwrap().unwrap();
697            assert_eq!(returned.expires_at(), stored.expires_at());
698            assert_eq!(returned.token(), session.token());
699            if difference > Duration::zero() {
700                assert_eq!(stored.expires_at(), expiry);
701            } else {
702                assert!(stored.expires_at() > expiry + Duration::hours(23));
703            }
704        }
705    }
706
707    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
708    #[tokio::test]
709    async fn create_session_without_metadata_uses_empty_strings() {
710        let db = test_database().await;
711        let mgr = SessionManager::new(test_config(), Arc::clone(&db));
712
713        let user = db
714            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
715            .await
716            .unwrap();
717
718        let session = mgr.create_session(&user, None, None).await.unwrap();
719        assert_eq!(session.ip_address.as_deref(), Some(""));
720        assert_eq!(session.user_agent.as_deref(), Some(""));
721    }
722
723    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
724    #[tokio::test]
725    async fn delete_session_removes_it() {
726        let db = test_database().await;
727        let mgr = SessionManager::new(test_config(), Arc::clone(&db));
728
729        let user = db
730            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
731            .await
732            .unwrap();
733
734        let session = mgr.create_session(&user, None, None).await.unwrap();
735        let token = session.token().to_owned();
736
737        mgr.delete_session(&token).await.unwrap();
738        let retrieved = mgr.get_session(&token).await.unwrap();
739        assert!(retrieved.is_none());
740    }
741
742    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
743    #[tokio::test]
744    async fn revoke_session_returns_true_when_found() {
745        let db = test_database().await;
746        let mgr = SessionManager::new(test_config(), Arc::clone(&db));
747
748        let user = db
749            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
750            .await
751            .unwrap();
752
753        let session = mgr.create_session(&user, None, None).await.unwrap();
754        let result = mgr.revoke_session(session.token()).await.unwrap();
755        assert!(result);
756    }
757
758    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
759    #[tokio::test]
760    async fn revoke_session_returns_false_when_not_found() {
761        let mgr = SessionManager::new(test_config(), test_database().await);
762        let result = mgr.revoke_session("nonexistent-token").await.unwrap();
763        assert!(!result);
764    }
765
766    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
767    #[tokio::test]
768    async fn list_user_sessions_excludes_expired() {
769        let db = test_database().await;
770        let mgr = SessionManager::new(test_config(), Arc::clone(&db));
771
772        let user = db
773            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
774            .await
775            .unwrap();
776
777        // Create two sessions
778        _ = mgr.create_session(&user, None, None).await.unwrap();
779        _ = mgr.create_session(&user, None, None).await.unwrap();
780
781        let sessions = mgr.list_user_sessions(user.id()).await.unwrap();
782        assert_eq!(sessions.len(), 2);
783    }
784
785    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
786    #[tokio::test]
787    async fn revoke_all_user_sessions() {
788        let db = test_database().await;
789        let mgr = SessionManager::new(test_config(), Arc::clone(&db));
790
791        let user = db
792            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
793            .await
794            .unwrap();
795
796        _ = mgr.create_session(&user, None, None).await.unwrap();
797        _ = mgr.create_session(&user, None, None).await.unwrap();
798
799        let count = mgr.revoke_all_user_sessions(user.id()).await.unwrap();
800        assert_eq!(count, 2);
801
802        let sessions = mgr.list_user_sessions(user.id()).await.unwrap();
803        assert_eq!(sessions, Vec::<SessionView>::new());
804    }
805
806    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
807    #[tokio::test]
808    async fn revoke_other_sessions_keeps_current() {
809        let db = test_database().await;
810        let mgr = SessionManager::new(test_config(), Arc::clone(&db));
811
812        let user = db
813            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
814            .await
815            .unwrap();
816
817        let current = mgr.create_session(&user, None, None).await.unwrap();
818        _ = mgr.create_session(&user, None, None).await.unwrap();
819        _ = mgr.create_session(&user, None, None).await.unwrap();
820
821        let count = mgr
822            .revoke_other_user_sessions(user.id(), current.token())
823            .await
824            .unwrap();
825        assert_eq!(count, 2);
826
827        let remaining = mgr.list_user_sessions(user.id()).await.unwrap();
828        assert_eq!(remaining.len(), 1);
829        assert_eq!(
830            (*(remaining)
831                .first()
832                .expect("fixture contains the requested index"))
833            .token(),
834            current.token()
835        );
836    }
837}
838// LCOV_EXCL_STOP