1mod 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#[derive(Clone, Debug)]
22pub struct MultiSessionConfig {
23 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 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 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 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 for token in &tokens_to_delete {
441 ctx.database.delete_session(token).await?;
442 }
443 let removed = tokens_to_delete.len();
444 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#[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