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