use better_auth_core::{
AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema, FieldMap,
FieldValue,
session::NativeSessionData,
utils::cookie_utils::{
create_clear_cookie, create_cookie, get_cookie, sign_cookie_value, verify_cookie_value,
},
};
use chrono::Utc;
use super::{
helpers::delete_session_cookies,
one_time_token::{find_session, session_required, session_token_body, token_body},
};
#[derive(better_auth_core::PluginConfig)]
#[plugin(name = "MultiSessionPlugin")]
pub struct MultiSessionConfig {
#[config(default = 5)]
pub maximum_sessions: usize,
}
pub struct MultiSessionPlugin {
config: MultiSessionConfig,
}
better_auth_core::impl_auth_plugin! {
MultiSessionPlugin, "multi-session";
routes {
get "/multi-session/list-device-sessions" => handle_list, "listDeviceSessions", require_headers = true;
post "/multi-session/set-active" => handle_set_active, "setActiveSession", body = session_token_body, require_headers = true;
post "/multi-session/revoke" => handle_revoke, "revokeDeviceSession", body = session_token_body, require_headers = true;
}
extra {
async fn after_request(&self, req: &AuthRequest, response: &mut AuthResponse, ctx: &AuthContext<S>) -> AuthResult<()> {
better_auth_core::observability::instrumentation::with_endpoint_hook(&ctx.config, req, "after", "plugin:multi-session", self.remember_session(req, response, ctx)).await?;
if req.path() == "/sign-out" {
better_auth_core::observability::instrumentation::with_endpoint_hook(&ctx.config, req, "after", "plugin:multi-session", async {
let mut tokens = Vec::new();
for (name, token) in device_cookies(req, &ctx.config) {
better_auth_core::utils::cookie_utils::remove_set_cookie_entries(req, Some(&mut response.headers), &name)?;
response.headers.append("Set-Cookie", create_clear_cookie(&name, &ctx.config)?);
tokens.push(token);
}
if !tokens.is_empty() {
ctx.database.delete_sessions(&tokens).await?;
}
Ok(()) }).await?;
}
Ok(())
}
}
}
impl MultiSessionPlugin {
async fn handle_list<S: AuthSchema>(
&self,
req: &AuthRequest,
ctx: &AuthContext<S>,
) -> AuthResult<AuthResponse> {
let sessions = list_sessions(req, ctx, true).await?;
let mut users: Vec<FieldValue> = Vec::new();
let mut output = Vec::new();
for data in sessions {
let id = data.user_field("id")?;
if users.iter().any(|seen| seen.strict_equals(&id)) {
continue;
}
users.push(id);
output.push(data.public_data(&ctx.config)?.field_value());
}
Ok(AuthResponse::native(None, output.into()))
}
async fn handle_set_active<S: AuthSchema>(
&self,
req: &AuthRequest,
ctx: &AuthContext<S>,
) -> AuthResult<AuthResponse> {
let token = token_body(req, "sessionToken")?;
let name = cookie_name(&token, &ctx.config);
let token = signed_device_token(req, &name, &ctx.config).ok_or_else(invalid_session)?;
let session = match find_session(ctx, &token.into()).await? {
Some(data)
if !better_auth_core::SchemaValue::<better_auth_core::FieldDate>::from_field(
data.session_property("expiresAt")?,
)
.is_before(Utc::now())? =>
{
Some(data)
}
_ => None,
};
let Some(data) = session else {
better_auth_core::utils::cookie_utils::remove_set_cookie_entries(req, None, &name)?;
req.append_response_header("Set-Cookie", create_clear_cookie(&name, &ctx.config)?)?;
return Err(invalid_session());
};
ctx.session_manager()
.set_native_session_cookie(req, data.clone(), None)
.await?;
Ok(AuthResponse::native(
None,
data.public_data(&ctx.config)?.field_value(),
))
}
async fn handle_revoke<S: AuthSchema>(
&self,
req: &AuthRequest,
ctx: &AuthContext<S>,
) -> AuthResult<AuthResponse> {
let token = token_body(req, "sessionToken")?;
let current = ctx
.require_native_session(req)
.await
.map_err(session_required)?;
let name = cookie_name(&token, &ctx.config);
let token = signed_device_token(req, &name, &ctx.config).ok_or_else(invalid_session)?;
ctx.database.delete_session(&token).await?;
better_auth_core::utils::cookie_utils::remove_set_cookie_entries(req, None, &name)?;
req.append_response_header("Set-Cookie", create_clear_cookie(&name, &ctx.config)?)?;
let response = AuthResponse::json(None, &serde_json::json!({ "status": true }))?;
if !current
.session_property("token")?
.strict_equals(&token.into())
{
return Ok(response);
}
if let Some(data) = list_sessions(req, ctx, false).await?.into_iter().next() {
ctx.session_manager()
.set_native_session_cookie(req, data, None)
.await?;
} else {
delete_session_cookies(req, &ctx.config, false, None)?;
}
Ok(response)
}
async fn remember_session<S: AuthSchema>(
&self,
req: &AuthRequest,
response: &mut AuthResponse,
ctx: &AuthContext<S>,
) -> AuthResult<()> {
if !response.headers.contains_key("set-cookie") {
return Ok(());
}
let cookie = ctx.config.auth_cookie("session_token", Default::default());
let adds_session = response
.headers
.get_all("set-cookie")
.any(|value| value.contains(&cookie.name));
let Some(data) = req.new_session()? else {
return Ok(());
};
let token_value =
better_auth_core::SchemaValue::<String>::from_field(data.session_property("token")?);
let token = token_value.typed()?;
let name = cookie_name(token, &ctx.config);
if get_cookie(req, &name).is_some_and(|value| !value.is_empty())
|| response
.headers
.get_all("set-cookie")
.any(|value| value.split_once('=').is_some_and(|(key, _)| key == name))
{
return Ok(());
}
let multi_count = req
.headers
.get("cookie")
.map(|header| {
cookie::Cookie::split_parse(header)
.flatten()
.filter(|cookie| cookie.name().contains("_multi-"))
.map(|cookie| cookie.name().to_owned())
.collect::<std::collections::HashSet<_>>()
.len()
})
.unwrap_or_default();
let mut tokens_to_delete = Vec::new();
for (name, previous_token) in device_cookies(req, &ctx.config) {
if previous_token.is_empty() {
continue;
}
let previous_id = find_session(ctx, &previous_token.as_str().into())
.await?
.map(|previous| previous.user_field("id"))
.transpose()?
.unwrap_or_default();
if previous_id.strict_equals(&data.user_field("id")?) {
response
.headers
.append("Set-Cookie", create_clear_cookie(&name, &ctx.config)?);
tokens_to_delete.push(previous_token);
}
}
if !tokens_to_delete.is_empty() {
ctx.database.delete_sessions(&tokens_to_delete).await?;
}
if multi_count - tokens_to_delete.len() + usize::from(adds_session)
> self.config.maximum_sessions
{
return Ok(());
}
response.headers.append(
"Set-Cookie",
create_cookie(
&name,
&sign_cookie_value(token, ctx.config.signing_secret()),
ctx.config.session.expires_in().as_seconds_f64(),
&ctx.config,
)?,
);
Ok(())
}
}
fn cookie_name(token: &str, config: &better_auth_core::AuthConfig) -> String {
format!(
"{}_multi-{}",
config.auth_cookie("session_token", Default::default()).name,
token.to_lowercase()
)
}
fn invalid_session() -> AuthError {
AuthError::from_code(
"UNAUTHORIZED",
"INVALID_SESSION_TOKEN",
"Invalid session token",
)
}
fn signed_device_token(
req: &AuthRequest,
name: &str,
config: &better_auth_core::AuthConfig,
) -> Option<String> {
get_cookie(req, name)
.and_then(|value| verify_cookie_value(&value, config.signing_secret()))
.filter(|token| !token.is_empty())
}
fn device_cookies(
req: &AuthRequest,
config: &better_auth_core::AuthConfig,
) -> Vec<(String, String)> {
let mut seen = std::collections::HashSet::new();
req.headers
.get("cookie")
.map(|header| {
cookie::Cookie::split_parse(header)
.flatten()
.filter(|cookie| cookie.name().contains("_multi-"))
.filter(|cookie| seen.insert(cookie.name().to_owned()))
.filter_map(|cookie| {
verify_cookie_value(cookie.value(), config.signing_secret())
.map(|token| (cookie.name().to_owned(), token))
})
.collect()
})
.unwrap_or_default()
}
async fn list_sessions<S: AuthSchema>(
req: &AuthRequest,
ctx: &AuthContext<S>,
only_active: bool,
) -> AuthResult<Vec<NativeSessionData>> {
let tokens = device_cookies(req, &ctx.config)
.into_iter()
.map(|(_, token)| token)
.collect::<Vec<_>>();
if tokens.is_empty() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
let mut missing_user = false;
for (session, snapshot) in ctx
.database
.get_session_snapshots(&tokens, only_active)
.await?
{
let data = if let Some(data) = snapshot {
NativeSessionData::from(data)
} else {
let user = ctx.database.get_user_by_id_field(&session.user_id).await?;
NativeSessionData::new(
session,
user.map(FieldMap::from)
.map(FieldValue::from)
.unwrap_or(FieldValue::Null),
)
};
if data.user_value()?.is_truthy() {
if better_auth_core::query::field_number(&data.session_property("expiresAt")?)?
> Utc::now().timestamp_millis() as f64
{
sessions.push(data);
}
} else {
missing_user = true;
}
}
if missing_user {
sessions.clear();
}
Ok(sessions)
}
#[cfg(test)]
mod tests;