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
17pub 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#[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 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
135pub(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
182impl 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()?, ¤t_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
285pub(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 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 #[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 #[tokio::test]
432 async fn test_get_session_unauthorized() {
433 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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 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 #[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}