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