use std::sync::Arc;
use async_trait::async_trait;
use better_auth_core::{
AuthContext, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
AuthSchema, FieldValue, HttpMethod, ResponseBody, session::NativeSessionData,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomSessionInput {
#[serde(flatten)]
pub data: NativeSessionData,
#[serde(skip_serializing_if = "Option::is_none")]
pub needs_refresh: Option<bool>,
}
impl CustomSessionInput {
fn from_field_value(value: &FieldValue) -> AuthResult<Self> {
let fields = value.as_object().ok_or_else(|| {
better_auth_core::AuthError::internal("Custom session input must be an object")
})?;
Ok(Self {
data: NativeSessionData::from_native(value.clone(), false),
needs_refresh: fields.get("needsRefresh")?.unwrap_or_default().decode()?,
})
}
fn from_response(body: &ResponseBody) -> AuthResult<Option<Self>> {
match body {
ResponseBody::Native(FieldValue::Null | FieldValue::Undefined) => Ok(None),
ResponseBody::Empty => Ok(None),
ResponseBody::Native(value) => Self::from_field_value(value).map(Some),
ResponseBody::Bytes(bytes) => Ok(serde_json::from_slice(bytes)?),
ResponseBody::Binary(_) | ResponseBody::Blob(_) => Err(
better_auth_core::AuthError::type_error("Custom session input must be an object"),
),
}
}
fn list_from_response(body: &ResponseBody) -> AuthResult<Option<Vec<Self>>> {
match body {
ResponseBody::Native(FieldValue::Null | FieldValue::Undefined) => Ok(None),
ResponseBody::Empty => Ok(None),
ResponseBody::Native(FieldValue::Array(values)) => values
.iter()?
.map(|value| Self::from_field_value(&value?))
.collect::<AuthResult<_>>()
.map(Some),
ResponseBody::Native(_) => Err(better_auth_core::AuthError::internal(
"Device sessions response must be an array",
)),
ResponseBody::Bytes(bytes) => Ok(serde_json::from_slice(bytes)?),
ResponseBody::Binary(_) | ResponseBody::Blob(_) => {
Err(better_auth_core::AuthError::type_error(
"Device sessions response must be an array",
))
}
}
}
}
#[async_trait]
pub trait CustomSessionCallback<S: AuthSchema>: Send + Sync + 'static {
async fn customize(
&self,
session: CustomSessionInput,
request: &AuthRequest,
context: &AuthContext<S>,
) -> AuthResult<Value>;
}
pub struct CustomSessionPlugin<S: AuthSchema> {
callback: Arc<dyn CustomSessionCallback<S>>,
mutate_list_device_sessions: bool,
}
impl<S: AuthSchema> Clone for CustomSessionPlugin<S> {
fn clone(&self) -> Self {
Self {
callback: self.callback.clone(),
mutate_list_device_sessions: self.mutate_list_device_sessions,
}
}
}
impl<S: AuthSchema> CustomSessionPlugin<S> {
pub fn new(callback: impl CustomSessionCallback<S>) -> Self {
Self {
callback: Arc::new(callback),
mutate_list_device_sessions: false,
}
}
pub fn mutate_list_device_sessions(mut self, enabled: bool) -> Self {
self.mutate_list_device_sessions = enabled;
self
}
pub(crate) async fn get_session(
&self,
request: &AuthRequest,
context: &AuthContext<S>,
) -> AuthResult<AuthResponse> {
let response = super::session_management::SessionManagementPlugin::new()
.handle_get_session(request, context)
.await;
let mut response = match response {
Ok(response) => response,
Err(_) => {
let _ = request.take_response_headers()?;
return AuthResponse::json(None, &Value::Null);
}
};
let data = CustomSessionInput::from_response(&response.body)?;
if let Some(data) = data {
let value = self.callback.customize(data, request, context).await?;
response.replace_json(&value)?;
}
Ok(response)
}
}
#[async_trait]
impl<S: AuthSchema> AuthPlugin<S> for CustomSessionPlugin<S> {
fn name(&self) -> &'static str {
"custom-session"
}
fn routes(&self) -> Vec<AuthRoute> {
vec![
AuthRoute::get("/get-session", "getSession")
.require_headers(true)
.query_validator_native(better_auth_core::query::session_query_native),
]
}
async fn on_init(&self, context: &mut AuthInitContext<S>) -> AuthResult<()> {
context.extensions.insert(self.clone());
Ok(())
}
async fn on_request(
&self,
request: &AuthRequest,
context: &AuthContext<S>,
) -> AuthResult<Option<AuthResponse>> {
match (request.method(), request.path()) {
(HttpMethod::Get, "/get-session") => self.get_session(request, context).await.map(Some),
(HttpMethod::Post, "/get-session") => Ok(Some(AuthResponse::new(404))),
_ => Ok(None),
}
}
async fn after_request(
&self,
request: &AuthRequest,
response: &mut AuthResponse,
context: &AuthContext<S>,
) -> AuthResult<()> {
if !(self.mutate_list_device_sessions
&& request.path() == "/multi-session/list-device-sessions")
{
return Ok(());
}
better_auth_core::observability::instrumentation::with_endpoint_hook(
&context.config,
request,
"after",
"plugin:custom-session",
async {
if !self.mutate_list_device_sessions
|| request.path() != "/multi-session/list-device-sessions"
|| response.status != 200
{
return Ok(());
}
let sessions = CustomSessionInput::list_from_response(&response.body)?;
if let Some(sessions) = sessions {
let context = Arc::new(context.clone());
let request = Arc::new(request.clone());
let callbacks = sessions.into_iter().map(|session| {
let callback = self.callback.clone();
let context = context.clone();
let request = request.clone();
async move { callback.customize(session, &request, &context).await }
});
let values = better_auth_core::request_runtime::concurrent::all(
"Custom session callbacks",
callbacks,
)
.await?;
response.replace_json(&values)?;
for (name, value) in request.take_response_headers()? {
if name.eq_ignore_ascii_case("set-cookie") {
response.headers.append(name, value);
} else {
let _ = response.headers.insert(name, value);
}
}
}
Ok(())
},
)
.await
}
}
#[cfg(test)]
#[path = "custom_session/runtime_tests.rs"]
mod runtime_tests;
#[cfg(test)]
mod tests {
use super::*;
use better_auth_core::{FieldDate, FieldMap};
#[test]
fn custom_session_input_keeps_native_user_values_and_aliases() -> AuthResult<()> {
let shared: FieldValue = FieldMap::from([
("ownUndefined".into(), FieldValue::Undefined),
("date".into(), FieldDate::from_milliseconds(123.0).into()),
])
.into();
for user in [
FieldValue::Undefined,
FieldValue::Null,
7.0.into(),
vec![shared.clone(), shared.clone()].into(),
FieldMap::from([("0".into(), shared.clone()), ("1".into(), shared.clone())]).into(),
] {
let body = ResponseBody::Native(
FieldMap::from([
("session".into(), FieldMap::new().into()),
("user".into(), user.clone()),
("needsRefresh".into(), true.into()),
])
.into(),
);
let input = CustomSessionInput::from_response(&body)?.ok_or_else(|| {
better_auth_core::AuthError::internal("Expected native custom session input")
})?;
assert!(input.data.user_value()?.strict_equals(&user));
assert_eq!(input.needs_refresh, Some(true));
}
Ok(())
}
#[test]
fn custom_session_bytes_preserve_absent_and_non_object_user_values() -> AuthResult<()> {
for source in [
serde_json::json!({"session": {}, "needsRefresh": false}),
serde_json::json!({"session": {}, "user": null}),
serde_json::json!({"session": {}, "user": 7}),
serde_json::json!({"session": {}, "user": [{"id": null}]}),
] {
let input = CustomSessionInput::from_response(&ResponseBody::Bytes(
serde_json::to_vec(&source)?,
))?
.ok_or_else(|| {
better_auth_core::AuthError::internal("Expected JSON custom session input")
})?;
assert_eq!(
input.data.user_value()?.is_undefined(),
source.get("user").is_none()
);
assert_eq!(serde_json::to_value(input)?, source);
}
Ok(())
}
}