Skip to main content

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