Skip to main content

better_auth_api/plugins/
custom_session.rs

1//! Application-defined session responses without changing stored sessions.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use better_auth_core::{
7    AuthContext, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
8    AuthSchema, FieldValue, FromFieldMap, HttpMethod, ResponseBody, session::SessionData,
9};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// The public session and the optional deferred-refresh signal.
14#[derive(Debug, Deserialize, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct CustomSessionInput {
17    #[serde(flatten)]
18    pub data: SessionData,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub needs_refresh: Option<bool>,
21}
22
23impl CustomSessionInput {
24    fn from_field_value(value: &FieldValue) -> AuthResult<Self> {
25        let fields = value.as_object().ok_or_else(|| {
26            better_auth_core::AuthError::internal("Custom session input must be an object")
27        })?;
28        Ok(Self {
29            data: SessionData::from_field_values(fields.clone())?,
30            needs_refresh: fields
31                .get("needsRefresh")
32                .cloned()
33                .unwrap_or_default()
34                .decode()?,
35        })
36    }
37
38    fn from_response(body: &ResponseBody) -> AuthResult<Option<Self>> {
39        match body {
40            ResponseBody::Native(FieldValue::Null | FieldValue::Undefined) => Ok(None),
41            ResponseBody::Native(value) => Self::from_field_value(value).map(Some),
42            ResponseBody::Bytes(bytes) => Ok(serde_json::from_slice(bytes)?),
43        }
44    }
45
46    fn list_from_response(body: &ResponseBody) -> AuthResult<Option<Vec<Self>>> {
47        match body {
48            ResponseBody::Native(FieldValue::Null | FieldValue::Undefined) => Ok(None),
49            ResponseBody::Native(FieldValue::Array(values)) => values
50                .iter()
51                .map(Self::from_field_value)
52                .collect::<AuthResult<_>>()
53                .map(Some),
54            ResponseBody::Native(_) => Err(better_auth_core::AuthError::internal(
55                "Device sessions response must be an array",
56            )),
57            ResponseBody::Bytes(bytes) => Ok(serde_json::from_slice(bytes)?),
58        }
59    }
60}
61
62/// Transform a public session with access to the request and typed runtime store.
63#[async_trait]
64pub trait CustomSessionCallback<S: AuthSchema>: Send + Sync + 'static {
65    async fn customize(
66        &self,
67        session: CustomSessionInput,
68        request: &AuthRequest,
69        context: &AuthContext<S>,
70    ) -> AuthResult<Value>;
71}
72
73/// Override the session response. Internal authentication retains the original session.
74pub struct CustomSessionPlugin<S: AuthSchema> {
75    callback: Arc<dyn CustomSessionCallback<S>>,
76    mutate_list_device_sessions: bool,
77}
78
79impl<S: AuthSchema> Clone for CustomSessionPlugin<S> {
80    fn clone(&self) -> Self {
81        Self {
82            callback: self.callback.clone(),
83            mutate_list_device_sessions: self.mutate_list_device_sessions,
84        }
85    }
86}
87
88impl<S: AuthSchema> CustomSessionPlugin<S> {
89    pub fn new(callback: impl CustomSessionCallback<S>) -> Self {
90        Self {
91            callback: Arc::new(callback),
92            mutate_list_device_sessions: false,
93        }
94    }
95
96    /// Apply the same callback to each remembered device session.
97    pub fn mutate_list_device_sessions(mut self, enabled: bool) -> Self {
98        self.mutate_list_device_sessions = enabled;
99        self
100    }
101
102    pub(crate) async fn get_session(
103        &self,
104        request: &AuthRequest,
105        context: &AuthContext<S>,
106    ) -> AuthResult<AuthResponse> {
107        // Upstream customSession catches the nested session read, including store errors.
108        // Callback errors propagate after that read and do not retain its response cookies.
109        let response = super::session_management::SessionManagementPlugin::new()
110            .handle_get_session(request, context)
111            .await;
112        let mut response = match response {
113            Ok(response) => response,
114            Err(_) => {
115                let _ = request.take_response_headers()?;
116                return Ok(AuthResponse::json(200, &Value::Null)?);
117            }
118        };
119        let data = CustomSessionInput::from_response(&response.body)?;
120        if let Some(data) = data {
121            let value = self.callback.customize(data, request, context).await?;
122            response.body = ResponseBody::Bytes(serde_json::to_vec(&value)?);
123        }
124        Ok(response)
125    }
126}
127
128#[async_trait]
129impl<S: AuthSchema> AuthPlugin<S> for CustomSessionPlugin<S> {
130    fn name(&self) -> &'static str {
131        "custom-session"
132    }
133
134    fn routes(&self) -> Vec<AuthRoute> {
135        vec![
136            AuthRoute::get("/get-session", "getSession")
137                .require_headers(true)
138                .query_validator(better_auth_core::query::session_query),
139        ]
140    }
141
142    async fn on_init(&self, context: &mut AuthInitContext<S>) -> AuthResult<()> {
143        context.extensions.insert(self.clone());
144        Ok(())
145    }
146
147    async fn on_request(
148        &self,
149        request: &AuthRequest,
150        context: &AuthContext<S>,
151    ) -> AuthResult<Option<AuthResponse>> {
152        match (request.method(), request.path()) {
153            (HttpMethod::Get, "/get-session") => self.get_session(request, context).await.map(Some),
154            (HttpMethod::Post, "/get-session") => Ok(Some(AuthResponse::new(404))),
155            _ => Ok(None),
156        }
157    }
158
159    async fn after_request(
160        &self,
161        request: &AuthRequest,
162        response: &mut AuthResponse,
163        context: &AuthContext<S>,
164    ) -> AuthResult<()> {
165        if !(self.mutate_list_device_sessions
166            && request.path() == "/multi-session/list-device-sessions")
167        {
168            return Ok(());
169        }
170        better_auth_core::observability::instrumentation::with_endpoint_hook(
171            &context.config,
172            request,
173            "after",
174            "plugin:custom-session",
175            async {
176                if !self.mutate_list_device_sessions
177                    || request.path() != "/multi-session/list-device-sessions"
178                    || response.status != 200
179                {
180                    return Ok(());
181                }
182                let sessions = CustomSessionInput::list_from_response(&response.body)?;
183                if let Some(sessions) = sessions {
184                    let context = Arc::new(context.clone());
185                    let request = Arc::new(request.clone());
186                    let hook_context = better_auth_core::hooks::current_request_hook_context();
187                    // Promise.all rejects early while the other callbacks keep running.
188                    // Dropping a JoinHandle detaches the callback instead of cancelling it.
189                    let callbacks: Vec<_> = sessions
190                        .into_iter()
191                        .map(|session| {
192                            let callback = self.callback.clone();
193                            let context = context.clone();
194                            let request = request.clone();
195                            let hook_context = hook_context.clone();
196                            tokio::spawn(async move {
197                                let future = callback.customize(session, &request, &context);
198                                match hook_context {
199                                    Some(hook_context) => {
200                                        better_auth_core::with_request_hook_context_value(
201                                            hook_context,
202                                            future,
203                                        )
204                                        .await
205                                    }
206                                    None => future.await,
207                                }
208                            })
209                        })
210                        .collect();
211                    let values = futures_util::future::try_join_all(callbacks.into_iter().map(
212                        |callback| async {
213                            callback.await.map_err(|error| {
214                                better_auth_core::AuthError::internal(format!(
215                                    "Custom session callback task failed: {error}"
216                                ))
217                            })?
218                        },
219                    ))
220                    .await?;
221                    response.body = ResponseBody::Bytes(serde_json::to_vec(&values)?);
222                    for (name, value) in request.take_response_headers()? {
223                        if name.eq_ignore_ascii_case("set-cookie") {
224                            response.headers.append(name, value);
225                        } else {
226                            let _ = response.headers.insert(name, value);
227                        }
228                    }
229                }
230                Ok(())
231            },
232        )
233        .await
234    }
235}