Skip to main content

better_auth_core/session/
mod.rs

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