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