1use better_auth_core::{
4 AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema, FieldMap,
5 FieldValue,
6 session::SessionData,
7 utils::cookie_utils::{
8 create_clear_cookie, create_cookie, get_cookie, sign_cookie_value, verify_cookie_value,
9 },
10 wire::{SessionView, UserView},
11};
12use chrono::Utc;
13
14use super::{
15 helpers::delete_session_cookies,
16 one_time_token::{
17 find_session, response_session_token, session_required, session_token_body, token_body,
18 },
19};
20
21#[derive(better_auth_core::PluginConfig)]
23#[plugin(name = "MultiSessionPlugin")]
24pub struct MultiSessionConfig {
25 #[config(default = 5)]
27 pub maximum_sessions: usize,
28}
29
30pub struct MultiSessionPlugin {
32 config: MultiSessionConfig,
33}
34
35better_auth_core::impl_auth_plugin! {
36 MultiSessionPlugin, "multi-session";
37 routes {
38 get "/multi-session/list-device-sessions" => handle_list, "listDeviceSessions", require_headers = true;
39 post "/multi-session/set-active" => handle_set_active, "setActiveSession", body = session_token_body, require_headers = true;
40 post "/multi-session/revoke" => handle_revoke, "revokeDeviceSession", body = session_token_body, require_headers = true;
41 }
42 extra {
43 async fn after_request(&self, req: &AuthRequest, response: &mut AuthResponse, ctx: &AuthContext<S>) -> AuthResult<()> {
44 better_auth_core::observability::instrumentation::with_endpoint_hook(&ctx.config, req, "after", "plugin:multi-session", self.remember_session(req, response, ctx)).await?;
45 if req.path() == "/sign-out" {
46 better_auth_core::observability::instrumentation::with_endpoint_hook(&ctx.config, req, "after", "plugin:multi-session", async {
47 let mut tokens = Vec::new();
48 for (name, token) in device_cookies(req, &ctx.config) {
49 better_auth_core::utils::cookie_utils::remove_set_cookie_entries(req, Some(&mut response.headers), &name)?;
50 response.headers.append("Set-Cookie", create_clear_cookie(&name, &ctx.config)?);
51 tokens.push(token);
52 }
53 if !tokens.is_empty() {
54 ctx.database.delete_sessions(&tokens).await?;
55 }
56 Ok(()) }).await?;
57 }
58 Ok(())
59 }
60 }
61}
62
63impl MultiSessionPlugin {
64 async fn handle_list<S: AuthSchema>(
65 &self,
66 req: &AuthRequest,
67 ctx: &AuthContext<S>,
68 ) -> AuthResult<AuthResponse> {
69 let sessions = list_sessions(req, ctx, true).await?;
70 let mut users = std::collections::HashSet::new();
71 let sessions: Vec<_> = sessions
72 .into_iter()
73 .filter(|(_, user)| users.insert(user.id.as_str().map(str::to_owned)))
74 .map(|(session, user)| FieldValue::from(FieldMap::from(SessionData { session, user })))
75 .collect();
76 Ok(AuthResponse::native(200, sessions.into()))
77 }
78
79 async fn handle_set_active<S: AuthSchema>(
80 &self,
81 req: &AuthRequest,
82 ctx: &AuthContext<S>,
83 ) -> AuthResult<AuthResponse> {
84 let token = match token_body(req, "sessionToken") {
85 Ok(token) => token,
86 Err(response) => return Ok(response),
87 };
88 let name = cookie_name(&token, &ctx.config);
89 let token = signed_device_token(req, &name, &ctx.config).ok_or_else(invalid_session)?;
90 let session = find_session(ctx, &token).await?;
91 let Some((session, user)) = session.filter(|(session, _)| {
92 session.expires_at.milliseconds() >= Utc::now().timestamp_millis() as f64
93 }) else {
94 better_auth_core::utils::cookie_utils::remove_set_cookie_entries(req, None, &name)?;
95 req.append_response_header("Set-Cookie", create_clear_cookie(&name, &ctx.config)?)?;
96 return Err(invalid_session());
97 };
98 let response = AuthResponse::json(
99 200,
100 &serde_json::json!({ "session": session, "user": user }),
101 )?;
102 ctx.session_manager()
103 .set_session_cookie(
104 req,
105 better_auth_core::session::SessionData { session, user },
106 None,
107 )
108 .await?;
109 Ok(response)
110 }
111
112 async fn handle_revoke<S: AuthSchema>(
113 &self,
114 req: &AuthRequest,
115 ctx: &AuthContext<S>,
116 ) -> AuthResult<AuthResponse> {
117 let token = match token_body(req, "sessionToken") {
118 Ok(token) => token,
119 Err(response) => return Ok(response),
120 };
121 let (_, current) = ctx.require_session(req).await.map_err(session_required)?;
122 let name = cookie_name(&token, &ctx.config);
123 let token = signed_device_token(req, &name, &ctx.config).ok_or_else(invalid_session)?;
124 ctx.database.delete_session(&token).await?;
125 better_auth_core::utils::cookie_utils::remove_set_cookie_entries(req, None, &name)?;
126 req.append_response_header("Set-Cookie", create_clear_cookie(&name, &ctx.config)?)?;
127 let response = AuthResponse::json(200, &serde_json::json!({ "status": true }))?;
128 if token != current.token {
129 return Ok(response);
130 }
131 if let Some((session, user)) = list_sessions(req, ctx, false).await?.into_iter().next() {
132 ctx.session_manager()
133 .set_session_cookie(
134 req,
135 better_auth_core::session::SessionData { session, user },
136 None,
137 )
138 .await?;
139 } else {
140 delete_session_cookies(req, &ctx.config, false, None)?;
141 }
142 Ok(response)
143 }
144
145 async fn remember_session<S: AuthSchema>(
146 &self,
147 req: &AuthRequest,
148 response: &mut AuthResponse,
149 ctx: &AuthContext<S>,
150 ) -> AuthResult<()> {
151 let Some(token) = response_session_token(response, &ctx.config) else {
152 return Ok(());
153 };
154 let Some((_, user)) = find_session(ctx, &token).await? else {
155 return Ok(());
156 };
157 let name = cookie_name(&token, &ctx.config);
158 if get_cookie(req, &name).is_some()
159 || response
160 .headers
161 .get_all("set-cookie")
162 .any(|value| value.split_once('=').is_some_and(|(key, _)| key == name))
163 {
164 return Ok(());
165 }
166 let multi_count = req
167 .headers
168 .get("cookie")
169 .map(|header| {
170 cookie::Cookie::split_parse(header)
171 .flatten()
172 .filter(|cookie| cookie.name().contains("_multi-"))
173 .map(|cookie| cookie.name().to_owned())
174 .collect::<std::collections::HashSet<_>>()
175 .len()
176 })
177 .unwrap_or_default();
178 let mut tokens_to_delete = Vec::new();
179 for (name, previous_token) in device_cookies(req, &ctx.config) {
180 if find_session(ctx, &previous_token)
181 .await?
182 .is_some_and(|(_, previous)| previous.id == user.id)
183 {
184 response
185 .headers
186 .append("Set-Cookie", create_clear_cookie(&name, &ctx.config)?);
187 tokens_to_delete.push(previous_token);
188 }
189 }
190 if !tokens_to_delete.is_empty() {
191 ctx.database.delete_sessions(&tokens_to_delete).await?;
192 }
193 if multi_count - tokens_to_delete.len() + 1 > self.config.maximum_sessions {
194 return Ok(());
195 }
196 response.headers.append(
197 "Set-Cookie",
198 create_cookie(
199 &name,
200 &sign_cookie_value(&token, ctx.config.signing_secret()),
201 ctx.config.session.expires_in().as_seconds_f64(),
202 &ctx.config,
203 )?,
204 );
205 Ok(())
206 }
207}
208
209fn cookie_name(token: &str, config: &better_auth_core::AuthConfig) -> String {
210 format!(
211 "{}_multi-{}",
212 config.auth_cookie("session_token", Default::default()).name,
213 token.to_lowercase()
214 )
215}
216
217fn invalid_session() -> AuthError {
218 AuthError::Upstream {
219 status: 401,
220 code: "INVALID_SESSION_TOKEN",
221 message: "Invalid session token",
222 }
223}
224
225fn signed_device_token(
226 req: &AuthRequest,
227 name: &str,
228 config: &better_auth_core::AuthConfig,
229) -> Option<String> {
230 get_cookie(req, name).and_then(|value| verify_cookie_value(&value, config.signing_secret()))
231}
232
233fn device_cookies(
234 req: &AuthRequest,
235 config: &better_auth_core::AuthConfig,
236) -> Vec<(String, String)> {
237 let mut seen = std::collections::HashSet::new();
238 req.headers
239 .get("cookie")
240 .map(|header| {
241 cookie::Cookie::split_parse(header)
242 .flatten()
243 .filter(|cookie| cookie.name().contains("_multi-"))
244 .filter(|cookie| seen.insert(cookie.name().to_owned()))
245 .filter_map(|cookie| {
246 verify_cookie_value(cookie.value(), config.signing_secret())
247 .map(|token| (cookie.name().to_owned(), token))
248 })
249 .collect()
250 })
251 .unwrap_or_default()
252}
253
254async fn list_sessions<S: AuthSchema>(
255 req: &AuthRequest,
256 ctx: &AuthContext<S>,
257 only_active: bool,
258) -> AuthResult<Vec<(SessionView, UserView)>> {
259 let tokens = device_cookies(req, &ctx.config)
260 .into_iter()
261 .map(|(_, token)| token)
262 .collect::<Vec<_>>();
263 let mut sessions = Vec::new();
264 let mut missing_user = false;
265 for (session, snapshot) in ctx
266 .database
267 .get_session_snapshots(&tokens, only_active)
268 .await?
269 {
270 let user = if let Some(data) = snapshot {
271 data.into_typed()?.map(|data| data.user)
272 } else {
273 ctx.database.get_user_by_id_field(&session.user_id).await?
274 };
275 if let Some(user) = user {
276 if session.expires_at.milliseconds() > Utc::now().timestamp_millis() as f64 {
277 sessions.push((session, user));
278 }
279 } else {
280 missing_user = true;
281 }
282 }
283 if missing_user {
284 sessions.clear();
285 }
286 Ok(sessions)
287}