Skip to main content

better_auth_api/plugins/
session_management.rs

1use async_trait::async_trait;
2use serde::Deserialize;
3
4#[cfg(test)]
5use better_auth_core::config::AuthConfig;
6use better_auth_core::entity::{AuthSession, AuthUser};
7use better_auth_core::wire::SessionView;
8use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
9
10use better_auth_core::{AuthError, AuthResult};
11use better_auth_core::{AuthRequest, AuthResponse, FieldMap, FieldValue, HttpMethod};
12
13use super::StatusResponse;
14use super::helpers::admin_plugin_enabled;
15use better_auth_core::SuccessResponse;
16
17/// Session management plugin for handling session operations
18pub struct SessionManagementPlugin {
19    config: SessionManagementConfig,
20}
21
22#[derive(Debug, Clone, better_auth_core::PluginConfig)]
23#[plugin(name = "SessionManagementPlugin")]
24pub struct SessionManagementConfig {
25    #[config(default = true)]
26    pub enable_session_listing: bool,
27    #[config(default = true)]
28    pub enable_session_revocation: bool,
29    #[config(default = true)]
30    pub require_authentication: bool,
31}
32
33// Request structures for session endpoints
34#[derive(Debug, Clone, Deserialize)]
35struct RevokeSessionRequest {
36    token: String,
37}
38
39fn revoke_session_body(
40    req: &AuthRequest,
41) -> AuthResult<better_auth_core::endpoint_input::ValidatedBody> {
42    let (typed, projection) =
43        super::json_body::string_input::<RevokeSessionRequest>(req, &[("token", true)])?;
44    Ok(better_auth_core::endpoint_input::ValidatedBody::new(
45        Some(projection),
46        typed,
47    ))
48}
49
50#[async_trait]
51impl<S: better_auth_core::AuthSchema> AuthPlugin<S> for SessionManagementPlugin {
52    fn name(&self) -> &'static str {
53        "session-management"
54    }
55
56    fn telemetry_plugin_id(&self) -> Option<&'static str> {
57        None
58    }
59
60    fn routes(&self) -> Vec<AuthRoute> {
61        vec![
62            AuthRoute::get("/get-session", "getSession")
63                .require_headers(true)
64                .query_validator(better_auth_core::query::session_query),
65            // Upstream declares `/get-session` as `method: ["GET", "POST"]`;
66            // the POST form requires `session.defer_session_refresh`.
67            AuthRoute::post("/get-session", "getSession")
68                .require_headers(true)
69                .query_validator(better_auth_core::query::session_query),
70            AuthRoute::post("/sign-out", "signOut")
71                .require_headers(true)
72                .body_validator(super::json_body::sign_out_body),
73            AuthRoute::post("/update-session", "updateSession")
74                .body_validator(better_auth_core::endpoint_input::record_body),
75            AuthRoute::get("/list-sessions", "listUserSessions").require_headers(true),
76            AuthRoute::post("/revoke-session", "revokeSession")
77                .require_headers(true)
78                .body_validator(revoke_session_body),
79            AuthRoute::post("/revoke-sessions", "revokeSessions").require_headers(true),
80            AuthRoute::post("/revoke-other-sessions", "revokeOtherSessions").require_headers(true),
81        ]
82    }
83
84    async fn on_request(
85        &self,
86        req: &AuthRequest,
87        ctx: &AuthContext<S>,
88    ) -> AuthResult<Option<AuthResponse>> {
89        if req.path() == "/get-session"
90            && let Some(custom) = ctx
91                .extensions
92                .get::<super::custom_session::CustomSessionPlugin<S>>()
93        {
94            return custom.on_request(req, ctx).await;
95        }
96        if req.path() == "/get-session" {
97            req.append_response_header("Cache-Control", "no-store".into())?;
98            req.append_response_header("Pragma", "no-cache".into())?;
99        }
100        match (req.method(), req.path()) {
101            (HttpMethod::Get, "/get-session") => Ok(Some(self.handle_get_session(req, ctx).await?)),
102            (HttpMethod::Post, "/get-session") => {
103                if !ctx.config.session.defer_session_refresh {
104                    return Err(AuthError::method_not_allowed(
105                        "POST method requires deferSessionRefresh to be enabled in session config",
106                    ));
107                }
108                Ok(Some(self.handle_get_session(req, ctx).await?))
109            }
110            (HttpMethod::Post, "/update-session") => {
111                Ok(Some(super::session_update::handle(req, ctx).await?))
112            }
113            (HttpMethod::Post, "/sign-out") => {
114                Ok(Some(super::oauth::handle_sign_out(req, ctx).await?))
115            }
116            (HttpMethod::Get, "/list-sessions") if self.config.enable_session_listing => {
117                Ok(Some(self.handle_list_sessions(req, ctx).await?))
118            }
119            (HttpMethod::Post, "/revoke-session") if self.config.enable_session_revocation => {
120                Ok(Some(self.handle_revoke_session(req, ctx).await?))
121            }
122            (HttpMethod::Post, "/revoke-sessions") if self.config.enable_session_revocation => {
123                Ok(Some(self.handle_revoke_sessions(req, ctx).await?))
124            }
125            (HttpMethod::Post, "/revoke-other-sessions")
126                if self.config.enable_session_revocation =>
127            {
128                Ok(Some(self.handle_revoke_other_sessions(req, ctx).await?))
129            }
130            _ => Ok(None),
131        }
132    }
133}
134
135// ---------------------------------------------------------------------------
136// Core functions — framework-agnostic business logic
137// ---------------------------------------------------------------------------
138
139pub(crate) async fn list_sessions_core(
140    user_id: impl AsRef<str>,
141    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
142) -> AuthResult<Vec<SessionView>> {
143    ctx.session_manager().list_user_session_views(user_id).await
144}
145
146pub(crate) async fn revoke_session_core(
147    user: &impl AuthUser,
148    token: &str,
149    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
150) -> AuthResult<StatusResponse> {
151    let session_manager = ctx.session_manager();
152    if let Some(session_to_revoke) = session_manager.get_session(token).await?
153        && session_to_revoke.user_id() == user.id()
154    {
155        ctx.database.delete_session(token).await?;
156    }
157    Ok(StatusResponse { status: true })
158}
159
160pub(crate) async fn revoke_sessions_core(
161    user_id: impl AsRef<str>,
162    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
163) -> AuthResult<StatusResponse> {
164    ctx.database.delete_user_sessions(user_id.as_ref()).await?;
165    Ok(StatusResponse { status: true })
166}
167
168pub(crate) async fn revoke_other_sessions_core(
169    user_id: impl AsRef<str>,
170    current_session: &impl AuthSession,
171    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
172) -> AuthResult<StatusResponse> {
173    let all_sessions = ctx.session_manager().list_user_sessions(user_id).await?;
174    for session in all_sessions {
175        if session.token() != current_session.token() {
176            ctx.database.delete_session(session.token()).await?;
177        }
178    }
179    Ok(StatusResponse { status: true })
180}
181
182// ---------------------------------------------------------------------------
183// Old handler methods — delegate to core functions
184// ---------------------------------------------------------------------------
185
186impl SessionManagementPlugin {
187    pub(crate) async fn handle_get_session(
188        &self,
189        req: &AuthRequest,
190        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
191    ) -> AuthResult<AuthResponse> {
192        let resolved = ctx
193            .session_manager()
194            .resolve(req, better_auth_core::session::SessionRead::Cached)
195            .await;
196        let resolved = match resolved {
197            Ok(value) => value,
198            Err(error) if error.status_code() < 500 => return Err(error),
199            Err(error) => {
200                better_auth_core::observability::logger::current().error(
201                    "Failed to read session",
202                    &[better_auth_core::observability::LogArgument::Error(&error)],
203                );
204                return Err(AuthError::Upstream {
205                    status: 500,
206                    code: "FAILED_TO_GET_SESSION",
207                    message: "Failed to get session",
208                });
209            }
210        };
211        let mut response = match resolved.data {
212            Some(data) => {
213                let mut fields = FieldMap::from(data);
214                if let Some(needs_refresh) = resolved.needs_refresh {
215                    let _ = fields.insert("needsRefresh".into(), needs_refresh.into());
216                }
217                AuthResponse::native(200, fields.into())
218            }
219            None => AuthResponse::native(200, FieldValue::Null),
220        };
221        let _ = response.headers.insert("Cache-Control", "no-store");
222        let _ = response.headers.insert("Pragma", "no-cache");
223        for (name, value) in req.take_response_headers()? {
224            response.headers.append(name, value);
225        }
226        Ok(response)
227    }
228
229    async fn handle_list_sessions(
230        &self,
231        req: &AuthRequest,
232        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
233    ) -> AuthResult<AuthResponse> {
234        let (user, _) = ctx.require_authoritative_session(req).await?;
235        let mut sessions = list_sessions_core(user.id().typed()?, ctx).await?;
236        if admin_plugin_enabled(ctx) {
237            sessions.retain(|session| session.impersonated_by.is_none());
238        }
239        Ok(AuthResponse::json(200, &sessions)?)
240    }
241
242    async fn handle_revoke_session(
243        &self,
244        req: &AuthRequest,
245        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
246    ) -> AuthResult<AuthResponse> {
247        let (user, _) = ctx.require_authoritative_session(req).await?;
248
249        let revoke_req: RevokeSessionRequest = match req.validated_body::<RevokeSessionRequest>() {
250            Some(body) => body.clone(),
251            None => super::json_body::string_input(req, &[("token", true)])?.0,
252        };
253
254        let response = revoke_session_core(&user, &revoke_req.token, ctx).await?;
255        Ok(AuthResponse::json(200, &response)?)
256    }
257
258    async fn handle_revoke_sessions(
259        &self,
260        req: &AuthRequest,
261        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
262    ) -> AuthResult<AuthResponse> {
263        let (user, _) = ctx.require_authoritative_session(req).await?;
264        let response = revoke_sessions_core(user.id().typed()?, ctx).await?;
265        Ok(AuthResponse::json(200, &response)?)
266    }
267
268    async fn handle_revoke_other_sessions(
269        &self,
270        req: &AuthRequest,
271        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
272    ) -> AuthResult<AuthResponse> {
273        let (user, current_session) = ctx.require_authoritative_session(req).await?;
274        let response =
275            revoke_other_sessions_core(user.id().typed()?, &current_session, ctx).await?;
276        Ok(AuthResponse::json(200, &response)?)
277    }
278}
279
280#[cfg(test)]
281fn related_cookie_name(config: &AuthConfig, suffix: &str) -> String {
282    better_auth_core::utils::cookie_utils::related_cookie_name(config, suffix)
283}
284
285/// Clear the local session before a provider-specific logout redirect is applied.
286pub(crate) async fn handle_sign_out(
287    req: &AuthRequest,
288    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
289) -> AuthResult<AuthResponse> {
290    if let Some(token) = ctx.session_manager().extract_session_token(req)
291        && let Err(error) = ctx.database.delete_session(&token).await
292    {
293        // Upstream completes browser logout even when deleting the stored session fails.
294        better_auth_core::observability::logger::current().error(
295            "Failed to delete session during sign-out",
296            &[better_auth_core::observability::LogArgument::Error(&error)],
297        );
298    }
299    ctx.session_manager().clear_cookies(req)?;
300    let mut response = AuthResponse::json(200, &SuccessResponse { success: true })?;
301    for (name, value) in req.take_response_headers()? {
302        response.headers.append(name, value);
303    }
304    Ok(response)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::plugins::test_helpers;
311    use better_auth_core::config::AccountConfig;
312    use better_auth_core::wire::SessionView;
313    use better_auth_core::{CreateSession, CreateUser};
314    use chrono::{Duration, Utc};
315
316    #[tokio::test]
317    async fn invalid_sign_out_body_preserves_session_without_oauth_plugin() {
318        let (ctx, _, session) = test_helpers::create_test_context_with_user(
319            CreateUser::new()
320                .with_email("logout-body@example.com")
321                .with_name("Fixture"),
322            Duration::hours(24),
323        )
324        .await;
325        let plugin = SessionManagementPlugin::new();
326        for body in [
327            serde_json::json!({"disableRedirect": null}),
328            serde_json::json!({"state": 5}),
329        ] {
330            let req = test_helpers::create_auth_json_request_no_query(
331                HttpMethod::Post,
332                "/sign-out",
333                Some(&session.token),
334                Some(body),
335            );
336            let response = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
337            assert_eq!(response.status, 400);
338            let body: serde_json::Value =
339                serde_json::from_slice(response.body.bytes().unwrap().as_ref()).unwrap();
340            assert_eq!(body["code"], "VALIDATION_ERROR");
341            assert!(
342                ctx.database
343                    .get_session(&session.token)
344                    .await
345                    .unwrap()
346                    .is_some()
347            );
348        }
349    }
350
351    #[tokio::test]
352    async fn storage_failure_returns_500_and_disables_http_caching() {
353        use better_auth_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
354        use better_auth_seaorm::{Database, SeaOrmStore};
355        use std::sync::Arc;
356
357        let connection = Database::connect("sqlite::memory:").await.unwrap();
358        let config = Arc::new(test_helpers::create_test_config());
359        let store = Arc::new(SeaOrmStore::<BundledSchema>::new(
360            config.clone(),
361            connection.clone(),
362        ));
363        let ctx = AuthContext::new(config, store);
364        connection.close().await.unwrap();
365        let req = test_helpers::create_auth_request_no_query(
366            HttpMethod::Get,
367            "/get-session",
368            Some("unavailable-store-token"),
369            None,
370        );
371        let error = SessionManagementPlugin::new()
372            .on_request(&req, &ctx)
373            .await
374            .unwrap_err();
375        let mut response = error.to_auth_response();
376        ctx.session_manager()
377            .finish_response(&req, &mut response)
378            .unwrap();
379        assert_eq!(response.status, 500);
380        assert_eq!(
381            response.headers.get("Cache-Control").map(String::as_str),
382            Some("no-store")
383        );
384        assert_eq!(
385            response.headers.get("Pragma").map(String::as_str),
386            Some("no-cache")
387        );
388        let body: serde_json::Value =
389            serde_json::from_slice(response.body.bytes().unwrap().as_ref()).unwrap();
390        assert_eq!(body["code"], "FAILED_TO_GET_SESSION");
391        assert_eq!(body["message"], "Failed to get session");
392    }
393
394    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
395    #[tokio::test]
396    async fn test_get_session_success() {
397        let plugin = SessionManagementPlugin::new();
398        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
399            CreateUser::new()
400                .with_email("test@example.com")
401                .with_name("Test User"),
402            Duration::hours(24),
403        )
404        .await;
405
406        let req = test_helpers::create_auth_request_no_query(
407            HttpMethod::Get,
408            "/get-session",
409            Some(&session.token),
410            None,
411        );
412        let response = plugin.handle_get_session(&req, &ctx).await.unwrap();
413
414        assert_eq!(response.status, 200);
415
416        let body_str = String::from_utf8(response.body.into_bytes().unwrap()).unwrap();
417        let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
418        assert_eq!(
419            response_data["session"]["token"].as_str().unwrap(),
420            session.token
421        );
422        assert_eq!(
423            response_data["user"]["email"]
424                .as_str()
425                .map(|s| s.to_string()),
426            Some("test@example.com".to_string())
427        );
428    }
429
430    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
431    #[tokio::test]
432    async fn test_get_session_unauthorized() {
433        // /get-session returns 200 with null body when unauthenticated.
434        let plugin = SessionManagementPlugin::new();
435        let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
436            CreateUser::new()
437                .with_email("test@example.com")
438                .with_name("Test User"),
439            Duration::hours(24),
440        )
441        .await;
442
443        let req =
444            test_helpers::create_auth_request_no_query(HttpMethod::Get, "/get-session", None, None);
445        let response = plugin.handle_get_session(&req, &ctx).await.unwrap();
446        assert_eq!(response.status, 200);
447        let body: serde_json::Value =
448            serde_json::from_slice(response.body.bytes().unwrap().as_ref()).expect("valid JSON");
449        assert!(body.is_null());
450    }
451
452    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
453    #[tokio::test]
454    async fn test_sign_out_success() {
455        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
456            CreateUser::new()
457                .with_email("test@example.com")
458                .with_name("Test User"),
459            Duration::hours(24),
460        )
461        .await;
462
463        let req = test_helpers::create_auth_request_no_query(
464            HttpMethod::Post,
465            "/sign-out",
466            Some(&session.token),
467            Some(b"{}".to_vec()),
468        );
469        let response = handle_sign_out(&req, &ctx).await.unwrap();
470
471        assert_eq!(response.status, 200);
472
473        let body_str = String::from_utf8(response.body.into_bytes().unwrap()).unwrap();
474        let response_data: SuccessResponse = serde_json::from_str(&body_str).unwrap();
475        assert!(response_data.success);
476
477        let session_check = ctx.database.get_session(&session.token).await.unwrap();
478        assert!(session_check.is_none());
479    }
480
481    #[tokio::test]
482    async fn test_sign_out_clears_account_cookie_when_enabled() {
483        let config = test_helpers::create_test_config().account(AccountConfig {
484            store_account_cookie: Some(true),
485            ..Default::default()
486        });
487        let ctx = test_helpers::create_test_context_with_config(config).await;
488        let (_user, session) = test_helpers::create_user_and_session(
489            &ctx,
490            CreateUser::new()
491                .with_email("test@example.com")
492                .with_name("Test User"),
493            Duration::hours(24),
494        )
495        .await;
496
497        let req = test_helpers::create_auth_request_no_query(
498            HttpMethod::Post,
499            "/sign-out",
500            Some(&session.token),
501            Some(b"{}".to_vec()),
502        );
503        let response = handle_sign_out(&req, &ctx).await.unwrap();
504
505        let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
506        assert!(
507            response
508                .headers
509                .get_all("Set-Cookie")
510                .any(|cookie| cookie.starts_with(&account_cookie_name)),
511            "sign-out should clear the account_data cookie when store_account_cookie is enabled"
512        );
513    }
514
515    #[tokio::test]
516    async fn test_sign_out_does_not_emit_account_cookie_when_disabled() {
517        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
518            CreateUser::new()
519                .with_email("test@example.com")
520                .with_name("Test User"),
521            Duration::hours(24),
522        )
523        .await;
524
525        let req = test_helpers::create_auth_request_no_query(
526            HttpMethod::Post,
527            "/sign-out",
528            Some(&session.token),
529            Some(b"{}".to_vec()),
530        );
531        let response = handle_sign_out(&req, &ctx).await.unwrap();
532
533        let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
534        assert!(
535            !response
536                .headers
537                .get_all("Set-Cookie")
538                .any(|cookie| cookie.starts_with(&account_cookie_name)),
539            "sign-out should not emit account_data clearing cookies when store_account_cookie is disabled"
540        );
541    }
542
543    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
544    #[tokio::test]
545    async fn test_list_sessions_success() {
546        let plugin = SessionManagementPlugin::new();
547        let (ctx, user, session) = test_helpers::create_test_context_with_user(
548            CreateUser::new()
549                .with_email("test@example.com")
550                .with_name("Test User"),
551            Duration::hours(24),
552        )
553        .await;
554
555        let create_session2 = CreateSession {
556            additional_fields: Default::default(),
557            user_id: user.id.clone(),
558            expires_at: (Utc::now() + Duration::hours(24)).into(),
559            ip_address: Some("192.168.1.1".to_string()),
560            user_agent: Some("another-agent".to_string()),
561            impersonated_by: None,
562            active_organization_id: None,
563        };
564        ctx.database.create_session(create_session2).await.unwrap();
565
566        let req = test_helpers::create_auth_request_no_query(
567            HttpMethod::Get,
568            "/list-sessions",
569            Some(&session.token),
570            None,
571        );
572        let response = plugin.handle_list_sessions(&req, &ctx).await.unwrap();
573
574        assert_eq!(response.status, 200);
575
576        let body_str = String::from_utf8(response.body.into_bytes().unwrap()).unwrap();
577        let sessions: Vec<SessionView> = serde_json::from_str(&body_str).unwrap();
578        assert_eq!(sessions.len(), 2);
579    }
580
581    #[tokio::test]
582    async fn test_list_sessions_filters_impersonated_sessions_when_admin_plugin_is_enabled() {
583        let plugin = SessionManagementPlugin::new();
584        let (mut ctx, user, session) = test_helpers::create_test_context_with_user(
585            CreateUser::new()
586                .with_email("test@example.com")
587                .with_name("Test User"),
588            Duration::hours(24),
589        )
590        .await;
591        ctx.set_metadata("admin.enabled", serde_json::Value::Bool(true));
592
593        let direct_session = CreateSession {
594            additional_fields: Default::default(),
595            user_id: user.id.clone(),
596            expires_at: (Utc::now() + Duration::hours(24)).into(),
597            ip_address: Some("192.168.1.1".to_string()),
598            user_agent: Some("another-agent".to_string()),
599            impersonated_by: None,
600            active_organization_id: None,
601        };
602        ctx.database.create_session(direct_session).await.unwrap();
603
604        let impersonated_session = CreateSession {
605            additional_fields: Default::default(),
606            user_id: user.id.clone(),
607            expires_at: (Utc::now() + Duration::hours(24)).into(),
608            ip_address: Some("10.0.0.5".to_string()),
609            user_agent: Some("impersonated-agent".to_string()),
610            impersonated_by: Some("admin-user".to_string()),
611            active_organization_id: None,
612        };
613        let impersonated = ctx
614            .database
615            .create_session(impersonated_session)
616            .await
617            .unwrap();
618
619        let req = test_helpers::create_auth_request_no_query(
620            HttpMethod::Get,
621            "/list-sessions",
622            Some(&session.token),
623            None,
624        );
625        let response = plugin.handle_list_sessions(&req, &ctx).await.unwrap();
626
627        assert_eq!(response.status, 200);
628
629        let body_str = String::from_utf8(response.body.into_bytes().unwrap()).unwrap();
630        let sessions: Vec<SessionView> = serde_json::from_str(&body_str).unwrap();
631        assert_eq!(sessions.len(), 2);
632        assert!(
633            sessions
634                .iter()
635                .all(|session| session.token != impersonated.token),
636            "impersonated sessions should not be returned from /list-sessions"
637        );
638    }
639
640    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
641    #[tokio::test]
642    async fn test_revoke_session_success() {
643        let plugin = SessionManagementPlugin::new();
644        let (ctx, user, session) = test_helpers::create_test_context_with_user(
645            CreateUser::new()
646                .with_email("test@example.com")
647                .with_name("Test User"),
648            Duration::hours(24),
649        )
650        .await;
651
652        let create_session2 = CreateSession {
653            additional_fields: Default::default(),
654            user_id: user.id.clone(),
655            expires_at: (Utc::now() + Duration::hours(24)).into(),
656            ip_address: Some("192.168.1.1".to_string()),
657            user_agent: Some("another-agent".to_string()),
658            impersonated_by: None,
659            active_organization_id: None,
660        };
661        let session2 = ctx.database.create_session(create_session2).await.unwrap();
662
663        let body = serde_json::json!({ "token": session2.token });
664        let req = test_helpers::create_auth_request_no_query(
665            HttpMethod::Post,
666            "/revoke-session",
667            Some(&session.token),
668            Some(body.to_string().into_bytes()),
669        );
670
671        let response = plugin.handle_revoke_session(&req, &ctx).await.unwrap();
672        assert_eq!(response.status, 200);
673
674        let session2_check = ctx.database.get_session(&session2.token).await.unwrap();
675        assert!(session2_check.is_none());
676
677        let session1_check = ctx.database.get_session(&session.token).await.unwrap();
678        assert!(session1_check.is_some());
679    }
680
681    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
682    #[tokio::test]
683    async fn test_revoke_session_forbidden_different_user() {
684        let plugin = SessionManagementPlugin::new();
685        let (ctx, _user1, session1) = test_helpers::create_test_context_with_user(
686            CreateUser::new()
687                .with_email("test@example.com")
688                .with_name("Test User"),
689            Duration::hours(24),
690        )
691        .await;
692
693        let create_user2 = CreateUser::new()
694            .with_email("user2@example.com")
695            .with_name("User Two");
696        let user2 = ctx.database.create_user(create_user2).await.unwrap();
697
698        let create_session2 = CreateSession {
699            additional_fields: Default::default(),
700            user_id: user2.id,
701            expires_at: (Utc::now() + Duration::hours(24)).into(),
702            ip_address: Some("192.168.1.1".to_string()),
703            user_agent: Some("another-agent".to_string()),
704            impersonated_by: None,
705            active_organization_id: None,
706        };
707        let session2 = ctx.database.create_session(create_session2).await.unwrap();
708
709        let body = serde_json::json!({ "token": session2.token });
710        let req = test_helpers::create_auth_request_no_query(
711            HttpMethod::Post,
712            "/revoke-session",
713            Some(&session1.token),
714            Some(body.to_string().into_bytes()),
715        );
716
717        let response = plugin.handle_revoke_session(&req, &ctx).await.unwrap();
718        assert_eq!(response.status, 200);
719
720        let body: serde_json::Value =
721            serde_json::from_slice(response.body.bytes().unwrap().as_ref()).unwrap();
722        assert_eq!(body["status"], true);
723
724        let still_exists = ctx.database.get_session(&session2.token).await.unwrap();
725        assert!(
726            still_exists.is_some(),
727            "other user's session must not be revoked"
728        );
729    }
730
731    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
732    #[tokio::test]
733    async fn test_revoke_sessions_success() {
734        let plugin = SessionManagementPlugin::new();
735        let (ctx, user, session1) = test_helpers::create_test_context_with_user(
736            CreateUser::new()
737                .with_email("test@example.com")
738                .with_name("Test User"),
739            Duration::hours(24),
740        )
741        .await;
742
743        let create_session2 = CreateSession {
744            additional_fields: Default::default(),
745            user_id: user.id.clone(),
746            expires_at: (Utc::now() + Duration::hours(24)).into(),
747            ip_address: Some("192.168.1.1".to_string()),
748            user_agent: Some("another-agent".to_string()),
749            impersonated_by: None,
750            active_organization_id: None,
751        };
752        ctx.database.create_session(create_session2).await.unwrap();
753
754        let req = test_helpers::create_auth_request_no_query(
755            HttpMethod::Post,
756            "/revoke-sessions",
757            Some(&session1.token),
758            Some(b"{}".to_vec()),
759        );
760        let response = plugin.handle_revoke_sessions(&req, &ctx).await.unwrap();
761
762        assert_eq!(response.status, 200);
763
764        let user_sessions = ctx
765            .database
766            .get_user_sessions(user.id.typed().unwrap())
767            .await
768            .unwrap();
769        assert_eq!(user_sessions.len(), 0);
770    }
771
772    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
773    #[tokio::test]
774    async fn test_plugin_routes() {
775        let plugin = SessionManagementPlugin::new();
776        let routes = AuthPlugin::<
777            better_auth_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
778        >::routes(&plugin);
779
780        assert_eq!(routes.len(), 8);
781        assert!(
782            routes
783                .iter()
784                .any(|r| r.path == "/get-session" && r.method == HttpMethod::Get)
785        );
786        // Upstream serves `/get-session` on both methods.
787        assert!(
788            routes
789                .iter()
790                .any(|r| r.path == "/get-session" && r.method == HttpMethod::Post)
791        );
792        assert!(
793            routes
794                .iter()
795                .any(|r| r.path == "/sign-out" && r.method == HttpMethod::Post)
796        );
797        assert!(
798            routes
799                .iter()
800                .any(|r| r.path == "/list-sessions" && r.method == HttpMethod::Get)
801        );
802        assert!(
803            routes
804                .iter()
805                .any(|r| r.path == "/revoke-session" && r.method == HttpMethod::Post)
806        );
807        assert!(
808            routes
809                .iter()
810                .any(|r| r.path == "/revoke-sessions" && r.method == HttpMethod::Post)
811        );
812    }
813
814    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
815    #[tokio::test]
816    async fn test_plugin_on_request_routing() {
817        let plugin = SessionManagementPlugin::new();
818        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
819            CreateUser::new()
820                .with_email("test@example.com")
821                .with_name("Test User"),
822            Duration::hours(24),
823        )
824        .await;
825
826        // Test valid route
827        let req = test_helpers::create_auth_request_no_query(
828            HttpMethod::Get,
829            "/get-session",
830            Some(&session.token),
831            None,
832        );
833        let response = plugin.on_request(&req, &ctx).await.unwrap();
834        assert!(response.is_some());
835        assert_eq!(response.unwrap().status, 200);
836
837        // POST /get-session is served, but rejected with 405 until
838        // `session.defer_session_refresh` is enabled.
839        let req = test_helpers::create_auth_request_no_query(
840            HttpMethod::Post,
841            "/get-session",
842            Some(&session.token),
843            Some(b"{}".to_vec()),
844        );
845        let err = plugin.on_request(&req, &ctx).await.unwrap_err();
846        assert_eq!(err.status_code(), 405);
847
848        // Test invalid route
849        let req = test_helpers::create_auth_request_no_query(
850            HttpMethod::Get,
851            "/invalid-route",
852            Some(&session.token),
853            None,
854        );
855        let response = plugin.on_request(&req, &ctx).await.unwrap();
856        assert!(response.is_none());
857    }
858
859    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
860    #[tokio::test]
861    async fn test_configuration() {
862        let plugin = SessionManagementPlugin::new()
863            .enable_session_listing(false)
864            .enable_session_revocation(false)
865            .require_authentication(false);
866
867        assert!(!plugin.config.enable_session_listing);
868        assert!(!plugin.config.enable_session_revocation);
869        assert!(!plugin.config.require_authentication);
870
871        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
872            CreateUser::new()
873                .with_email("test@example.com")
874                .with_name("Test User"),
875            Duration::hours(24),
876        )
877        .await;
878
879        let req = test_helpers::create_auth_request_no_query(
880            HttpMethod::Get,
881            "/list-sessions",
882            Some(&session.token),
883            None,
884        );
885        let response = plugin.on_request(&req, &ctx).await.unwrap();
886        assert!(response.is_none());
887
888        let req = test_helpers::create_auth_request_no_query(
889            HttpMethod::Post,
890            "/revoke-session",
891            Some(&session.token),
892            Some(b"{}".to_vec()),
893        );
894        let response = plugin.on_request(&req, &ctx).await.unwrap();
895        assert!(response.is_none());
896    }
897}