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