Skip to main content

alibi_plugins/session_management/
mod.rs

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