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        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            // Upstream declares `/get-session` as `method: ["GET", "POST"]`;
350            // the POST form requires `session.defer_session_refresh`.
351            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    /// Read the current session through installed endpoint hooks and return its headers.
394    ///
395    /// Pass incoming logical headers in `EndpointOptions::headers`. The output decodes
396    /// to `Option<GetSessionResponse>` and retains all cookie updates in `headers()`.
397    /// Use `with_query(&GetSessionQuery { .. })` for cache/refresh options, and
398    /// `EndpointOptions::method` for POST when deferred session refresh is enabled.
399    #[must_use]
400    pub const fn get_session_endpoint() -> ServerEndpoint<Option<GetSessionResponse>> {
401        ServerEndpoint::new("session-management", "getSession")
402    }
403
404    /// Sign out using the incoming signed session token and return cookie updates.
405    /// Guests are accepted. Supply logical headers in `EndpointOptions::headers`;
406    /// optionally pass [`SignOutRequest`] with `with_body` for provider logout.
407    #[must_use]
408    pub const fn sign_out_endpoint() -> ServerEndpoint<SignOutResponse> {
409        ServerEndpoint::new("session-management", "signOut")
410    }
411
412    /// List the fresh authenticated caller's active sessions through logical hooks.
413    /// Supply incoming logical headers in `EndpointOptions::headers`.
414    #[must_use]
415    pub const fn list_sessions_endpoint() -> ServerEndpoint<Vec<SessionView>> {
416        ServerEndpoint::new("session-management", "listSessions")
417    }
418
419    /// Revoke a session belonging to the authoritative current user.
420    ///
421    /// Supply genuine incoming headers through `EndpointOptions::headers`. The
422    /// operation validates input before session admission and preserves logical hooks.
423    ///
424    /// # Errors
425    /// Returns an error if the logical input cannot be serialized.
426    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    /// Revoke the current user's other active sessions, retaining the current session.
433    /// Supply incoming logical headers in `EndpointOptions::headers`.
434    #[must_use]
435    pub const fn revoke_other_sessions_endpoint() -> ServerEndpoint<alibi_core::StatusResponse> {
436        ServerEndpoint::new("session-management", "revokeOtherSessions")
437    }
438
439    /// Revoke all sessions belonging to the authoritative current user.
440    /// Supply incoming logical headers in `EndpointOptions::headers`.
441    #[must_use]
442    pub const fn revoke_sessions_endpoint() -> ServerEndpoint<alibi_core::StatusResponse> {
443        ServerEndpoint::new("session-management", "revokeSessions")
444    }
445
446    /// Update configured fields for the authenticated current session.
447    /// Supply genuine logical session headers in `EndpointOptions::headers`.
448    ///
449    /// # Errors
450    /// Returns an error if the logical input cannot be serialized.
451    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        // Completed hooks observe the actual pre-expiry lookup, even when the
685        // session is retired and the public result is null. No authority is installed.
686        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                // A stored session and user own provider selection; request account IDs
738                // and cached account cookies never authorize disclosure of token hints.
739                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(), &current_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    // Each launched deletion keeps its ownership after the aggregate rejects.
930    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
964/// Parse application fields after admission, shared by HTTP and logical callers.
965fn 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// LCOV_EXCL_START
977#[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    // 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.
988    #[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    // 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.
1034    #[tokio::test]
1035    async fn test_get_session_unauthorized() {
1036        // /get-session returns 200 with null body when unauthenticated.
1037        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    // 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.
1055    #[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    // 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.
1149    #[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    // 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.
1252    #[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    // 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.
1295    #[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    // 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.
1349    #[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    // 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.
1388    #[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        // Upstream serves `/get-session` on both methods.
1402        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    // 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.
1430    #[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        // Test valid route
1442        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        // POST /get-session is served, but rejected with 405 until
1453        // `session.defer_session_refresh` is enabled.
1454        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        // Test invalid route
1479        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    // 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.
1490    #[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// LCOV_EXCL_STOP