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