1use super::StatusResponse;
2use super::authentication_helpers::{
3 JsonField, RequestBody, json_type, parse_body, validation_response, with_session_cookies,
4};
5use super::helpers::{
6 admin_plugin_enabled, delete_session_cookie_headers, unauthorized_if_unauthenticated,
7};
8use alibi_core::entity::{AuthSession, AuthUser};
9use alibi_core::field_policy::{FieldInputError, SessionFields};
10use alibi_core::utils::cookie_utils::{related_cookie_name, verify_cookie_value};
11use alibi_core::utils::json::JsValue;
12use alibi_core::wire::SessionView;
13use alibi_core::{
14 AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
15 HttpMethod, SuccessResponse,
16};
17use async_trait::async_trait;
18use serde::{Deserialize, Serialize};
19
20pub struct SessionManagementPlugin {
22 config: SessionManagementConfig,
23}
24
25#[derive(Debug, Clone, alibi_core::PluginConfig)]
26#[plugin(name = "SessionManagementPlugin")]
27pub struct SessionManagementConfig {
28 #[config(default = true)]
29 pub enable_session_listing: bool,
30 #[config(default = true)]
31 pub enable_session_revocation: bool,
32 #[config(default = true)]
33 pub require_authentication: bool,
34}
35
36#[derive(Debug, Deserialize)]
37struct RevokeSessionRequest {
38 token: String,
39}
40
41impl RequestBody for RevokeSessionRequest {
42 const FIELDS: &'static [JsonField] = &[JsonField::string("token", true)];
43}
44
45#[derive(Debug, Serialize)]
46struct GetSessionResponse<S, U> {
47 session: S,
48 user: U,
49 #[serde(rename = "needsRefresh", skip_serializing_if = "Option::is_none")]
50 needs_refresh: Option<bool>,
51}
52
53#[async_trait]
54impl<S: alibi_core::AuthSchema> AuthPlugin<S> for SessionManagementPlugin {
55 route_openapi_metadata!(S);
56
57 fn name(&self) -> &'static str {
58 "session-management"
59 }
60
61 fn routes(&self) -> Vec<AuthRoute> {
62 vec![
63 AuthRoute::get("/get-session", "get_session"),
64 AuthRoute::post("/get-session", "get_session"),
67 AuthRoute::post("/sign-out", "sign_out"),
68 AuthRoute::post("/update-session", "updateSession"),
69 AuthRoute::get("/list-sessions", "list_sessions"),
70 AuthRoute::post("/revoke-session", "revoke_session"),
71 AuthRoute::post("/revoke-sessions", "revoke_sessions"),
72 AuthRoute::post("/revoke-other-sessions", "revoke_other_sessions"),
73 ]
74 }
75
76 async fn on_request(
77 &self,
78 req: &AuthRequest,
79 ctx: &AuthContext<S>,
80 ) -> AuthResult<Option<AuthResponse>> {
81 let response = match (req.method(), req.path()) {
82 (HttpMethod::Get | HttpMethod::Post, "/get-session") => {
83 self.handle_get_session(req, ctx).await?
84 }
85 (HttpMethod::Post, "/sign-out") => self.handle_sign_out(req, ctx).await?,
86 (HttpMethod::Post, "/update-session") => self.handle_update_session(req, ctx).await?,
87 (HttpMethod::Get, "/list-sessions") if self.config.enable_session_listing => {
88 self.handle_list_sessions(req, ctx).await?
89 }
90 (HttpMethod::Post, "/revoke-session") if self.config.enable_session_revocation => {
91 self.handle_revoke_session(req, ctx).await?
92 }
93 (HttpMethod::Post, "/revoke-sessions") if self.config.enable_session_revocation => {
94 self.handle_revoke_sessions(req, ctx).await?
95 }
96 (HttpMethod::Post, "/revoke-other-sessions")
97 if self.config.enable_session_revocation =>
98 {
99 self.handle_revoke_other_sessions(req, ctx).await?
100 }
101 _ => return Ok(None),
102 };
103 Ok(Some(response))
104 }
105}
106
107impl SessionManagementPlugin {
108 #[expect(
109 clippy::too_many_lines,
110 reason = "Keep validated session field updates and persistence callbacks in request order"
111 )]
112 async fn handle_update_session(
113 &self,
114 req: &AuthRequest,
115 ctx: &AuthContext<impl alibi_core::AuthSchema>,
116 ) -> AuthResult<AuthResponse> {
117 if req.body.is_some() {
118 let content_type = req
119 .header("content-type")
120 .map(String::as_str)
121 .unwrap_or_default();
122 if !content_type
123 .split(';')
124 .next()
125 .unwrap_or_default()
126 .trim()
127 .to_lowercase()
128 .contains("application/json")
129 {
130 let message = if content_type.is_empty() {
131 "Content-Type is required. Allowed types: application/json".to_owned()
132 } else {
133 format!(
134 "Content-Type \"{content_type}\" is not allowed. Allowed types: application/json"
135 )
136 };
137 return Ok(AuthResponse::json(
138 415,
139 &serde_json::json!({"code":"UNSUPPORTED_MEDIA_TYPE","message":message}),
140 )?);
141 }
142 }
143 let value: Option<JsValue> = match req.body.as_ref() {
144 Some(body) => match alibi_core::utils::json::from_slice(body) {
145 Ok(value) => Some(value),
146 Err(_) => {
147 return Ok(AuthResponse::json(
148 400,
149 &serde_json::json!({"code":"BAD_REQUEST","message":"Invalid JSON in request body"}),
150 )?);
151 }
152 },
153 None => None,
154 };
155 let Some(input) = value.as_ref().and_then(JsValue::as_object) else {
156 return Ok(validation_response(&format!(
157 "[body] Invalid input: expected record, received {}",
158 json_type(value.as_ref())
159 )));
160 };
161 let (user, session) = match super::helpers::ordinary_session(req, ctx).await {
162 Ok(session) => session,
163 Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => {
164 return Ok(AuthResponse::json(
165 401,
166 &serde_json::json!({"code":"UNAUTHORIZED","message":"Unauthorized"}),
167 )?);
168 }
169 Err(error) => return Err(error),
170 };
171 let fields = ctx.extensions.get::<SessionFields>().map_or_else(
172 || SessionFields(ctx.config.session.additional_fields.clone()),
173 |fields| (*fields).clone(),
174 );
175 let allowed = match fields.parse_update(input) {
176 Ok(allowed) => allowed,
177 Err(FieldInputError::Validation { code, message }) => {
178 return Ok(AuthResponse::json(
179 400,
180 &serde_json::json!({"code":code,"message":message}),
181 )?);
182 }
183 Err(FieldInputError::Transform(AuthError::Internal(_))) => {
184 return Ok(AuthResponse::new(500));
185 }
186 Err(FieldInputError::Transform(error)) => return Err(error),
187 };
188 if !allowed.has_input_fields() {
189 return Ok(AuthResponse::json(
190 400,
191 &serde_json::json!({"message":"No fields to update"}),
192 )?);
193 }
194 let updated = match ctx
195 .database
196 .update_session_fields_record(session.token(), allowed)
197 .await
198 {
199 Ok(updated) => updated,
200 Err(error @ AuthError::Internal(_)) => {
201 return Err(AuthError::CallbackFailure(Box::new(error)));
202 }
203 Err(error) => return Err(error),
204 };
205 let Some(updated) = updated else {
206 let mut response = AuthResponse::json(
207 401,
208 &serde_json::json!({"code":"FAILED_TO_GET_SESSION","message":"Failed to get session"}),
209 )?;
210 for cookie in delete_session_cookie_headers(&ctx.config)? {
211 response.headers.append("Set-Cookie", cookie);
212 }
213 return Ok(response);
214 };
215
216 alibi_core::session::cookie_cache::runtime::emit_issuance(ctx, &user, &updated).await?;
217
218 let preference = related_cookie_name(&ctx.config, "dont_remember");
219 let dont_remember = req.header("cookie").is_some_and(|header| {
220 cookie::Cookie::split_parse(header)
221 .flatten()
222 .find(|cookie| cookie.name() == preference)
223 .and_then(|cookie| verify_cookie_value(cookie.value(), ctx.config.current_secret()))
224 .is_some_and(|signed| !signed.is_empty())
225 });
226 with_session_cookies(
227 AuthResponse::json(
228 200,
229 &serde_json::json!({"session":ctx.session_view(&updated)}),
230 )?,
231 &ctx.config,
232 updated.token(),
233 dont_remember,
234 )
235 }
236
237 pub(crate) async fn handle_get_session(
238 &self,
239 req: &AuthRequest,
240 ctx: &AuthContext<impl alibi_core::AuthSchema>,
241 ) -> AuthResult<AuthResponse> {
242 let response =
243 if req.method() == &HttpMethod::Post && !ctx.config.session.defer_session_refresh {
244 AuthError::Upstream {
245 status: 405,
246 code: "METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED",
247 message: "POST method requires deferSessionRefresh to be enabled in session config",
248 }.to_auth_response()
249 } else {
250 match self.get_session_response(req, ctx).await {
251 Ok(response) => response,
252 Err(error @ (AuthError::Upstream { .. } | AuthError::Api { .. })) => {
253 error.to_auth_response()
254 }
255 Err(_) => AuthError::Upstream {
256 status: 500,
257 code: "FAILED_TO_GET_SESSION",
258 message: "Failed to get session",
259 }
260 .to_auth_response(),
261 }
262 };
263 Ok(response
264 .with_header("cache-control", "no-store")
265 .with_header("pragma", "no-cache"))
266 }
267
268 async fn get_session_response(
269 &self,
270 req: &AuthRequest,
271 ctx: &AuthContext<impl alibi_core::AuthSchema>,
272 ) -> AuthResult<AuthResponse> {
273 let Some(read) =
274 alibi_core::session::cookie_cache::runtime::authenticated(ctx, req, true).await?
275 else {
276 return Ok(AuthResponse::json(200, &serde_json::Value::Null)?);
277 };
278 Ok(AuthResponse::json(
279 200,
280 &GetSessionResponse {
281 session: read.session,
282 user: match read.user {
283 alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(&user),
284 alibi_core::AuthenticatedUser::Cached(user) => *user,
285 },
286 needs_refresh: read.needs_refresh,
287 },
288 )?)
289 }
290
291 async fn handle_sign_out(
292 &self,
293 req: &AuthRequest,
294 ctx: &AuthContext<impl alibi_core::AuthSchema>,
295 ) -> AuthResult<AuthResponse> {
296 let body = if req.body.is_some() {
297 match parse_body::<super::oauth::logout::SignOutRequest>(req) {
298 Ok(body) => body,
299 Err(response) => return Ok(response),
300 }
301 } else {
302 super::oauth::logout::SignOutRequest::default()
303 };
304 let mut current_user = None;
305 if let Some(token) = ctx.session_manager().extract_session_token(req) {
306 if let Ok(Some(session)) = ctx.database.get_session(&token).await {
307 if ctx
310 .database
311 .get_user_by_id(&session.user_id())
312 .await
313 .ok()
314 .flatten()
315 .is_some()
316 {
317 current_user = Some(session.user_id().into_owned());
318 }
319 _ = sign_out_core(&session, ctx).await;
320 } else {
321 _ = ctx.database.delete_session(&token).await;
322 }
323 }
324
325 let mut response = AuthResponse::json(200, &SuccessResponse { success: true })?;
326 for cookie in delete_session_cookie_headers(&ctx.config)? {
327 response.headers.append("Set-Cookie", cookie);
328 }
329 for (logical, account) in [("session_data", false), ("account_data", true)] {
330 if account && !ctx.config.account.store_account_cookie {
331 continue;
332 }
333 let base = alibi_core::utils::cookie_utils::related_cookie_name(&ctx.config, logical);
334 for header in alibi_core::session::cookie_cache::runtime::chunked_cookie_headers(
335 &base,
336 "",
337 Some(0.0),
338 &ctx.config,
339 &req.headers,
340 account,
341 )? {
342 if !header.starts_with(&format!("{base}=")) {
343 response.headers.append("Set-Cookie", header);
344 }
345 }
346 }
347 if let Some(user_id) = current_user
348 && let Some(url) = super::oauth::logout::provider_logout_url(&user_id, &body, ctx).await
349 {
350 let redirect = body.disable_redirect != Some(true);
351 response.body = AuthResponse::json(
352 200,
353 &serde_json::json!({
354 "success": true, "url": url, "redirect": redirect,
355 }),
356 )?
357 .body;
358 if redirect {
359 _ = response.headers.insert("Location", url);
360 }
361 }
362 Ok(response)
363 }
364
365 async fn handle_list_sessions(
366 &self,
367 req: &AuthRequest,
368 ctx: &AuthContext<impl alibi_core::AuthSchema>,
369 ) -> AuthResult<AuthResponse> {
370 let (user, session) = super::helpers::ordinary_session(req, ctx).await?;
371 if !ctx.session_manager().is_session_fresh(&session) {
372 return Err(AuthError::Upstream {
373 status: 403,
374 code: "SESSION_NOT_FRESH",
375 message: "Session is not fresh",
376 });
377 }
378 let Ok(mut sessions) = list_sessions_core(user.id(), ctx).await else {
379 tracing::error!("Session listing failed");
380 return Ok(AuthResponse::new(500).with_header("content-type", "application/json"));
381 };
382 if admin_plugin_enabled(ctx) {
383 sessions.retain(|listed| listed.impersonated_by.is_none());
384 }
385 Ok(AuthResponse::json(200, &sessions)?)
386 }
387
388 async fn handle_revoke_session(
389 &self,
390 req: &AuthRequest,
391 ctx: &AuthContext<impl alibi_core::AuthSchema>,
392 ) -> AuthResult<AuthResponse> {
393 let revoke_req: RevokeSessionRequest = match parse_body(req) {
394 Ok(v) => v,
395 Err(resp) => return Ok(resp),
396 };
397
398 let (user, _) = ctx
399 .require_authoritative_cached_session(req)
400 .await
401 .map_err(unauthorized_if_unauthenticated)?;
402 let response = revoke_session_core(&user, &revoke_req.token, ctx).await?;
403 Ok(AuthResponse::json(200, &response)?)
404 }
405
406 async fn handle_revoke_sessions(
407 &self,
408 req: &AuthRequest,
409 ctx: &AuthContext<impl alibi_core::AuthSchema>,
410 ) -> AuthResult<AuthResponse> {
411 let (user, _) = ctx
412 .require_authoritative_cached_session(req)
413 .await
414 .map_err(unauthorized_if_unauthenticated)?;
415 let response = revoke_sessions_core(user.id(), ctx).await?;
416 Ok(AuthResponse::json(200, &response)?)
417 }
418
419 async fn handle_revoke_other_sessions(
420 &self,
421 req: &AuthRequest,
422 ctx: &AuthContext<impl alibi_core::AuthSchema>,
423 ) -> AuthResult<AuthResponse> {
424 let (user, current_session) = ctx
425 .require_authoritative_cached_session(req)
426 .await
427 .map_err(unauthorized_if_unauthenticated)?;
428 let response = revoke_other_sessions_core(user.id(), ¤t_session, ctx).await?;
429 Ok(AuthResponse::json(200, &response)?)
430 }
431}
432
433impl std::fmt::Debug for SessionManagementPlugin {
434 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435 f.debug_struct("SessionManagementPlugin")
436 .finish_non_exhaustive()
437 }
438}
439
440pub(crate) async fn sign_out_core(
441 session: &impl AuthSession,
442 ctx: &AuthContext<impl alibi_core::AuthSchema>,
443) -> AuthResult<SuccessResponse> {
444 ctx.database.delete_session(session.token()).await?;
445 Ok(SuccessResponse { success: true })
446}
447
448pub(crate) async fn list_sessions_core(
449 user_id: impl AsRef<str>,
450 ctx: &AuthContext<impl alibi_core::AuthSchema>,
451) -> AuthResult<Vec<SessionView>> {
452 let sessions = ctx
453 .database
454 .get_active_user_sessions_record(user_id.as_ref())
455 .await?;
456 let now = chrono::Utc::now();
457 Ok(sessions
458 .iter()
459 .filter(|session| session.expires_at() > now && session.active())
460 .map(|session| ctx.session_view(session))
461 .collect())
462}
463
464pub(crate) async fn revoke_session_core(
465 user: &impl AuthUser,
466 token: &str,
467 ctx: &AuthContext<impl alibi_core::AuthSchema>,
468) -> AuthResult<StatusResponse> {
469 if let Some(session_to_revoke) = ctx.database.get_session(token).await?
470 && session_to_revoke.user_id() == user.id()
471 {
472 ctx.database
473 .delete_session(token)
474 .await
475 .map_err(revocation_storage_error)?;
476 }
477 Ok(StatusResponse { status: true })
478}
479
480pub(crate) async fn revoke_sessions_core(
481 user_id: impl AsRef<str>,
482 ctx: &AuthContext<impl alibi_core::AuthSchema>,
483) -> AuthResult<StatusResponse> {
484 ctx.database
485 .delete_user_sessions(user_id.as_ref())
486 .await
487 .map_err(revocation_storage_error)?;
488 Ok(StatusResponse { status: true })
489}
490
491pub(crate) async fn revoke_other_sessions_core(
492 user_id: impl AsRef<str>,
493 current_session: &impl AuthSession,
494 ctx: &AuthContext<impl alibi_core::AuthSchema>,
495) -> AuthResult<StatusResponse> {
496 let all_sessions = ctx.session_manager().list_user_sessions(user_id).await?;
497 let tokens: Vec<String> = all_sessions
498 .into_iter()
499 .filter(|session| session.token() != current_session.token())
500 .map(|session| session.token().to_owned())
501 .collect();
502 let count = tokens.len();
503 let database = ctx.database.clone();
504 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
505 crate::helpers::spawn_in_request_context(async move {
507 _ = futures_util::future::join_all(tokens.into_iter().map(|token| {
508 let database = &database;
509 let sender = &sender;
510 async move {
511 let result = database.delete_session(&token).await;
512 _ = sender.send(result);
513 }
514 }))
515 .await;
516 });
517 for _ in 0..count {
518 receiver
519 .recv()
520 .await
521 .ok_or_else(|| AuthError::internal("Session revocation task stopped"))?
522 .map_err(crate::helpers::callback_failure)?;
523 }
524 Ok(StatusResponse { status: true })
525}
526
527#[expect(
528 clippy::needless_pass_by_value,
529 reason = "used by value as a `map_err` callback"
530)]
531fn revocation_storage_error(error: AuthError) -> AuthError {
532 tracing::error!(error = %error, "Session revocation failed");
533 AuthError::Upstream {
534 status: 500,
535 code: "INTERNAL_SERVER_ERROR",
536 message: "Internal Server Error",
537 }
538}
539
540#[cfg(test)]
542mod tests {
543 use super::*;
544 use crate::test_helpers;
545 use alibi_core::config::AccountConfig;
546 use alibi_core::utils::cookie_utils::related_cookie_name;
547 use alibi_core::wire::SessionView;
548 use alibi_core::{CreateSession, CreateUser};
549 use chrono::{Duration, Utc};
550
551 #[tokio::test]
553 async fn test_get_session_success() {
554 let plugin = SessionManagementPlugin::new();
555 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
556 CreateUser::new()
557 .with_email("test@example.com")
558 .with_name("Test User"),
559 Duration::hours(24),
560 )
561 .await;
562
563 let req = test_helpers::create_auth_request_no_query(
564 HttpMethod::Get,
565 "/get-session",
566 Some(&session.token),
567 None,
568 );
569 let response = plugin.handle_get_session(&req, &ctx).await.unwrap();
570
571 assert_eq!(response.status, 200);
572
573 let body_str = String::from_utf8(response.body).unwrap();
574 let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
575 assert_eq!(
576 (*(*(response_data)
577 .get("session")
578 .unwrap_or(&serde_json::Value::Null))
579 .get("token")
580 .unwrap_or(&serde_json::Value::Null))
581 .as_str()
582 .unwrap(),
583 session.token
584 );
585 assert_eq!(
586 (*(*(response_data)
587 .get("user")
588 .unwrap_or(&serde_json::Value::Null))
589 .get("email")
590 .unwrap_or(&serde_json::Value::Null))
591 .as_str()
592 .map(ToOwned::to_owned),
593 Some("test@example.com".to_owned())
594 );
595 }
596
597 #[tokio::test]
599 async fn test_get_session_unauthorized() {
600 let plugin = SessionManagementPlugin::new();
602 let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
603 CreateUser::new()
604 .with_email("test@example.com")
605 .with_name("Test User"),
606 Duration::hours(24),
607 )
608 .await;
609
610 let req =
611 test_helpers::create_auth_request_no_query(HttpMethod::Get, "/get-session", None, None);
612 let response = plugin.handle_get_session(&req, &ctx).await.unwrap();
613 assert_eq!(response.status, 200);
614 let body: serde_json::Value = serde_json::from_slice(&response.body).expect("valid JSON");
615 assert!(body.is_null());
616 }
617
618 #[tokio::test]
620 async fn test_sign_out_success() {
621 let plugin = SessionManagementPlugin::new();
622 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
623 CreateUser::new()
624 .with_email("test@example.com")
625 .with_name("Test User"),
626 Duration::hours(24),
627 )
628 .await;
629
630 let req = test_helpers::create_auth_request_no_query(
631 HttpMethod::Post,
632 "/sign-out",
633 Some(&session.token),
634 Some(b"{}".to_vec()),
635 );
636 let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
637
638 assert_eq!(response.status, 200);
639
640 let body_str = String::from_utf8(response.body).unwrap();
641 let response_data: SuccessResponse = serde_json::from_str(&body_str).unwrap();
642 assert!(response_data.success);
643
644 let session_check = ctx.database.get_session(&session.token).await.unwrap();
645 assert!(session_check.is_none());
646 }
647
648 #[tokio::test]
649 async fn test_sign_out_clears_account_cookie_when_enabled() {
650 let plugin = SessionManagementPlugin::new();
651 let config = test_helpers::create_test_config().account(AccountConfig {
652 store_account_cookie: true,
653 ..Default::default()
654 });
655 let ctx = test_helpers::create_test_context_with_config(config).await;
656 let (_user, session) = test_helpers::create_user_and_session(
657 &ctx,
658 CreateUser::new()
659 .with_email("test@example.com")
660 .with_name("Test User"),
661 Duration::hours(24),
662 )
663 .await;
664
665 let req = test_helpers::create_auth_request_no_query(
666 HttpMethod::Post,
667 "/sign-out",
668 Some(&session.token),
669 Some(b"{}".to_vec()),
670 );
671 let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
672
673 let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
674 assert!(
675 response
676 .headers
677 .get_all("Set-Cookie")
678 .any(|cookie| cookie.starts_with(&account_cookie_name)),
679 "sign-out should clear the account_data cookie when store_account_cookie is enabled"
680 );
681 }
682
683 #[tokio::test]
684 async fn test_sign_out_does_not_emit_account_cookie_when_disabled() {
685 let plugin = SessionManagementPlugin::new();
686 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
687 CreateUser::new()
688 .with_email("test@example.com")
689 .with_name("Test User"),
690 Duration::hours(24),
691 )
692 .await;
693
694 let req = test_helpers::create_auth_request_no_query(
695 HttpMethod::Post,
696 "/sign-out",
697 Some(&session.token),
698 Some(b"{}".to_vec()),
699 );
700 let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
701
702 let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
703 assert!(
704 !response
705 .headers
706 .get_all("Set-Cookie")
707 .any(|cookie| cookie.starts_with(&account_cookie_name)),
708 "sign-out should not emit account_data clearing cookies when store_account_cookie is disabled"
709 );
710 }
711
712 #[tokio::test]
714 async fn test_list_sessions_success() {
715 let plugin = SessionManagementPlugin::new();
716 let (ctx, user, session) = test_helpers::create_test_context_with_user(
717 CreateUser::new()
718 .with_email("test@example.com")
719 .with_name("Test User"),
720 Duration::hours(24),
721 )
722 .await;
723
724 let create_session2 = CreateSession {
725 additional_fields: alibi_core::field_policy::FieldValues::default(),
726 token: None,
727 user_id: user.id.clone(),
728 expires_at: Utc::now() + Duration::hours(24),
729 ip_address: Some("192.168.1.1".to_owned()),
730 user_agent: Some("another-agent".to_owned()),
731 impersonated_by: None,
732 active_organization_id: None,
733 active_team_id: None,
734 };
735 ctx.database.create_session(create_session2).await.unwrap();
736
737 let req = test_helpers::create_auth_request_no_query(
738 HttpMethod::Get,
739 "/list-sessions",
740 Some(&session.token),
741 None,
742 );
743 let response = plugin.handle_list_sessions(&req, &ctx).await.unwrap();
744
745 assert_eq!(response.status, 200);
746
747 let body_str = String::from_utf8(response.body).unwrap();
748 let sessions: Vec<SessionView> = serde_json::from_str(&body_str).unwrap();
749 assert_eq!(sessions.len(), 2);
750 }
751
752 #[tokio::test]
753 async fn test_list_sessions_filters_impersonated_sessions_when_admin_plugin_is_enabled() {
754 let plugin = SessionManagementPlugin::new();
755 let (mut ctx, user, session) = test_helpers::create_test_context_with_user(
756 CreateUser::new()
757 .with_email("test@example.com")
758 .with_name("Test User"),
759 Duration::hours(24),
760 )
761 .await;
762 ctx.set_metadata("admin.enabled", serde_json::Value::Bool(true));
763
764 let direct_session = CreateSession {
765 additional_fields: alibi_core::field_policy::FieldValues::default(),
766 token: None,
767 user_id: user.id.clone(),
768 expires_at: Utc::now() + Duration::hours(24),
769 ip_address: Some("192.168.1.1".to_owned()),
770 user_agent: Some("another-agent".to_owned()),
771 impersonated_by: None,
772 active_organization_id: None,
773 active_team_id: None,
774 };
775 ctx.database.create_session(direct_session).await.unwrap();
776
777 let impersonated_session = CreateSession {
778 additional_fields: alibi_core::field_policy::FieldValues::default(),
779 token: None,
780 user_id: user.id.clone(),
781 expires_at: Utc::now() + Duration::hours(24),
782 ip_address: Some("10.0.0.5".to_owned()),
783 user_agent: Some("impersonated-agent".to_owned()),
784 impersonated_by: Some("admin-user".to_owned()),
785 active_organization_id: None,
786 active_team_id: None,
787 };
788 let impersonated = ctx
789 .database
790 .create_session(impersonated_session)
791 .await
792 .unwrap();
793
794 let req = test_helpers::create_auth_request_no_query(
795 HttpMethod::Get,
796 "/list-sessions",
797 Some(&session.token),
798 None,
799 );
800 let response = plugin.handle_list_sessions(&req, &ctx).await.unwrap();
801
802 assert_eq!(response.status, 200);
803
804 let body_str = String::from_utf8(response.body).unwrap();
805 let sessions: Vec<SessionView> = serde_json::from_str(&body_str).unwrap();
806 assert_eq!(sessions.len(), 2);
807 assert!(
808 sessions
809 .iter()
810 .all(|candidate| candidate.token != impersonated.token),
811 "impersonated sessions should not be returned from /list-sessions"
812 );
813 }
814
815 #[tokio::test]
817 async fn test_revoke_session_success() {
818 let plugin = SessionManagementPlugin::new();
819 let (ctx, user, session) = test_helpers::create_test_context_with_user(
820 CreateUser::new()
821 .with_email("test@example.com")
822 .with_name("Test User"),
823 Duration::hours(24),
824 )
825 .await;
826
827 let create_session2 = CreateSession {
828 additional_fields: alibi_core::field_policy::FieldValues::default(),
829 token: None,
830 user_id: user.id.clone(),
831 expires_at: Utc::now() + Duration::hours(24),
832 ip_address: Some("192.168.1.1".to_owned()),
833 user_agent: Some("another-agent".to_owned()),
834 impersonated_by: None,
835 active_organization_id: None,
836 active_team_id: None,
837 };
838 let session2 = ctx.database.create_session(create_session2).await.unwrap();
839
840 let body = serde_json::json!({ "token": session2.token });
841 let req = test_helpers::create_auth_request_no_query(
842 HttpMethod::Post,
843 "/revoke-session",
844 Some(&session.token),
845 Some(body.to_string().into_bytes()),
846 );
847
848 let response = plugin.handle_revoke_session(&req, &ctx).await.unwrap();
849 assert_eq!(response.status, 200);
850
851 let session2_check = ctx.database.get_session(&session2.token).await.unwrap();
852 assert!(session2_check.is_none());
853
854 let session1_check = ctx.database.get_session(&session.token).await.unwrap();
855 assert!(session1_check.is_some());
856 }
857
858 #[tokio::test]
860 async fn test_revoke_session_forbidden_different_user() {
861 let plugin = SessionManagementPlugin::new();
862 let (ctx, _user1, session1) = test_helpers::create_test_context_with_user(
863 CreateUser::new()
864 .with_email("test@example.com")
865 .with_name("Test User"),
866 Duration::hours(24),
867 )
868 .await;
869
870 let create_user2 = CreateUser::new()
871 .with_email("user2@example.com")
872 .with_name("User Two");
873 let user2 = ctx.database.create_user(create_user2).await.unwrap();
874
875 let create_session2 = CreateSession {
876 additional_fields: alibi_core::field_policy::FieldValues::default(),
877 token: None,
878 user_id: user2.id,
879 expires_at: Utc::now() + Duration::hours(24),
880 ip_address: Some("192.168.1.1".to_owned()),
881 user_agent: Some("another-agent".to_owned()),
882 impersonated_by: None,
883 active_organization_id: None,
884 active_team_id: None,
885 };
886 let session2 = ctx.database.create_session(create_session2).await.unwrap();
887
888 let body = serde_json::json!({ "token": session2.token });
889 let req = test_helpers::create_auth_request_no_query(
890 HttpMethod::Post,
891 "/revoke-session",
892 Some(&session1.token),
893 Some(body.to_string().into_bytes()),
894 );
895
896 let response = plugin.handle_revoke_session(&req, &ctx).await.unwrap();
897 assert_eq!(response.status, 200);
898
899 let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
900 assert_eq!(
901 (*(body_2).get("status").unwrap_or(&serde_json::Value::Null)),
902 true
903 );
904
905 let still_exists = ctx.database.get_session(&session2.token).await.unwrap();
906 assert!(
907 still_exists.is_some(),
908 "other user's session must not be revoked"
909 );
910 }
911
912 #[tokio::test]
914 async fn test_revoke_sessions_success() {
915 let plugin = SessionManagementPlugin::new();
916 let (ctx, user, session1) = test_helpers::create_test_context_with_user(
917 CreateUser::new()
918 .with_email("test@example.com")
919 .with_name("Test User"),
920 Duration::hours(24),
921 )
922 .await;
923
924 let create_session2 = CreateSession {
925 additional_fields: alibi_core::field_policy::FieldValues::default(),
926 token: None,
927 user_id: user.id.clone(),
928 expires_at: Utc::now() + Duration::hours(24),
929 ip_address: Some("192.168.1.1".to_owned()),
930 user_agent: Some("another-agent".to_owned()),
931 impersonated_by: None,
932 active_organization_id: None,
933 active_team_id: None,
934 };
935 ctx.database.create_session(create_session2).await.unwrap();
936
937 let req = test_helpers::create_auth_request_no_query(
938 HttpMethod::Post,
939 "/revoke-sessions",
940 Some(&session1.token),
941 Some(b"{}".to_vec()),
942 );
943 let response = plugin.handle_revoke_sessions(&req, &ctx).await.unwrap();
944
945 assert_eq!(response.status, 200);
946
947 let user_sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
948 assert_eq!(user_sessions.len(), 0);
949 }
950
951 #[tokio::test]
953 async fn test_plugin_routes() {
954 let plugin = SessionManagementPlugin::new();
955 let routes = AuthPlugin::<
956 alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
957 >::routes(&plugin);
958
959 assert_eq!(routes.len(), 8);
960 assert!(
961 routes
962 .iter()
963 .any(|r| r.path == "/get-session" && r.method == HttpMethod::Get)
964 );
965 assert!(
967 routes
968 .iter()
969 .any(|r| r.path == "/get-session" && r.method == HttpMethod::Post)
970 );
971 assert!(
972 routes
973 .iter()
974 .any(|r| r.path == "/sign-out" && r.method == HttpMethod::Post)
975 );
976 assert!(
977 routes
978 .iter()
979 .any(|r| r.path == "/list-sessions" && r.method == HttpMethod::Get)
980 );
981 assert!(
982 routes
983 .iter()
984 .any(|r| r.path == "/revoke-session" && r.method == HttpMethod::Post)
985 );
986 assert!(
987 routes
988 .iter()
989 .any(|r| r.path == "/revoke-sessions" && r.method == HttpMethod::Post)
990 );
991 }
992
993 #[tokio::test]
995 async fn test_plugin_on_request_routing() {
996 let plugin = SessionManagementPlugin::new();
997 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
998 CreateUser::new()
999 .with_email("test@example.com")
1000 .with_name("Test User"),
1001 Duration::hours(24),
1002 )
1003 .await;
1004
1005 let req = test_helpers::create_auth_request_no_query(
1007 HttpMethod::Get,
1008 "/get-session",
1009 Some(&session.token),
1010 None,
1011 );
1012 let response = plugin.on_request(&req, &ctx).await.unwrap();
1013 assert!(response.is_some());
1014 assert_eq!(response.unwrap().status, 200);
1015
1016 let req_2 = test_helpers::create_auth_request_no_query(
1019 HttpMethod::Post,
1020 "/get-session",
1021 Some(&session.token),
1022 Some(b"{}".to_vec()),
1023 );
1024 let response_2 = plugin.on_request(&req_2, &ctx).await.unwrap().unwrap();
1025 assert_eq!(response_2.status, 405);
1026 assert_eq!(
1027 serde_json::from_slice::<serde_json::Value>(&response_2.body).unwrap(),
1028 serde_json::json!({
1029 "code": "METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED",
1030 "message": "POST method requires deferSessionRefresh to be enabled in session config",
1031 }),
1032 );
1033 assert_eq!(
1034 response_2.headers.get("cache-control"),
1035 Some(&"no-store".to_owned())
1036 );
1037 assert_eq!(
1038 response_2.headers.get("pragma"),
1039 Some(&"no-cache".to_owned())
1040 );
1041
1042 let req_3 = test_helpers::create_auth_request_no_query(
1044 HttpMethod::Get,
1045 "/invalid-route",
1046 Some(&session.token),
1047 None,
1048 );
1049 let response_3 = plugin.on_request(&req_3, &ctx).await.unwrap();
1050 assert!(response_3.is_none());
1051 }
1052
1053 #[tokio::test]
1055 async fn test_configuration() {
1056 let plugin = SessionManagementPlugin::new()
1057 .enable_session_listing(false)
1058 .enable_session_revocation(false)
1059 .require_authentication(false);
1060
1061 assert!(!plugin.config.enable_session_listing);
1062 assert!(!plugin.config.enable_session_revocation);
1063 assert!(!plugin.config.require_authentication);
1064
1065 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
1066 CreateUser::new()
1067 .with_email("test@example.com")
1068 .with_name("Test User"),
1069 Duration::hours(24),
1070 )
1071 .await;
1072
1073 let req = test_helpers::create_auth_request_no_query(
1074 HttpMethod::Get,
1075 "/list-sessions",
1076 Some(&session.token),
1077 None,
1078 );
1079 let response = plugin.on_request(&req, &ctx).await.unwrap();
1080 assert!(response.is_none());
1081
1082 let req_2 = test_helpers::create_auth_request_no_query(
1083 HttpMethod::Post,
1084 "/revoke-session",
1085 Some(&session.token),
1086 Some(b"{}".to_vec()),
1087 );
1088 let response_2 = plugin.on_request(&req_2, &ctx).await.unwrap();
1089 assert!(response_2.is_none());
1090 }
1091}
1092