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