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 async fn session_hook_headers(
306 &self,
307 req: &impl alibi_core::session::SessionRequest,
308 path: &str,
309 ctx: &AuthContext<impl AuthSchema>,
310 headers: &alibi_core::Headers,
311 ) -> AuthResult<alibi_core::Headers> {
312 let mut additional = alibi_core::Headers::new();
313 if path == "/sign-out" {
314 for (name, token) in Self::signed_tokens(req, ctx) {
315 if token.is_empty() {
318 continue;
319 }
320 ctx.database.delete_session(&token).await?;
321 additional.append(
322 "set-cookie",
323 create_derived_session_cookie(
324 &name.to_lowercase().replacen("__secure-", "__Secure-", 1),
325 "",
326 true,
327 &ctx.config,
328 )?,
329 );
330 }
331 return Ok(additional);
332 }
333 if headers.get_all("set-cookie").next().is_none() {
334 return Ok(additional);
335 }
336 let Some((user, session)) =
337 alibi_core::session::cookie_cache::runtime::published_session(req)
338 else {
339 return Ok(additional);
340 };
341 let name = Self::cookie_name(session.token(), ctx);
342 if Self::cookie_value(req, &name).is_some_and(|value| !value.is_empty())
343 || headers.get_all("set-cookie").any(|header| {
344 cookie::Cookie::parse(header.clone()).is_ok_and(|cookie| cookie.name() == name)
345 })
346 {
347 return Ok(additional);
348 }
349 let cookies = Self::signed_tokens(req, ctx);
350 let mut tokens_to_delete = Vec::new();
351 for (old_name, token) in &cookies {
352 if token.is_empty() {
353 continue;
354 }
355 if let Some(old) = ctx.database.get_session_record(token).await?
356 && old.user_id() == user.id()
357 {
358 tokens_to_delete.push(token);
359 additional.append(
360 "set-cookie",
361 create_derived_session_cookie(old_name, "", true, &ctx.config)?,
362 );
363 }
364 }
365 for token in &tokens_to_delete {
367 ctx.database.delete_session(token).await?;
368 }
369 let removed = tokens_to_delete.len();
370 let count = Self::multi_cookies(req).len();
372 #[expect(
373 clippy::cast_precision_loss,
374 reason = "Match upstream JavaScript numeric comparison"
375 )]
376 let budget = (count.saturating_sub(removed) + 1) as f64;
377 if budget > self.config.maximum_sessions {
378 return Ok(additional);
379 }
380 let signed = sign_cookie_value(session.token(), ctx.config.current_secret());
381 additional.append(
382 "set-cookie",
383 create_derived_session_cookie(&name, &signed, false, &ctx.config)?,
384 );
385 Ok(additional)
386 }
387}
388
389#[derive(Deserialize)]
390#[serde(rename_all = "camelCase")]
391struct SessionTokenRequest {
392 session_token: String,
393}
394
395impl RequestBody for SessionTokenRequest {
396 const FIELDS: &'static [JsonField] = &[JsonField::string("sessionToken", true)];
397}
398
399#[async_trait]
400impl<S: AuthSchema> AuthPlugin<S> for MultiSessionPlugin {
401 route_openapi_metadata!(S);
402
403 fn endpoint_hooks(&self) -> Vec<&dyn alibi_core::endpoint::EndpointHook<S>> {
404 vec![self]
405 }
406
407 fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
408 endpoint::definitions()
409 }
410 fn validate_endpoint(
411 &self,
412 call: &alibi_core::endpoint::EndpointCall,
413 _ctx: &AuthContext<S>,
414 ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
415 endpoint::validate(call)
416 }
417 async fn on_endpoint(
418 &self,
419 call: &alibi_core::endpoint::EndpointCall,
420 ctx: &AuthContext<S>,
421 ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
422 let output = if call.operation_id() == "setActiveSession" {
423 self.set_active_core(call, ctx, call.body_as()?).await?
424 } else if call.operation_id() == "revokeDeviceSession" {
425 self.revoke_core(call, ctx, call.body_as()?).await?
426 } else {
427 self.list(call, ctx).await?
428 };
429 let mut response = if output.status >= 400 {
430 alibi_core::endpoint::EndpointResponse::error(crate::endpoint::error_response(
431 output.clone(),
432 ))
433 } else {
434 alibi_core::endpoint::EndpointResponse::value(alibi_core::utils::json::from_slice(
435 &output.body,
436 )?)
437 };
438 response.set_status(Some(output.status));
439 response.merge_headers(output.headers);
440 Ok(response)
441 }
442 fn name(&self) -> &'static str {
443 "multi-session"
444 }
445 fn routes(&self) -> Vec<AuthRoute> {
446 vec![
447 AuthRoute::get(
448 "/multi-session/list-device-sessions",
449 "list_device_sessions",
450 ),
451 AuthRoute::post("/multi-session/set-active", "set_active_session"),
452 AuthRoute::post("/multi-session/revoke", "revoke_device_session"),
453 ]
454 }
455 async fn on_request(
456 &self,
457 req: &AuthRequest,
458 ctx: &AuthContext<S>,
459 ) -> AuthResult<Option<AuthResponse>> {
460 let response = match (req.method(), req.path()) {
461 (HttpMethod::Get, "/multi-session/list-device-sessions") => self.list(req, ctx).await?,
462 (HttpMethod::Post, "/multi-session/set-active") => self.set_active(req, ctx).await?,
463 (HttpMethod::Post, "/multi-session/revoke") => self.revoke(req, ctx).await?,
464 _ => return Ok(None),
465 };
466 Ok(Some(response))
467 }
468 async fn after_request(
469 &self,
470 req: &AuthRequest,
471 ctx: &AuthContext<S>,
472 mut response: AuthResponse,
473 ) -> AuthResult<AuthResponse> {
474 let headers = self
475 .session_hook_headers(req, req.path(), ctx, &response.headers)
476 .await?;
477 for (name, value) in headers {
478 response.headers.append(name, value);
479 }
480 Ok(response)
481 }
482}
483
484#[async_trait]
485impl<S: AuthSchema> alibi_core::endpoint::EndpointHook<S> for MultiSessionPlugin {
486 async fn after(
487 &self,
488 call: &alibi_core::endpoint::EndpointCall,
489 ctx: &AuthContext<S>,
490 mut response: alibi_core::endpoint::EndpointResponse,
491 ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
492 let headers = self
493 .session_hook_headers(
494 call,
495 call.path().unwrap_or_default(),
496 ctx,
497 response.headers(),
498 )
499 .await?;
500 response.merge_headers(headers);
501 Ok(response)
502 }
503}
504
505const fn invalid_token() -> AuthError {
506 AuthError::Upstream {
507 status: 401,
508 code: codes::INVALID_SESSION_TOKEN,
509 message: "Invalid session token",
510 }
511}
512
513#[cfg(test)]
515mod tests {
516 #![allow(
517 clippy::unwrap_used,
518 clippy::indexing_slicing,
519 reason = "SQLite plugin contract assertions"
520 )]
521
522 use super::*;
523 use crate::test_helpers::{
524 create_auth_json_request_no_query, create_test_context, create_user_and_session,
525 };
526 use alibi_core::utils::cookie_utils::create_session_cookie;
527 use alibi_core::{CreateSession, CreateUser};
528 use chrono::Duration;
529
530 fn request_with_cookies(
531 method: HttpMethod,
532 path: &str,
533 cookies: &[String],
534 body: Option<serde_json::Value>,
535 ) -> AuthRequest {
536 let mut request = create_auth_json_request_no_query(method, path, None, body);
537 request.headers.insert("cookie".into(), cookies.join("; "));
538 request
539 }
540
541 fn pair(header: &str) -> String {
542 header.split(';').next().unwrap().to_owned()
543 }
544
545 #[tokio::test]
546 #[expect(
547 clippy::too_many_lines,
548 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
549 )]
550 async fn signed_browser_sessions_select_other_accounts_and_reject_unrelated_tokens() {
551 let ctx = create_test_context().await;
552 let plugin = MultiSessionPlugin::new();
553 let (bob, _) = create_user_and_session(
554 &ctx,
555 CreateUser::new().with_email("bob@example.test"),
556 Duration::days(1),
557 )
558 .await;
559 let (alice, _) = create_user_and_session(
560 &ctx,
561 CreateUser::new().with_email("alice@example.test"),
562 Duration::days(1),
563 )
564 .await;
565 let custom = |user_id: String, token: &str| CreateSession {
566 user_id,
567 token: Some(token.to_owned()),
568 expires_at: Utc::now() + Duration::days(1),
569 ip_address: None,
570 user_agent: None,
571 impersonated_by: None,
572 active_organization_id: None,
573 active_team_id: None,
574 additional_fields: alibi_core::field_policy::FieldValues::default(),
575 };
576 let bob_session = ctx
577 .database
578 .create_session(custom(bob.id.clone(), "z-last"))
579 .await
580 .unwrap();
581 let alice_session = ctx
582 .database
583 .create_session(custom(alice.id.clone(), "a-first"))
584 .await
585 .unwrap();
586 let cookie_for = |token: &str| {
587 format!(
588 "{}={}",
589 MultiSessionPlugin::cookie_name(token, &ctx),
590 sign_cookie_value(token, &ctx.config.secret)
591 )
592 };
593 let cookies = vec![
594 pair(&create_session_cookie(&bob_session.token, &ctx.config).unwrap()),
595 cookie_for(&bob_session.token),
596 cookie_for(&alice_session.token),
597 ];
598 let request = request_with_cookies(
599 HttpMethod::Get,
600 "/multi-session/list-device-sessions",
601 &cookies,
602 None,
603 );
604 let listed = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
605 let body: serde_json::Value = serde_json::from_slice(&listed.body).unwrap();
606 assert_eq!(body.as_array().unwrap().len(), 2);
607 assert_eq!(body[0]["user"]["id"], alice.id);
608 assert_eq!(body[1]["user"]["id"], bob.id);
609 let duplicate = request_with_cookies(
610 HttpMethod::Get,
611 "/multi-session/list-device-sessions",
612 &[
613 cookie_for(&alice_session.token),
614 format!(
615 "{}=invalid-last",
616 MultiSessionPlugin::cookie_name(&alice_session.token, &ctx)
617 ),
618 cookie_for(&bob_session.token),
619 ],
620 None,
621 );
622 let duplicate = plugin.on_request(&duplicate, &ctx).await.unwrap().unwrap();
623 let listed_2: serde_json::Value = serde_json::from_slice(&duplicate.body).unwrap();
624 assert_eq!(listed_2.as_array().unwrap().len(), 1);
625 assert_eq!(listed_2[0]["user"]["id"], bob.id);
626 for (values, succeeds) in [
627 (
628 vec![
629 cookie_for(&alice_session.token),
630 format!(
631 "{}=invalid-last",
632 MultiSessionPlugin::cookie_name(&alice_session.token, &ctx)
633 ),
634 ],
635 false,
636 ),
637 (
638 vec![
639 format!(
640 "{}=invalid-first",
641 MultiSessionPlugin::cookie_name(&alice_session.token, &ctx)
642 ),
643 cookie_for(&alice_session.token),
644 ],
645 true,
646 ),
647 ] {
648 let selection = request_with_cookies(
649 HttpMethod::Post,
650 "/multi-session/set-active",
651 &values,
652 Some(json!({"sessionToken":alice_session.token})),
653 );
654 let result = plugin.on_request(&selection, &ctx).await;
655 if succeeds {
656 assert_eq!(result.unwrap().unwrap().status, 200);
657 } else {
658 assert_eq!(result.unwrap_err().status_code(), 401);
659 }
660 }
661 for path in ["/multi-session/revoke", "/multi-session/set-active"] {
662 let empty_proof = request_with_cookies(
663 HttpMethod::Post,
664 path,
665 &[
666 pair(&create_session_cookie(&bob_session.token, &ctx.config).unwrap()),
667 format!(
668 "{}={}",
669 MultiSessionPlugin::cookie_name(&alice_session.token, &ctx),
670 sign_cookie_value("", &ctx.config.secret)
671 ),
672 ],
673 Some(json!({"sessionToken":alice_session.token})),
674 );
675 let rejected = plugin
676 .on_request(&empty_proof, &ctx)
677 .await
678 .unwrap_or_else(|error| Some(error.to_auth_response()))
679 .unwrap();
680 assert_eq!(rejected.status, 401);
681 assert_eq!(
682 serde_json::from_slice::<serde_json::Value>(&rejected.body).unwrap()["code"],
683 "INVALID_SESSION_TOKEN"
684 );
685 assert!(rejected.headers.get_all("set-cookie").next().is_none());
686 assert!(
687 ctx.database
688 .get_session(&alice_session.token)
689 .await
690 .unwrap()
691 .is_some()
692 );
693 assert!(
694 ctx.database
695 .get_session(&bob_session.token)
696 .await
697 .unwrap()
698 .is_some()
699 );
700 }
701 let select = request_with_cookies(
702 HttpMethod::Post,
703 "/multi-session/set-active",
704 &cookies,
705 Some(json!({"sessionToken":alice_session.token})),
706 );
707 let selected = plugin.on_request(&select, &ctx).await.unwrap().unwrap();
708 assert_eq!(selected.status, 200);
709 let body_2: serde_json::Value = serde_json::from_slice(&selected.body).unwrap();
710 assert_eq!(body_2["user"]["id"], alice.id);
711 assert_eq!(body_2["session"]["userId"], alice.id);
712 let unrelated = create_auth_json_request_no_query(
713 HttpMethod::Post,
714 "/multi-session/set-active",
715 Some(&bob_session.token),
716 Some(json!({"sessionToken":alice_session.token})),
717 );
718 assert_eq!(
719 plugin
720 .on_request(&unrelated, &ctx)
721 .await
722 .unwrap_err()
723 .status_code(),
724 401
725 );
726 assert!(
727 ctx.database
728 .get_session(&alice_session.token)
729 .await
730 .unwrap()
731 .is_some()
732 );
733 }
734
735 #[tokio::test]
736 #[expect(
737 clippy::too_many_lines,
738 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
739 )]
740 async fn cookie_tampering_expiry_revocation_fallback_and_logout_preserve_state() {
741 let ctx = create_test_context().await;
742 let plugin = MultiSessionPlugin::new();
743 let (_, one) = create_user_and_session(
744 &ctx,
745 CreateUser::new().with_email("one@example.test"),
746 Duration::days(1),
747 )
748 .await;
749 let (_, two) = create_user_and_session(
750 &ctx,
751 CreateUser::new().with_email("two@example.test"),
752 Duration::days(1),
753 )
754 .await;
755 let first = format!(
756 "{}={}",
757 MultiSessionPlugin::cookie_name(&one.token, &ctx),
758 sign_cookie_value(&one.token, &ctx.config.secret)
759 );
760 let second = format!(
761 "{}={}",
762 MultiSessionPlugin::cookie_name(&two.token, &ctx),
763 sign_cookie_value(&two.token, &ctx.config.secret)
764 );
765 let cookies = vec![
766 pair(&create_session_cookie(&one.token, &ctx.config).unwrap()),
767 first.clone(),
768 second.clone(),
769 ];
770 let revoke = request_with_cookies(
771 HttpMethod::Post,
772 "/multi-session/revoke",
773 &cookies,
774 Some(json!({"sessionToken":one.token})),
775 );
776 let revoked = plugin.on_request(&revoke, &ctx).await.unwrap().unwrap();
777 assert_eq!(revoked.status, 200);
778 assert!(
779 ctx.database
780 .get_session(&one.token)
781 .await
782 .unwrap()
783 .is_none()
784 );
785 let new_session_cookie = revoked
786 .headers
787 .get_all("set-cookie")
788 .filter_map(|v| cookie::Cookie::parse(v.clone()).ok())
789 .find(|v| v.name() == ctx.config.session.cookie_name)
790 .unwrap();
791 assert_eq!(
792 verify_cookie_value(new_session_cookie.value(), &ctx.config.secret),
793 Some(two.token.clone())
794 );
795 let tampered = vec![second.replace('=', "=tampered-")];
796 let select = request_with_cookies(
797 HttpMethod::Post,
798 "/multi-session/set-active",
799 &tampered,
800 Some(json!({"sessionToken":two.token})),
801 );
802 assert_eq!(
803 plugin
804 .on_request(&select, &ctx)
805 .await
806 .unwrap_err()
807 .status_code(),
808 401
809 );
810 ctx.database
811 .update_session_expiry(&two.token, Utc::now() - Duration::seconds(1))
812 .await
813 .unwrap();
814 let expired = request_with_cookies(
815 HttpMethod::Post,
816 "/multi-session/set-active",
817 std::slice::from_ref(&second),
818 Some(json!({"sessionToken":two.token})),
819 );
820 let expired = plugin.on_request(&expired, &ctx).await.unwrap().unwrap();
821 assert_eq!(expired.status, 401);
822 assert!(
823 expired
824 .headers
825 .get("set-cookie")
826 .unwrap()
827 .contains("Max-Age=0")
828 );
829 let signout = request_with_cookies(HttpMethod::Post, "/sign-out", &[second], None);
830 _ = plugin
831 .after_request(
832 &signout,
833 &ctx,
834 AuthResponse::json(200, &json!({"success":true})).unwrap(),
835 )
836 .await
837 .unwrap();
838 assert!(
839 ctx.database
840 .get_session(&two.token)
841 .await
842 .unwrap()
843 .is_none()
844 );
845 }
846}
847