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