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 Self::held_sessions(&tokens, ctx).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        let mut tokens: Vec<String> = Vec::new();
173        for (_, token) in Self::signed_tokens(req, ctx) {
174            if !tokens.contains(&token) {
175                tokens.push(token);
176            }
177        }
178        tokens
179    }
180
181    /// The held sessions in cookie order, as upstream 1.7.12 `findSessions`
182    /// returns them, rather than in storage order.
183    async fn held_sessions<S: AuthSchema>(
184        tokens: &[String],
185        ctx: &AuthContext<S>,
186    ) -> AuthResult<Vec<alibi_core::AdapterRecord<S::Session>>> {
187        let mut sessions = ctx.database.get_sessions_by_tokens_record(tokens).await?;
188        sessions.sort_by_key(|session| {
189            tokens
190                .iter()
191                .position(|token| token == session.token())
192                .unwrap_or(usize::MAX)
193        });
194        Ok(sessions)
195    }
196
197    async fn emit_selected_snapshot<S: AuthSchema>(
198        ctx: &AuthContext<S>,
199        user: &S::User,
200        session: &impl AuthSession,
201    ) -> AuthResult<()> {
202        let user_view = ctx.user_view(user);
203        let session_view = ctx.session_view(session);
204        alibi_core::session::cookie_cache::runtime::emit_issuance_snapshot(
205            ctx,
206            alibi_core::CacheVersionContext::created(
207                user_view.clone(),
208                session_view.clone(),
209                user_view,
210                session_view,
211            ),
212        )
213        .await
214    }
215
216    /// The cookie name and verified token a request's `sessionToken` selects.
217    fn selected_token(
218        req: &impl alibi_core::session::SessionRequest,
219        ctx: &AuthContext<impl AuthSchema>,
220        body: &SessionTokenRequest,
221    ) -> AuthResult<(String, String)> {
222        let name = Self::cookie_name(&body.session_token, ctx);
223        let token = Self::cookie_value(req, &name)
224            .and_then(|value| verify_cookie_value(&value, ctx.config.current_secret()))
225            .filter(|token| !token.is_empty())
226            .ok_or_else(invalid_token)?;
227        Ok((name, token))
228    }
229
230    async fn set_active<S: AuthSchema>(
231        &self,
232        req: &AuthRequest,
233        ctx: &AuthContext<S>,
234    ) -> AuthResult<AuthResponse> {
235        let body: SessionTokenRequest = match parse_body(req) {
236            Ok(body) => body,
237            Err(response) => return Ok(response),
238        };
239        self.set_active_core(req, ctx, body).await
240    }
241
242    async fn set_active_core<S: AuthSchema>(
243        &self,
244        req: &impl alibi_core::session::SessionRequest,
245        ctx: &AuthContext<S>,
246        body: SessionTokenRequest,
247    ) -> AuthResult<AuthResponse> {
248        let (name, token) = Self::selected_token(req, ctx, &body)?;
249        let session = ctx.database.get_session_record(&token).await?;
250        let session = session.filter(|session| session.expires_at() > Utc::now());
251        let Some(session) = session else {
252            let mut response = invalid_token().to_auth_response();
253            response.headers.append(
254                "set-cookie",
255                create_derived_session_cookie(&name, "", true, &ctx.config)?,
256            );
257            return Ok(response);
258        };
259        let user = ctx
260            .session_user(&session)
261            .await?
262            .ok_or_else(invalid_token)?;
263        let mut response = AuthResponse::json(
264            200,
265            &json!({"session":ctx.session_view(&session),"user":ctx.user_view(&user)}),
266        )?;
267        Self::emit_selected_snapshot(ctx, &user, &session).await?;
268        Self::set_active_cookie(req, ctx, session.token(), &mut response)?;
269        super::helpers::record_completed_session::<S>(&user, session.stored());
270        Ok(response)
271    }
272
273    async fn revoke<S: AuthSchema>(
274        &self,
275        req: &AuthRequest,
276        ctx: &AuthContext<S>,
277    ) -> AuthResult<AuthResponse> {
278        let body: SessionTokenRequest = match parse_body(req) {
279            Ok(body) => body,
280            Err(response) => return Ok(response),
281        };
282        self.revoke_core(req, ctx, body).await
283    }
284
285    async fn revoke_core<S: AuthSchema>(
286        &self,
287        req: &impl alibi_core::session::SessionRequest,
288        ctx: &AuthContext<S>,
289        body: SessionTokenRequest,
290    ) -> AuthResult<AuthResponse> {
291        let (_, current_session) = super::helpers::ordinary_session(req, ctx).await?;
292        let (name, token) = Self::selected_token(req, ctx, &body)?;
293        ctx.database.delete_session(&token).await?;
294        let mut response = AuthResponse::json(200, &json!({"status":true}))?;
295        response.headers.append(
296            "set-cookie",
297            create_derived_session_cookie(&name, "", true, &ctx.config)?,
298        );
299        if current_session.token() != token {
300            return Ok(response);
301        }
302        let tokens = Self::session_tokens(req, ctx);
303        let mut next = None;
304        for session in Self::held_sessions(&tokens, ctx).await? {
305            if session.expires_at() > Utc::now() && ctx.session_user(&session).await?.is_some() {
306                next = Some(session);
307                break;
308            }
309        }
310        if let Some(session) = next {
311            let user = ctx
312                .session_user(&session)
313                .await?
314                .ok_or(AuthError::UserNotFound)?;
315            Self::emit_selected_snapshot(ctx, &user, &session).await?;
316            Self::set_active_cookie(req, ctx, session.token(), &mut response)?;
317        } else {
318            for header in delete_session_cookie_headers(&ctx.config)? {
319                response.headers.append("set-cookie", header);
320            }
321        }
322        Ok(response)
323    }
324    async fn session_hook_headers(
325        &self,
326        req: &impl alibi_core::session::SessionRequest,
327        path: &str,
328        ctx: &AuthContext<impl AuthSchema>,
329        headers: &alibi_core::Headers,
330    ) -> AuthResult<alibi_core::Headers> {
331        let mut additional = alibi_core::Headers::new();
332        if path == "/sign-out" {
333            for (name, token) in Self::signed_tokens(req, ctx) {
334                // The logout hook grants retirement only to truthy signed payloads.
335                // List and fallback intentionally retain string-valued semantics.
336                if token.is_empty() {
337                    continue;
338                }
339                ctx.database.delete_session(&token).await?;
340                additional.append(
341                    "set-cookie",
342                    create_derived_session_cookie(
343                        &name.to_lowercase().replacen("__secure-", "__Secure-", 1),
344                        "",
345                        true,
346                        &ctx.config,
347                    )?,
348                );
349            }
350            return Ok(additional);
351        }
352        if headers.get_all("set-cookie").next().is_none() {
353            return Ok(additional);
354        }
355        let Some((user, session)) =
356            alibi_core::session::cookie_cache::runtime::published_session(req)
357        else {
358            return Ok(additional);
359        };
360        let name = Self::cookie_name(session.token(), ctx);
361        if Self::cookie_value(req, &name).is_some_and(|value| !value.is_empty())
362            || headers.get_all("set-cookie").any(|header| {
363                cookie::Cookie::parse(header.clone()).is_ok_and(|cookie| cookie.name() == name)
364            })
365        {
366            return Ok(additional);
367        }
368        let cookies = Self::signed_tokens(req, ctx);
369        let mut tokens_to_delete = Vec::new();
370        for (old_name, token) in &cookies {
371            if token.is_empty() {
372                continue;
373            }
374            if let Some(old) = ctx.database.get_session_record(token).await?
375                && old.user_id() == user.id()
376            {
377                tokens_to_delete.push(token);
378                additional.append(
379                    "set-cookie",
380                    create_derived_session_cookie(old_name, "", true, &ctx.config)?,
381                );
382            }
383        }
384        // Resolve every proof before deleting: multiple names can carry the same token.
385        for token in &tokens_to_delete {
386            ctx.database.delete_session(token).await?;
387        }
388        let removed = tokens_to_delete.len();
389        // Upstream counts every named cookie, including invalid signatures.
390        let count = Self::multi_cookies(req).len();
391        #[expect(
392            clippy::cast_precision_loss,
393            reason = "Match upstream JavaScript numeric comparison"
394        )]
395        let budget = (count.saturating_sub(removed) + 1) as f64;
396        if budget > self.config.maximum_sessions {
397            return Ok(additional);
398        }
399        let signed = sign_cookie_value(session.token(), ctx.config.current_secret());
400        additional.append(
401            "set-cookie",
402            create_derived_session_cookie(&name, &signed, false, &ctx.config)?,
403        );
404        Ok(additional)
405    }
406}
407
408#[derive(Deserialize)]
409#[serde(rename_all = "camelCase")]
410struct SessionTokenRequest {
411    session_token: String,
412}
413
414impl RequestBody for SessionTokenRequest {
415    const FIELDS: &'static [JsonField] = &[JsonField::string("sessionToken", true)];
416}
417
418#[async_trait]
419impl<S: AuthSchema> AuthPlugin<S> for MultiSessionPlugin {
420    route_openapi_metadata!(S);
421
422    fn endpoint_hooks(&self) -> Vec<&dyn alibi_core::endpoint::EndpointHook<S>> {
423        vec![self]
424    }
425
426    fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
427        endpoint::definitions()
428    }
429    fn validate_endpoint(
430        &self,
431        call: &alibi_core::endpoint::EndpointCall,
432        _ctx: &AuthContext<S>,
433    ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
434        endpoint::validate(call)
435    }
436    async fn on_endpoint(
437        &self,
438        call: &alibi_core::endpoint::EndpointCall,
439        ctx: &AuthContext<S>,
440    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
441        let output = if call.operation_id() == "setActiveSession" {
442            self.set_active_core(call, ctx, call.body_as()?).await?
443        } else if call.operation_id() == "revokeDeviceSession" {
444            self.revoke_core(call, ctx, call.body_as()?).await?
445        } else {
446            self.list(call, ctx).await?
447        };
448        let mut response = if output.status >= 400 {
449            alibi_core::endpoint::EndpointResponse::error(crate::endpoint::error_response(
450                output.clone(),
451            ))
452        } else {
453            alibi_core::endpoint::EndpointResponse::value(alibi_core::utils::json::from_slice(
454                &output.body,
455            )?)
456        };
457        response.set_status(Some(output.status));
458        response.merge_headers(output.headers);
459        Ok(response)
460    }
461    fn name(&self) -> &'static str {
462        "multi-session"
463    }
464    fn routes(&self) -> Vec<AuthRoute> {
465        vec![
466            AuthRoute::get(
467                "/multi-session/list-device-sessions",
468                "list_device_sessions",
469            ),
470            AuthRoute::post("/multi-session/set-active", "set_active_session"),
471            AuthRoute::post("/multi-session/revoke", "revoke_device_session"),
472        ]
473    }
474    async fn on_request(
475        &self,
476        req: &AuthRequest,
477        ctx: &AuthContext<S>,
478    ) -> AuthResult<Option<AuthResponse>> {
479        let response = match (req.method(), req.path()) {
480            (HttpMethod::Get, "/multi-session/list-device-sessions") => self.list(req, ctx).await?,
481            (HttpMethod::Post, "/multi-session/set-active") => self.set_active(req, ctx).await?,
482            (HttpMethod::Post, "/multi-session/revoke") => self.revoke(req, ctx).await?,
483            _ => return Ok(None),
484        };
485        Ok(Some(response))
486    }
487    async fn after_request(
488        &self,
489        req: &AuthRequest,
490        ctx: &AuthContext<S>,
491        mut response: AuthResponse,
492    ) -> AuthResult<AuthResponse> {
493        let headers = self
494            .session_hook_headers(req, req.path(), ctx, &response.headers)
495            .await?;
496        for (name, value) in headers {
497            response.headers.append(name, value);
498        }
499        Ok(response)
500    }
501}
502
503#[async_trait]
504impl<S: AuthSchema> alibi_core::endpoint::EndpointHook<S> for MultiSessionPlugin {
505    async fn after(
506        &self,
507        call: &alibi_core::endpoint::EndpointCall,
508        ctx: &AuthContext<S>,
509        mut response: alibi_core::endpoint::EndpointResponse,
510    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
511        let headers = self
512            .session_hook_headers(
513                call,
514                call.path().unwrap_or_default(),
515                ctx,
516                response.headers(),
517            )
518            .await?;
519        response.merge_headers(headers);
520        Ok(response)
521    }
522}
523
524const fn invalid_token() -> AuthError {
525    AuthError::Upstream {
526        status: 401,
527        code: codes::INVALID_SESSION_TOKEN,
528        message: "Invalid session token",
529    }
530}
531
532// LCOV_EXCL_START
533#[cfg(test)]
534mod tests {
535    #![allow(
536        clippy::unwrap_used,
537        clippy::indexing_slicing,
538        reason = "SQLite plugin contract assertions"
539    )]
540
541    use super::*;
542    use crate::test_helpers::{
543        create_auth_json_request_no_query, create_test_context, create_user_and_session,
544    };
545    use alibi_core::utils::cookie_utils::create_session_cookie;
546    use alibi_core::{CreateSession, CreateUser};
547    use chrono::Duration;
548
549    fn request_with_cookies(
550        method: HttpMethod,
551        path: &str,
552        cookies: &[String],
553        body: Option<serde_json::Value>,
554    ) -> AuthRequest {
555        let mut request = create_auth_json_request_no_query(method, path, None, body);
556        request.headers.insert("cookie".into(), cookies.join("; "));
557        request
558    }
559
560    fn pair(header: &str) -> String {
561        header.split(';').next().unwrap().to_owned()
562    }
563
564    #[tokio::test]
565    #[expect(
566        clippy::too_many_lines,
567        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
568    )]
569    async fn signed_browser_sessions_select_other_accounts_and_reject_unrelated_tokens() {
570        let ctx = create_test_context().await;
571        let plugin = MultiSessionPlugin::new();
572        let (bob, _) = create_user_and_session(
573            &ctx,
574            CreateUser::new().with_email("bob@example.test"),
575            Duration::days(1),
576        )
577        .await;
578        let (alice, _) = create_user_and_session(
579            &ctx,
580            CreateUser::new().with_email("alice@example.test"),
581            Duration::days(1),
582        )
583        .await;
584        let custom = |user_id: String, token: &str| CreateSession {
585            user_id,
586            token: Some(token.to_owned()),
587            expires_at: Utc::now() + Duration::days(1),
588            ip_address: None,
589            user_agent: None,
590            impersonated_by: None,
591            active_organization_id: None,
592            active_team_id: None,
593            additional_fields: alibi_core::field_policy::FieldValues::default(),
594        };
595        let bob_session = ctx
596            .database
597            .create_session(custom(bob.id.clone(), "z-last"))
598            .await
599            .unwrap();
600        let alice_session = ctx
601            .database
602            .create_session(custom(alice.id.clone(), "a-first"))
603            .await
604            .unwrap();
605        let cookie_for = |token: &str| {
606            format!(
607                "{}={}",
608                MultiSessionPlugin::cookie_name(token, &ctx),
609                sign_cookie_value(token, &ctx.config.secret)
610            )
611        };
612        let cookies = vec![
613            pair(&create_session_cookie(&bob_session.token, &ctx.config).unwrap()),
614            cookie_for(&bob_session.token),
615            cookie_for(&alice_session.token),
616        ];
617        let request = request_with_cookies(
618            HttpMethod::Get,
619            "/multi-session/list-device-sessions",
620            &cookies,
621            None,
622        );
623        let listed = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
624        let body: serde_json::Value = serde_json::from_slice(&listed.body).unwrap();
625        assert_eq!(body.as_array().unwrap().len(), 2);
626        // Since 1.7.12 held sessions follow cookie order, not storage order.
627        assert_eq!(body[0]["user"]["id"], bob.id);
628        assert_eq!(body[1]["user"]["id"], alice.id);
629        let duplicate = request_with_cookies(
630            HttpMethod::Get,
631            "/multi-session/list-device-sessions",
632            &[
633                cookie_for(&alice_session.token),
634                format!(
635                    "{}=invalid-last",
636                    MultiSessionPlugin::cookie_name(&alice_session.token, &ctx)
637                ),
638                cookie_for(&bob_session.token),
639            ],
640            None,
641        );
642        let duplicate = plugin.on_request(&duplicate, &ctx).await.unwrap().unwrap();
643        let listed_2: serde_json::Value = serde_json::from_slice(&duplicate.body).unwrap();
644        assert_eq!(listed_2.as_array().unwrap().len(), 1);
645        assert_eq!(listed_2[0]["user"]["id"], bob.id);
646        for (values, succeeds) in [
647            (
648                vec![
649                    cookie_for(&alice_session.token),
650                    format!(
651                        "{}=invalid-last",
652                        MultiSessionPlugin::cookie_name(&alice_session.token, &ctx)
653                    ),
654                ],
655                false,
656            ),
657            (
658                vec![
659                    format!(
660                        "{}=invalid-first",
661                        MultiSessionPlugin::cookie_name(&alice_session.token, &ctx)
662                    ),
663                    cookie_for(&alice_session.token),
664                ],
665                true,
666            ),
667        ] {
668            let selection = request_with_cookies(
669                HttpMethod::Post,
670                "/multi-session/set-active",
671                &values,
672                Some(json!({"sessionToken":alice_session.token})),
673            );
674            let result = plugin.on_request(&selection, &ctx).await;
675            if succeeds {
676                assert_eq!(result.unwrap().unwrap().status, 200);
677            } else {
678                assert_eq!(result.unwrap_err().status_code(), 401);
679            }
680        }
681        for path in ["/multi-session/revoke", "/multi-session/set-active"] {
682            let empty_proof = request_with_cookies(
683                HttpMethod::Post,
684                path,
685                &[
686                    pair(&create_session_cookie(&bob_session.token, &ctx.config).unwrap()),
687                    format!(
688                        "{}={}",
689                        MultiSessionPlugin::cookie_name(&alice_session.token, &ctx),
690                        sign_cookie_value("", &ctx.config.secret)
691                    ),
692                ],
693                Some(json!({"sessionToken":alice_session.token})),
694            );
695            let rejected = plugin
696                .on_request(&empty_proof, &ctx)
697                .await
698                .unwrap_or_else(|error| Some(error.to_auth_response()))
699                .unwrap();
700            assert_eq!(rejected.status, 401);
701            assert_eq!(
702                serde_json::from_slice::<serde_json::Value>(&rejected.body).unwrap()["code"],
703                "INVALID_SESSION_TOKEN"
704            );
705            assert!(rejected.headers.get_all("set-cookie").next().is_none());
706            assert!(
707                ctx.database
708                    .get_session(&alice_session.token)
709                    .await
710                    .unwrap()
711                    .is_some()
712            );
713            assert!(
714                ctx.database
715                    .get_session(&bob_session.token)
716                    .await
717                    .unwrap()
718                    .is_some()
719            );
720        }
721        let select = request_with_cookies(
722            HttpMethod::Post,
723            "/multi-session/set-active",
724            &cookies,
725            Some(json!({"sessionToken":alice_session.token})),
726        );
727        let selected = plugin.on_request(&select, &ctx).await.unwrap().unwrap();
728        assert_eq!(selected.status, 200);
729        let body_2: serde_json::Value = serde_json::from_slice(&selected.body).unwrap();
730        assert_eq!(body_2["user"]["id"], alice.id);
731        assert_eq!(body_2["session"]["userId"], alice.id);
732        let unrelated = create_auth_json_request_no_query(
733            HttpMethod::Post,
734            "/multi-session/set-active",
735            Some(&bob_session.token),
736            Some(json!({"sessionToken":alice_session.token})),
737        );
738        assert_eq!(
739            plugin
740                .on_request(&unrelated, &ctx)
741                .await
742                .unwrap_err()
743                .status_code(),
744            401
745        );
746        assert!(
747            ctx.database
748                .get_session(&alice_session.token)
749                .await
750                .unwrap()
751                .is_some()
752        );
753    }
754
755    #[tokio::test]
756    #[expect(
757        clippy::too_many_lines,
758        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
759    )]
760    async fn cookie_tampering_expiry_revocation_fallback_and_logout_preserve_state() {
761        let ctx = create_test_context().await;
762        let plugin = MultiSessionPlugin::new();
763        let (_, one) = create_user_and_session(
764            &ctx,
765            CreateUser::new().with_email("one@example.test"),
766            Duration::days(1),
767        )
768        .await;
769        let (_, two) = create_user_and_session(
770            &ctx,
771            CreateUser::new().with_email("two@example.test"),
772            Duration::days(1),
773        )
774        .await;
775        let first = format!(
776            "{}={}",
777            MultiSessionPlugin::cookie_name(&one.token, &ctx),
778            sign_cookie_value(&one.token, &ctx.config.secret)
779        );
780        let second = format!(
781            "{}={}",
782            MultiSessionPlugin::cookie_name(&two.token, &ctx),
783            sign_cookie_value(&two.token, &ctx.config.secret)
784        );
785        let cookies = vec![
786            pair(&create_session_cookie(&one.token, &ctx.config).unwrap()),
787            first.clone(),
788            second.clone(),
789        ];
790        let revoke = request_with_cookies(
791            HttpMethod::Post,
792            "/multi-session/revoke",
793            &cookies,
794            Some(json!({"sessionToken":one.token})),
795        );
796        let revoked = plugin.on_request(&revoke, &ctx).await.unwrap().unwrap();
797        assert_eq!(revoked.status, 200);
798        assert!(
799            ctx.database
800                .get_session(&one.token)
801                .await
802                .unwrap()
803                .is_none()
804        );
805        let new_session_cookie = revoked
806            .headers
807            .get_all("set-cookie")
808            .filter_map(|v| cookie::Cookie::parse(v.clone()).ok())
809            .find(|v| v.name() == ctx.config.session.cookie_name)
810            .unwrap();
811        assert_eq!(
812            verify_cookie_value(new_session_cookie.value(), &ctx.config.secret),
813            Some(two.token.clone())
814        );
815        let tampered = vec![second.replace('=', "=tampered-")];
816        let select = request_with_cookies(
817            HttpMethod::Post,
818            "/multi-session/set-active",
819            &tampered,
820            Some(json!({"sessionToken":two.token})),
821        );
822        assert_eq!(
823            plugin
824                .on_request(&select, &ctx)
825                .await
826                .unwrap_err()
827                .status_code(),
828            401
829        );
830        ctx.database
831            .update_session_expiry(&two.token, Utc::now() - Duration::seconds(1))
832            .await
833            .unwrap();
834        let expired = request_with_cookies(
835            HttpMethod::Post,
836            "/multi-session/set-active",
837            std::slice::from_ref(&second),
838            Some(json!({"sessionToken":two.token})),
839        );
840        let expired = plugin.on_request(&expired, &ctx).await.unwrap().unwrap();
841        assert_eq!(expired.status, 401);
842        assert!(
843            expired
844                .headers
845                .get("set-cookie")
846                .unwrap()
847                .contains("Max-Age=0")
848        );
849        let signout = request_with_cookies(HttpMethod::Post, "/sign-out", &[second], None);
850        _ = plugin
851            .after_request(
852                &signout,
853                &ctx,
854                AuthResponse::json(200, &json!({"success":true})).unwrap(),
855            )
856            .await
857            .unwrap();
858        assert!(
859            ctx.database
860                .get_session(&two.token)
861                .await
862                .unwrap()
863                .is_none()
864        );
865    }
866}
867// LCOV_EXCL_STOP