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