Skip to main content

alibi_plugins/session_management/
mod.rs

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
26/// Session management plugin for handling session operations
27pub 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/// Input for the logical single-session revocation operation.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct RevokeSessionRequest {
45    /// Session token to revoke; foreign and unknown tokens leave storage unchanged.
46    pub token: String,
47}
48
49impl RequestBody for RevokeSessionRequest {
50    const FIELDS: &'static [JsonField] = &[JsonField::string("token", true)];
51}
52
53/// Optional input for public server sign-out and provider logout destinations.
54#[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/// Public sign-out output, including an optional provider logout destination.
77#[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/// Result of a current-session update, including configured public session fields.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct UpdateSessionResponse {
89    pub session: SessionView,
90}
91
92/// Public get-session output, including configured fields and cached projections.
93/// An absent or expired session is represented by `Option<GetSessionResponse>::None`.
94#[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/// Options for [`SessionManagementPlugin::get_session_endpoint`].
103#[derive(Debug, Clone, Default, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase", default)]
105pub struct GetSessionQuery {
106    /// Bypass the cookie cache and read the session from storage.
107    pub disable_cookie_cache: bool,
108    /// Suppress stored-session refresh for this call.
109    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            // Sensitive Source middleware re-reads real authority and catches getter failures.
315            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        let response = match self.get_session_response(call, ctx).await {
335            Ok(value) => EndpointResponse::json(&value)?,
336            Err(error) => EndpointResponse::error(error),
337        };
338        Ok(response
339            .with_header("cache-control", "no-store")
340            .with_header("pragma", "no-cache"))
341    }
342
343    fn routes(&self) -> Vec<AuthRoute> {
344        vec![
345            AuthRoute::get("/get-session", "get_session"),
346            // Upstream declares `/get-session` as `method: ["GET", "POST"]`;
347            // the POST form requires `session.defer_session_refresh`.
348            AuthRoute::post("/get-session", "get_session"),
349            AuthRoute::post("/sign-out", "sign_out"),
350            AuthRoute::post("/update-session", "updateSession"),
351            AuthRoute::get("/list-sessions", "list_sessions"),
352            AuthRoute::post("/revoke-session", "revoke_session"),
353            AuthRoute::post("/revoke-sessions", "revoke_sessions"),
354            AuthRoute::post("/revoke-other-sessions", "revoke_other_sessions"),
355        ]
356    }
357
358    async fn on_request(
359        &self,
360        req: &AuthRequest,
361        ctx: &AuthContext<S>,
362    ) -> AuthResult<Option<AuthResponse>> {
363        let response = match (req.method(), req.path()) {
364            (HttpMethod::Get | HttpMethod::Post, "/get-session") => {
365                self.handle_get_session(req, ctx).await?
366            }
367            (HttpMethod::Post, "/sign-out") => self.handle_sign_out(req, ctx).await?,
368            (HttpMethod::Post, "/update-session") => self.handle_update_session(req, ctx).await?,
369            (HttpMethod::Get, "/list-sessions") if self.config.enable_session_listing => {
370                self.handle_list_sessions(req, ctx).await?
371            }
372            (HttpMethod::Post, "/revoke-session") if self.config.enable_session_revocation => {
373                self.handle_revoke_session(req, ctx).await?
374            }
375            (HttpMethod::Post, "/revoke-sessions") if self.config.enable_session_revocation => {
376                self.handle_revoke_sessions(req, ctx).await?
377            }
378            (HttpMethod::Post, "/revoke-other-sessions")
379                if self.config.enable_session_revocation =>
380            {
381                self.handle_revoke_other_sessions(req, ctx).await?
382            }
383            _ => return Ok(None),
384        };
385        Ok(Some(response))
386    }
387}
388
389impl SessionManagementPlugin {
390    /// Read the current session through installed endpoint hooks and return its headers.
391    ///
392    /// Pass incoming logical headers in `EndpointOptions::headers`. The output decodes
393    /// to `Option<GetSessionResponse>` and retains all cookie updates in `headers()`.
394    /// Use `with_query(&GetSessionQuery { .. })` for cache/refresh options, and
395    /// `EndpointOptions::method` for POST when deferred session refresh is enabled.
396    #[must_use]
397    pub const fn get_session_endpoint() -> ServerEndpoint<Option<GetSessionResponse>> {
398        ServerEndpoint::new("session-management", "getSession")
399    }
400
401    /// Sign out using the incoming signed session token and return cookie updates.
402    /// Guests are accepted. Supply logical headers in `EndpointOptions::headers`;
403    /// optionally pass [`SignOutRequest`] with `with_body` for provider logout.
404    #[must_use]
405    pub const fn sign_out_endpoint() -> ServerEndpoint<SignOutResponse> {
406        ServerEndpoint::new("session-management", "signOut")
407    }
408
409    /// List the fresh authenticated caller's active sessions through logical hooks.
410    /// Supply incoming logical headers in `EndpointOptions::headers`.
411    #[must_use]
412    pub const fn list_sessions_endpoint() -> ServerEndpoint<Vec<SessionView>> {
413        ServerEndpoint::new("session-management", "listSessions")
414    }
415
416    /// Revoke a session belonging to the authoritative current user.
417    ///
418    /// Supply genuine incoming headers through `EndpointOptions::headers`. The
419    /// operation validates input before session admission and preserves logical hooks.
420    ///
421    /// # Errors
422    /// Returns an error if the logical input cannot be serialized.
423    pub fn revoke_session_endpoint(
424        input: &RevokeSessionRequest,
425    ) -> AuthResult<ServerEndpoint<alibi_core::StatusResponse>> {
426        ServerEndpoint::new("session-management", "revokeSession").with_body(input)
427    }
428
429    /// Revoke the current user's other active sessions, retaining the current session.
430    /// Supply incoming logical headers in `EndpointOptions::headers`.
431    #[must_use]
432    pub const fn revoke_other_sessions_endpoint() -> ServerEndpoint<alibi_core::StatusResponse> {
433        ServerEndpoint::new("session-management", "revokeOtherSessions")
434    }
435
436    /// Revoke all sessions belonging to the authoritative current user.
437    /// Supply incoming logical headers in `EndpointOptions::headers`.
438    #[must_use]
439    pub const fn revoke_sessions_endpoint() -> ServerEndpoint<alibi_core::StatusResponse> {
440        ServerEndpoint::new("session-management", "revokeSessions")
441    }
442
443    /// Update configured fields for the authenticated current session.
444    /// Supply genuine logical session headers in `EndpointOptions::headers`.
445    ///
446    /// # Errors
447    /// Returns an error if the logical input cannot be serialized.
448    pub fn update_session_endpoint(
449        input: &impl Serialize,
450    ) -> AuthResult<ServerEndpoint<UpdateSessionResponse>> {
451        ServerEndpoint::new("session-management", "updateSession").with_body(input)
452    }
453
454    async fn update_session_endpoint_response<S: alibi_core::AuthSchema>(
455        &self,
456        call: &EndpointCall,
457        ctx: &AuthContext<S>,
458    ) -> AuthResult<EndpointResponse> {
459        let (user, session) = ctx
460            .require_cached_session(call)
461            .await
462            .map_err(unauthorized_if_unauthenticated)?;
463        call.record_authenticated_session(
464            match &user {
465                alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(user),
466                alibi_core::AuthenticatedUser::Cached(user) => (**user).clone(),
467            },
468            session.clone(),
469        );
470        let input = call
471            .body()
472            .and_then(JsValue::as_object)
473            .ok_or_else(|| crate::endpoint::validation("[body] Invalid input: expected record"))?;
474        let allowed = parse_session_update_fields(input, ctx).map_err(|error| match error {
475            FieldInputError::Validation { code, message } => AuthError::Api {
476                status: 400,
477                code: Some(code.into()),
478                message,
479            },
480            FieldInputError::Transform(error) => error,
481        })?;
482        let response = self
483            .update_session_response(call, ctx, &user, &session, allowed)
484            .await?;
485        let value: JsValue = alibi_core::utils::json::from_slice(&response.body)?;
486        let mut result = if response.status >= 400 {
487            EndpointResponse::error(AuthError::Api {
488                status: response.status,
489                code: value
490                    .get("code")
491                    .and_then(JsValue::as_str)
492                    .map(str::to_owned),
493                message: value
494                    .get("message")
495                    .and_then(JsValue::as_str)
496                    .unwrap_or_default()
497                    .into(),
498            })
499            .with_error_body(value)
500        } else {
501            EndpointResponse::value(value)
502        };
503        for (name, value) in response.headers {
504            if !name.eq_ignore_ascii_case("content-type") {
505                result = result.with_header(name, value);
506            }
507        }
508        Ok(result)
509    }
510
511    async fn handle_update_session(
512        &self,
513        req: &AuthRequest,
514        ctx: &AuthContext<impl alibi_core::AuthSchema>,
515    ) -> AuthResult<AuthResponse> {
516        if req.body.is_some() {
517            let content_type = req
518                .header("content-type")
519                .map(String::as_str)
520                .unwrap_or_default();
521            if !content_type
522                .split(';')
523                .next()
524                .unwrap_or_default()
525                .trim()
526                .to_lowercase()
527                .contains("application/json")
528            {
529                let message = if content_type.is_empty() {
530                    "Content-Type is required. Allowed types: application/json".to_owned()
531                } else {
532                    format!(
533                        "Content-Type \"{content_type}\" is not allowed. Allowed types: application/json"
534                    )
535                };
536                return Ok(AuthResponse::json(
537                    415,
538                    &serde_json::json!({"code":codes::UNSUPPORTED_MEDIA_TYPE,"message":message}),
539                )?);
540            }
541        }
542        let value: Option<JsValue> = match req.body.as_ref() {
543            Some(body) => match alibi_core::utils::json::from_slice(body) {
544                Ok(value) => Some(value),
545                Err(_) => {
546                    return Ok(AuthResponse::json(
547                        400,
548                        &serde_json::json!({"code":codes::BAD_REQUEST,"message":"Invalid JSON in request body"}),
549                    )?);
550                }
551            },
552            None => None,
553        };
554        let Some(input) = value.as_ref().and_then(JsValue::as_object) else {
555            return Ok(validation_response(&format!(
556                "[body] Invalid input: expected record, received {}",
557                json_type(value.as_ref())
558            )));
559        };
560        let (user, session) = match super::helpers::ordinary_session(req, ctx).await {
561            Ok(session) => session,
562            Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => {
563                return Ok(AuthResponse::json(
564                    401,
565                    &serde_json::json!({"code":codes::UNAUTHORIZED,"message":"Unauthorized"}),
566                )?);
567            }
568            Err(error) => return Err(error),
569        };
570        let allowed = match parse_session_update_fields(input, ctx) {
571            Ok(allowed) => allowed,
572            Err(FieldInputError::Validation { code, message }) => {
573                return Ok(AuthResponse::json(
574                    400,
575                    &serde_json::json!({"code":code,"message":message}),
576                )?);
577            }
578            Err(FieldInputError::Transform(AuthError::Internal(_))) => {
579                return Ok(AuthResponse::new(500));
580            }
581            Err(FieldInputError::Transform(error)) => return Err(error),
582        };
583        self.update_session_response(req, ctx, &user, &session, allowed)
584            .await
585    }
586
587    async fn update_session_response<S: alibi_core::AuthSchema>(
588        &self,
589        req: &impl SessionRequest,
590        ctx: &AuthContext<S>,
591        user: &alibi_core::AuthenticatedUser<S>,
592        session: &SessionView,
593        allowed: FieldValues,
594    ) -> AuthResult<AuthResponse> {
595        if !allowed.has_input_fields() {
596            return Ok(AuthResponse::json(
597                400,
598                &serde_json::json!({"message":"No fields to update"}),
599            )?);
600        }
601        let updated = match ctx
602            .database
603            .update_session_fields_record(session.token(), allowed)
604            .await
605        {
606            Ok(updated) => updated,
607            Err(error @ AuthError::Internal(_)) => {
608                return Err(AuthError::CallbackFailure(Box::new(error)));
609            }
610            Err(error) => return Err(error),
611        };
612        let Some(updated) = updated else {
613            let mut response = AuthResponse::json(
614                401,
615                &serde_json::json!({"code":codes::FAILED_TO_GET_SESSION,"message":"Failed to get session"}),
616            )?;
617            for cookie in delete_session_cookie_headers(&ctx.config)? {
618                response.headers.append("Set-Cookie", cookie);
619            }
620            return Ok(response);
621        };
622
623        alibi_core::session::cookie_cache::runtime::emit_issuance(ctx, user, &updated).await?;
624
625        let preference = related_cookie_name(&ctx.config, "dont_remember");
626        let dont_remember = req.session_headers().get("cookie").is_some_and(|header| {
627            cookie::Cookie::split_parse(header)
628                .flatten()
629                .find(|cookie| cookie.name() == preference)
630                .and_then(|cookie| verify_cookie_value(cookie.value(), ctx.config.current_secret()))
631                .is_some_and(|signed| !signed.is_empty())
632        });
633        with_session_cookies(
634            AuthResponse::json(
635                200,
636                &serde_json::json!({"session":ctx.session_view(&updated)}),
637            )?,
638            &ctx.config,
639            updated.token(),
640            dont_remember,
641        )
642    }
643
644    pub(crate) async fn handle_get_session(
645        &self,
646        req: &AuthRequest,
647        ctx: &AuthContext<impl alibi_core::AuthSchema>,
648    ) -> AuthResult<AuthResponse> {
649        let response = match self.get_session_response(req, ctx).await {
650            Ok(response) => AuthResponse::json(200, &response)?,
651            Err(error) => error.to_auth_response(),
652        };
653        Ok(response
654            .with_header("cache-control", "no-store")
655            .with_header("pragma", "no-cache"))
656    }
657
658    async fn get_session_response(
659        &self,
660        req: &impl SessionRequest,
661        ctx: &AuthContext<impl alibi_core::AuthSchema>,
662    ) -> AuthResult<Option<GetSessionResponse>> {
663        if req.session_method() == &HttpMethod::Post && !ctx.config.session.defer_session_refresh {
664            return Err(AuthError::Upstream {
665                status: 405,
666                code: codes::METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED,
667                message: "POST method requires deferSessionRefresh to be enabled in session config",
668            });
669        }
670        let read = alibi_core::session::cookie_cache::runtime::authenticated(ctx, req, true).await;
671        // Completed hooks observe the actual pre-expiry lookup, even when the
672        // session is retired and the public result is null. No authority is installed.
673        if let Some(call) = req.endpoint_call()
674            && let Some((user, session)) = call.session_hook_snapshot()
675        {
676            call.record_authenticated_session(user, session);
677        }
678        let Some(read) = read.map_err(|error| match error {
679            error @ (AuthError::Upstream { .. } | AuthError::Api { .. }) => error,
680            _ => AuthError::Upstream {
681                status: 500,
682                code: codes::FAILED_TO_GET_SESSION,
683                message: "Failed to get session",
684            },
685        })?
686        else {
687            return Ok(None);
688        };
689        Ok(Some(GetSessionResponse {
690            session: read.session,
691            user: match read.user {
692                alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(&user),
693                alibi_core::AuthenticatedUser::Cached(user) => *user,
694            },
695            needs_refresh: read.needs_refresh,
696        }))
697    }
698
699    async fn handle_sign_out(
700        &self,
701        req: &AuthRequest,
702        ctx: &AuthContext<impl alibi_core::AuthSchema>,
703    ) -> AuthResult<AuthResponse> {
704        let body = if req.body.is_some() {
705            match parse_body::<SignOutRequest>(req) {
706                Ok(body) => body,
707                Err(response) => return Ok(response),
708            }
709        } else {
710            SignOutRequest::default()
711        };
712        self.sign_out_response(req, &body, ctx).await
713    }
714
715    async fn sign_out_response(
716        &self,
717        req: &impl SessionRequest,
718        body: &SignOutRequest,
719        ctx: &AuthContext<impl alibi_core::AuthSchema>,
720    ) -> AuthResult<AuthResponse> {
721        let mut current_user = None;
722        if let Some(token) = ctx.session_manager().extract_session_token(req) {
723            if let Ok(Some(session)) = ctx.database.get_session(&token).await {
724                // A stored session and user own provider selection; request account IDs
725                // and cached account cookies never authorize disclosure of token hints.
726                if ctx
727                    .database
728                    .get_user_by_id(&session.user_id())
729                    .await
730                    .ok()
731                    .flatten()
732                    .is_some()
733                {
734                    current_user = Some(session.user_id().into_owned());
735                }
736                _ = sign_out_core(&session, ctx).await;
737            } else {
738                _ = ctx.database.delete_session(&token).await;
739            }
740        }
741
742        let mut response = AuthResponse::json(
743            200,
744            &SignOutResponse {
745                success: true,
746                url: None,
747                redirect: None,
748            },
749        )?;
750        for cookie in alibi_core::session::cookie_cache::runtime::session_cleanup_headers(
751            &ctx.config,
752            req.session_headers(),
753            false,
754        )? {
755            response.headers.append("Set-Cookie", cookie);
756        }
757        if let Some(user_id) = current_user
758            && let Some(url) = super::oauth::logout::provider_logout_url(&user_id, body, ctx).await
759        {
760            let redirect = body.disable_redirect != Some(true);
761            response.body = AuthResponse::json(
762                200,
763                &serde_json::json!({
764                    "success": true, "url": url, "redirect": redirect,
765                }),
766            )?
767            .body;
768            if redirect {
769                _ = response.headers.insert("Location", url);
770            }
771        }
772        Ok(response)
773    }
774
775    async fn handle_list_sessions(
776        &self,
777        req: &AuthRequest,
778        ctx: &AuthContext<impl alibi_core::AuthSchema>,
779    ) -> AuthResult<AuthResponse> {
780        let (user, session) = super::helpers::ordinary_session(req, ctx).await?;
781        if !ctx.session_manager().is_session_fresh(&session) {
782            return Err(AuthError::Upstream {
783                status: 403,
784                code: codes::SESSION_NOT_FRESH,
785                message: "Session is not fresh",
786            });
787        }
788        let Ok(sessions) = list_sessions_core(user.id(), ctx).await else {
789            tracing::error!("Session listing failed");
790            return Ok(AuthResponse::new(500).with_header("content-type", "application/json"));
791        };
792        Ok(AuthResponse::json(200, &sessions)?)
793    }
794
795    async fn handle_revoke_session(
796        &self,
797        req: &AuthRequest,
798        ctx: &AuthContext<impl alibi_core::AuthSchema>,
799    ) -> AuthResult<AuthResponse> {
800        let revoke_req: RevokeSessionRequest = match parse_body(req) {
801            Ok(v) => v,
802            Err(resp) => return Ok(resp),
803        };
804
805        let (user, _) = ctx
806            .require_authoritative_cached_session(req)
807            .await
808            .map_err(unauthorized_if_unauthenticated)?;
809        let response = revoke_session_core(&user, &revoke_req.token, ctx).await?;
810        Ok(AuthResponse::json(200, &response)?)
811    }
812
813    async fn handle_revoke_sessions(
814        &self,
815        req: &AuthRequest,
816        ctx: &AuthContext<impl alibi_core::AuthSchema>,
817    ) -> AuthResult<AuthResponse> {
818        let (user, _) = ctx
819            .require_authoritative_cached_session(req)
820            .await
821            .map_err(unauthorized_if_unauthenticated)?;
822        let response = revoke_sessions_core(user.id(), ctx).await?;
823        Ok(AuthResponse::json(200, &response)?)
824    }
825
826    async fn handle_revoke_other_sessions(
827        &self,
828        req: &AuthRequest,
829        ctx: &AuthContext<impl alibi_core::AuthSchema>,
830    ) -> AuthResult<AuthResponse> {
831        let (user, current_session) = ctx
832            .require_authoritative_cached_session(req)
833            .await
834            .map_err(unauthorized_if_unauthenticated)?;
835        let response = revoke_other_sessions_core(user.id(), &current_session, ctx).await?;
836        Ok(AuthResponse::json(200, &response)?)
837    }
838}
839
840impl std::fmt::Debug for SessionManagementPlugin {
841    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
842        f.debug_struct("SessionManagementPlugin")
843            .finish_non_exhaustive()
844    }
845}
846
847pub(crate) async fn sign_out_core(
848    session: &impl AuthSession,
849    ctx: &AuthContext<impl alibi_core::AuthSchema>,
850) -> AuthResult<SuccessResponse> {
851    ctx.database.delete_session(session.token()).await?;
852    Ok(SuccessResponse { success: true })
853}
854
855pub(crate) async fn list_sessions_core(
856    user_id: impl AsRef<str>,
857    ctx: &AuthContext<impl alibi_core::AuthSchema>,
858) -> AuthResult<Vec<SessionView>> {
859    let sessions = ctx
860        .database
861        .get_active_user_sessions_record(user_id.as_ref())
862        .await?;
863    let now = chrono::Utc::now();
864    let mut sessions: Vec<_> = sessions
865        .iter()
866        .filter(|session| session.expires_at() > now)
867        .map(|session| ctx.session_view(session))
868        .collect();
869    if admin_plugin_enabled(ctx) {
870        sessions.retain(|listed| listed.impersonated_by.is_none());
871    }
872    Ok(sessions)
873}
874
875pub(crate) async fn revoke_session_core(
876    user: &impl AuthUser,
877    token: &str,
878    ctx: &AuthContext<impl alibi_core::AuthSchema>,
879) -> AuthResult<StatusResponse> {
880    if let Some(session_to_revoke) = ctx.database.get_session(token).await?
881        && session_to_revoke.user_id() == user.id()
882    {
883        ctx.database
884            .delete_session(token)
885            .await
886            .map_err(revocation_storage_error)?;
887    }
888    Ok(StatusResponse { status: true })
889}
890
891pub(crate) async fn revoke_sessions_core(
892    user_id: impl AsRef<str>,
893    ctx: &AuthContext<impl alibi_core::AuthSchema>,
894) -> AuthResult<StatusResponse> {
895    ctx.database
896        .delete_user_sessions(user_id.as_ref())
897        .await
898        .map_err(revocation_storage_error)?;
899    Ok(StatusResponse { status: true })
900}
901
902pub(crate) async fn revoke_other_sessions_core(
903    user_id: impl AsRef<str>,
904    current_session: &impl AuthSession,
905    ctx: &AuthContext<impl alibi_core::AuthSchema>,
906) -> AuthResult<StatusResponse> {
907    let all_sessions = ctx.session_manager().list_user_sessions(user_id).await?;
908    let tokens: Vec<String> = all_sessions
909        .into_iter()
910        .filter(|session| session.token() != current_session.token())
911        .map(|session| session.token().to_owned())
912        .collect();
913    let count = tokens.len();
914    let database = ctx.database.clone();
915    let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
916    // Each launched deletion keeps its ownership after the aggregate rejects.
917    crate::helpers::spawn_in_request_context(async move {
918        _ = futures_util::future::join_all(tokens.into_iter().map(|token| {
919            let database = &database;
920            let sender = &sender;
921            async move {
922                let result = database.delete_session(&token).await;
923                _ = sender.send(result);
924            }
925        }))
926        .await;
927    });
928    for _ in 0..count {
929        receiver
930            .recv()
931            .await
932            .ok_or_else(|| AuthError::internal("Session revocation task stopped"))?
933            .map_err(crate::helpers::callback_failure)?;
934    }
935    Ok(StatusResponse { status: true })
936}
937
938#[expect(
939    clippy::needless_pass_by_value,
940    reason = "used by value as a `map_err` callback"
941)]
942fn revocation_storage_error(error: AuthError) -> AuthError {
943    tracing::error!(error = %error, "Session revocation failed");
944    AuthError::Upstream {
945        status: 500,
946        code: codes::INTERNAL_SERVER_ERROR,
947        message: "Internal Server Error",
948    }
949}
950
951/// Parse application fields after admission, shared by HTTP and logical callers.
952fn parse_session_update_fields(
953    input: &indexmap::IndexMap<String, JsValue>,
954    ctx: &AuthContext<impl alibi_core::AuthSchema>,
955) -> Result<FieldValues, FieldInputError> {
956    let fields = ctx.extensions.get::<SessionFields>().map_or_else(
957        || SessionFields(ctx.config.session.additional_fields.clone()),
958        |fields| (*fields).clone(),
959    );
960    fields.parse_update(input)
961}
962
963// LCOV_EXCL_START
964#[cfg(test)]
965mod tests {
966    use super::*;
967    use crate::test_helpers;
968    use alibi_core::config::AccountConfig;
969    use alibi_core::utils::cookie_utils::related_cookie_name;
970    use alibi_core::wire::SessionView;
971    use alibi_core::{CreateSession, CreateUser};
972    use chrono::{Duration, Utc};
973
974    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
975    #[tokio::test]
976    async fn test_get_session_success() {
977        let plugin = SessionManagementPlugin::new();
978        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
979            CreateUser::new()
980                .with_email("test@example.com")
981                .with_name("Test User"),
982            Duration::hours(24),
983        )
984        .await;
985
986        let req = test_helpers::create_auth_request_no_query(
987            HttpMethod::Get,
988            "/get-session",
989            Some(&session.token),
990            None,
991        );
992        let response = plugin.handle_get_session(&req, &ctx).await.unwrap();
993
994        assert_eq!(response.status, 200);
995
996        let body_str = String::from_utf8(response.body).unwrap();
997        let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
998        assert_eq!(
999            (*(*(response_data)
1000                .get("session")
1001                .unwrap_or(&serde_json::Value::Null))
1002            .get("token")
1003            .unwrap_or(&serde_json::Value::Null))
1004            .as_str()
1005            .unwrap(),
1006            session.token
1007        );
1008        assert_eq!(
1009            (*(*(response_data)
1010                .get("user")
1011                .unwrap_or(&serde_json::Value::Null))
1012            .get("email")
1013            .unwrap_or(&serde_json::Value::Null))
1014            .as_str()
1015            .map(ToOwned::to_owned),
1016            Some("test@example.com".to_owned())
1017        );
1018    }
1019
1020    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
1021    #[tokio::test]
1022    async fn test_get_session_unauthorized() {
1023        // /get-session returns 200 with null body when unauthenticated.
1024        let plugin = SessionManagementPlugin::new();
1025        let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
1026            CreateUser::new()
1027                .with_email("test@example.com")
1028                .with_name("Test User"),
1029            Duration::hours(24),
1030        )
1031        .await;
1032
1033        let req =
1034            test_helpers::create_auth_request_no_query(HttpMethod::Get, "/get-session", None, None);
1035        let response = plugin.handle_get_session(&req, &ctx).await.unwrap();
1036        assert_eq!(response.status, 200);
1037        let body: serde_json::Value = serde_json::from_slice(&response.body).expect("valid JSON");
1038        assert!(body.is_null());
1039    }
1040
1041    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
1042    #[tokio::test]
1043    async fn test_sign_out_success() {
1044        let plugin = SessionManagementPlugin::new();
1045        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
1046            CreateUser::new()
1047                .with_email("test@example.com")
1048                .with_name("Test User"),
1049            Duration::hours(24),
1050        )
1051        .await;
1052
1053        let req = test_helpers::create_auth_request_no_query(
1054            HttpMethod::Post,
1055            "/sign-out",
1056            Some(&session.token),
1057            Some(b"{}".to_vec()),
1058        );
1059        let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
1060
1061        assert_eq!(response.status, 200);
1062
1063        let body_str = String::from_utf8(response.body).unwrap();
1064        let response_data: SuccessResponse = serde_json::from_str(&body_str).unwrap();
1065        assert!(response_data.success);
1066
1067        let session_check = ctx.database.get_session(&session.token).await.unwrap();
1068        assert!(session_check.is_none());
1069    }
1070
1071    #[tokio::test]
1072    async fn test_sign_out_clears_account_cookie_when_enabled() {
1073        let plugin = SessionManagementPlugin::new();
1074        let config = test_helpers::create_test_config().account(AccountConfig {
1075            store_account_cookie: true,
1076            ..Default::default()
1077        });
1078        let ctx = test_helpers::create_test_context_with_config(config).await;
1079        let (_user, session) = test_helpers::create_user_and_session(
1080            &ctx,
1081            CreateUser::new()
1082                .with_email("test@example.com")
1083                .with_name("Test User"),
1084            Duration::hours(24),
1085        )
1086        .await;
1087
1088        let req = test_helpers::create_auth_request_no_query(
1089            HttpMethod::Post,
1090            "/sign-out",
1091            Some(&session.token),
1092            Some(b"{}".to_vec()),
1093        );
1094        let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
1095
1096        let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
1097        assert!(
1098            response
1099                .headers
1100                .get_all("Set-Cookie")
1101                .any(|cookie| cookie.starts_with(&account_cookie_name)),
1102            "sign-out should clear the account_data cookie when store_account_cookie is enabled"
1103        );
1104    }
1105
1106    #[tokio::test]
1107    async fn test_sign_out_does_not_emit_account_cookie_when_disabled() {
1108        let plugin = SessionManagementPlugin::new();
1109        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
1110            CreateUser::new()
1111                .with_email("test@example.com")
1112                .with_name("Test User"),
1113            Duration::hours(24),
1114        )
1115        .await;
1116
1117        let req = test_helpers::create_auth_request_no_query(
1118            HttpMethod::Post,
1119            "/sign-out",
1120            Some(&session.token),
1121            Some(b"{}".to_vec()),
1122        );
1123        let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
1124
1125        let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
1126        assert!(
1127            !response
1128                .headers
1129                .get_all("Set-Cookie")
1130                .any(|cookie| cookie.starts_with(&account_cookie_name)),
1131            "sign-out should not emit account_data clearing cookies when store_account_cookie is disabled"
1132        );
1133    }
1134
1135    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
1136    #[tokio::test]
1137    async fn test_list_sessions_success() {
1138        let plugin = SessionManagementPlugin::new();
1139        let (ctx, user, session) = test_helpers::create_test_context_with_user(
1140            CreateUser::new()
1141                .with_email("test@example.com")
1142                .with_name("Test User"),
1143            Duration::hours(24),
1144        )
1145        .await;
1146
1147        let create_session2 = CreateSession {
1148            additional_fields: alibi_core::field_policy::FieldValues::default(),
1149            token: None,
1150            user_id: user.id.clone(),
1151            expires_at: Utc::now() + Duration::hours(24),
1152            ip_address: Some("192.168.1.1".to_owned()),
1153            user_agent: Some("another-agent".to_owned()),
1154            impersonated_by: None,
1155            active_organization_id: None,
1156            active_team_id: None,
1157        };
1158        ctx.database.create_session(create_session2).await.unwrap();
1159
1160        let req = test_helpers::create_auth_request_no_query(
1161            HttpMethod::Get,
1162            "/list-sessions",
1163            Some(&session.token),
1164            None,
1165        );
1166        let response = plugin.handle_list_sessions(&req, &ctx).await.unwrap();
1167
1168        assert_eq!(response.status, 200);
1169
1170        let body_str = String::from_utf8(response.body).unwrap();
1171        let sessions: Vec<SessionView> = serde_json::from_str(&body_str).unwrap();
1172        assert_eq!(sessions.len(), 2);
1173    }
1174
1175    #[tokio::test]
1176    async fn test_list_sessions_filters_impersonated_sessions_when_admin_plugin_is_enabled() {
1177        let plugin = SessionManagementPlugin::new();
1178        let (mut ctx, user, session) = test_helpers::create_test_context_with_user(
1179            CreateUser::new()
1180                .with_email("test@example.com")
1181                .with_name("Test User"),
1182            Duration::hours(24),
1183        )
1184        .await;
1185        ctx.set_metadata("admin.enabled", serde_json::Value::Bool(true));
1186
1187        let direct_session = CreateSession {
1188            additional_fields: alibi_core::field_policy::FieldValues::default(),
1189            token: None,
1190            user_id: user.id.clone(),
1191            expires_at: Utc::now() + Duration::hours(24),
1192            ip_address: Some("192.168.1.1".to_owned()),
1193            user_agent: Some("another-agent".to_owned()),
1194            impersonated_by: None,
1195            active_organization_id: None,
1196            active_team_id: None,
1197        };
1198        ctx.database.create_session(direct_session).await.unwrap();
1199
1200        let impersonated_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("10.0.0.5".to_owned()),
1206            user_agent: Some("impersonated-agent".to_owned()),
1207            impersonated_by: Some("admin-user".to_owned()),
1208            active_organization_id: None,
1209            active_team_id: None,
1210        };
1211        let impersonated = ctx
1212            .database
1213            .create_session(impersonated_session)
1214            .await
1215            .unwrap();
1216
1217        let req = test_helpers::create_auth_request_no_query(
1218            HttpMethod::Get,
1219            "/list-sessions",
1220            Some(&session.token),
1221            None,
1222        );
1223        let response = plugin.handle_list_sessions(&req, &ctx).await.unwrap();
1224
1225        assert_eq!(response.status, 200);
1226
1227        let body_str = String::from_utf8(response.body).unwrap();
1228        let sessions: Vec<SessionView> = serde_json::from_str(&body_str).unwrap();
1229        assert_eq!(sessions.len(), 2);
1230        assert!(
1231            sessions
1232                .iter()
1233                .all(|candidate| candidate.token != impersonated.token),
1234            "impersonated sessions should not be returned from /list-sessions"
1235        );
1236    }
1237
1238    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
1239    #[tokio::test]
1240    async fn test_revoke_session_success() {
1241        let plugin = SessionManagementPlugin::new();
1242        let (ctx, user, session) = test_helpers::create_test_context_with_user(
1243            CreateUser::new()
1244                .with_email("test@example.com")
1245                .with_name("Test User"),
1246            Duration::hours(24),
1247        )
1248        .await;
1249
1250        let create_session2 = CreateSession {
1251            additional_fields: alibi_core::field_policy::FieldValues::default(),
1252            token: None,
1253            user_id: user.id.clone(),
1254            expires_at: Utc::now() + Duration::hours(24),
1255            ip_address: Some("192.168.1.1".to_owned()),
1256            user_agent: Some("another-agent".to_owned()),
1257            impersonated_by: None,
1258            active_organization_id: None,
1259            active_team_id: None,
1260        };
1261        let session2 = ctx.database.create_session(create_session2).await.unwrap();
1262
1263        let body = serde_json::json!({ "token": session2.token });
1264        let req = test_helpers::create_auth_request_no_query(
1265            HttpMethod::Post,
1266            "/revoke-session",
1267            Some(&session.token),
1268            Some(body.to_string().into_bytes()),
1269        );
1270
1271        let response = plugin.handle_revoke_session(&req, &ctx).await.unwrap();
1272        assert_eq!(response.status, 200);
1273
1274        let session2_check = ctx.database.get_session(&session2.token).await.unwrap();
1275        assert!(session2_check.is_none());
1276
1277        let session1_check = ctx.database.get_session(&session.token).await.unwrap();
1278        assert!(session1_check.is_some());
1279    }
1280
1281    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
1282    #[tokio::test]
1283    async fn test_revoke_session_forbidden_different_user() {
1284        let plugin = SessionManagementPlugin::new();
1285        let (ctx, _user1, session1) = test_helpers::create_test_context_with_user(
1286            CreateUser::new()
1287                .with_email("test@example.com")
1288                .with_name("Test User"),
1289            Duration::hours(24),
1290        )
1291        .await;
1292
1293        let create_user2 = CreateUser::new()
1294            .with_email("user2@example.com")
1295            .with_name("User Two");
1296        let user2 = ctx.database.create_user(create_user2).await.unwrap();
1297
1298        let create_session2 = CreateSession {
1299            additional_fields: alibi_core::field_policy::FieldValues::default(),
1300            token: None,
1301            user_id: user2.id,
1302            expires_at: Utc::now() + Duration::hours(24),
1303            ip_address: Some("192.168.1.1".to_owned()),
1304            user_agent: Some("another-agent".to_owned()),
1305            impersonated_by: None,
1306            active_organization_id: None,
1307            active_team_id: None,
1308        };
1309        let session2 = ctx.database.create_session(create_session2).await.unwrap();
1310
1311        let body = serde_json::json!({ "token": session2.token });
1312        let req = test_helpers::create_auth_request_no_query(
1313            HttpMethod::Post,
1314            "/revoke-session",
1315            Some(&session1.token),
1316            Some(body.to_string().into_bytes()),
1317        );
1318
1319        let response = plugin.handle_revoke_session(&req, &ctx).await.unwrap();
1320        assert_eq!(response.status, 200);
1321
1322        let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1323        assert_eq!(
1324            (*(body_2).get("status").unwrap_or(&serde_json::Value::Null)),
1325            true
1326        );
1327
1328        let still_exists = ctx.database.get_session(&session2.token).await.unwrap();
1329        assert!(
1330            still_exists.is_some(),
1331            "other user's session must not be revoked"
1332        );
1333    }
1334
1335    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
1336    #[tokio::test]
1337    async fn test_revoke_sessions_success() {
1338        let plugin = SessionManagementPlugin::new();
1339        let (ctx, user, session1) = test_helpers::create_test_context_with_user(
1340            CreateUser::new()
1341                .with_email("test@example.com")
1342                .with_name("Test User"),
1343            Duration::hours(24),
1344        )
1345        .await;
1346
1347        let create_session2 = CreateSession {
1348            additional_fields: alibi_core::field_policy::FieldValues::default(),
1349            token: None,
1350            user_id: user.id.clone(),
1351            expires_at: Utc::now() + Duration::hours(24),
1352            ip_address: Some("192.168.1.1".to_owned()),
1353            user_agent: Some("another-agent".to_owned()),
1354            impersonated_by: None,
1355            active_organization_id: None,
1356            active_team_id: None,
1357        };
1358        ctx.database.create_session(create_session2).await.unwrap();
1359
1360        let req = test_helpers::create_auth_request_no_query(
1361            HttpMethod::Post,
1362            "/revoke-sessions",
1363            Some(&session1.token),
1364            Some(b"{}".to_vec()),
1365        );
1366        let response = plugin.handle_revoke_sessions(&req, &ctx).await.unwrap();
1367
1368        assert_eq!(response.status, 200);
1369
1370        let user_sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
1371        assert_eq!(user_sessions.len(), 0);
1372    }
1373
1374    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
1375    #[tokio::test]
1376    async fn test_plugin_routes() {
1377        let plugin = SessionManagementPlugin::new();
1378        let routes = AuthPlugin::<
1379            alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
1380        >::routes(&plugin);
1381
1382        assert_eq!(routes.len(), 8);
1383        assert!(
1384            routes
1385                .iter()
1386                .any(|r| r.path == "/get-session" && r.method == HttpMethod::Get)
1387        );
1388        // Upstream serves `/get-session` on both methods.
1389        assert!(
1390            routes
1391                .iter()
1392                .any(|r| r.path == "/get-session" && r.method == HttpMethod::Post)
1393        );
1394        assert!(
1395            routes
1396                .iter()
1397                .any(|r| r.path == "/sign-out" && r.method == HttpMethod::Post)
1398        );
1399        assert!(
1400            routes
1401                .iter()
1402                .any(|r| r.path == "/list-sessions" && r.method == HttpMethod::Get)
1403        );
1404        assert!(
1405            routes
1406                .iter()
1407                .any(|r| r.path == "/revoke-session" && r.method == HttpMethod::Post)
1408        );
1409        assert!(
1410            routes
1411                .iter()
1412                .any(|r| r.path == "/revoke-sessions" && r.method == HttpMethod::Post)
1413        );
1414    }
1415
1416    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
1417    #[tokio::test]
1418    async fn test_plugin_on_request_routing() {
1419        let plugin = SessionManagementPlugin::new();
1420        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
1421            CreateUser::new()
1422                .with_email("test@example.com")
1423                .with_name("Test User"),
1424            Duration::hours(24),
1425        )
1426        .await;
1427
1428        // Test valid route
1429        let req = test_helpers::create_auth_request_no_query(
1430            HttpMethod::Get,
1431            "/get-session",
1432            Some(&session.token),
1433            None,
1434        );
1435        let response = plugin.on_request(&req, &ctx).await.unwrap();
1436        assert!(response.is_some());
1437        assert_eq!(response.unwrap().status, 200);
1438
1439        // POST /get-session is served, but rejected with 405 until
1440        // `session.defer_session_refresh` is enabled.
1441        let req_2 = test_helpers::create_auth_request_no_query(
1442            HttpMethod::Post,
1443            "/get-session",
1444            Some(&session.token),
1445            Some(b"{}".to_vec()),
1446        );
1447        let response_2 = plugin.on_request(&req_2, &ctx).await.unwrap().unwrap();
1448        assert_eq!(response_2.status, 405);
1449        assert_eq!(
1450            serde_json::from_slice::<serde_json::Value>(&response_2.body).unwrap(),
1451            serde_json::json!({
1452                "code": "METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED",
1453                "message": "POST method requires deferSessionRefresh to be enabled in session config",
1454            }),
1455        );
1456        assert_eq!(
1457            response_2.headers.get("cache-control"),
1458            Some(&"no-store".to_owned())
1459        );
1460        assert_eq!(
1461            response_2.headers.get("pragma"),
1462            Some(&"no-cache".to_owned())
1463        );
1464
1465        // Test invalid route
1466        let req_3 = test_helpers::create_auth_request_no_query(
1467            HttpMethod::Get,
1468            "/invalid-route",
1469            Some(&session.token),
1470            None,
1471        );
1472        let response_3 = plugin.on_request(&req_3, &ctx).await.unwrap();
1473        assert!(response_3.is_none());
1474    }
1475
1476    // Upstream reference: packages/better-auth/src/api/routes/session-api.test.ts :: describe("session") and packages/better-auth/src/api/routes/sign-out.test.ts :: describe("sign-out"); adapted to the Rust session-management plugin.
1477    #[tokio::test]
1478    async fn test_configuration() {
1479        let plugin = SessionManagementPlugin::new()
1480            .enable_session_listing(false)
1481            .enable_session_revocation(false)
1482            .require_authentication(false);
1483
1484        assert!(!plugin.config.enable_session_listing);
1485        assert!(!plugin.config.enable_session_revocation);
1486        assert!(!plugin.config.require_authentication);
1487
1488        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
1489            CreateUser::new()
1490                .with_email("test@example.com")
1491                .with_name("Test User"),
1492            Duration::hours(24),
1493        )
1494        .await;
1495
1496        let req = test_helpers::create_auth_request_no_query(
1497            HttpMethod::Get,
1498            "/list-sessions",
1499            Some(&session.token),
1500            None,
1501        );
1502        let response = plugin.on_request(&req, &ctx).await.unwrap();
1503        assert!(response.is_none());
1504
1505        let req_2 = test_helpers::create_auth_request_no_query(
1506            HttpMethod::Post,
1507            "/revoke-session",
1508            Some(&session.token),
1509            Some(b"{}".to_vec()),
1510        );
1511        let response_2 = plugin.on_request(&req_2, &ctx).await.unwrap();
1512        assert!(response_2.is_none());
1513    }
1514}
1515// LCOV_EXCL_STOP