Skip to main content

alibi_api/plugins/session_management/
mod.rs

1use super::StatusResponse;
2use super::authentication_helpers::{JsonField, RequestBody, parse_body};
3use super::helpers::{admin_plugin_enabled, delete_session_cookie_headers};
4use alibi_core::SuccessResponse;
5use alibi_core::entity::{AuthSession, AuthUser};
6use alibi_core::wire::SessionView;
7use alibi_core::{AuthContext, AuthPlugin, AuthRoute};
8use alibi_core::{AuthError, AuthResult};
9use alibi_core::{AuthRequest, AuthResponse, HttpMethod};
10use async_trait::async_trait;
11use serde::{Deserialize, Serialize};
12
13/// Session management plugin for handling session operations
14pub struct SessionManagementPlugin {
15    config: SessionManagementConfig,
16}
17
18#[derive(Debug, Clone, alibi_core::PluginConfig)]
19#[plugin(name = "SessionManagementPlugin")]
20pub struct SessionManagementConfig {
21    #[config(default = true)]
22    pub enable_session_listing: bool,
23    #[config(default = true)]
24    pub enable_session_revocation: bool,
25    #[config(default = true)]
26    pub require_authentication: bool,
27}
28
29// Request structures for session endpoints
30#[derive(Debug, Deserialize)]
31struct RevokeSessionRequest {
32    token: String,
33}
34
35impl RequestBody for RevokeSessionRequest {
36    const FIELDS: &'static [JsonField] = &[JsonField::string("token", true)];
37}
38
39#[derive(Debug, Serialize)]
40struct GetSessionResponse<S, U> {
41    session: S,
42    user: U,
43    #[serde(rename = "needsRefresh", skip_serializing_if = "Option::is_none")]
44    needs_refresh: Option<bool>,
45}
46
47#[async_trait]
48impl<S: alibi_core::AuthSchema> AuthPlugin<S> for SessionManagementPlugin {
49    fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
50        crate::metadata::plugin_metadata(
51            <Self as alibi_core::AuthPlugin<S>>::name(self),
52            &<Self as alibi_core::AuthPlugin<S>>::routes(self),
53        )
54    }
55
56    fn openapi_metadata(
57        &self,
58        ctx: &alibi_core::AuthInitContext<S>,
59    ) -> alibi_core::PluginOpenApiMetadata {
60        crate::metadata::instance_plugin_metadata(
61            <Self as alibi_core::AuthPlugin<S>>::name(self),
62            &<Self as alibi_core::AuthPlugin<S>>::routes(self),
63            ctx,
64        )
65    }
66
67    fn name(&self) -> &'static str {
68        "session-management"
69    }
70
71    fn routes(&self) -> Vec<AuthRoute> {
72        vec![
73            AuthRoute::get("/get-session", "get_session"),
74            // Upstream declares `/get-session` as `method: ["GET", "POST"]`;
75            // the POST form requires `session.defer_session_refresh`.
76            AuthRoute::post("/get-session", "get_session"),
77            AuthRoute::post("/sign-out", "sign_out"),
78            AuthRoute::post("/update-session", "updateSession"),
79            AuthRoute::get("/list-sessions", "list_sessions"),
80            AuthRoute::post("/revoke-session", "revoke_session"),
81            AuthRoute::post("/revoke-sessions", "revoke_sessions"),
82            AuthRoute::post("/revoke-other-sessions", "revoke_other_sessions"),
83        ]
84    }
85
86    async fn on_request(
87        &self,
88        req: &AuthRequest,
89        ctx: &AuthContext<S>,
90    ) -> AuthResult<Option<AuthResponse>> {
91        match (req.method(), req.path()) {
92            (HttpMethod::Get | HttpMethod::Post, "/get-session") => {
93                Ok(Some(self.handle_get_session(req, ctx).await?))
94            }
95            (HttpMethod::Post, "/sign-out") => Ok(Some(self.handle_sign_out(req, ctx).await?)),
96            (HttpMethod::Post, "/update-session") => {
97                Ok(Some(self.handle_update_session(req, ctx).await?))
98            }
99            (HttpMethod::Get, "/list-sessions") if self.config.enable_session_listing => {
100                Ok(Some(self.handle_list_sessions(req, ctx).await?))
101            }
102            (HttpMethod::Post, "/revoke-session") if self.config.enable_session_revocation => {
103                Ok(Some(self.handle_revoke_session(req, ctx).await?))
104            }
105            (HttpMethod::Post, "/revoke-sessions") if self.config.enable_session_revocation => {
106                Ok(Some(self.handle_revoke_sessions(req, ctx).await?))
107            }
108            (HttpMethod::Post, "/revoke-other-sessions")
109                if self.config.enable_session_revocation =>
110            {
111                Ok(Some(self.handle_revoke_other_sessions(req, ctx).await?))
112            }
113            _ => Ok(None),
114        }
115    }
116}
117
118// ---------------------------------------------------------------------------
119// Old handler methods — delegate to core functions
120// ---------------------------------------------------------------------------
121
122impl SessionManagementPlugin {
123    #[expect(
124        clippy::too_many_lines,
125        reason = "Keep validated session field updates and persistence callbacks in request order"
126    )]
127    async fn handle_update_session(
128        &self,
129        req: &AuthRequest,
130        ctx: &AuthContext<impl alibi_core::AuthSchema>,
131    ) -> AuthResult<AuthResponse> {
132        use super::authentication_helpers::{json_type, validation_response};
133        use alibi_core::field_policy::{FieldInputError, SessionFields};
134        use alibi_core::utils::cookie_utils::{
135            create_session_cookie_with_max_age, create_session_like_cookie, related_cookie_name,
136            sign_cookie_value, verify_cookie_value,
137        };
138        use alibi_core::utils::json::JsValue;
139
140        if req.body.is_some() {
141            let content_type = req
142                .header("content-type")
143                .map(String::as_str)
144                .unwrap_or_default();
145            if !content_type
146                .split(';')
147                .next()
148                .unwrap_or_default()
149                .trim()
150                .to_lowercase()
151                .contains("application/json")
152            {
153                let message = if content_type.is_empty() {
154                    "Content-Type is required. Allowed types: application/json".to_owned()
155                } else {
156                    format!(
157                        "Content-Type \"{content_type}\" is not allowed. Allowed types: application/json"
158                    )
159                };
160                return Ok(AuthResponse::json(
161                    415,
162                    &serde_json::json!({"code":"UNSUPPORTED_MEDIA_TYPE","message":message}),
163                )?);
164            }
165        }
166        let value: Option<JsValue> = match req.body.as_ref() {
167            Some(body) => match alibi_core::utils::json::from_slice(body) {
168                Ok(value) => Some(value),
169                Err(_) => {
170                    return Ok(AuthResponse::json(
171                        400,
172                        &serde_json::json!({"code":"BAD_REQUEST","message":"Invalid JSON in request body"}),
173                    )?);
174                }
175            },
176            None => None,
177        };
178        let Some(input) = value.as_ref().and_then(JsValue::as_object) else {
179            return Ok(validation_response(&format!(
180                "[body] Invalid input: expected record, received {}",
181                json_type(value.as_ref())
182            )));
183        };
184        let (user, session) = match super::helpers::ordinary_session(req, ctx).await {
185            Ok(session) => session,
186            Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => {
187                return Ok(AuthResponse::json(
188                    401,
189                    &serde_json::json!({"code":"UNAUTHORIZED","message":"Unauthorized"}),
190                )?);
191            }
192            Err(error) => return Err(error),
193        };
194        let fields = ctx.extensions.get::<SessionFields>().map_or_else(
195            || SessionFields(ctx.config.session.additional_fields.clone()),
196            |fields| (*fields).clone(),
197        );
198        let allowed = match fields.parse_update(input) {
199            Ok(allowed) => allowed,
200            Err(FieldInputError::Validation { code, message }) => {
201                return Ok(AuthResponse::json(
202                    400,
203                    &serde_json::json!({"code":code,"message":message}),
204                )?);
205            }
206            Err(FieldInputError::Transform(AuthError::Internal(_))) => {
207                return Ok(AuthResponse::new(500));
208            }
209            Err(FieldInputError::Transform(error)) => return Err(error),
210        };
211        if !allowed.has_input_fields() {
212            return Ok(AuthResponse::json(
213                400,
214                &serde_json::json!({"message":"No fields to update"}),
215            )?);
216        }
217        let updated = match ctx
218            .database
219            .update_session_fields_record(session.token(), allowed)
220            .await
221        {
222            Ok(updated) => updated,
223            Err(error @ AuthError::Internal(_)) => {
224                return Err(AuthError::CallbackFailure(Box::new(error)));
225            }
226            Err(error) => return Err(error),
227        };
228        let Some(updated) = updated else {
229            let mut response = AuthResponse::json(
230                401,
231                &serde_json::json!({"code":"FAILED_TO_GET_SESSION","message":"Failed to get session"}),
232            )?;
233            for cookie in delete_session_cookie_headers(&ctx.config)? {
234                response.headers.append("Set-Cookie", cookie);
235            }
236            return Ok(response);
237        };
238
239        alibi_core::session::cookie_cache::runtime::emit_issuance(ctx, &user, &updated).await?;
240
241        let preference = related_cookie_name(&ctx.config, "dont_remember");
242        let dont_remember = req.header("cookie").is_some_and(|header| {
243            cookie::Cookie::split_parse(header)
244                .flatten()
245                .find(|cookie| cookie.name() == preference)
246                .and_then(|cookie| verify_cookie_value(cookie.value(), ctx.config.current_secret()))
247                .is_some_and(|value_2| !value_2.is_empty())
248        });
249        let mut response = AuthResponse::json(
250            200,
251            &serde_json::json!({"session":ctx.session_view(&updated)}),
252        )?
253        .with_appended_header(
254            "Set-Cookie",
255            create_session_cookie_with_max_age(
256                Some(updated.token()),
257                if dont_remember {
258                    None
259                } else {
260                    Some(ctx.config.session.expires_in.num_seconds())
261                },
262                &ctx.config,
263            )?,
264        );
265        if dont_remember {
266            response = response.with_appended_header(
267                "Set-Cookie",
268                create_session_like_cookie(
269                    &preference,
270                    &sign_cookie_value("true", ctx.config.current_secret()),
271                    None,
272                    &ctx.config,
273                )?,
274            );
275        }
276        Ok(response)
277    }
278
279    pub(in crate::plugins) async fn handle_get_session(
280        &self,
281        req: &AuthRequest,
282        ctx: &AuthContext<impl alibi_core::AuthSchema>,
283    ) -> AuthResult<AuthResponse> {
284        let response =
285            if req.method() == &HttpMethod::Post && !ctx.config.session.defer_session_refresh {
286                AuthError::Upstream {
287                status: 405,
288                code: "METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED",
289                message: "POST method requires deferSessionRefresh to be enabled in session config",
290            }.to_auth_response()
291            } else {
292                match self.get_session_response(req, ctx).await {
293                    Ok(response) => response,
294                    Err(error @ (AuthError::Upstream { .. } | AuthError::Api { .. })) => {
295                        error.to_auth_response()
296                    }
297                    Err(_) => AuthError::Upstream {
298                        status: 500,
299                        code: "FAILED_TO_GET_SESSION",
300                        message: "Failed to get session",
301                    }
302                    .to_auth_response(),
303                }
304            };
305        Ok(response
306            .with_header("cache-control", "no-store")
307            .with_header("pragma", "no-cache"))
308    }
309
310    async fn get_session_response(
311        &self,
312        req: &AuthRequest,
313        ctx: &AuthContext<impl alibi_core::AuthSchema>,
314    ) -> AuthResult<AuthResponse> {
315        let Some(read) =
316            alibi_core::session::cookie_cache::runtime::authenticated(ctx, req, true).await?
317        else {
318            return Ok(AuthResponse::json(200, &serde_json::Value::Null)?);
319        };
320        Ok(AuthResponse::json(
321            200,
322            &GetSessionResponse {
323                session: read.session,
324                user: match read.user {
325                    alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(&user),
326                    alibi_core::AuthenticatedUser::Cached(user) => *user,
327                },
328                needs_refresh: read.needs_refresh,
329            },
330        )?)
331    }
332
333    async fn handle_sign_out(
334        &self,
335        req: &AuthRequest,
336        ctx: &AuthContext<impl alibi_core::AuthSchema>,
337    ) -> AuthResult<AuthResponse> {
338        let body = if req.body.is_some() {
339            match parse_body::<super::oauth::logout::SignOutRequest>(req) {
340                Ok(body) => body,
341                Err(response) => return Ok(response),
342            }
343        } else {
344            super::oauth::logout::SignOutRequest::default()
345        };
346        let mut current_user = None;
347        if let Some(token) = ctx.session_manager().extract_session_token(req) {
348            if let Ok(Some(session)) = ctx.database.get_session(&token).await {
349                // A stored session and user own provider selection; request account IDs
350                // and cached account cookies never authorize disclosure of token hints.
351                if ctx
352                    .database
353                    .get_user_by_id(&session.user_id())
354                    .await
355                    .ok()
356                    .flatten()
357                    .is_some()
358                {
359                    current_user = Some(session.user_id().into_owned());
360                }
361                drop(sign_out_core(&session, ctx).await);
362            } else {
363                drop(ctx.database.delete_session(&token).await);
364            }
365        }
366
367        let mut response = AuthResponse::json(200, &SuccessResponse { success: true })?;
368        for cookie in delete_session_cookie_headers(&ctx.config)? {
369            response.headers.append("Set-Cookie", cookie);
370        }
371        for (logical, account) in [("session_data", false), ("account_data", true)] {
372            if account && !ctx.config.account.store_account_cookie {
373                continue;
374            }
375            let base = alibi_core::utils::cookie_utils::related_cookie_name(&ctx.config, logical);
376            for header in alibi_core::session::cookie_cache::runtime::chunked_cookie_headers(
377                &base,
378                "",
379                Some(0.0),
380                &ctx.config,
381                &req.headers,
382                account,
383            )? {
384                if !header.starts_with(&format!("{base}=")) {
385                    response.headers.append("Set-Cookie", header);
386                }
387            }
388        }
389        if let Some(user_id) = current_user
390            && let Some(url) = super::oauth::logout::provider_logout_url(&user_id, &body, ctx).await
391        {
392            let redirect = body.disable_redirect != Some(true);
393            response.body = AuthResponse::json(
394                200,
395                &serde_json::json!({
396                    "success": true, "url": url, "redirect": redirect,
397                }),
398            )?
399            .body;
400            if redirect {
401                drop(response.headers.insert("Location", url));
402            }
403        }
404        Ok(response)
405    }
406
407    async fn handle_list_sessions(
408        &self,
409        req: &AuthRequest,
410        ctx: &AuthContext<impl alibi_core::AuthSchema>,
411    ) -> AuthResult<AuthResponse> {
412        let (user, session) = super::helpers::ordinary_session(req, ctx).await?;
413        if !ctx.session_manager().is_session_fresh(&session) {
414            return Err(AuthError::Upstream {
415                status: 403,
416                code: "SESSION_NOT_FRESH",
417                message: "Session is not fresh",
418            });
419        }
420        let mut sessions = match list_sessions_core(user.id(), ctx).await {
421            Ok(sessions) => sessions,
422            Err(_) => {
423                tracing::error!("Session listing failed");
424                return Ok(AuthResponse::new(500).with_header("content-type", "application/json"));
425            }
426        };
427        if admin_plugin_enabled(ctx) {
428            sessions.retain(|session_2| session_2.impersonated_by.is_none());
429        }
430        Ok(AuthResponse::json(200, &sessions)?)
431    }
432
433    async fn handle_revoke_session(
434        &self,
435        req: &AuthRequest,
436        ctx: &AuthContext<impl alibi_core::AuthSchema>,
437    ) -> AuthResult<AuthResponse> {
438        let revoke_req: RevokeSessionRequest = match parse_body(req) {
439            Ok(v) => v,
440            Err(resp) => return Ok(resp),
441        };
442
443        let (user, _) = ctx
444            .require_authoritative_cached_session(req)
445            .await
446            .map_err(session_authorization_error)?;
447        let response = revoke_session_core(&user, &revoke_req.token, ctx).await?;
448        Ok(AuthResponse::json(200, &response)?)
449    }
450
451    async fn handle_revoke_sessions(
452        &self,
453        req: &AuthRequest,
454        ctx: &AuthContext<impl alibi_core::AuthSchema>,
455    ) -> AuthResult<AuthResponse> {
456        let (user, _) = ctx
457            .require_authoritative_cached_session(req)
458            .await
459            .map_err(session_authorization_error)?;
460        let response = revoke_sessions_core(user.id(), ctx).await?;
461        Ok(AuthResponse::json(200, &response)?)
462    }
463
464    async fn handle_revoke_other_sessions(
465        &self,
466        req: &AuthRequest,
467        ctx: &AuthContext<impl alibi_core::AuthSchema>,
468    ) -> AuthResult<AuthResponse> {
469        let (user, current_session) = ctx
470            .require_authoritative_cached_session(req)
471            .await
472            .map_err(session_authorization_error)?;
473        let response = revoke_other_sessions_core(user.id(), &current_session, ctx).await?;
474        Ok(AuthResponse::json(200, &response)?)
475    }
476}
477
478impl std::fmt::Debug for SessionManagementPlugin {
479    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480        f.debug_struct("SessionManagementPlugin")
481            .finish_non_exhaustive()
482    }
483}
484
485// ---------------------------------------------------------------------------
486// Core functions — framework-agnostic business logic
487// ---------------------------------------------------------------------------
488
489///
490/// # Errors
491/// Returns an error when validation, storage, or an application callback fails.
492pub(in crate::plugins) async fn sign_out_core(
493    session: &impl AuthSession,
494    ctx: &AuthContext<impl alibi_core::AuthSchema>,
495) -> AuthResult<SuccessResponse> {
496    ctx.database.delete_session(session.token()).await?;
497    Ok(SuccessResponse { success: true })
498}
499
500///
501/// # Errors
502/// Returns an error when validation, storage, or an application callback fails.
503pub(in crate::plugins) async fn list_sessions_core(
504    user_id: impl AsRef<str>,
505    ctx: &AuthContext<impl alibi_core::AuthSchema>,
506) -> AuthResult<Vec<SessionView>> {
507    let sessions = ctx
508        .database
509        .get_active_user_sessions_record(user_id.as_ref())
510        .await?;
511    let now = chrono::Utc::now();
512    Ok(sessions
513        .iter()
514        .filter(|session| session.expires_at() > now && session.active())
515        .map(|session| ctx.session_view(session))
516        .collect())
517}
518
519///
520/// # Errors
521/// Returns an error when validation, storage, or an application callback fails.
522pub(in crate::plugins) async fn revoke_session_core(
523    user: &impl AuthUser,
524    token: &str,
525    ctx: &AuthContext<impl alibi_core::AuthSchema>,
526) -> AuthResult<StatusResponse> {
527    if let Some(session_to_revoke) = ctx.database.get_session(token).await?
528        && session_to_revoke.user_id() == user.id()
529    {
530        ctx.database.delete_session(token).await?;
531    }
532    Ok(StatusResponse { status: true })
533}
534
535///
536/// # Errors
537/// Returns an error when validation, storage, or an application callback fails.
538pub(in crate::plugins) async fn revoke_sessions_core(
539    user_id: impl AsRef<str>,
540    ctx: &AuthContext<impl alibi_core::AuthSchema>,
541) -> AuthResult<StatusResponse> {
542    ctx.database.delete_user_sessions(user_id.as_ref()).await?;
543    Ok(StatusResponse { status: true })
544}
545
546///
547/// # Errors
548/// Returns an error when validation, storage, or an application callback fails.
549pub(in crate::plugins) async fn revoke_other_sessions_core(
550    user_id: impl AsRef<str>,
551    current_session: &impl AuthSession,
552    ctx: &AuthContext<impl alibi_core::AuthSchema>,
553) -> AuthResult<StatusResponse> {
554    let all_sessions = ctx.session_manager().list_user_sessions(user_id).await?;
555    for session in all_sessions {
556        if session.token() != current_session.token() {
557            ctx.database.delete_session(session.token()).await?;
558        }
559    }
560    Ok(StatusResponse { status: true })
561}
562
563fn session_authorization_error(error: AuthError) -> AuthError {
564    if matches!(error, AuthError::Unauthenticated) {
565        AuthError::Upstream {
566            status: 401,
567            code: "UNAUTHORIZED",
568            message: "Unauthorized",
569        }
570    } else {
571        error
572    }
573}
574
575// LCOV_EXCL_START
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use crate::plugins::test_helpers;
580    use alibi_core::config::AccountConfig;
581    use alibi_core::utils::cookie_utils::related_cookie_name;
582    use alibi_core::wire::SessionView;
583    use alibi_core::{CreateSession, CreateUser};
584    use chrono::{Duration, Utc};
585
586    // 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.
587    #[tokio::test]
588    async fn test_get_session_success() {
589        let plugin = SessionManagementPlugin::new();
590        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
591            CreateUser::new()
592                .with_email("test@example.com")
593                .with_name("Test User"),
594            Duration::hours(24),
595        )
596        .await;
597
598        let req = test_helpers::create_auth_request_no_query(
599            HttpMethod::Get,
600            "/get-session",
601            Some(&session.token),
602            None,
603        );
604        let response = plugin.handle_get_session(&req, &ctx).await.unwrap();
605
606        assert_eq!(response.status, 200);
607
608        let body_str = String::from_utf8(response.body).unwrap();
609        let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
610        assert_eq!(
611            (*(*(response_data)
612                .get("session")
613                .unwrap_or(&serde_json::Value::Null))
614            .get("token")
615            .unwrap_or(&serde_json::Value::Null))
616            .as_str()
617            .unwrap(),
618            session.token
619        );
620        assert_eq!(
621            (*(*(response_data)
622                .get("user")
623                .unwrap_or(&serde_json::Value::Null))
624            .get("email")
625            .unwrap_or(&serde_json::Value::Null))
626            .as_str()
627            .map(ToOwned::to_owned),
628            Some("test@example.com".to_owned())
629        );
630    }
631
632    // 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.
633    #[tokio::test]
634    async fn test_get_session_unauthorized() {
635        // /get-session returns 200 with null body when unauthenticated.
636        let plugin = SessionManagementPlugin::new();
637        let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
638            CreateUser::new()
639                .with_email("test@example.com")
640                .with_name("Test User"),
641            Duration::hours(24),
642        )
643        .await;
644
645        let req =
646            test_helpers::create_auth_request_no_query(HttpMethod::Get, "/get-session", None, None);
647        let response = plugin.handle_get_session(&req, &ctx).await.unwrap();
648        assert_eq!(response.status, 200);
649        let body: serde_json::Value = serde_json::from_slice(&response.body).expect("valid JSON");
650        assert!(body.is_null());
651    }
652
653    // 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.
654    #[tokio::test]
655    async fn test_sign_out_success() {
656        let plugin = SessionManagementPlugin::new();
657        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
658            CreateUser::new()
659                .with_email("test@example.com")
660                .with_name("Test User"),
661            Duration::hours(24),
662        )
663        .await;
664
665        let req = test_helpers::create_auth_request_no_query(
666            HttpMethod::Post,
667            "/sign-out",
668            Some(&session.token),
669            Some(b"{}".to_vec()),
670        );
671        let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
672
673        assert_eq!(response.status, 200);
674
675        let body_str = String::from_utf8(response.body).unwrap();
676        let response_data: SuccessResponse = serde_json::from_str(&body_str).unwrap();
677        assert!(response_data.success);
678
679        let session_check = ctx.database.get_session(&session.token).await.unwrap();
680        assert!(session_check.is_none());
681    }
682
683    #[tokio::test]
684    async fn test_sign_out_clears_account_cookie_when_enabled() {
685        let plugin = SessionManagementPlugin::new();
686        let config = test_helpers::create_test_config().account(AccountConfig {
687            store_account_cookie: true,
688            ..Default::default()
689        });
690        let ctx = test_helpers::create_test_context_with_config(config).await;
691        let (_user, session) = test_helpers::create_user_and_session(
692            &ctx,
693            CreateUser::new()
694                .with_email("test@example.com")
695                .with_name("Test User"),
696            Duration::hours(24),
697        )
698        .await;
699
700        let req = test_helpers::create_auth_request_no_query(
701            HttpMethod::Post,
702            "/sign-out",
703            Some(&session.token),
704            Some(b"{}".to_vec()),
705        );
706        let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
707
708        let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
709        assert!(
710            response
711                .headers
712                .get_all("Set-Cookie")
713                .any(|cookie| cookie.starts_with(&account_cookie_name)),
714            "sign-out should clear the account_data cookie when store_account_cookie is enabled"
715        );
716    }
717
718    #[tokio::test]
719    async fn test_sign_out_does_not_emit_account_cookie_when_disabled() {
720        let plugin = SessionManagementPlugin::new();
721        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
722            CreateUser::new()
723                .with_email("test@example.com")
724                .with_name("Test User"),
725            Duration::hours(24),
726        )
727        .await;
728
729        let req = test_helpers::create_auth_request_no_query(
730            HttpMethod::Post,
731            "/sign-out",
732            Some(&session.token),
733            Some(b"{}".to_vec()),
734        );
735        let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
736
737        let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
738        assert!(
739            !response
740                .headers
741                .get_all("Set-Cookie")
742                .any(|cookie| cookie.starts_with(&account_cookie_name)),
743            "sign-out should not emit account_data clearing cookies when store_account_cookie is disabled"
744        );
745    }
746
747    // 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.
748    #[tokio::test]
749    async fn test_list_sessions_success() {
750        let plugin = SessionManagementPlugin::new();
751        let (ctx, user, session) = test_helpers::create_test_context_with_user(
752            CreateUser::new()
753                .with_email("test@example.com")
754                .with_name("Test User"),
755            Duration::hours(24),
756        )
757        .await;
758
759        let create_session2 = CreateSession {
760            additional_fields: alibi_core::field_policy::FieldValues::default(),
761            token: None,
762            user_id: user.id.clone(),
763            expires_at: Utc::now() + Duration::hours(24),
764            ip_address: Some("192.168.1.1".to_owned()),
765            user_agent: Some("another-agent".to_owned()),
766            impersonated_by: None,
767            active_organization_id: None,
768            active_team_id: None,
769        };
770        ctx.database.create_session(create_session2).await.unwrap();
771
772        let req = test_helpers::create_auth_request_no_query(
773            HttpMethod::Get,
774            "/list-sessions",
775            Some(&session.token),
776            None,
777        );
778        let response = plugin.handle_list_sessions(&req, &ctx).await.unwrap();
779
780        assert_eq!(response.status, 200);
781
782        let body_str = String::from_utf8(response.body).unwrap();
783        let sessions: Vec<SessionView> = serde_json::from_str(&body_str).unwrap();
784        assert_eq!(sessions.len(), 2);
785    }
786
787    #[tokio::test]
788    async fn test_list_sessions_filters_impersonated_sessions_when_admin_plugin_is_enabled() {
789        let plugin = SessionManagementPlugin::new();
790        let (mut ctx, user, session) = test_helpers::create_test_context_with_user(
791            CreateUser::new()
792                .with_email("test@example.com")
793                .with_name("Test User"),
794            Duration::hours(24),
795        )
796        .await;
797        ctx.set_metadata("admin.enabled", serde_json::Value::Bool(true));
798
799        let direct_session = CreateSession {
800            additional_fields: alibi_core::field_policy::FieldValues::default(),
801            token: None,
802            user_id: user.id.clone(),
803            expires_at: Utc::now() + Duration::hours(24),
804            ip_address: Some("192.168.1.1".to_owned()),
805            user_agent: Some("another-agent".to_owned()),
806            impersonated_by: None,
807            active_organization_id: None,
808            active_team_id: None,
809        };
810        ctx.database.create_session(direct_session).await.unwrap();
811
812        let impersonated_session = CreateSession {
813            additional_fields: alibi_core::field_policy::FieldValues::default(),
814            token: None,
815            user_id: user.id.clone(),
816            expires_at: Utc::now() + Duration::hours(24),
817            ip_address: Some("10.0.0.5".to_owned()),
818            user_agent: Some("impersonated-agent".to_owned()),
819            impersonated_by: Some("admin-user".to_owned()),
820            active_organization_id: None,
821            active_team_id: None,
822        };
823        let impersonated = ctx
824            .database
825            .create_session(impersonated_session)
826            .await
827            .unwrap();
828
829        let req = test_helpers::create_auth_request_no_query(
830            HttpMethod::Get,
831            "/list-sessions",
832            Some(&session.token),
833            None,
834        );
835        let response = plugin.handle_list_sessions(&req, &ctx).await.unwrap();
836
837        assert_eq!(response.status, 200);
838
839        let body_str = String::from_utf8(response.body).unwrap();
840        let sessions: Vec<SessionView> = serde_json::from_str(&body_str).unwrap();
841        assert_eq!(sessions.len(), 2);
842        assert!(
843            sessions
844                .iter()
845                .all(|candidate| candidate.token != impersonated.token),
846            "impersonated sessions should not be returned from /list-sessions"
847        );
848    }
849
850    // 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.
851    #[tokio::test]
852    async fn test_revoke_session_success() {
853        let plugin = SessionManagementPlugin::new();
854        let (ctx, user, session) = test_helpers::create_test_context_with_user(
855            CreateUser::new()
856                .with_email("test@example.com")
857                .with_name("Test User"),
858            Duration::hours(24),
859        )
860        .await;
861
862        let create_session2 = CreateSession {
863            additional_fields: alibi_core::field_policy::FieldValues::default(),
864            token: None,
865            user_id: user.id.clone(),
866            expires_at: Utc::now() + Duration::hours(24),
867            ip_address: Some("192.168.1.1".to_owned()),
868            user_agent: Some("another-agent".to_owned()),
869            impersonated_by: None,
870            active_organization_id: None,
871            active_team_id: None,
872        };
873        let session2 = ctx.database.create_session(create_session2).await.unwrap();
874
875        let body = serde_json::json!({ "token": session2.token });
876        let req = test_helpers::create_auth_request_no_query(
877            HttpMethod::Post,
878            "/revoke-session",
879            Some(&session.token),
880            Some(body.to_string().into_bytes()),
881        );
882
883        let response = plugin.handle_revoke_session(&req, &ctx).await.unwrap();
884        assert_eq!(response.status, 200);
885
886        let session2_check = ctx.database.get_session(&session2.token).await.unwrap();
887        assert!(session2_check.is_none());
888
889        let session1_check = ctx.database.get_session(&session.token).await.unwrap();
890        assert!(session1_check.is_some());
891    }
892
893    // 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.
894    #[tokio::test]
895    async fn test_revoke_session_forbidden_different_user() {
896        let plugin = SessionManagementPlugin::new();
897        let (ctx, _user1, session1) = test_helpers::create_test_context_with_user(
898            CreateUser::new()
899                .with_email("test@example.com")
900                .with_name("Test User"),
901            Duration::hours(24),
902        )
903        .await;
904
905        let create_user2 = CreateUser::new()
906            .with_email("user2@example.com")
907            .with_name("User Two");
908        let user2 = ctx.database.create_user(create_user2).await.unwrap();
909
910        let create_session2 = CreateSession {
911            additional_fields: alibi_core::field_policy::FieldValues::default(),
912            token: None,
913            user_id: user2.id,
914            expires_at: Utc::now() + Duration::hours(24),
915            ip_address: Some("192.168.1.1".to_owned()),
916            user_agent: Some("another-agent".to_owned()),
917            impersonated_by: None,
918            active_organization_id: None,
919            active_team_id: None,
920        };
921        let session2 = ctx.database.create_session(create_session2).await.unwrap();
922
923        let body = serde_json::json!({ "token": session2.token });
924        let req = test_helpers::create_auth_request_no_query(
925            HttpMethod::Post,
926            "/revoke-session",
927            Some(&session1.token),
928            Some(body.to_string().into_bytes()),
929        );
930
931        let response = plugin.handle_revoke_session(&req, &ctx).await.unwrap();
932        assert_eq!(response.status, 200);
933
934        let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
935        assert_eq!(
936            (*(body_2).get("status").unwrap_or(&serde_json::Value::Null)),
937            true
938        );
939
940        let still_exists = ctx.database.get_session(&session2.token).await.unwrap();
941        assert!(
942            still_exists.is_some(),
943            "other user's session must not be revoked"
944        );
945    }
946
947    // 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.
948    #[tokio::test]
949    async fn test_revoke_sessions_success() {
950        let plugin = SessionManagementPlugin::new();
951        let (ctx, user, session1) = test_helpers::create_test_context_with_user(
952            CreateUser::new()
953                .with_email("test@example.com")
954                .with_name("Test User"),
955            Duration::hours(24),
956        )
957        .await;
958
959        let create_session2 = CreateSession {
960            additional_fields: alibi_core::field_policy::FieldValues::default(),
961            token: None,
962            user_id: user.id.clone(),
963            expires_at: Utc::now() + Duration::hours(24),
964            ip_address: Some("192.168.1.1".to_owned()),
965            user_agent: Some("another-agent".to_owned()),
966            impersonated_by: None,
967            active_organization_id: None,
968            active_team_id: None,
969        };
970        ctx.database.create_session(create_session2).await.unwrap();
971
972        let req = test_helpers::create_auth_request_no_query(
973            HttpMethod::Post,
974            "/revoke-sessions",
975            Some(&session1.token),
976            Some(b"{}".to_vec()),
977        );
978        let response = plugin.handle_revoke_sessions(&req, &ctx).await.unwrap();
979
980        assert_eq!(response.status, 200);
981
982        let user_sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
983        assert_eq!(user_sessions.len(), 0);
984    }
985
986    // 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.
987    #[tokio::test]
988    async fn test_plugin_routes() {
989        let plugin = SessionManagementPlugin::new();
990        let routes = AuthPlugin::<
991            alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
992        >::routes(&plugin);
993
994        assert_eq!(routes.len(), 8);
995        assert!(
996            routes
997                .iter()
998                .any(|r| r.path == "/get-session" && r.method == HttpMethod::Get)
999        );
1000        // Upstream serves `/get-session` on both methods.
1001        assert!(
1002            routes
1003                .iter()
1004                .any(|r| r.path == "/get-session" && r.method == HttpMethod::Post)
1005        );
1006        assert!(
1007            routes
1008                .iter()
1009                .any(|r| r.path == "/sign-out" && r.method == HttpMethod::Post)
1010        );
1011        assert!(
1012            routes
1013                .iter()
1014                .any(|r| r.path == "/list-sessions" && r.method == HttpMethod::Get)
1015        );
1016        assert!(
1017            routes
1018                .iter()
1019                .any(|r| r.path == "/revoke-session" && r.method == HttpMethod::Post)
1020        );
1021        assert!(
1022            routes
1023                .iter()
1024                .any(|r| r.path == "/revoke-sessions" && r.method == HttpMethod::Post)
1025        );
1026    }
1027
1028    // 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.
1029    #[tokio::test]
1030    async fn test_plugin_on_request_routing() {
1031        let plugin = SessionManagementPlugin::new();
1032        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
1033            CreateUser::new()
1034                .with_email("test@example.com")
1035                .with_name("Test User"),
1036            Duration::hours(24),
1037        )
1038        .await;
1039
1040        // Test valid route
1041        let req = test_helpers::create_auth_request_no_query(
1042            HttpMethod::Get,
1043            "/get-session",
1044            Some(&session.token),
1045            None,
1046        );
1047        let response = plugin.on_request(&req, &ctx).await.unwrap();
1048        assert!(response.is_some());
1049        assert_eq!(response.unwrap().status, 200);
1050
1051        // POST /get-session is served, but rejected with 405 until
1052        // `session.defer_session_refresh` is enabled.
1053        let req_2 = test_helpers::create_auth_request_no_query(
1054            HttpMethod::Post,
1055            "/get-session",
1056            Some(&session.token),
1057            Some(b"{}".to_vec()),
1058        );
1059        let response_2 = plugin.on_request(&req_2, &ctx).await.unwrap().unwrap();
1060        assert_eq!(response_2.status, 405);
1061        assert_eq!(
1062            serde_json::from_slice::<serde_json::Value>(&response_2.body).unwrap(),
1063            serde_json::json!({
1064                "code": "METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED",
1065                "message": "POST method requires deferSessionRefresh to be enabled in session config",
1066            }),
1067        );
1068        assert_eq!(
1069            response_2.headers.get("cache-control"),
1070            Some(&"no-store".to_owned())
1071        );
1072        assert_eq!(
1073            response_2.headers.get("pragma"),
1074            Some(&"no-cache".to_owned())
1075        );
1076
1077        // Test invalid route
1078        let req_3 = test_helpers::create_auth_request_no_query(
1079            HttpMethod::Get,
1080            "/invalid-route",
1081            Some(&session.token),
1082            None,
1083        );
1084        let response_3 = plugin.on_request(&req_3, &ctx).await.unwrap();
1085        assert!(response_3.is_none());
1086    }
1087
1088    // 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.
1089    #[tokio::test]
1090    async fn test_configuration() {
1091        let plugin = SessionManagementPlugin::new()
1092            .enable_session_listing(false)
1093            .enable_session_revocation(false)
1094            .require_authentication(false);
1095
1096        assert!(!plugin.config.enable_session_listing);
1097        assert!(!plugin.config.enable_session_revocation);
1098        assert!(!plugin.config.require_authentication);
1099
1100        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
1101            CreateUser::new()
1102                .with_email("test@example.com")
1103                .with_name("Test User"),
1104            Duration::hours(24),
1105        )
1106        .await;
1107
1108        let req = test_helpers::create_auth_request_no_query(
1109            HttpMethod::Get,
1110            "/list-sessions",
1111            Some(&session.token),
1112            None,
1113        );
1114        let response = plugin.on_request(&req, &ctx).await.unwrap();
1115        assert!(response.is_none());
1116
1117        let req_2 = test_helpers::create_auth_request_no_query(
1118            HttpMethod::Post,
1119            "/revoke-session",
1120            Some(&session.token),
1121            Some(b"{}".to_vec()),
1122        );
1123        let response_2 = plugin.on_request(&req_2, &ctx).await.unwrap();
1124        assert!(response_2.is_none());
1125    }
1126}
1127// LCOV_EXCL_STOP