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