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}
306
307#[derive(Deserialize)]
308#[serde(rename_all = "camelCase")]
309struct SessionTokenRequest {
310    session_token: String,
311}
312
313impl RequestBody for SessionTokenRequest {
314    const FIELDS: &'static [JsonField] = &[JsonField::string("sessionToken", true)];
315}
316
317#[async_trait]
318impl<S: AuthSchema> AuthPlugin<S> for MultiSessionPlugin {
319    route_openapi_metadata!(S);
320
321    fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
322        endpoint::definitions()
323    }
324    fn validate_endpoint(
325        &self,
326        call: &alibi_core::endpoint::EndpointCall,
327        _ctx: &AuthContext<S>,
328    ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
329        endpoint::validate(call)
330    }
331    async fn on_endpoint(
332        &self,
333        call: &alibi_core::endpoint::EndpointCall,
334        ctx: &AuthContext<S>,
335    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
336        let output = if call.operation_id() == "setActiveSession" {
337            self.set_active_core(call, ctx, call.body_as()?).await?
338        } else if call.operation_id() == "revokeDeviceSession" {
339            self.revoke_core(call, ctx, call.body_as()?).await?
340        } else {
341            self.list(call, ctx).await?
342        };
343        let mut response = if output.status >= 400 {
344            alibi_core::endpoint::EndpointResponse::error(crate::endpoint::error_response(
345                output.clone(),
346            ))
347        } else {
348            alibi_core::endpoint::EndpointResponse::value(alibi_core::utils::json::from_slice(
349                &output.body,
350            )?)
351        };
352        response.set_status(Some(output.status));
353        response.merge_headers(output.headers);
354        Ok(response)
355    }
356    fn name(&self) -> &'static str {
357        "multi-session"
358    }
359    fn routes(&self) -> Vec<AuthRoute> {
360        vec![
361            AuthRoute::get(
362                "/multi-session/list-device-sessions",
363                "list_device_sessions",
364            ),
365            AuthRoute::post("/multi-session/set-active", "set_active_session"),
366            AuthRoute::post("/multi-session/revoke", "revoke_device_session"),
367        ]
368    }
369    async fn on_request(
370        &self,
371        req: &AuthRequest,
372        ctx: &AuthContext<S>,
373    ) -> AuthResult<Option<AuthResponse>> {
374        let response = match (req.method(), req.path()) {
375            (HttpMethod::Get, "/multi-session/list-device-sessions") => self.list(req, ctx).await?,
376            (HttpMethod::Post, "/multi-session/set-active") => self.set_active(req, ctx).await?,
377            (HttpMethod::Post, "/multi-session/revoke") => self.revoke(req, ctx).await?,
378            _ => return Ok(None),
379        };
380        Ok(Some(response))
381    }
382    async fn after_request(
383        &self,
384        req: &AuthRequest,
385        ctx: &AuthContext<S>,
386        mut response: AuthResponse,
387    ) -> AuthResult<AuthResponse> {
388        if req.path() == "/sign-out" {
389            for (name, token) in Self::signed_tokens(req, ctx) {
390                // The logout hook grants retirement only to truthy signed payloads.
391                // List and fallback intentionally retain string-valued semantics.
392                if token.is_empty() {
393                    continue;
394                }
395                ctx.database.delete_session(&token).await?;
396                response.headers.append(
397                    "set-cookie",
398                    create_derived_session_cookie(
399                        &name.to_lowercase().replacen("__secure-", "__Secure-", 1),
400                        "",
401                        true,
402                        &ctx.config,
403                    )?,
404                );
405            }
406            return Ok(response);
407        }
408        if response.headers.get_all("set-cookie").next().is_none() {
409            return Ok(response);
410        }
411        let Some((user, session)) =
412            alibi_core::session::cookie_cache::runtime::published_session(req)
413        else {
414            return Ok(response);
415        };
416        let name = Self::cookie_name(session.token(), ctx);
417        if Self::cookie_value(req, &name).is_some_and(|value| !value.is_empty())
418            || response.headers.get_all("set-cookie").any(|header| {
419                cookie::Cookie::parse(header.clone()).is_ok_and(|cookie| cookie.name() == name)
420            })
421        {
422            return Ok(response);
423        }
424        let cookies = Self::signed_tokens(req, ctx);
425        let mut tokens_to_delete = Vec::new();
426        for (old_name, token) in &cookies {
427            if token.is_empty() {
428                continue;
429            }
430            if let Some(old) = ctx.database.get_session_record(token).await?
431                && old.user_id() == user.id()
432            {
433                tokens_to_delete.push(token);
434                response.headers.append(
435                    "set-cookie",
436                    create_derived_session_cookie(old_name, "", true, &ctx.config)?,
437                );
438            }
439        }
440        // Resolve every proof before deleting: multiple names can carry the same token.
441        for token in &tokens_to_delete {
442            ctx.database.delete_session(token).await?;
443        }
444        let removed = tokens_to_delete.len();
445        // Upstream counts every named cookie, including invalid signatures.
446        let count = Self::multi_cookies(req).len();
447        #[expect(
448            clippy::cast_precision_loss,
449            reason = "Match upstream JavaScript numeric comparison"
450        )]
451        let budget = (count.saturating_sub(removed) + 1) as f64;
452        if budget > self.config.maximum_sessions {
453            return Ok(response);
454        }
455        let signed = sign_cookie_value(session.token(), ctx.config.current_secret());
456        response.headers.append(
457            "set-cookie",
458            create_derived_session_cookie(&name, &signed, false, &ctx.config)?,
459        );
460        Ok(response)
461    }
462}
463
464const fn invalid_token() -> AuthError {
465    AuthError::Upstream {
466        status: 401,
467        code: codes::INVALID_SESSION_TOKEN,
468        message: "Invalid session token",
469    }
470}
471
472// LCOV_EXCL_START
473#[cfg(test)]
474mod tests {
475    #![allow(
476        clippy::unwrap_used,
477        clippy::indexing_slicing,
478        reason = "SQLite plugin contract assertions"
479    )]
480
481    use super::*;
482    use crate::test_helpers::{
483        create_auth_json_request_no_query, create_test_context, create_user_and_session,
484    };
485    use alibi_core::utils::cookie_utils::create_session_cookie;
486    use alibi_core::{CreateSession, CreateUser};
487    use chrono::Duration;
488
489    fn request_with_cookies(
490        method: HttpMethod,
491        path: &str,
492        cookies: &[String],
493        body: Option<serde_json::Value>,
494    ) -> AuthRequest {
495        let mut request = create_auth_json_request_no_query(method, path, None, body);
496        request.headers.insert("cookie".into(), cookies.join("; "));
497        request
498    }
499
500    fn pair(header: &str) -> String {
501        header.split(';').next().unwrap().to_owned()
502    }
503
504    #[tokio::test]
505    #[expect(
506        clippy::too_many_lines,
507        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
508    )]
509    async fn signed_browser_sessions_select_other_accounts_and_reject_unrelated_tokens() {
510        let ctx = create_test_context().await;
511        let plugin = MultiSessionPlugin::new();
512        let (bob, _) = create_user_and_session(
513            &ctx,
514            CreateUser::new().with_email("bob@example.test"),
515            Duration::days(1),
516        )
517        .await;
518        let (alice, _) = create_user_and_session(
519            &ctx,
520            CreateUser::new().with_email("alice@example.test"),
521            Duration::days(1),
522        )
523        .await;
524        let custom = |user_id: String, token: &str| CreateSession {
525            user_id,
526            token: Some(token.to_owned()),
527            expires_at: Utc::now() + Duration::days(1),
528            ip_address: None,
529            user_agent: None,
530            impersonated_by: None,
531            active_organization_id: None,
532            active_team_id: None,
533            additional_fields: alibi_core::field_policy::FieldValues::default(),
534        };
535        let bob_session = ctx
536            .database
537            .create_session(custom(bob.id.clone(), "z-last"))
538            .await
539            .unwrap();
540        let alice_session = ctx
541            .database
542            .create_session(custom(alice.id.clone(), "a-first"))
543            .await
544            .unwrap();
545        let cookie_for = |token: &str| {
546            format!(
547                "{}={}",
548                MultiSessionPlugin::cookie_name(token, &ctx),
549                sign_cookie_value(token, &ctx.config.secret)
550            )
551        };
552        let cookies = vec![
553            pair(&create_session_cookie(&bob_session.token, &ctx.config).unwrap()),
554            cookie_for(&bob_session.token),
555            cookie_for(&alice_session.token),
556        ];
557        let request = request_with_cookies(
558            HttpMethod::Get,
559            "/multi-session/list-device-sessions",
560            &cookies,
561            None,
562        );
563        let listed = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
564        let body: serde_json::Value = serde_json::from_slice(&listed.body).unwrap();
565        assert_eq!(body.as_array().unwrap().len(), 2);
566        assert_eq!(body[0]["user"]["id"], alice.id);
567        assert_eq!(body[1]["user"]["id"], bob.id);
568        let duplicate = request_with_cookies(
569            HttpMethod::Get,
570            "/multi-session/list-device-sessions",
571            &[
572                cookie_for(&alice_session.token),
573                format!(
574                    "{}=invalid-last",
575                    MultiSessionPlugin::cookie_name(&alice_session.token, &ctx)
576                ),
577                cookie_for(&bob_session.token),
578            ],
579            None,
580        );
581        let duplicate = plugin.on_request(&duplicate, &ctx).await.unwrap().unwrap();
582        let listed_2: serde_json::Value = serde_json::from_slice(&duplicate.body).unwrap();
583        assert_eq!(listed_2.as_array().unwrap().len(), 1);
584        assert_eq!(listed_2[0]["user"]["id"], bob.id);
585        for (values, succeeds) in [
586            (
587                vec![
588                    cookie_for(&alice_session.token),
589                    format!(
590                        "{}=invalid-last",
591                        MultiSessionPlugin::cookie_name(&alice_session.token, &ctx)
592                    ),
593                ],
594                false,
595            ),
596            (
597                vec![
598                    format!(
599                        "{}=invalid-first",
600                        MultiSessionPlugin::cookie_name(&alice_session.token, &ctx)
601                    ),
602                    cookie_for(&alice_session.token),
603                ],
604                true,
605            ),
606        ] {
607            let selection = request_with_cookies(
608                HttpMethod::Post,
609                "/multi-session/set-active",
610                &values,
611                Some(json!({"sessionToken":alice_session.token})),
612            );
613            let result = plugin.on_request(&selection, &ctx).await;
614            if succeeds {
615                assert_eq!(result.unwrap().unwrap().status, 200);
616            } else {
617                assert_eq!(result.unwrap_err().status_code(), 401);
618            }
619        }
620        for path in ["/multi-session/revoke", "/multi-session/set-active"] {
621            let empty_proof = request_with_cookies(
622                HttpMethod::Post,
623                path,
624                &[
625                    pair(&create_session_cookie(&bob_session.token, &ctx.config).unwrap()),
626                    format!(
627                        "{}={}",
628                        MultiSessionPlugin::cookie_name(&alice_session.token, &ctx),
629                        sign_cookie_value("", &ctx.config.secret)
630                    ),
631                ],
632                Some(json!({"sessionToken":alice_session.token})),
633            );
634            let rejected = plugin
635                .on_request(&empty_proof, &ctx)
636                .await
637                .unwrap_or_else(|error| Some(error.to_auth_response()))
638                .unwrap();
639            assert_eq!(rejected.status, 401);
640            assert_eq!(
641                serde_json::from_slice::<serde_json::Value>(&rejected.body).unwrap()["code"],
642                "INVALID_SESSION_TOKEN"
643            );
644            assert!(rejected.headers.get_all("set-cookie").next().is_none());
645            assert!(
646                ctx.database
647                    .get_session(&alice_session.token)
648                    .await
649                    .unwrap()
650                    .is_some()
651            );
652            assert!(
653                ctx.database
654                    .get_session(&bob_session.token)
655                    .await
656                    .unwrap()
657                    .is_some()
658            );
659        }
660        let select = request_with_cookies(
661            HttpMethod::Post,
662            "/multi-session/set-active",
663            &cookies,
664            Some(json!({"sessionToken":alice_session.token})),
665        );
666        let selected = plugin.on_request(&select, &ctx).await.unwrap().unwrap();
667        assert_eq!(selected.status, 200);
668        let body_2: serde_json::Value = serde_json::from_slice(&selected.body).unwrap();
669        assert_eq!(body_2["user"]["id"], alice.id);
670        assert_eq!(body_2["session"]["userId"], alice.id);
671        let unrelated = create_auth_json_request_no_query(
672            HttpMethod::Post,
673            "/multi-session/set-active",
674            Some(&bob_session.token),
675            Some(json!({"sessionToken":alice_session.token})),
676        );
677        assert_eq!(
678            plugin
679                .on_request(&unrelated, &ctx)
680                .await
681                .unwrap_err()
682                .status_code(),
683            401
684        );
685        assert!(
686            ctx.database
687                .get_session(&alice_session.token)
688                .await
689                .unwrap()
690                .is_some()
691        );
692    }
693
694    #[tokio::test]
695    #[expect(
696        clippy::too_many_lines,
697        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
698    )]
699    async fn cookie_tampering_expiry_revocation_fallback_and_logout_preserve_state() {
700        let ctx = create_test_context().await;
701        let plugin = MultiSessionPlugin::new();
702        let (_, one) = create_user_and_session(
703            &ctx,
704            CreateUser::new().with_email("one@example.test"),
705            Duration::days(1),
706        )
707        .await;
708        let (_, two) = create_user_and_session(
709            &ctx,
710            CreateUser::new().with_email("two@example.test"),
711            Duration::days(1),
712        )
713        .await;
714        let first = format!(
715            "{}={}",
716            MultiSessionPlugin::cookie_name(&one.token, &ctx),
717            sign_cookie_value(&one.token, &ctx.config.secret)
718        );
719        let second = format!(
720            "{}={}",
721            MultiSessionPlugin::cookie_name(&two.token, &ctx),
722            sign_cookie_value(&two.token, &ctx.config.secret)
723        );
724        let cookies = vec![
725            pair(&create_session_cookie(&one.token, &ctx.config).unwrap()),
726            first.clone(),
727            second.clone(),
728        ];
729        let revoke = request_with_cookies(
730            HttpMethod::Post,
731            "/multi-session/revoke",
732            &cookies,
733            Some(json!({"sessionToken":one.token})),
734        );
735        let revoked = plugin.on_request(&revoke, &ctx).await.unwrap().unwrap();
736        assert_eq!(revoked.status, 200);
737        assert!(
738            ctx.database
739                .get_session(&one.token)
740                .await
741                .unwrap()
742                .is_none()
743        );
744        let new_session_cookie = revoked
745            .headers
746            .get_all("set-cookie")
747            .filter_map(|v| cookie::Cookie::parse(v.clone()).ok())
748            .find(|v| v.name() == ctx.config.session.cookie_name)
749            .unwrap();
750        assert_eq!(
751            verify_cookie_value(new_session_cookie.value(), &ctx.config.secret),
752            Some(two.token.clone())
753        );
754        let tampered = vec![second.replace('=', "=tampered-")];
755        let select = request_with_cookies(
756            HttpMethod::Post,
757            "/multi-session/set-active",
758            &tampered,
759            Some(json!({"sessionToken":two.token})),
760        );
761        assert_eq!(
762            plugin
763                .on_request(&select, &ctx)
764                .await
765                .unwrap_err()
766                .status_code(),
767            401
768        );
769        ctx.database
770            .update_session_expiry(&two.token, Utc::now() - Duration::seconds(1))
771            .await
772            .unwrap();
773        let expired = request_with_cookies(
774            HttpMethod::Post,
775            "/multi-session/set-active",
776            std::slice::from_ref(&second),
777            Some(json!({"sessionToken":two.token})),
778        );
779        let expired = plugin.on_request(&expired, &ctx).await.unwrap().unwrap();
780        assert_eq!(expired.status, 401);
781        assert!(
782            expired
783                .headers
784                .get("set-cookie")
785                .unwrap()
786                .contains("Max-Age=0")
787        );
788        let signout = request_with_cookies(HttpMethod::Post, "/sign-out", &[second], None);
789        _ = plugin
790            .after_request(
791                &signout,
792                &ctx,
793                AuthResponse::json(200, &json!({"success":true})).unwrap(),
794            )
795            .await
796            .unwrap();
797        assert!(
798            ctx.database
799                .get_session(&two.token)
800                .await
801                .unwrap()
802                .is_none()
803        );
804    }
805}
806// LCOV_EXCL_STOP