1use super::StatusResponse;
2use super::authentication_helpers::{
3 JsonField, JsonFieldKind, RequestBody, json_type, parse_body, validation_response,
4 with_session_cookies,
5};
6use super::helpers::{
7 admin_plugin_enabled, delete_session_cookie_headers, unauthorized_if_unauthenticated,
8};
9use alibi_core::endpoint::{
10 EndpointCall, EndpointDefinition, EndpointInput, EndpointResponse, ServerEndpoint,
11};
12use alibi_core::entity::{AuthSession, AuthUser};
13use alibi_core::field_policy::{FieldInputError, FieldValues, SessionFields};
14use alibi_core::session::SessionRequest;
15use alibi_core::utils::cookie_utils::{related_cookie_name, verify_cookie_value};
16use alibi_core::utils::json::JsValue;
17use alibi_core::wire::{SessionView, UserView};
18use alibi_core::{
19 AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
20 HttpMethod, SuccessResponse,
21};
22use async_trait::async_trait;
23use serde::{Deserialize, Serialize};
24
25pub struct SessionManagementPlugin {
27 config: SessionManagementConfig,
28}
29
30#[derive(Debug, Clone, alibi_core::PluginConfig)]
31#[plugin(name = "SessionManagementPlugin")]
32pub struct SessionManagementConfig {
33 #[config(default = true)]
34 pub enable_session_listing: bool,
35 #[config(default = true)]
36 pub enable_session_revocation: bool,
37 #[config(default = true)]
38 pub require_authentication: bool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct RevokeSessionRequest {
44 pub token: String,
46}
47
48impl RequestBody for RevokeSessionRequest {
49 const FIELDS: &'static [JsonField] = &[JsonField::string("token", true)];
50}
51
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct SignOutRequest {
56 #[serde(rename = "callbackURL", skip_serializing_if = "Option::is_none")]
57 pub callback_url: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub disable_redirect: Option<bool>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub state: Option<String>,
62}
63impl RequestBody for SignOutRequest {
64 const FIELDS: &'static [JsonField] = &[
65 JsonField::string("callbackURL", false),
66 JsonField {
67 name: "disableRedirect",
68 kind: JsonFieldKind::Boolean,
69 required: false,
70 },
71 JsonField::string("state", false),
72 ];
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct SignOutResponse {
78 pub success: bool,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub url: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub redirect: Option<bool>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct UpdateSessionResponse {
88 pub session: SessionView,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct GetSessionResponse {
95 pub session: SessionView,
96 pub user: UserView,
97 #[serde(rename = "needsRefresh", skip_serializing_if = "Option::is_none")]
98 pub needs_refresh: Option<bool>,
99}
100
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase", default)]
104pub struct GetSessionQuery {
105 pub disable_cookie_cache: bool,
107 pub disable_refresh: bool,
109}
110
111#[async_trait]
112impl<S: alibi_core::AuthSchema> AuthPlugin<S> for SessionManagementPlugin {
113 route_openapi_metadata!(S);
114
115 fn name(&self) -> &'static str {
116 "session-management"
117 }
118
119 fn server_endpoints(&self) -> Vec<EndpointDefinition> {
120 let mut endpoints = vec![crate::endpoint::definition(
121 "getSession",
122 "get_session",
123 Some("/get-session"),
124 HttpMethod::Get,
125 )];
126 endpoints.push(crate::endpoint::definition(
127 "updateSession",
128 "updateSession",
129 Some("/update-session"),
130 HttpMethod::Post,
131 ));
132 endpoints.push(crate::endpoint::definition(
133 "signOut",
134 "signOut",
135 Some("/sign-out"),
136 HttpMethod::Post,
137 ));
138 if self.config.enable_session_listing {
139 endpoints.push(crate::endpoint::definition(
140 "listSessions",
141 "listSessions",
142 Some("/list-sessions"),
143 HttpMethod::Get,
144 ));
145 }
146 if self.config.enable_session_revocation {
147 for (operation, path) in [
148 ("revokeSession", "/revoke-session"),
149 ("revokeOtherSessions", "/revoke-other-sessions"),
150 ("revokeSessions", "/revoke-sessions"),
151 ] {
152 endpoints.push(crate::endpoint::definition(
153 operation,
154 operation,
155 Some(path),
156 HttpMethod::Post,
157 ));
158 }
159 }
160 endpoints
161 }
162
163 fn validate_endpoint(
164 &self,
165 call: &EndpointCall,
166 _: &AuthContext<S>,
167 ) -> AuthResult<EndpointInput> {
168 if call.operation_id() == "updateSession" {
169 if !matches!(call.body(), Some(JsValue::Object(_))) {
170 return Err(crate::endpoint::validation(format!(
171 "[body] Invalid input: expected record, received {}",
172 json_type(call.body()),
173 )));
174 }
175 return Ok(EndpointInput {
176 body: call.body().cloned(),
177 query: call.query().cloned(),
178 });
179 }
180 if call.operation_id() == "signOut" {
181 let body = call
182 .body()
183 .map(|body| {
184 crate::endpoint::validate_fields(Some(body), "body", SignOutRequest::FIELDS)
185 })
186 .transpose()?;
187 if call.headers().is_none() {
188 return Err(crate::endpoint::validation("Headers is required"));
189 }
190 return Ok(EndpointInput {
191 body,
192 query: call.query().cloned(),
193 });
194 }
195 if call.operation_id() == "listSessions" {
196 if call.headers().is_none() {
197 return Err(crate::endpoint::validation("Headers is required"));
198 }
199 return Ok(EndpointInput {
200 body: call.body().cloned(),
201 query: call.query().cloned(),
202 });
203 }
204 if matches!(
205 call.operation_id(),
206 "revokeSession" | "revokeOtherSessions" | "revokeSessions"
207 ) {
208 let body = if call.operation_id() == "revokeSession" {
209 Some(crate::endpoint::validate_fields(
210 call.body(),
211 "body",
212 RevokeSessionRequest::FIELDS,
213 )?)
214 } else {
215 call.body().cloned()
216 };
217 if call.headers().is_none() {
218 return Err(crate::endpoint::validation("Headers is required"));
219 }
220 return Ok(EndpointInput {
221 body,
222 query: call.query().cloned(),
223 });
224 }
225 let query = match call.query() {
226 None => None,
227 Some(JsValue::Object(input)) => Some(JsValue::Object(
228 input
229 .iter()
230 .filter(|(name, _)| {
231 ["disableCookieCache", "disableRefresh"].contains(&name.as_str())
232 })
233 .map(|(name, value)| (name.clone(), JsValue::Bool(value.is_truthy())))
234 .collect(),
235 )),
236 Some(value) => {
237 return Err(crate::endpoint::validation(format!(
238 "[query] Invalid input: expected object, received {}",
239 json_type(Some(value)),
240 )));
241 }
242 };
243 if call.headers().is_none() {
244 return Err(crate::endpoint::validation("Headers is required"));
245 }
246 Ok(EndpointInput {
247 body: call.body().cloned(),
248 query,
249 })
250 }
251
252 async fn on_endpoint(
253 &self,
254 call: &EndpointCall,
255 ctx: &AuthContext<S>,
256 ) -> AuthResult<EndpointResponse> {
257 if call.operation_id() == "updateSession" {
258 return self.update_session_endpoint_response(call, ctx).await;
259 }
260 if call.operation_id() == "signOut" {
261 let body = if call.body().is_some() {
262 call.body_as()?
263 } else {
264 SignOutRequest::default()
265 };
266 let response = self.sign_out_response(call, &body, ctx).await?;
267 let mut result =
268 EndpointResponse::value(alibi_core::utils::json::from_slice(&response.body)?);
269 for (name, value) in response.headers {
270 if !name.eq_ignore_ascii_case("content-type") {
271 result = result.with_header(name, value);
272 }
273 }
274 return Ok(result);
275 }
276 if call.operation_id() == "listSessions" {
277 let (user, session) = ctx
278 .require_cached_session(call)
279 .await
280 .map_err(unauthorized_if_unauthenticated)?;
281 call.record_authenticated_session(
282 match &user {
283 alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(user),
284 alibi_core::AuthenticatedUser::Cached(user) => (**user).clone(),
285 },
286 session.clone(),
287 );
288 if !ctx.session_manager().is_session_fresh(&session) {
289 return Err(AuthError::Upstream {
290 status: 403,
291 code: "SESSION_NOT_FRESH",
292 message: "Session is not fresh",
293 });
294 }
295 let sessions = match list_sessions_core(user.id(), ctx).await {
296 Ok(sessions) => sessions,
297 Err(error) => {
298 tracing::error!(error = %error, "Session listing failed");
299 return Ok(EndpointResponse::error(AuthError::Api {
300 status: 500,
301 code: None,
302 message: String::new(),
303 })
304 .with_error_body(JsValue::Null));
305 }
306 };
307 return EndpointResponse::json(&sessions);
308 }
309 if matches!(
310 call.operation_id(),
311 "revokeSession" | "revokeOtherSessions" | "revokeSessions"
312 ) {
313 let (user, session) = ctx
315 .require_authoritative_session_record(call)
316 .await
317 .map_err(|_| AuthError::Upstream {
318 status: 401,
319 code: "UNAUTHORIZED",
320 message: "Unauthorized",
321 })?;
322 call.record_authenticated_session(ctx.user_view(&user), session.clone());
323 let response = if call.operation_id() == "revokeSession" {
324 let input: RevokeSessionRequest = call.body_as()?;
325 revoke_session_core(&user, &input.token, ctx).await?
326 } else if call.operation_id() == "revokeOtherSessions" {
327 revoke_other_sessions_core(user.id(), &session, ctx).await?
328 } else {
329 revoke_sessions_core(user.id(), ctx).await?
330 };
331 return EndpointResponse::json(&response);
332 }
333 let response = match self.get_session_response(call, ctx).await {
334 Ok(value) => EndpointResponse::json(&value)?,
335 Err(error) => EndpointResponse::error(error),
336 };
337 Ok(response
338 .with_header("cache-control", "no-store")
339 .with_header("pragma", "no-cache"))
340 }
341
342 fn routes(&self) -> Vec<AuthRoute> {
343 vec![
344 AuthRoute::get("/get-session", "get_session"),
345 AuthRoute::post("/get-session", "get_session"),
348 AuthRoute::post("/sign-out", "sign_out"),
349 AuthRoute::post("/update-session", "updateSession"),
350 AuthRoute::get("/list-sessions", "list_sessions"),
351 AuthRoute::post("/revoke-session", "revoke_session"),
352 AuthRoute::post("/revoke-sessions", "revoke_sessions"),
353 AuthRoute::post("/revoke-other-sessions", "revoke_other_sessions"),
354 ]
355 }
356
357 async fn on_request(
358 &self,
359 req: &AuthRequest,
360 ctx: &AuthContext<S>,
361 ) -> AuthResult<Option<AuthResponse>> {
362 let response = match (req.method(), req.path()) {
363 (HttpMethod::Get | HttpMethod::Post, "/get-session") => {
364 self.handle_get_session(req, ctx).await?
365 }
366 (HttpMethod::Post, "/sign-out") => self.handle_sign_out(req, ctx).await?,
367 (HttpMethod::Post, "/update-session") => self.handle_update_session(req, ctx).await?,
368 (HttpMethod::Get, "/list-sessions") if self.config.enable_session_listing => {
369 self.handle_list_sessions(req, ctx).await?
370 }
371 (HttpMethod::Post, "/revoke-session") if self.config.enable_session_revocation => {
372 self.handle_revoke_session(req, ctx).await?
373 }
374 (HttpMethod::Post, "/revoke-sessions") if self.config.enable_session_revocation => {
375 self.handle_revoke_sessions(req, ctx).await?
376 }
377 (HttpMethod::Post, "/revoke-other-sessions")
378 if self.config.enable_session_revocation =>
379 {
380 self.handle_revoke_other_sessions(req, ctx).await?
381 }
382 _ => return Ok(None),
383 };
384 Ok(Some(response))
385 }
386}
387
388impl SessionManagementPlugin {
389 #[must_use]
396 pub const fn get_session_endpoint() -> ServerEndpoint<Option<GetSessionResponse>> {
397 ServerEndpoint::new("session-management", "getSession")
398 }
399
400 #[must_use]
404 pub const fn sign_out_endpoint() -> ServerEndpoint<SignOutResponse> {
405 ServerEndpoint::new("session-management", "signOut")
406 }
407
408 #[must_use]
411 pub const fn list_sessions_endpoint() -> ServerEndpoint<Vec<SessionView>> {
412 ServerEndpoint::new("session-management", "listSessions")
413 }
414
415 pub fn revoke_session_endpoint(
423 input: &RevokeSessionRequest,
424 ) -> AuthResult<ServerEndpoint<alibi_core::StatusResponse>> {
425 ServerEndpoint::new("session-management", "revokeSession").with_body(input)
426 }
427
428 #[must_use]
431 pub const fn revoke_other_sessions_endpoint() -> ServerEndpoint<alibi_core::StatusResponse> {
432 ServerEndpoint::new("session-management", "revokeOtherSessions")
433 }
434
435 #[must_use]
438 pub const fn revoke_sessions_endpoint() -> ServerEndpoint<alibi_core::StatusResponse> {
439 ServerEndpoint::new("session-management", "revokeSessions")
440 }
441
442 pub fn update_session_endpoint(
448 input: &impl Serialize,
449 ) -> AuthResult<ServerEndpoint<UpdateSessionResponse>> {
450 ServerEndpoint::new("session-management", "updateSession").with_body(input)
451 }
452
453 async fn update_session_endpoint_response<S: alibi_core::AuthSchema>(
454 &self,
455 call: &EndpointCall,
456 ctx: &AuthContext<S>,
457 ) -> AuthResult<EndpointResponse> {
458 let (user, session) = ctx
459 .require_cached_session(call)
460 .await
461 .map_err(unauthorized_if_unauthenticated)?;
462 call.record_authenticated_session(
463 match &user {
464 alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(user),
465 alibi_core::AuthenticatedUser::Cached(user) => (**user).clone(),
466 },
467 session.clone(),
468 );
469 let input = call
470 .body()
471 .and_then(JsValue::as_object)
472 .ok_or_else(|| crate::endpoint::validation("[body] Invalid input: expected record"))?;
473 let allowed = parse_session_update_fields(input, ctx).map_err(|error| match error {
474 FieldInputError::Validation { code, message } => AuthError::Api {
475 status: 400,
476 code: Some(code.into()),
477 message,
478 },
479 FieldInputError::Transform(error) => error,
480 })?;
481 let response = self
482 .update_session_response(call, ctx, &user, &session, allowed)
483 .await?;
484 let value: JsValue = alibi_core::utils::json::from_slice(&response.body)?;
485 let mut result = if response.status >= 400 {
486 EndpointResponse::error(AuthError::Api {
487 status: response.status,
488 code: value
489 .get("code")
490 .and_then(JsValue::as_str)
491 .map(str::to_owned),
492 message: value
493 .get("message")
494 .and_then(JsValue::as_str)
495 .unwrap_or_default()
496 .into(),
497 })
498 .with_error_body(value)
499 } else {
500 EndpointResponse::value(value)
501 };
502 for (name, value) in response.headers {
503 if !name.eq_ignore_ascii_case("content-type") {
504 result = result.with_header(name, value);
505 }
506 }
507 Ok(result)
508 }
509
510 async fn handle_update_session(
511 &self,
512 req: &AuthRequest,
513 ctx: &AuthContext<impl alibi_core::AuthSchema>,
514 ) -> AuthResult<AuthResponse> {
515 if req.body.is_some() {
516 let content_type = req
517 .header("content-type")
518 .map(String::as_str)
519 .unwrap_or_default();
520 if !content_type
521 .split(';')
522 .next()
523 .unwrap_or_default()
524 .trim()
525 .to_lowercase()
526 .contains("application/json")
527 {
528 let message = if content_type.is_empty() {
529 "Content-Type is required. Allowed types: application/json".to_owned()
530 } else {
531 format!(
532 "Content-Type \"{content_type}\" is not allowed. Allowed types: application/json"
533 )
534 };
535 return Ok(AuthResponse::json(
536 415,
537 &serde_json::json!({"code":"UNSUPPORTED_MEDIA_TYPE","message":message}),
538 )?);
539 }
540 }
541 let value: Option<JsValue> = match req.body.as_ref() {
542 Some(body) => match alibi_core::utils::json::from_slice(body) {
543 Ok(value) => Some(value),
544 Err(_) => {
545 return Ok(AuthResponse::json(
546 400,
547 &serde_json::json!({"code":"BAD_REQUEST","message":"Invalid JSON in request body"}),
548 )?);
549 }
550 },
551 None => None,
552 };
553 let Some(input) = value.as_ref().and_then(JsValue::as_object) else {
554 return Ok(validation_response(&format!(
555 "[body] Invalid input: expected record, received {}",
556 json_type(value.as_ref())
557 )));
558 };
559 let (user, session) = match super::helpers::ordinary_session(req, ctx).await {
560 Ok(session) => session,
561 Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => {
562 return Ok(AuthResponse::json(
563 401,
564 &serde_json::json!({"code":"UNAUTHORIZED","message":"Unauthorized"}),
565 )?);
566 }
567 Err(error) => return Err(error),
568 };
569 let allowed = match parse_session_update_fields(input, ctx) {
570 Ok(allowed) => allowed,
571 Err(FieldInputError::Validation { code, message }) => {
572 return Ok(AuthResponse::json(
573 400,
574 &serde_json::json!({"code":code,"message":message}),
575 )?);
576 }
577 Err(FieldInputError::Transform(AuthError::Internal(_))) => {
578 return Ok(AuthResponse::new(500));
579 }
580 Err(FieldInputError::Transform(error)) => return Err(error),
581 };
582 self.update_session_response(req, ctx, &user, &session, allowed)
583 .await
584 }
585
586 async fn update_session_response<S: alibi_core::AuthSchema>(
587 &self,
588 req: &impl SessionRequest,
589 ctx: &AuthContext<S>,
590 user: &alibi_core::AuthenticatedUser<S>,
591 session: &SessionView,
592 allowed: FieldValues,
593 ) -> AuthResult<AuthResponse> {
594 if !allowed.has_input_fields() {
595 return Ok(AuthResponse::json(
596 400,
597 &serde_json::json!({"message":"No fields to update"}),
598 )?);
599 }
600 let updated = match ctx
601 .database
602 .update_session_fields_record(session.token(), allowed)
603 .await
604 {
605 Ok(updated) => updated,
606 Err(error @ AuthError::Internal(_)) => {
607 return Err(AuthError::CallbackFailure(Box::new(error)));
608 }
609 Err(error) => return Err(error),
610 };
611 let Some(updated) = updated else {
612 let mut response = AuthResponse::json(
613 401,
614 &serde_json::json!({"code":"FAILED_TO_GET_SESSION","message":"Failed to get session"}),
615 )?;
616 for cookie in delete_session_cookie_headers(&ctx.config)? {
617 response.headers.append("Set-Cookie", cookie);
618 }
619 return Ok(response);
620 };
621
622 alibi_core::session::cookie_cache::runtime::emit_issuance(ctx, user, &updated).await?;
623
624 let preference = related_cookie_name(&ctx.config, "dont_remember");
625 let dont_remember = req.session_headers().get("cookie").is_some_and(|header| {
626 cookie::Cookie::split_parse(header)
627 .flatten()
628 .find(|cookie| cookie.name() == preference)
629 .and_then(|cookie| verify_cookie_value(cookie.value(), ctx.config.current_secret()))
630 .is_some_and(|signed| !signed.is_empty())
631 });
632 with_session_cookies(
633 AuthResponse::json(
634 200,
635 &serde_json::json!({"session":ctx.session_view(&updated)}),
636 )?,
637 &ctx.config,
638 updated.token(),
639 dont_remember,
640 )
641 }
642
643 pub(crate) async fn handle_get_session(
644 &self,
645 req: &AuthRequest,
646 ctx: &AuthContext<impl alibi_core::AuthSchema>,
647 ) -> AuthResult<AuthResponse> {
648 let response = match self.get_session_response(req, ctx).await {
649 Ok(response) => AuthResponse::json(200, &response)?,
650 Err(error) => error.to_auth_response(),
651 };
652 Ok(response
653 .with_header("cache-control", "no-store")
654 .with_header("pragma", "no-cache"))
655 }
656
657 async fn get_session_response(
658 &self,
659 req: &impl SessionRequest,
660 ctx: &AuthContext<impl alibi_core::AuthSchema>,
661 ) -> AuthResult<Option<GetSessionResponse>> {
662 if req.session_method() == &HttpMethod::Post && !ctx.config.session.defer_session_refresh {
663 return Err(AuthError::Upstream {
664 status: 405,
665 code: "METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED",
666 message: "POST method requires deferSessionRefresh to be enabled in session config",
667 });
668 }
669 let read = alibi_core::session::cookie_cache::runtime::authenticated(ctx, req, true).await;
670 if let Some(call) = req.endpoint_call()
673 && let Some((user, session)) = call.session_hook_snapshot()
674 {
675 call.record_authenticated_session(user, session);
676 }
677 let Some(read) = read.map_err(|error| match error {
678 error @ (AuthError::Upstream { .. } | AuthError::Api { .. }) => error,
679 _ => AuthError::Upstream {
680 status: 500,
681 code: "FAILED_TO_GET_SESSION",
682 message: "Failed to get session",
683 },
684 })?
685 else {
686 return Ok(None);
687 };
688 Ok(Some(GetSessionResponse {
689 session: read.session,
690 user: match read.user {
691 alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(&user),
692 alibi_core::AuthenticatedUser::Cached(user) => *user,
693 },
694 needs_refresh: read.needs_refresh,
695 }))
696 }
697
698 async fn handle_sign_out(
699 &self,
700 req: &AuthRequest,
701 ctx: &AuthContext<impl alibi_core::AuthSchema>,
702 ) -> AuthResult<AuthResponse> {
703 let body = if req.body.is_some() {
704 match parse_body::<SignOutRequest>(req) {
705 Ok(body) => body,
706 Err(response) => return Ok(response),
707 }
708 } else {
709 SignOutRequest::default()
710 };
711 self.sign_out_response(req, &body, ctx).await
712 }
713
714 async fn sign_out_response(
715 &self,
716 req: &impl SessionRequest,
717 body: &SignOutRequest,
718 ctx: &AuthContext<impl alibi_core::AuthSchema>,
719 ) -> AuthResult<AuthResponse> {
720 let mut current_user = None;
721 if let Some(token) = ctx.session_manager().extract_session_token(req) {
722 if let Ok(Some(session)) = ctx.database.get_session(&token).await {
723 if ctx
726 .database
727 .get_user_by_id(&session.user_id())
728 .await
729 .ok()
730 .flatten()
731 .is_some()
732 {
733 current_user = Some(session.user_id().into_owned());
734 }
735 _ = sign_out_core(&session, ctx).await;
736 } else {
737 _ = ctx.database.delete_session(&token).await;
738 }
739 }
740
741 let mut response = AuthResponse::json(
742 200,
743 &SignOutResponse {
744 success: true,
745 url: None,
746 redirect: None,
747 },
748 )?;
749 for cookie in alibi_core::session::cookie_cache::runtime::session_cleanup_headers(
750 &ctx.config,
751 req.session_headers(),
752 false,
753 )? {
754 response.headers.append("Set-Cookie", cookie);
755 }
756 if let Some(user_id) = current_user
757 && let Some(url) = super::oauth::logout::provider_logout_url(&user_id, body, ctx).await
758 {
759 let redirect = body.disable_redirect != Some(true);
760 response.body = AuthResponse::json(
761 200,
762 &serde_json::json!({
763 "success": true, "url": url, "redirect": redirect,
764 }),
765 )?
766 .body;
767 if redirect {
768 _ = response.headers.insert("Location", url);
769 }
770 }
771 Ok(response)
772 }
773
774 async fn handle_list_sessions(
775 &self,
776 req: &AuthRequest,
777 ctx: &AuthContext<impl alibi_core::AuthSchema>,
778 ) -> AuthResult<AuthResponse> {
779 let (user, session) = super::helpers::ordinary_session(req, ctx).await?;
780 if !ctx.session_manager().is_session_fresh(&session) {
781 return Err(AuthError::Upstream {
782 status: 403,
783 code: "SESSION_NOT_FRESH",
784 message: "Session is not fresh",
785 });
786 }
787 let Ok(sessions) = list_sessions_core(user.id(), ctx).await else {
788 tracing::error!("Session listing failed");
789 return Ok(AuthResponse::new(500).with_header("content-type", "application/json"));
790 };
791 Ok(AuthResponse::json(200, &sessions)?)
792 }
793
794 async fn handle_revoke_session(
795 &self,
796 req: &AuthRequest,
797 ctx: &AuthContext<impl alibi_core::AuthSchema>,
798 ) -> AuthResult<AuthResponse> {
799 let revoke_req: RevokeSessionRequest = match parse_body(req) {
800 Ok(v) => v,
801 Err(resp) => return Ok(resp),
802 };
803
804 let (user, _) = ctx
805 .require_authoritative_cached_session(req)
806 .await
807 .map_err(unauthorized_if_unauthenticated)?;
808 let response = revoke_session_core(&user, &revoke_req.token, ctx).await?;
809 Ok(AuthResponse::json(200, &response)?)
810 }
811
812 async fn handle_revoke_sessions(
813 &self,
814 req: &AuthRequest,
815 ctx: &AuthContext<impl alibi_core::AuthSchema>,
816 ) -> AuthResult<AuthResponse> {
817 let (user, _) = ctx
818 .require_authoritative_cached_session(req)
819 .await
820 .map_err(unauthorized_if_unauthenticated)?;
821 let response = revoke_sessions_core(user.id(), ctx).await?;
822 Ok(AuthResponse::json(200, &response)?)
823 }
824
825 async fn handle_revoke_other_sessions(
826 &self,
827 req: &AuthRequest,
828 ctx: &AuthContext<impl alibi_core::AuthSchema>,
829 ) -> AuthResult<AuthResponse> {
830 let (user, current_session) = ctx
831 .require_authoritative_cached_session(req)
832 .await
833 .map_err(unauthorized_if_unauthenticated)?;
834 let response = revoke_other_sessions_core(user.id(), ¤t_session, ctx).await?;
835 Ok(AuthResponse::json(200, &response)?)
836 }
837}
838
839impl std::fmt::Debug for SessionManagementPlugin {
840 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
841 f.debug_struct("SessionManagementPlugin")
842 .finish_non_exhaustive()
843 }
844}
845
846pub(crate) async fn sign_out_core(
847 session: &impl AuthSession,
848 ctx: &AuthContext<impl alibi_core::AuthSchema>,
849) -> AuthResult<SuccessResponse> {
850 ctx.database.delete_session(session.token()).await?;
851 Ok(SuccessResponse { success: true })
852}
853
854pub(crate) async fn list_sessions_core(
855 user_id: impl AsRef<str>,
856 ctx: &AuthContext<impl alibi_core::AuthSchema>,
857) -> AuthResult<Vec<SessionView>> {
858 let sessions = ctx
859 .database
860 .get_active_user_sessions_record(user_id.as_ref())
861 .await?;
862 let now = chrono::Utc::now();
863 let mut sessions: Vec<_> = sessions
864 .iter()
865 .filter(|session| session.expires_at() > now)
866 .map(|session| ctx.session_view(session))
867 .collect();
868 if admin_plugin_enabled(ctx) {
869 sessions.retain(|listed| listed.impersonated_by.is_none());
870 }
871 Ok(sessions)
872}
873
874pub(crate) async fn revoke_session_core(
875 user: &impl AuthUser,
876 token: &str,
877 ctx: &AuthContext<impl alibi_core::AuthSchema>,
878) -> AuthResult<StatusResponse> {
879 if let Some(session_to_revoke) = ctx.database.get_session(token).await?
880 && session_to_revoke.user_id() == user.id()
881 {
882 ctx.database
883 .delete_session(token)
884 .await
885 .map_err(revocation_storage_error)?;
886 }
887 Ok(StatusResponse { status: true })
888}
889
890pub(crate) async fn revoke_sessions_core(
891 user_id: impl AsRef<str>,
892 ctx: &AuthContext<impl alibi_core::AuthSchema>,
893) -> AuthResult<StatusResponse> {
894 ctx.database
895 .delete_user_sessions(user_id.as_ref())
896 .await
897 .map_err(revocation_storage_error)?;
898 Ok(StatusResponse { status: true })
899}
900
901pub(crate) async fn revoke_other_sessions_core(
902 user_id: impl AsRef<str>,
903 current_session: &impl AuthSession,
904 ctx: &AuthContext<impl alibi_core::AuthSchema>,
905) -> AuthResult<StatusResponse> {
906 let all_sessions = ctx.session_manager().list_user_sessions(user_id).await?;
907 let tokens: Vec<String> = all_sessions
908 .into_iter()
909 .filter(|session| session.token() != current_session.token())
910 .map(|session| session.token().to_owned())
911 .collect();
912 let count = tokens.len();
913 let database = ctx.database.clone();
914 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
915 crate::helpers::spawn_in_request_context(async move {
917 _ = futures_util::future::join_all(tokens.into_iter().map(|token| {
918 let database = &database;
919 let sender = &sender;
920 async move {
921 let result = database.delete_session(&token).await;
922 _ = sender.send(result);
923 }
924 }))
925 .await;
926 });
927 for _ in 0..count {
928 receiver
929 .recv()
930 .await
931 .ok_or_else(|| AuthError::internal("Session revocation task stopped"))?
932 .map_err(crate::helpers::callback_failure)?;
933 }
934 Ok(StatusResponse { status: true })
935}
936
937#[expect(
938 clippy::needless_pass_by_value,
939 reason = "used by value as a `map_err` callback"
940)]
941fn revocation_storage_error(error: AuthError) -> AuthError {
942 tracing::error!(error = %error, "Session revocation failed");
943 AuthError::Upstream {
944 status: 500,
945 code: "INTERNAL_SERVER_ERROR",
946 message: "Internal Server Error",
947 }
948}
949
950fn parse_session_update_fields(
952 input: &indexmap::IndexMap<String, JsValue>,
953 ctx: &AuthContext<impl alibi_core::AuthSchema>,
954) -> Result<FieldValues, FieldInputError> {
955 let fields = ctx.extensions.get::<SessionFields>().map_or_else(
956 || SessionFields(ctx.config.session.additional_fields.clone()),
957 |fields| (*fields).clone(),
958 );
959 fields.parse_update(input)
960}
961
962#[cfg(test)]
964mod tests {
965 use super::*;
966 use crate::test_helpers;
967 use alibi_core::config::AccountConfig;
968 use alibi_core::utils::cookie_utils::related_cookie_name;
969 use alibi_core::wire::SessionView;
970 use alibi_core::{CreateSession, CreateUser};
971 use chrono::{Duration, Utc};
972
973 #[tokio::test]
975 async fn test_get_session_success() {
976 let plugin = SessionManagementPlugin::new();
977 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
978 CreateUser::new()
979 .with_email("test@example.com")
980 .with_name("Test User"),
981 Duration::hours(24),
982 )
983 .await;
984
985 let req = test_helpers::create_auth_request_no_query(
986 HttpMethod::Get,
987 "/get-session",
988 Some(&session.token),
989 None,
990 );
991 let response = plugin.handle_get_session(&req, &ctx).await.unwrap();
992
993 assert_eq!(response.status, 200);
994
995 let body_str = String::from_utf8(response.body).unwrap();
996 let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
997 assert_eq!(
998 (*(*(response_data)
999 .get("session")
1000 .unwrap_or(&serde_json::Value::Null))
1001 .get("token")
1002 .unwrap_or(&serde_json::Value::Null))
1003 .as_str()
1004 .unwrap(),
1005 session.token
1006 );
1007 assert_eq!(
1008 (*(*(response_data)
1009 .get("user")
1010 .unwrap_or(&serde_json::Value::Null))
1011 .get("email")
1012 .unwrap_or(&serde_json::Value::Null))
1013 .as_str()
1014 .map(ToOwned::to_owned),
1015 Some("test@example.com".to_owned())
1016 );
1017 }
1018
1019 #[tokio::test]
1021 async fn test_get_session_unauthorized() {
1022 let plugin = SessionManagementPlugin::new();
1024 let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
1025 CreateUser::new()
1026 .with_email("test@example.com")
1027 .with_name("Test User"),
1028 Duration::hours(24),
1029 )
1030 .await;
1031
1032 let req =
1033 test_helpers::create_auth_request_no_query(HttpMethod::Get, "/get-session", None, None);
1034 let response = plugin.handle_get_session(&req, &ctx).await.unwrap();
1035 assert_eq!(response.status, 200);
1036 let body: serde_json::Value = serde_json::from_slice(&response.body).expect("valid JSON");
1037 assert!(body.is_null());
1038 }
1039
1040 #[tokio::test]
1042 async fn test_sign_out_success() {
1043 let plugin = SessionManagementPlugin::new();
1044 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
1045 CreateUser::new()
1046 .with_email("test@example.com")
1047 .with_name("Test User"),
1048 Duration::hours(24),
1049 )
1050 .await;
1051
1052 let req = test_helpers::create_auth_request_no_query(
1053 HttpMethod::Post,
1054 "/sign-out",
1055 Some(&session.token),
1056 Some(b"{}".to_vec()),
1057 );
1058 let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
1059
1060 assert_eq!(response.status, 200);
1061
1062 let body_str = String::from_utf8(response.body).unwrap();
1063 let response_data: SuccessResponse = serde_json::from_str(&body_str).unwrap();
1064 assert!(response_data.success);
1065
1066 let session_check = ctx.database.get_session(&session.token).await.unwrap();
1067 assert!(session_check.is_none());
1068 }
1069
1070 #[tokio::test]
1071 async fn test_sign_out_clears_account_cookie_when_enabled() {
1072 let plugin = SessionManagementPlugin::new();
1073 let config = test_helpers::create_test_config().account(AccountConfig {
1074 store_account_cookie: true,
1075 ..Default::default()
1076 });
1077 let ctx = test_helpers::create_test_context_with_config(config).await;
1078 let (_user, session) = test_helpers::create_user_and_session(
1079 &ctx,
1080 CreateUser::new()
1081 .with_email("test@example.com")
1082 .with_name("Test User"),
1083 Duration::hours(24),
1084 )
1085 .await;
1086
1087 let req = test_helpers::create_auth_request_no_query(
1088 HttpMethod::Post,
1089 "/sign-out",
1090 Some(&session.token),
1091 Some(b"{}".to_vec()),
1092 );
1093 let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
1094
1095 let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
1096 assert!(
1097 response
1098 .headers
1099 .get_all("Set-Cookie")
1100 .any(|cookie| cookie.starts_with(&account_cookie_name)),
1101 "sign-out should clear the account_data cookie when store_account_cookie is enabled"
1102 );
1103 }
1104
1105 #[tokio::test]
1106 async fn test_sign_out_does_not_emit_account_cookie_when_disabled() {
1107 let plugin = SessionManagementPlugin::new();
1108 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
1109 CreateUser::new()
1110 .with_email("test@example.com")
1111 .with_name("Test User"),
1112 Duration::hours(24),
1113 )
1114 .await;
1115
1116 let req = test_helpers::create_auth_request_no_query(
1117 HttpMethod::Post,
1118 "/sign-out",
1119 Some(&session.token),
1120 Some(b"{}".to_vec()),
1121 );
1122 let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
1123
1124 let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
1125 assert!(
1126 !response
1127 .headers
1128 .get_all("Set-Cookie")
1129 .any(|cookie| cookie.starts_with(&account_cookie_name)),
1130 "sign-out should not emit account_data clearing cookies when store_account_cookie is disabled"
1131 );
1132 }
1133
1134 #[tokio::test]
1136 async fn test_list_sessions_success() {
1137 let plugin = SessionManagementPlugin::new();
1138 let (ctx, user, session) = test_helpers::create_test_context_with_user(
1139 CreateUser::new()
1140 .with_email("test@example.com")
1141 .with_name("Test User"),
1142 Duration::hours(24),
1143 )
1144 .await;
1145
1146 let create_session2 = CreateSession {
1147 additional_fields: alibi_core::field_policy::FieldValues::default(),
1148 token: None,
1149 user_id: user.id.clone(),
1150 expires_at: Utc::now() + Duration::hours(24),
1151 ip_address: Some("192.168.1.1".to_owned()),
1152 user_agent: Some("another-agent".to_owned()),
1153 impersonated_by: None,
1154 active_organization_id: None,
1155 active_team_id: None,
1156 };
1157 ctx.database.create_session(create_session2).await.unwrap();
1158
1159 let req = test_helpers::create_auth_request_no_query(
1160 HttpMethod::Get,
1161 "/list-sessions",
1162 Some(&session.token),
1163 None,
1164 );
1165 let response = plugin.handle_list_sessions(&req, &ctx).await.unwrap();
1166
1167 assert_eq!(response.status, 200);
1168
1169 let body_str = String::from_utf8(response.body).unwrap();
1170 let sessions: Vec<SessionView> = serde_json::from_str(&body_str).unwrap();
1171 assert_eq!(sessions.len(), 2);
1172 }
1173
1174 #[tokio::test]
1175 async fn test_list_sessions_filters_impersonated_sessions_when_admin_plugin_is_enabled() {
1176 let plugin = SessionManagementPlugin::new();
1177 let (mut ctx, user, session) = test_helpers::create_test_context_with_user(
1178 CreateUser::new()
1179 .with_email("test@example.com")
1180 .with_name("Test User"),
1181 Duration::hours(24),
1182 )
1183 .await;
1184 ctx.set_metadata("admin.enabled", serde_json::Value::Bool(true));
1185
1186 let direct_session = CreateSession {
1187 additional_fields: alibi_core::field_policy::FieldValues::default(),
1188 token: None,
1189 user_id: user.id.clone(),
1190 expires_at: Utc::now() + Duration::hours(24),
1191 ip_address: Some("192.168.1.1".to_owned()),
1192 user_agent: Some("another-agent".to_owned()),
1193 impersonated_by: None,
1194 active_organization_id: None,
1195 active_team_id: None,
1196 };
1197 ctx.database.create_session(direct_session).await.unwrap();
1198
1199 let impersonated_session = CreateSession {
1200 additional_fields: alibi_core::field_policy::FieldValues::default(),
1201 token: None,
1202 user_id: user.id.clone(),
1203 expires_at: Utc::now() + Duration::hours(24),
1204 ip_address: Some("10.0.0.5".to_owned()),
1205 user_agent: Some("impersonated-agent".to_owned()),
1206 impersonated_by: Some("admin-user".to_owned()),
1207 active_organization_id: None,
1208 active_team_id: None,
1209 };
1210 let impersonated = ctx
1211 .database
1212 .create_session(impersonated_session)
1213 .await
1214 .unwrap();
1215
1216 let req = test_helpers::create_auth_request_no_query(
1217 HttpMethod::Get,
1218 "/list-sessions",
1219 Some(&session.token),
1220 None,
1221 );
1222 let response = plugin.handle_list_sessions(&req, &ctx).await.unwrap();
1223
1224 assert_eq!(response.status, 200);
1225
1226 let body_str = String::from_utf8(response.body).unwrap();
1227 let sessions: Vec<SessionView> = serde_json::from_str(&body_str).unwrap();
1228 assert_eq!(sessions.len(), 2);
1229 assert!(
1230 sessions
1231 .iter()
1232 .all(|candidate| candidate.token != impersonated.token),
1233 "impersonated sessions should not be returned from /list-sessions"
1234 );
1235 }
1236
1237 #[tokio::test]
1239 async fn test_revoke_session_success() {
1240 let plugin = SessionManagementPlugin::new();
1241 let (ctx, user, session) = test_helpers::create_test_context_with_user(
1242 CreateUser::new()
1243 .with_email("test@example.com")
1244 .with_name("Test User"),
1245 Duration::hours(24),
1246 )
1247 .await;
1248
1249 let create_session2 = CreateSession {
1250 additional_fields: alibi_core::field_policy::FieldValues::default(),
1251 token: None,
1252 user_id: user.id.clone(),
1253 expires_at: Utc::now() + Duration::hours(24),
1254 ip_address: Some("192.168.1.1".to_owned()),
1255 user_agent: Some("another-agent".to_owned()),
1256 impersonated_by: None,
1257 active_organization_id: None,
1258 active_team_id: None,
1259 };
1260 let session2 = ctx.database.create_session(create_session2).await.unwrap();
1261
1262 let body = serde_json::json!({ "token": session2.token });
1263 let req = test_helpers::create_auth_request_no_query(
1264 HttpMethod::Post,
1265 "/revoke-session",
1266 Some(&session.token),
1267 Some(body.to_string().into_bytes()),
1268 );
1269
1270 let response = plugin.handle_revoke_session(&req, &ctx).await.unwrap();
1271 assert_eq!(response.status, 200);
1272
1273 let session2_check = ctx.database.get_session(&session2.token).await.unwrap();
1274 assert!(session2_check.is_none());
1275
1276 let session1_check = ctx.database.get_session(&session.token).await.unwrap();
1277 assert!(session1_check.is_some());
1278 }
1279
1280 #[tokio::test]
1282 async fn test_revoke_session_forbidden_different_user() {
1283 let plugin = SessionManagementPlugin::new();
1284 let (ctx, _user1, session1) = test_helpers::create_test_context_with_user(
1285 CreateUser::new()
1286 .with_email("test@example.com")
1287 .with_name("Test User"),
1288 Duration::hours(24),
1289 )
1290 .await;
1291
1292 let create_user2 = CreateUser::new()
1293 .with_email("user2@example.com")
1294 .with_name("User Two");
1295 let user2 = ctx.database.create_user(create_user2).await.unwrap();
1296
1297 let create_session2 = CreateSession {
1298 additional_fields: alibi_core::field_policy::FieldValues::default(),
1299 token: None,
1300 user_id: user2.id,
1301 expires_at: Utc::now() + Duration::hours(24),
1302 ip_address: Some("192.168.1.1".to_owned()),
1303 user_agent: Some("another-agent".to_owned()),
1304 impersonated_by: None,
1305 active_organization_id: None,
1306 active_team_id: None,
1307 };
1308 let session2 = ctx.database.create_session(create_session2).await.unwrap();
1309
1310 let body = serde_json::json!({ "token": session2.token });
1311 let req = test_helpers::create_auth_request_no_query(
1312 HttpMethod::Post,
1313 "/revoke-session",
1314 Some(&session1.token),
1315 Some(body.to_string().into_bytes()),
1316 );
1317
1318 let response = plugin.handle_revoke_session(&req, &ctx).await.unwrap();
1319 assert_eq!(response.status, 200);
1320
1321 let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1322 assert_eq!(
1323 (*(body_2).get("status").unwrap_or(&serde_json::Value::Null)),
1324 true
1325 );
1326
1327 let still_exists = ctx.database.get_session(&session2.token).await.unwrap();
1328 assert!(
1329 still_exists.is_some(),
1330 "other user's session must not be revoked"
1331 );
1332 }
1333
1334 #[tokio::test]
1336 async fn test_revoke_sessions_success() {
1337 let plugin = SessionManagementPlugin::new();
1338 let (ctx, user, session1) = test_helpers::create_test_context_with_user(
1339 CreateUser::new()
1340 .with_email("test@example.com")
1341 .with_name("Test User"),
1342 Duration::hours(24),
1343 )
1344 .await;
1345
1346 let create_session2 = CreateSession {
1347 additional_fields: alibi_core::field_policy::FieldValues::default(),
1348 token: None,
1349 user_id: user.id.clone(),
1350 expires_at: Utc::now() + Duration::hours(24),
1351 ip_address: Some("192.168.1.1".to_owned()),
1352 user_agent: Some("another-agent".to_owned()),
1353 impersonated_by: None,
1354 active_organization_id: None,
1355 active_team_id: None,
1356 };
1357 ctx.database.create_session(create_session2).await.unwrap();
1358
1359 let req = test_helpers::create_auth_request_no_query(
1360 HttpMethod::Post,
1361 "/revoke-sessions",
1362 Some(&session1.token),
1363 Some(b"{}".to_vec()),
1364 );
1365 let response = plugin.handle_revoke_sessions(&req, &ctx).await.unwrap();
1366
1367 assert_eq!(response.status, 200);
1368
1369 let user_sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
1370 assert_eq!(user_sessions.len(), 0);
1371 }
1372
1373 #[tokio::test]
1375 async fn test_plugin_routes() {
1376 let plugin = SessionManagementPlugin::new();
1377 let routes = AuthPlugin::<
1378 alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
1379 >::routes(&plugin);
1380
1381 assert_eq!(routes.len(), 8);
1382 assert!(
1383 routes
1384 .iter()
1385 .any(|r| r.path == "/get-session" && r.method == HttpMethod::Get)
1386 );
1387 assert!(
1389 routes
1390 .iter()
1391 .any(|r| r.path == "/get-session" && r.method == HttpMethod::Post)
1392 );
1393 assert!(
1394 routes
1395 .iter()
1396 .any(|r| r.path == "/sign-out" && r.method == HttpMethod::Post)
1397 );
1398 assert!(
1399 routes
1400 .iter()
1401 .any(|r| r.path == "/list-sessions" && r.method == HttpMethod::Get)
1402 );
1403 assert!(
1404 routes
1405 .iter()
1406 .any(|r| r.path == "/revoke-session" && r.method == HttpMethod::Post)
1407 );
1408 assert!(
1409 routes
1410 .iter()
1411 .any(|r| r.path == "/revoke-sessions" && r.method == HttpMethod::Post)
1412 );
1413 }
1414
1415 #[tokio::test]
1417 async fn test_plugin_on_request_routing() {
1418 let plugin = SessionManagementPlugin::new();
1419 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
1420 CreateUser::new()
1421 .with_email("test@example.com")
1422 .with_name("Test User"),
1423 Duration::hours(24),
1424 )
1425 .await;
1426
1427 let req = test_helpers::create_auth_request_no_query(
1429 HttpMethod::Get,
1430 "/get-session",
1431 Some(&session.token),
1432 None,
1433 );
1434 let response = plugin.on_request(&req, &ctx).await.unwrap();
1435 assert!(response.is_some());
1436 assert_eq!(response.unwrap().status, 200);
1437
1438 let req_2 = test_helpers::create_auth_request_no_query(
1441 HttpMethod::Post,
1442 "/get-session",
1443 Some(&session.token),
1444 Some(b"{}".to_vec()),
1445 );
1446 let response_2 = plugin.on_request(&req_2, &ctx).await.unwrap().unwrap();
1447 assert_eq!(response_2.status, 405);
1448 assert_eq!(
1449 serde_json::from_slice::<serde_json::Value>(&response_2.body).unwrap(),
1450 serde_json::json!({
1451 "code": "METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED",
1452 "message": "POST method requires deferSessionRefresh to be enabled in session config",
1453 }),
1454 );
1455 assert_eq!(
1456 response_2.headers.get("cache-control"),
1457 Some(&"no-store".to_owned())
1458 );
1459 assert_eq!(
1460 response_2.headers.get("pragma"),
1461 Some(&"no-cache".to_owned())
1462 );
1463
1464 let req_3 = test_helpers::create_auth_request_no_query(
1466 HttpMethod::Get,
1467 "/invalid-route",
1468 Some(&session.token),
1469 None,
1470 );
1471 let response_3 = plugin.on_request(&req_3, &ctx).await.unwrap();
1472 assert!(response_3.is_none());
1473 }
1474
1475 #[tokio::test]
1477 async fn test_configuration() {
1478 let plugin = SessionManagementPlugin::new()
1479 .enable_session_listing(false)
1480 .enable_session_revocation(false)
1481 .require_authentication(false);
1482
1483 assert!(!plugin.config.enable_session_listing);
1484 assert!(!plugin.config.enable_session_revocation);
1485 assert!(!plugin.config.require_authentication);
1486
1487 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
1488 CreateUser::new()
1489 .with_email("test@example.com")
1490 .with_name("Test User"),
1491 Duration::hours(24),
1492 )
1493 .await;
1494
1495 let req = test_helpers::create_auth_request_no_query(
1496 HttpMethod::Get,
1497 "/list-sessions",
1498 Some(&session.token),
1499 None,
1500 );
1501 let response = plugin.on_request(&req, &ctx).await.unwrap();
1502 assert!(response.is_none());
1503
1504 let req_2 = test_helpers::create_auth_request_no_query(
1505 HttpMethod::Post,
1506 "/revoke-session",
1507 Some(&session.token),
1508 Some(b"{}".to_vec()),
1509 );
1510 let response_2 = plugin.on_request(&req_2, &ctx).await.unwrap();
1511 assert!(response_2.is_none());
1512 }
1513}
1514