Skip to main content

better_auth_api/plugins/
multi_session.rs

1//! Multiple signed device sessions with explicit active-session selection.
2
3use 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/// Device session configuration.
22#[derive(better_auth_core::PluginConfig)]
23#[plugin(name = "MultiSessionPlugin")]
24pub struct MultiSessionConfig {
25    /// Maximum remembered accounts in one browser.
26    #[config(default = 5)]
27    pub maximum_sessions: usize,
28}
29
30/// Keep one signed device cookie for each remembered account.
31pub 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}