Skip to main content

alibi_api/plugins/multi_session/
mod.rs

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