Skip to main content

better_auth_core/
session.rs

1use chrono::Utc;
2use std::sync::Arc;
3
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;
10
11/// Session manager handles session creation, validation, and cleanup
12pub struct SessionManager<S: AuthSchema> {
13    config: Arc<AuthConfig>,
14    database: Arc<dyn AuthStore<S>>,
15}
16
17impl<S: AuthSchema> Clone for SessionManager<S> {
18    fn clone(&self) -> Self {
19        Self {
20            config: self.config.clone(),
21            database: self.database.clone(),
22        }
23    }
24}
25
26impl<S: AuthSchema> SessionManager<S> {
27    pub fn new(config: Arc<AuthConfig>, database: Arc<dyn AuthStore<S>>) -> Self {
28        Self { config, database }
29    }
30
31    /// Create a new session for a user
32    pub async fn create_session(
33        &self,
34        user: &impl AuthUser,
35        ip_address: Option<String>,
36        user_agent: Option<String>,
37    ) -> AuthResult<S::Session> {
38        let expires_at = Utc::now() + self.config.session.expires_in;
39
40        let create_session = CreateSession {
41            user_id: user.id().to_string(),
42            expires_at,
43            ip_address,
44            user_agent,
45            impersonated_by: None,
46            active_organization_id: None,
47        };
48
49        let session = self.database.create_session(create_session).await?;
50        Ok(session)
51    }
52
53    /// Get session by token
54    pub async fn get_session(&self, token: &str) -> AuthResult<Option<S::Session>> {
55        let mut session = self.database.get_session(token).await?;
56
57        // Check if session exists and is not expired
58        let should_refresh = if let Some(ref s) = session {
59            let now = Utc::now();
60
61            if s.expires_at() < now || !s.active() {
62                // Session expired or inactive — best-effort cleanup. A DB
63                // hiccup here shouldn't turn "your session is expired" into
64                // a 500; the row will be caught by the next access or the
65                // periodic `cleanup_expired_sessions` sweep.
66                if let Err(err) = self.database.delete_session(token).await {
67                    tracing::warn!(
68                        error = %err,
69                        "Failed to delete expired session; will be retried later"
70                    );
71                }
72                return Ok(None);
73            }
74
75            // Update session if configured to do so
76            if !self.config.session.disable_session_refresh {
77                match self.config.session.update_age {
78                    Some(age) => {
79                        // Only refresh if the session was last updated more than
80                        // `update_age` ago.
81                        let updated = s.updated_at();
82                        Utc::now().signed_duration_since(updated) >= age
83                    }
84                    // No update_age set → refresh on every access.
85                    None => true,
86                }
87            } else {
88                false
89            }
90        } else {
91            false
92        };
93
94        if should_refresh {
95            let new_expires_at = Utc::now() + self.config.session.expires_in;
96            match self
97                .database
98                .update_session_expiry(token, new_expires_at)
99                .await
100            {
101                Ok(()) => {
102                    // Re-read so the returned session reflects the new expiry.
103                    // Both failure modes fall back to the pre-refresh session:
104                    // a concurrent revoke (re-read returns None) shouldn't log
105                    // the user out mid-request, and a second DB hiccup
106                    // shouldn't turn a successful refresh into a 500.
107                    match self.database.get_session(token).await {
108                        Ok(Some(refreshed)) => session = Some(refreshed),
109                        Ok(None) => {
110                            tracing::warn!(
111                                "Session re-read after refresh returned None (concurrent revoke?); returning pre-refresh value"
112                            );
113                        }
114                        Err(err) => {
115                            tracing::warn!(
116                                error = %err,
117                                "Session re-read after refresh failed; returning pre-refresh value"
118                            );
119                        }
120                    }
121                }
122                Err(err) => {
123                    // Transient write failure (connection reset, contention,
124                    // etc.) must not fail the whole request. Keep the
125                    // pre-refresh session — auth still works, the refresh
126                    // window will be retried on the next call.
127                    tracing::warn!(
128                        error = %err,
129                        "Failed to refresh session expiry; returning pre-refresh session"
130                    );
131                }
132            }
133        }
134
135        Ok(session)
136    }
137
138    /// Delete a session
139    pub async fn delete_session(&self, token: &str) -> AuthResult<()> {
140        self.database.delete_session(token).await?;
141        Ok(())
142    }
143
144    /// Delete all sessions for a user
145    pub async fn delete_user_sessions(&self, user_id: impl AsRef<str>) -> AuthResult<()> {
146        self.database.delete_user_sessions(user_id.as_ref()).await?;
147        Ok(())
148    }
149
150    /// Get all active sessions for a user
151    pub async fn list_user_sessions(
152        &self,
153        user_id: impl AsRef<str>,
154    ) -> AuthResult<Vec<S::Session>> {
155        let sessions = self.database.get_user_sessions(user_id.as_ref()).await?;
156        let now = Utc::now();
157
158        // Filter out expired sessions
159        let active_sessions = sessions
160            .into_iter()
161            .filter(|session| session.expires_at() > now && session.active())
162            .collect();
163
164        Ok(active_sessions)
165    }
166
167    /// Revoke a specific session by token
168    pub async fn revoke_session(&self, token: &str) -> AuthResult<bool> {
169        // Check if session exists before trying to delete
170        let session_exists = self.get_session(token).await?.is_some();
171
172        if session_exists {
173            self.delete_session(token).await?;
174            Ok(true)
175        } else {
176            Ok(false)
177        }
178    }
179
180    /// Revoke all sessions for a user
181    pub async fn revoke_all_user_sessions(&self, user_id: impl AsRef<str>) -> AuthResult<usize> {
182        // Get count of sessions before deletion for return value
183        let user_id = user_id.as_ref();
184        let sessions = self.list_user_sessions(user_id).await?;
185        let count = sessions.len();
186
187        self.delete_user_sessions(user_id).await?;
188        Ok(count)
189    }
190
191    /// Revoke all sessions for a user except the current one
192    pub async fn revoke_other_user_sessions(
193        &self,
194        user_id: impl AsRef<str>,
195        current_token: &str,
196    ) -> AuthResult<usize> {
197        let sessions = self.list_user_sessions(user_id).await?;
198        let mut count = 0;
199
200        for session in sessions {
201            if session.token() != current_token {
202                self.delete_session(session.token()).await?;
203                count += 1;
204            }
205        }
206
207        Ok(count)
208    }
209
210    /// Cleanup expired sessions
211    pub async fn cleanup_expired_sessions(&self) -> AuthResult<usize> {
212        let count = self.database.delete_expired_sessions().await?;
213        Ok(count)
214    }
215
216    /// Check whether a session is "fresh" (created recently enough for
217    /// sensitive operations like password change or account deletion).
218    ///
219    /// Returns `true` when `fresh_age` is set and
220    /// `session.created_at() + fresh_age > now`.
221    /// If `fresh_age` is `None`, the session is never considered fresh.
222    pub fn is_session_fresh(&self, session: &impl AuthSession) -> bool {
223        match self.config.session.fresh_age {
224            Some(fresh_age) => session.created_at() + fresh_age > Utc::now(),
225            None => false,
226        }
227    }
228
229    /// Validate session token format
230    pub fn validate_token_format(&self, token: &str) -> bool {
231        token.starts_with("session_") && token.len() > 40
232    }
233
234    /// Extract session token from a request.
235    ///
236    /// Tries Bearer token from Authorization header first, then falls back
237    /// to parsing the configured cookie from the Cookie header.
238    pub fn extract_session_token(&self, req: &crate::types::AuthRequest) -> Option<String> {
239        // Try Bearer token first
240        if let Some(auth_header) = req.headers.get("authorization")
241            && let Some(token) = auth_header.strip_prefix("Bearer ")
242        {
243            return Some(token.to_string());
244        }
245
246        // Fall back to cookie (using the `cookie` crate for correct parsing)
247        if let Some(cookie_header) = req.headers.get("cookie") {
248            let cookie_name = &self.config.session.cookie_name;
249            for c in cookie::Cookie::split_parse(cookie_header).flatten() {
250                if c.name() == cookie_name && !c.value().is_empty() {
251                    return Some(c.value().to_string());
252                }
253            }
254        }
255
256        None
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::entity::AuthSession;
264    use crate::test_store::{BundledSchema, test_config, test_database};
265    use crate::types::AuthRequest;
266    use crate::types::HttpMethod;
267    use crate::wire::SessionView;
268    use chrono::Duration;
269
270    fn test_manager() -> SessionManager<BundledSchema> {
271        let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
272        SessionManager::new(test_config(), runtime.block_on(test_database()))
273    }
274
275    // ── validate_token_format ───────────────────────────────────────────
276
277    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
278    #[test]
279    fn valid_token_format() {
280        let mgr = test_manager();
281        let token = "session_abcdefghijklmnopqrstuvwxyz1234567890";
282        assert!(mgr.validate_token_format(token));
283    }
284
285    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
286    #[test]
287    fn invalid_token_no_prefix() {
288        let mgr = test_manager();
289        assert!(!mgr.validate_token_format("abcdefghijklmnopqrstuvwxyz1234567890"));
290    }
291
292    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
293    #[test]
294    fn invalid_token_too_short() {
295        let mgr = test_manager();
296        assert!(!mgr.validate_token_format("session_short"));
297    }
298
299    // ── extract_session_token ───────────────────────────────────────────
300
301    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
302    #[test]
303    fn extract_from_bearer() {
304        let mgr = test_manager();
305        let mut req = AuthRequest::new(HttpMethod::Get, "/test");
306        let _ = req
307            .headers
308            .insert("authorization".into(), "Bearer my-token".into());
309        assert_eq!(mgr.extract_session_token(&req), Some("my-token".into()));
310    }
311
312    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
313    #[test]
314    fn extract_from_cookie() {
315        let mgr = test_manager();
316        let mut req = AuthRequest::new(HttpMethod::Get, "/test");
317        let _ = req.headers.insert(
318            "cookie".into(),
319            "better-auth.session_token=tok123; other=val".into(),
320        );
321        assert_eq!(mgr.extract_session_token(&req), Some("tok123".into()));
322    }
323
324    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
325    #[test]
326    fn extract_bearer_takes_precedence_over_cookie() {
327        let mgr = test_manager();
328        let mut req = AuthRequest::new(HttpMethod::Get, "/test");
329        let _ = req
330            .headers
331            .insert("authorization".into(), "Bearer bearer-tok".into());
332        let _ = req.headers.insert(
333            "cookie".into(),
334            "better-auth.session_token=cookie-tok".into(),
335        );
336        assert_eq!(mgr.extract_session_token(&req), Some("bearer-tok".into()));
337    }
338
339    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
340    #[test]
341    fn extract_returns_none_without_auth() {
342        let mgr = test_manager();
343        let req = AuthRequest::new(HttpMethod::Get, "/test");
344        assert_eq!(mgr.extract_session_token(&req), None);
345    }
346
347    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
348    #[test]
349    fn extract_skips_empty_cookie_value() {
350        let mgr = test_manager();
351        let mut req = AuthRequest::new(HttpMethod::Get, "/test");
352        let _ = req
353            .headers
354            .insert("cookie".into(), "better-auth.session_token=".into());
355        assert_eq!(mgr.extract_session_token(&req), None);
356    }
357
358    // ── is_session_fresh ────────────────────────────────────────────────
359
360    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
361    #[test]
362    fn session_fresh_when_within_window() {
363        let mut config = AuthConfig::new("test-secret-min-32-chars-1234567");
364        config.session.fresh_age = Some(Duration::minutes(10));
365        let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
366        let mgr = SessionManager::new(Arc::new(config), runtime.block_on(test_database()));
367
368        // A session created "now" is fresh within a 10-minute window.
369        let session = SessionView {
370            id: "s1".into(),
371            expires_at: Utc::now() + Duration::hours(1),
372            token: "tok".into(),
373            created_at: Utc::now(),
374            updated_at: Utc::now(),
375            ip_address: None,
376            user_agent: None,
377            user_id: "u1".into(),
378            impersonated_by: None,
379            active_organization_id: None,
380            active: true,
381        };
382        assert!(mgr.is_session_fresh(&session));
383    }
384
385    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
386    #[test]
387    fn session_not_fresh_when_old() {
388        let mut config = AuthConfig::new("test-secret-min-32-chars-1234567");
389        config.session.fresh_age = Some(Duration::minutes(10));
390        let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
391        let mgr = SessionManager::new(Arc::new(config), runtime.block_on(test_database()));
392
393        let session = SessionView {
394            id: "s1".into(),
395            expires_at: Utc::now() + Duration::hours(1),
396            token: "tok".into(),
397            created_at: Utc::now() - Duration::minutes(20),
398            updated_at: Utc::now(),
399            ip_address: None,
400            user_agent: None,
401            user_id: "u1".into(),
402            impersonated_by: None,
403            active_organization_id: None,
404            active: true,
405        };
406        assert!(!mgr.is_session_fresh(&session));
407    }
408
409    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
410    #[test]
411    fn session_never_fresh_when_no_fresh_age() {
412        let mgr = test_manager(); // default: fresh_age = None
413        let session = SessionView {
414            id: "s1".into(),
415            expires_at: Utc::now() + Duration::hours(1),
416            token: "tok".into(),
417            created_at: Utc::now(),
418            updated_at: Utc::now(),
419            ip_address: None,
420            user_agent: None,
421            user_id: "u1".into(),
422            impersonated_by: None,
423            active_organization_id: None,
424            active: true,
425        };
426        assert!(!mgr.is_session_fresh(&session));
427    }
428
429    // ── async operations ────────────────────────────────────────────────
430
431    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
432    #[tokio::test]
433    async fn create_and_get_session() {
434        let db = test_database().await;
435        let mgr = SessionManager::new(test_config(), db.clone());
436
437        // Create a user first
438        let user = db
439            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
440            .await
441            .unwrap();
442
443        let session = mgr.create_session(&user, None, None).await.unwrap();
444        let token = session.token().to_string();
445
446        let retrieved = mgr.get_session(&token).await.unwrap();
447        assert!(retrieved.is_some());
448    }
449
450    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
451    #[tokio::test]
452    async fn refresh_returns_the_persisted_expiry() {
453        let db = test_database().await;
454        let mut config = AuthConfig::new("test-secret-min-32-chars-1234567");
455        // Refresh on every access so a single `get_session` exercises the path.
456        config.session.update_age = None;
457        let mgr = SessionManager::new(Arc::new(config), db.clone());
458
459        let user = db
460            .create_user(crate::types::CreateUser::new().with_email("refresh@test.com"))
461            .await
462            .unwrap();
463        let session = mgr.create_session(&user, None, None).await.unwrap();
464        let token = session.token().to_string();
465
466        // Move the stored expiry back so the refresh is observable.
467        let stale = session.expires_at() - Duration::minutes(30);
468        db.update_session_expiry(&token, stale).await.unwrap();
469
470        let returned = mgr
471            .get_session(&token)
472            .await
473            .unwrap()
474            .expect("session should still be live");
475        let stored = db
476            .get_session(&token)
477            .await
478            .unwrap()
479            .expect("session should still be stored");
480
481        assert!(
482            returned.expires_at() > stale,
483            "refresh should have extended the expiry"
484        );
485        assert_eq!(
486            returned.expires_at(),
487            stored.expires_at(),
488            "returned session must reflect the persisted expiry, not the pre-refresh value"
489        );
490    }
491
492    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
493    #[tokio::test]
494    async fn create_session_without_metadata_uses_empty_strings() {
495        let db = test_database().await;
496        let mgr = SessionManager::new(test_config(), db.clone());
497
498        let user = db
499            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
500            .await
501            .unwrap();
502
503        let session = mgr.create_session(&user, None, None).await.unwrap();
504        assert_eq!(session.ip_address.as_deref(), Some(""));
505        assert_eq!(session.user_agent.as_deref(), Some(""));
506    }
507
508    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
509    #[tokio::test]
510    async fn delete_session_removes_it() {
511        let db = test_database().await;
512        let mgr = SessionManager::new(test_config(), db.clone());
513
514        let user = db
515            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
516            .await
517            .unwrap();
518
519        let session = mgr.create_session(&user, None, None).await.unwrap();
520        let token = session.token().to_string();
521
522        mgr.delete_session(&token).await.unwrap();
523        let retrieved = mgr.get_session(&token).await.unwrap();
524        assert!(retrieved.is_none());
525    }
526
527    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
528    #[tokio::test]
529    async fn revoke_session_returns_true_when_found() {
530        let db = test_database().await;
531        let mgr = SessionManager::new(test_config(), db.clone());
532
533        let user = db
534            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
535            .await
536            .unwrap();
537
538        let session = mgr.create_session(&user, None, None).await.unwrap();
539        let result = mgr.revoke_session(session.token()).await.unwrap();
540        assert!(result);
541    }
542
543    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
544    #[tokio::test]
545    async fn revoke_session_returns_false_when_not_found() {
546        let mgr = SessionManager::new(test_config(), test_database().await);
547        let result = mgr.revoke_session("nonexistent-token").await.unwrap();
548        assert!(!result);
549    }
550
551    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
552    #[tokio::test]
553    async fn list_user_sessions_excludes_expired() {
554        let db = test_database().await;
555        let mgr = SessionManager::new(test_config(), db.clone());
556
557        let user = db
558            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
559            .await
560            .unwrap();
561
562        // Create two sessions
563        let _ = mgr.create_session(&user, None, None).await.unwrap();
564        let _ = mgr.create_session(&user, None, None).await.unwrap();
565
566        let sessions = mgr.list_user_sessions(user.id()).await.unwrap();
567        assert_eq!(sessions.len(), 2);
568    }
569
570    // Rust-specific surface: `SessionManager` and its token/session helper APIs are public Rust APIs with no direct TS analogue.
571    #[tokio::test]
572    async fn revoke_all_user_sessions() {
573        let db = test_database().await;
574        let mgr = SessionManager::new(test_config(), db.clone());
575
576        let user = db
577            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
578            .await
579            .unwrap();
580
581        let _ = mgr.create_session(&user, None, None).await.unwrap();
582        let _ = mgr.create_session(&user, None, None).await.unwrap();
583
584        let count = mgr.revoke_all_user_sessions(user.id()).await.unwrap();
585        assert_eq!(count, 2);
586
587        let sessions = mgr.list_user_sessions(user.id()).await.unwrap();
588        assert!(sessions.is_empty());
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 revoke_other_sessions_keeps_current() {
594        let db = test_database().await;
595        let mgr = SessionManager::new(test_config(), db.clone());
596
597        let user = db
598            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
599            .await
600            .unwrap();
601
602        let current = mgr.create_session(&user, None, None).await.unwrap();
603        let _ = mgr.create_session(&user, None, None).await.unwrap();
604        let _ = mgr.create_session(&user, None, None).await.unwrap();
605
606        let count = mgr
607            .revoke_other_user_sessions(user.id(), current.token())
608            .await
609            .unwrap();
610        assert_eq!(count, 2);
611
612        let remaining = mgr.list_user_sessions(user.id()).await.unwrap();
613        assert_eq!(remaining.len(), 1);
614        assert_eq!(remaining[0].token(), current.token());
615    }
616}