Skip to main content

alibi_plugins/multi_session/
mod.rs

1//! Browser-local selection and revocation of several authenticated accounts.
2
3mod endpoint;
4use super::authentication_helpers::{JsonField, RequestBody, parse_body};
5use super::helpers::delete_session_cookie_headers;
6use alibi_core::error::codes;
7use alibi_core::utils::cookie_utils::{
8    create_derived_session_cookie, create_session_cookie_with_max_age, create_session_like_cookie,
9    related_cookie_name, sign_cookie_value, verify_cookie_value,
10};
11use alibi_core::{
12    AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
13    AuthSchema, AuthSession, AuthUser, HttpMethod,
14};
15use async_trait::async_trait;
16use chrono::Utc;
17pub use endpoint::{DeviceSession, DeviceSessionRevoked};
18use serde::Deserialize;
19use serde_json::json;
20
21/// Number of distinct accounts retained in this browser's signed cookies.
22#[derive(Clone, Debug)]
23pub struct MultiSessionConfig {
24    /// Raw JavaScript number comparison: zero and negative limits suppress proofs,
25    /// fractional limits admit their integer floor, and NaN/positive infinity
26    /// admit every proof. No eviction or defaulting is performed.
27    pub maximum_sessions: f64,
28}
29
30impl Default for MultiSessionConfig {
31    fn default() -> Self {
32        Self {
33            maximum_sessions: 5.0,
34        }
35    }
36}
37
38#[derive(Clone, Default)]
39pub struct MultiSessionPlugin {
40    config: MultiSessionConfig,
41}
42
43impl std::fmt::Debug for MultiSessionPlugin {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("MultiSessionPlugin").finish_non_exhaustive()
46    }
47}
48
49impl MultiSessionPlugin {
50    #[must_use]
51    pub fn new() -> Self {
52        Self::default()
53    }
54    #[must_use]
55    pub const fn with_config(config: MultiSessionConfig) -> Self {
56        Self { config }
57    }
58
59    fn cookie_name(token: &str, ctx: &AuthContext<impl AuthSchema>) -> String {
60        format!(
61            "{}_multi-{}",
62            related_cookie_name(&ctx.config, "session_token"),
63            token.to_lowercase()
64        )
65    }
66
67    fn cookie_value(req: &impl alibi_core::session::SessionRequest, name: &str) -> Option<String> {
68        req.session_headers()
69            .get("cookie")
70            .into_iter()
71            .flat_map(|header| cookie::Cookie::split_parse(header).flatten())
72            .filter(|cookie| cookie.name() == name)
73            .map(|cookie| cookie.value().to_owned())
74            .last()
75    }
76
77    fn set_active_cookie(
78        req: &impl alibi_core::session::SessionRequest,
79        ctx: &AuthContext<impl AuthSchema>,
80        token: &str,
81        response: &mut AuthResponse,
82    ) -> AuthResult<()> {
83        let name = related_cookie_name(&ctx.config, "dont_remember");
84        let dont_remember = Self::cookie_value(req, &name)
85            .and_then(|value| verify_cookie_value(&value, ctx.config.current_secret()))
86            .is_some_and(|value| !value.is_empty());
87        response.headers.append(
88            "set-cookie",
89            create_session_cookie_with_max_age(
90                Some(token),
91                if dont_remember {
92                    None
93                } else {
94                    Some(ctx.config.session.expires_in.num_seconds())
95                },
96                &ctx.config,
97            )?,
98        );
99        if dont_remember {
100            response.headers.append(
101                "set-cookie",
102                create_session_like_cookie(
103                    &name,
104                    &sign_cookie_value("true", ctx.config.current_secret()),
105                    None,
106                    &ctx.config,
107                )?,
108            );
109        }
110        Ok(())
111    }
112
113    fn multi_cookies(req: &impl alibi_core::session::SessionRequest) -> Vec<(String, String)> {
114        let mut cookies: Vec<(String, String)> = Vec::new();
115        let headers = req.session_headers();
116        for cookie in headers
117            .get("cookie")
118            .into_iter()
119            .flat_map(|header| cookie::Cookie::split_parse(header).flatten())
120            .filter(|cookie| cookie.name().contains("_multi-"))
121        {
122            // Upstream parses to a Map: duplicate names use the last value,
123            // while retaining the name's first insertion position.
124            if let Some((_, value)) = cookies.iter_mut().find(|(name, _)| name == cookie.name()) {
125                cookie.value().clone_into(value);
126            } else {
127                cookies.push((cookie.name().to_owned(), cookie.value().to_owned()));
128            }
129        }
130        cookies
131    }
132
133    fn signed_tokens(
134        req: &impl alibi_core::session::SessionRequest,
135        ctx: &AuthContext<impl AuthSchema>,
136    ) -> Vec<(String, String)> {
137        Self::multi_cookies(req)
138            .into_iter()
139            .filter_map(|(name, value)| {
140                verify_cookie_value(&value, ctx.config.current_secret()).map(|token| (name, token))
141            })
142            .collect()
143    }
144
145    async fn list(
146        &self,
147        req: &impl alibi_core::session::SessionRequest,
148        ctx: &AuthContext<impl AuthSchema>,
149    ) -> AuthResult<AuthResponse> {
150        let tokens = Self::session_tokens(req, ctx);
151        let now = Utc::now();
152        let mut seen_users = std::collections::HashSet::new();
153        let mut listed = Vec::new();
154        for session in ctx.database.get_sessions_by_tokens_record(&tokens).await? {
155            if session.expires_at() > now
156                && let Some(user) = ctx.session_user(&session).await?
157                && seen_users.insert(user.id().to_string())
158            {
159                listed.push(json!({
160                    "session": ctx.session_view(&session),
161                    "user": ctx.user_view(&user),
162                }));
163            }
164        }
165        Ok(AuthResponse::json(200, &listed)?)
166    }
167
168    fn session_tokens(
169        req: &impl alibi_core::session::SessionRequest,
170        ctx: &AuthContext<impl AuthSchema>,
171    ) -> Vec<String> {
172        Self::signed_tokens(req, ctx)
173            .into_iter()
174            .map(|(_, token)| token)
175            .collect()
176    }
177
178    async fn emit_selected_snapshot<S: AuthSchema>(
179        ctx: &AuthContext<S>,
180        user: &S::User,
181        session: &impl AuthSession,
182    ) -> AuthResult<()> {
183        let user_view = ctx.user_view(user);
184        let session_view = ctx.session_view(session);
185        alibi_core::session::cookie_cache::runtime::emit_issuance_snapshot(
186            ctx,
187            alibi_core::CacheVersionContext::created(
188                user_view.clone(),
189                session_view.clone(),
190                user_view,
191                session_view,
192            ),
193        )
194        .await
195    }
196
197    /// The cookie name and verified token a request's `sessionToken` selects.
198    fn selected_token(
199        req: &impl alibi_core::session::SessionRequest,
200        ctx: &AuthContext<impl AuthSchema>,
201        body: &SessionTokenRequest,
202    ) -> AuthResult<(String, String)> {
203        let name = Self::cookie_name(&body.session_token, ctx);
204        let token = Self::cookie_value(req, &name)
205            .and_then(|value| verify_cookie_value(&value, ctx.config.current_secret()))
206            .filter(|token| !token.is_empty())
207            .ok_or_else(invalid_token)?;
208        Ok((name, token))
209    }
210
211    async fn set_active<S: AuthSchema>(
212        &self,
213        req: &AuthRequest,
214        ctx: &AuthContext<S>,
215    ) -> AuthResult<AuthResponse> {
216        let body: SessionTokenRequest = match parse_body(req) {
217            Ok(body) => body,
218            Err(response) => return Ok(response),
219        };
220        self.set_active_core(req, ctx, body).await
221    }
222
223    async fn set_active_core<S: AuthSchema>(
224        &self,
225        req: &impl alibi_core::session::SessionRequest,
226        ctx: &AuthContext<S>,
227        body: SessionTokenRequest,
228    ) -> AuthResult<AuthResponse> {
229        let (name, token) = Self::selected_token(req, ctx, &body)?;
230        let session = ctx.database.get_session_record(&token).await?;
231        let session = session.filter(|session| session.expires_at() > Utc::now());
232        let Some(session) = session else {
233            let mut response = invalid_token().to_auth_response();
234            response.headers.append(
235                "set-cookie",
236                create_derived_session_cookie(&name, "", true, &ctx.config)?,
237            );
238            return Ok(response);
239        };
240        let user = ctx
241            .session_user(&session)
242            .await?
243            .ok_or_else(invalid_token)?;
244        let mut response = AuthResponse::json(
245            200,
246            &json!({"session":ctx.session_view(&session),"user":ctx.user_view(&user)}),
247        )?;
248        Self::emit_selected_snapshot(ctx, &user, &session).await?;
249        Self::set_active_cookie(req, ctx, session.token(), &mut response)?;
250        super::helpers::record_completed_session::<S>(&user, session.stored());
251        Ok(response)
252    }
253
254    async fn revoke<S: AuthSchema>(
255        &self,
256        req: &AuthRequest,
257        ctx: &AuthContext<S>,
258    ) -> AuthResult<AuthResponse> {
259        let body: SessionTokenRequest = match parse_body(req) {
260            Ok(body) => body,
261            Err(response) => return Ok(response),
262        };
263        self.revoke_core(req, ctx, body).await
264    }
265
266    async fn revoke_core<S: AuthSchema>(
267        &self,
268        req: &impl alibi_core::session::SessionRequest,
269        ctx: &AuthContext<S>,
270        body: SessionTokenRequest,
271    ) -> AuthResult<AuthResponse> {
272        let (_, current_session) = super::helpers::ordinary_session(req, ctx).await?;
273        let (name, token) = Self::selected_token(req, ctx, &body)?;
274        ctx.database.delete_session(&token).await?;
275        let mut response = AuthResponse::json(200, &json!({"status":true}))?;
276        response.headers.append(
277            "set-cookie",
278            create_derived_session_cookie(&name, "", true, &ctx.config)?,
279        );
280        if current_session.token() != token {
281            return Ok(response);
282        }
283        let tokens = Self::session_tokens(req, ctx);
284        let mut next = None;
285        for session in ctx.database.get_sessions_by_tokens_record(&tokens).await? {
286            if session.expires_at() > Utc::now() && ctx.session_user(&session).await?.is_some() {
287                next = Some(session);
288                break;
289            }
290        }
291        if let Some(session) = next {
292            let user = ctx
293                .session_user(&session)
294                .await?
295                .ok_or(AuthError::UserNotFound)?;
296            Self::emit_selected_snapshot(ctx, &user, &session).await?;
297            Self::set_active_cookie(req, ctx, session.token(), &mut response)?;
298        } else {
299            for header in delete_session_cookie_headers(&ctx.config)? {
300                response.headers.append("set-cookie", header);
301            }
302        }
303        Ok(response)
304    }
305    async fn session_hook_headers(
306        &self,
307        req: &impl alibi_core::session::SessionRequest,
308        path: &str,
309        ctx: &AuthContext<impl AuthSchema>,
310        headers: &alibi_core::Headers,
311    ) -> AuthResult<alibi_core::Headers> {
312        let mut additional = alibi_core::Headers::new();
313        if path == "/sign-out" {
314            for (name, token) in Self::signed_tokens(req, ctx) {
315                // The logout hook grants retirement only to truthy signed payloads.
316                // List and fallback intentionally retain string-valued semantics.
317                if token.is_empty() {
318                    continue;
319                }
320                ctx.database.delete_session(&token).await?;
321                additional.append(
322                    "set-cookie",
323                    create_derived_session_cookie(
324                        &name.to_lowercase().replacen("__secure-", "__Secure-", 1),
325                        "",
326                        true,
327                        &ctx.config,
328                    )?,
329                );
330            }
331            return Ok(additional);
332        }
333        if headers.get_all("set-cookie").next().is_none() {
334            return Ok(additional);
335        }
336        let Some((user, session)) =
337            alibi_core::session::cookie_cache::runtime::published_session(req)
338        else {
339            return Ok(additional);
340        };
341        let name = Self::cookie_name(session.token(), ctx);
342        if Self::cookie_value(req, &name).is_some_and(|value| !value.is_empty())
343            || headers.get_all("set-cookie").any(|header| {
344                cookie::Cookie::parse(header.clone()).is_ok_and(|cookie| cookie.name() == name)
345            })
346        {
347            return Ok(additional);
348        }
349        let cookies = Self::signed_tokens(req, ctx);
350        let mut tokens_to_delete = Vec::new();
351        for (old_name, token) in &cookies {
352            if token.is_empty() {
353                continue;
354            }
355            if let Some(old) = ctx.database.get_session_record(token).await?
356                && old.user_id() == user.id()
357            {
358                tokens_to_delete.push(token);
359                additional.append(
360                    "set-cookie",
361                    create_derived_session_cookie(old_name, "", true, &ctx.config)?,
362                );
363            }
364        }
365        // Resolve every proof before deleting: multiple names can carry the same token.
366        for token in &tokens_to_delete {
367            ctx.database.delete_session(token).await?;
368        }
369        let removed = tokens_to_delete.len();
370        // Upstream counts every named cookie, including invalid signatures.
371        let count = Self::multi_cookies(req).len();
372        #[expect(
373            clippy::cast_precision_loss,
374            reason = "Match upstream JavaScript numeric comparison"
375        )]
376        let budget = (count.saturating_sub(removed) + 1) as f64;
377        if budget > self.config.maximum_sessions {
378            return Ok(additional);
379        }
380        let signed = sign_cookie_value(session.token(), ctx.config.current_secret());
381        additional.append(
382            "set-cookie",
383            create_derived_session_cookie(&name, &signed, false, &ctx.config)?,
384        );
385        Ok(additional)
386    }
387}
388
389#[derive(Deserialize)]
390#[serde(rename_all = "camelCase")]
391struct SessionTokenRequest {
392    session_token: String,
393}
394
395impl RequestBody for SessionTokenRequest {
396    const FIELDS: &'static [JsonField] = &[JsonField::string("sessionToken", true)];
397}
398
399#[async_trait]
400impl<S: AuthSchema> AuthPlugin<S> for MultiSessionPlugin {
401    route_openapi_metadata!(S);
402
403    fn endpoint_hooks(&self) -> Vec<&dyn alibi_core::endpoint::EndpointHook<S>> {
404        vec![self]
405    }
406
407    fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
408        endpoint::definitions()
409    }
410    fn validate_endpoint(
411        &self,
412        call: &alibi_core::endpoint::EndpointCall,
413        _ctx: &AuthContext<S>,
414    ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
415        endpoint::validate(call)
416    }
417    async fn on_endpoint(
418        &self,
419        call: &alibi_core::endpoint::EndpointCall,
420        ctx: &AuthContext<S>,
421    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
422        let output = if call.operation_id() == "setActiveSession" {
423            self.set_active_core(call, ctx, call.body_as()?).await?
424        } else if call.operation_id() == "revokeDeviceSession" {
425            self.revoke_core(call, ctx, call.body_as()?).await?
426        } else {
427            self.list(call, ctx).await?
428        };
429        let mut response = if output.status >= 400 {
430            alibi_core::endpoint::EndpointResponse::error(crate::endpoint::error_response(
431                output.clone(),
432            ))
433        } else {
434            alibi_core::endpoint::EndpointResponse::value(alibi_core::utils::json::from_slice(
435                &output.body,
436            )?)
437        };
438        response.set_status(Some(output.status));
439        response.merge_headers(output.headers);
440        Ok(response)
441    }
442    fn name(&self) -> &'static str {
443        "multi-session"
444    }
445    fn routes(&self) -> Vec<AuthRoute> {
446        vec![
447            AuthRoute::get(
448                "/multi-session/list-device-sessions",
449                "list_device_sessions",
450            ),
451            AuthRoute::post("/multi-session/set-active", "set_active_session"),
452            AuthRoute::post("/multi-session/revoke", "revoke_device_session"),
453        ]
454    }
455    async fn on_request(
456        &self,
457        req: &AuthRequest,
458        ctx: &AuthContext<S>,
459    ) -> AuthResult<Option<AuthResponse>> {
460        let response = match (req.method(), req.path()) {
461            (HttpMethod::Get, "/multi-session/list-device-sessions") => self.list(req, ctx).await?,
462            (HttpMethod::Post, "/multi-session/set-active") => self.set_active(req, ctx).await?,
463            (HttpMethod::Post, "/multi-session/revoke") => self.revoke(req, ctx).await?,
464            _ => return Ok(None),
465        };
466        Ok(Some(response))
467    }
468    async fn after_request(
469        &self,
470        req: &AuthRequest,
471        ctx: &AuthContext<S>,
472        mut response: AuthResponse,
473    ) -> AuthResult<AuthResponse> {
474        let headers = self
475            .session_hook_headers(req, req.path(), ctx, &response.headers)
476            .await?;
477        for (name, value) in headers {
478            response.headers.append(name, value);
479        }
480        Ok(response)
481    }
482}
483
484#[async_trait]
485impl<S: AuthSchema> alibi_core::endpoint::EndpointHook<S> for MultiSessionPlugin {
486    async fn after(
487        &self,
488        call: &alibi_core::endpoint::EndpointCall,
489        ctx: &AuthContext<S>,
490        mut response: alibi_core::endpoint::EndpointResponse,
491    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
492        let headers = self
493            .session_hook_headers(
494                call,
495                call.path().unwrap_or_default(),
496                ctx,
497                response.headers(),
498            )
499            .await?;
500        response.merge_headers(headers);
501        Ok(response)
502    }
503}
504
505const fn invalid_token() -> AuthError {
506    AuthError::Upstream {
507        status: 401,
508        code: codes::INVALID_SESSION_TOKEN,
509        message: "Invalid session token",
510    }
511}
512
513// LCOV_EXCL_START
514#[cfg(test)]
515mod tests {
516    #![allow(
517        clippy::unwrap_used,
518        clippy::indexing_slicing,
519        reason = "SQLite plugin contract assertions"
520    )]
521
522    use super::*;
523    use crate::test_helpers::{
524        create_auth_json_request_no_query, create_test_context, create_user_and_session,
525    };
526    use alibi_core::utils::cookie_utils::create_session_cookie;
527    use alibi_core::{CreateSession, CreateUser};
528    use chrono::Duration;
529
530    fn request_with_cookies(
531        method: HttpMethod,
532        path: &str,
533        cookies: &[String],
534        body: Option<serde_json::Value>,
535    ) -> AuthRequest {
536        let mut request = create_auth_json_request_no_query(method, path, None, body);
537        request.headers.insert("cookie".into(), cookies.join("; "));
538        request
539    }
540
541    fn pair(header: &str) -> String {
542        header.split(';').next().unwrap().to_owned()
543    }
544
545    #[tokio::test]
546    #[expect(
547        clippy::too_many_lines,
548        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
549    )]
550    async fn signed_browser_sessions_select_other_accounts_and_reject_unrelated_tokens() {
551        let ctx = create_test_context().await;
552        let plugin = MultiSessionPlugin::new();
553        let (bob, _) = create_user_and_session(
554            &ctx,
555            CreateUser::new().with_email("bob@example.test"),
556            Duration::days(1),
557        )
558        .await;
559        let (alice, _) = create_user_and_session(
560            &ctx,
561            CreateUser::new().with_email("alice@example.test"),
562            Duration::days(1),
563        )
564        .await;
565        let custom = |user_id: String, token: &str| CreateSession {
566            user_id,
567            token: Some(token.to_owned()),
568            expires_at: Utc::now() + Duration::days(1),
569            ip_address: None,
570            user_agent: None,
571            impersonated_by: None,
572            active_organization_id: None,
573            active_team_id: None,
574            additional_fields: alibi_core::field_policy::FieldValues::default(),
575        };
576        let bob_session = ctx
577            .database
578            .create_session(custom(bob.id.clone(), "z-last"))
579            .await
580            .unwrap();
581        let alice_session = ctx
582            .database
583            .create_session(custom(alice.id.clone(), "a-first"))
584            .await
585            .unwrap();
586        let cookie_for = |token: &str| {
587            format!(
588                "{}={}",
589                MultiSessionPlugin::cookie_name(token, &ctx),
590                sign_cookie_value(token, &ctx.config.secret)
591            )
592        };
593        let cookies = vec![
594            pair(&create_session_cookie(&bob_session.token, &ctx.config).unwrap()),
595            cookie_for(&bob_session.token),
596            cookie_for(&alice_session.token),
597        ];
598        let request = request_with_cookies(
599            HttpMethod::Get,
600            "/multi-session/list-device-sessions",
601            &cookies,
602            None,
603        );
604        let listed = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
605        let body: serde_json::Value = serde_json::from_slice(&listed.body).unwrap();
606        assert_eq!(body.as_array().unwrap().len(), 2);
607        assert_eq!(body[0]["user"]["id"], alice.id);
608        assert_eq!(body[1]["user"]["id"], bob.id);
609        let duplicate = request_with_cookies(
610            HttpMethod::Get,
611            "/multi-session/list-device-sessions",
612            &[
613                cookie_for(&alice_session.token),
614                format!(
615                    "{}=invalid-last",
616                    MultiSessionPlugin::cookie_name(&alice_session.token, &ctx)
617                ),
618                cookie_for(&bob_session.token),
619            ],
620            None,
621        );
622        let duplicate = plugin.on_request(&duplicate, &ctx).await.unwrap().unwrap();
623        let listed_2: serde_json::Value = serde_json::from_slice(&duplicate.body).unwrap();
624        assert_eq!(listed_2.as_array().unwrap().len(), 1);
625        assert_eq!(listed_2[0]["user"]["id"], bob.id);
626        for (values, succeeds) in [
627            (
628                vec![
629                    cookie_for(&alice_session.token),
630                    format!(
631                        "{}=invalid-last",
632                        MultiSessionPlugin::cookie_name(&alice_session.token, &ctx)
633                    ),
634                ],
635                false,
636            ),
637            (
638                vec![
639                    format!(
640                        "{}=invalid-first",
641                        MultiSessionPlugin::cookie_name(&alice_session.token, &ctx)
642                    ),
643                    cookie_for(&alice_session.token),
644                ],
645                true,
646            ),
647        ] {
648            let selection = request_with_cookies(
649                HttpMethod::Post,
650                "/multi-session/set-active",
651                &values,
652                Some(json!({"sessionToken":alice_session.token})),
653            );
654            let result = plugin.on_request(&selection, &ctx).await;
655            if succeeds {
656                assert_eq!(result.unwrap().unwrap().status, 200);
657            } else {
658                assert_eq!(result.unwrap_err().status_code(), 401);
659            }
660        }
661        for path in ["/multi-session/revoke", "/multi-session/set-active"] {
662            let empty_proof = request_with_cookies(
663                HttpMethod::Post,
664                path,
665                &[
666                    pair(&create_session_cookie(&bob_session.token, &ctx.config).unwrap()),
667                    format!(
668                        "{}={}",
669                        MultiSessionPlugin::cookie_name(&alice_session.token, &ctx),
670                        sign_cookie_value("", &ctx.config.secret)
671                    ),
672                ],
673                Some(json!({"sessionToken":alice_session.token})),
674            );
675            let rejected = plugin
676                .on_request(&empty_proof, &ctx)
677                .await
678                .unwrap_or_else(|error| Some(error.to_auth_response()))
679                .unwrap();
680            assert_eq!(rejected.status, 401);
681            assert_eq!(
682                serde_json::from_slice::<serde_json::Value>(&rejected.body).unwrap()["code"],
683                "INVALID_SESSION_TOKEN"
684            );
685            assert!(rejected.headers.get_all("set-cookie").next().is_none());
686            assert!(
687                ctx.database
688                    .get_session(&alice_session.token)
689                    .await
690                    .unwrap()
691                    .is_some()
692            );
693            assert!(
694                ctx.database
695                    .get_session(&bob_session.token)
696                    .await
697                    .unwrap()
698                    .is_some()
699            );
700        }
701        let select = request_with_cookies(
702            HttpMethod::Post,
703            "/multi-session/set-active",
704            &cookies,
705            Some(json!({"sessionToken":alice_session.token})),
706        );
707        let selected = plugin.on_request(&select, &ctx).await.unwrap().unwrap();
708        assert_eq!(selected.status, 200);
709        let body_2: serde_json::Value = serde_json::from_slice(&selected.body).unwrap();
710        assert_eq!(body_2["user"]["id"], alice.id);
711        assert_eq!(body_2["session"]["userId"], alice.id);
712        let unrelated = create_auth_json_request_no_query(
713            HttpMethod::Post,
714            "/multi-session/set-active",
715            Some(&bob_session.token),
716            Some(json!({"sessionToken":alice_session.token})),
717        );
718        assert_eq!(
719            plugin
720                .on_request(&unrelated, &ctx)
721                .await
722                .unwrap_err()
723                .status_code(),
724            401
725        );
726        assert!(
727            ctx.database
728                .get_session(&alice_session.token)
729                .await
730                .unwrap()
731                .is_some()
732        );
733    }
734
735    #[tokio::test]
736    #[expect(
737        clippy::too_many_lines,
738        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
739    )]
740    async fn cookie_tampering_expiry_revocation_fallback_and_logout_preserve_state() {
741        let ctx = create_test_context().await;
742        let plugin = MultiSessionPlugin::new();
743        let (_, one) = create_user_and_session(
744            &ctx,
745            CreateUser::new().with_email("one@example.test"),
746            Duration::days(1),
747        )
748        .await;
749        let (_, two) = create_user_and_session(
750            &ctx,
751            CreateUser::new().with_email("two@example.test"),
752            Duration::days(1),
753        )
754        .await;
755        let first = format!(
756            "{}={}",
757            MultiSessionPlugin::cookie_name(&one.token, &ctx),
758            sign_cookie_value(&one.token, &ctx.config.secret)
759        );
760        let second = format!(
761            "{}={}",
762            MultiSessionPlugin::cookie_name(&two.token, &ctx),
763            sign_cookie_value(&two.token, &ctx.config.secret)
764        );
765        let cookies = vec![
766            pair(&create_session_cookie(&one.token, &ctx.config).unwrap()),
767            first.clone(),
768            second.clone(),
769        ];
770        let revoke = request_with_cookies(
771            HttpMethod::Post,
772            "/multi-session/revoke",
773            &cookies,
774            Some(json!({"sessionToken":one.token})),
775        );
776        let revoked = plugin.on_request(&revoke, &ctx).await.unwrap().unwrap();
777        assert_eq!(revoked.status, 200);
778        assert!(
779            ctx.database
780                .get_session(&one.token)
781                .await
782                .unwrap()
783                .is_none()
784        );
785        let new_session_cookie = revoked
786            .headers
787            .get_all("set-cookie")
788            .filter_map(|v| cookie::Cookie::parse(v.clone()).ok())
789            .find(|v| v.name() == ctx.config.session.cookie_name)
790            .unwrap();
791        assert_eq!(
792            verify_cookie_value(new_session_cookie.value(), &ctx.config.secret),
793            Some(two.token.clone())
794        );
795        let tampered = vec![second.replace('=', "=tampered-")];
796        let select = request_with_cookies(
797            HttpMethod::Post,
798            "/multi-session/set-active",
799            &tampered,
800            Some(json!({"sessionToken":two.token})),
801        );
802        assert_eq!(
803            plugin
804                .on_request(&select, &ctx)
805                .await
806                .unwrap_err()
807                .status_code(),
808            401
809        );
810        ctx.database
811            .update_session_expiry(&two.token, Utc::now() - Duration::seconds(1))
812            .await
813            .unwrap();
814        let expired = request_with_cookies(
815            HttpMethod::Post,
816            "/multi-session/set-active",
817            std::slice::from_ref(&second),
818            Some(json!({"sessionToken":two.token})),
819        );
820        let expired = plugin.on_request(&expired, &ctx).await.unwrap().unwrap();
821        assert_eq!(expired.status, 401);
822        assert!(
823            expired
824                .headers
825                .get("set-cookie")
826                .unwrap()
827                .contains("Max-Age=0")
828        );
829        let signout = request_with_cookies(HttpMethod::Post, "/sign-out", &[second], None);
830        _ = plugin
831            .after_request(
832                &signout,
833                &ctx,
834                AuthResponse::json(200, &json!({"success":true})).unwrap(),
835            )
836            .await
837            .unwrap();
838        assert!(
839            ctx.database
840                .get_session(&two.token)
841                .await
842                .unwrap()
843                .is_none()
844        );
845    }
846}
847// LCOV_EXCL_STOP