Skip to main content

better_auth_core/
endpoint_dispatch.rs

1//! Shared endpoint hooks, validation, handler and response lifecycle.
2use crate::hooks::{
3    RequestHookContext, update_request_hook_context, with_request_hook_context_value,
4};
5use crate::observability::EndpointHooks;
6use crate::{
7    AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
8    AuthSchema, BeforeRequestAction, HttpMethod,
9};
10use std::{future::Future, sync::Arc};
11
12/// Immutable dispatch data. No authentication context is retained by this executor.
13pub struct EndpointDispatcher<S: AuthSchema> {
14    plugins: Arc<Vec<Box<dyn AuthPlugin<S>>>>,
15    hooks: EndpointHooks<S>,
16    routes: Vec<AuthRoute>,
17}
18impl<S: AuthSchema> EndpointDispatcher<S> {
19    pub fn new(
20        plugins: Arc<Vec<Box<dyn AuthPlugin<S>>>>,
21        hooks: EndpointHooks<S>,
22        core_routes: impl IntoIterator<Item = AuthRoute>,
23    ) -> Self {
24        let routes = core_routes
25            .into_iter()
26            .chain(plugins.iter().flat_map(|plugin| plugin.routes()))
27            .collect();
28        Self {
29            plugins,
30            hooks,
31            routes,
32        }
33    }
34
35    /// Dispatch a native-only endpoint without importing an ambient HTTP Request.
36    pub async fn native<F, Fut>(
37        &self,
38        mut request: AuthRequest,
39        route: AuthRoute,
40        context: &AuthContext<S>,
41        handler: F,
42    ) -> AuthResult<AuthResponse>
43    where
44        F: FnOnce(AuthRequest) -> Fut,
45        Fut: Future<Output = AuthResult<AuthResponse>>,
46    {
47        if !route.server_only {
48            return Err(AuthError::config(
49                "Native facade requires a server-only endpoint",
50            ));
51        }
52        request.path = "/".into();
53        request.set_server_only();
54        let mut scope = RequestHookContext::from_request(&request)?;
55        scope.path = None;
56        scope.body = request.input_field_value()?;
57        scope.operation_id = Some(route.operation_id.clone());
58        scope.meta = crate::RequestMeta::from_request_with_config(
59            &request,
60            &context.config.advanced.ip_address,
61        );
62        let method = match request.method() {
63            HttpMethod::Get => "GET",
64            HttpMethod::Post => "POST",
65            HttpMethod::Put => "PUT",
66            HttpMethod::Patch => "PATCH",
67            HttpMethod::Delete => "DELETE",
68            HttpMethod::Options => "OPTIONS",
69            HttpMethod::Head => "HEAD",
70        };
71        let name = format!("{method} /:virtual");
72        let operation_id = route.operation_id.clone();
73        with_request_hook_context_value(
74            scope,
75            crate::observability::with_span(
76                &context.config.experimental.instrumentation,
77                &name,
78                crate::observability::SpanAttributes {
79                    route: Some("/:virtual"),
80                    operation_id: Some(&operation_id),
81                    ..Default::default()
82                },
83                self.run(&mut request, false, context, Some(route), handler),
84            ),
85        )
86        .await
87    }
88
89    /// Run the same pipeline for HTTP and native endpoints. The handler may borrow an active transaction.
90    pub async fn run<F, Fut>(
91        &self,
92        req: &mut AuthRequest,
93        http: bool,
94        context: &AuthContext<S>,
95        native_route: Option<AuthRoute>,
96        handler: F,
97    ) -> AuthResult<AuthResponse>
98    where
99        F: FnOnce(AuthRequest) -> Fut,
100        Fut: Future<Output = AuthResult<AuthResponse>>,
101    {
102        // Strip base_path prefix from the request path for internal routing.
103        // This happens BEFORE plugin hooks so that `before_request` sees the
104        // same normalised path that `on_request` / core handlers use.
105        // HTTP routing already selected a base-relative path. Native callers may include the base path.
106        let base_path = context.base_path();
107        let stripped_path = if !http && !base_path.is_empty() && base_path != "/" {
108            req.path().strip_prefix(base_path).unwrap_or(req.path())
109        } else {
110            req.path()
111        };
112
113        // Build a request with the stripped path for all subsequent dispatch
114        let mut internal_req = if stripped_path != req.path() {
115            let mut r = req.clone();
116            r.path = stripped_path.to_string();
117            r
118        } else {
119            req.clone()
120        };
121        internal_req.set_endpoint_body(internal_req.unvalidated_input()?);
122        update_request_hook_context(&internal_req)?;
123
124        let mut input_patch = crate::endpoint_input::EndpointInputPatch::default();
125        if let Some(hook) = &self.hooks.before {
126            let action = match crate::observability::instrumentation::with_endpoint_hook(
127                &context.config,
128                &internal_req,
129                "before",
130                "user",
131                hook.before(&internal_req, context),
132            )
133            .await
134            {
135                Ok(action) => action,
136                Err(error) => return Err(endpoint_error(error, &internal_req)?),
137            };
138            if let Some(response) =
139                apply_before_action(action, &mut internal_req, req, &mut input_patch)?
140            {
141                return Ok(response);
142            }
143        }
144
145        // Run plugin before_request hooks (e.g. API-key → session emulation)
146        // Plugins now see the normalised (base_path-stripped) path.
147        for plugin in self.plugins.iter() {
148            let action = match plugin.before_request(&internal_req, context).await {
149                Ok(action) => action,
150                Err(error) => return Err(endpoint_error(error, &internal_req)?),
151            };
152            if let Some(response) =
153                apply_before_action(action, &mut internal_req, req, &mut input_patch)?
154            {
155                return Ok(response);
156            }
157        }
158
159        if http && internal_req.original_request().is_none() {
160            internal_req = internal_req.with_original_request(req.clone());
161        }
162        input_patch.apply(&mut internal_req)?;
163        update_request_hook_context(&internal_req)?;
164        let route = native_route.or_else(|| {
165            self.routes
166                .iter()
167                .find(|route| route.matches(internal_req.method(), internal_req.path()))
168                .cloned()
169        });
170        let snapshot = crate::hooks::current_request_hook_context();
171        let route_name = if internal_req.is_server_only() {
172            "/:virtual"
173        } else {
174            snapshot.as_ref().map_or(internal_req.path(), |snapshot| {
175                snapshot.path.as_deref().unwrap_or("/:virtual")
176            })
177        };
178        let operation_id = snapshot
179            .as_ref()
180            .and_then(|snapshot| snapshot.operation_id.as_deref())
181            .unwrap_or(route_name);
182        let result = crate::observability::with_span(
183            &context.config.experimental.instrumentation,
184            &format!("handler {route_name}"),
185            crate::observability::SpanAttributes {
186                route: Some(route_name),
187                operation_id: Some(operation_id),
188                ..Default::default()
189            },
190            async {
191                let input = async {
192                    let body = match route
193                        .as_ref()
194                        .and_then(|route| route.body_validator.as_ref())
195                    {
196                        Some(validate) => validate.validate(&internal_req).await?,
197                        None => internal_req.unvalidated_input()?,
198                    };
199                    let query = match route.as_ref().and_then(|route| route.query_validator) {
200                        Some(validate) => validate(internal_req.query.clone())?,
201                        None => internal_req.query.clone(),
202                    };
203                    if route.as_ref().is_some_and(|route| route.require_headers)
204                        && internal_req.endpoint_headers().is_none()
205                    {
206                        return Err(AuthError::Upstream {
207                            status: 400,
208                            code: "VALIDATION_ERROR",
209                            message: "Headers is required",
210                        });
211                    }
212                    let mut request = internal_req.clone();
213                    request.set_endpoint_body(body);
214                    request.query = query;
215                    Ok(request)
216                }
217                .await;
218                match input {
219                    Ok(mut request) => {
220                        if request.is_server_only() {
221                            request.path = "virtual:".into();
222                        }
223                        let future = crate::endpoint_input::with_validated_input(
224                            request.input_field_value()?,
225                            request.query.clone(),
226                            handler(request.clone()),
227                        );
228                        if request.is_server_only() {
229                            let mut scope = match crate::hooks::current_request_hook_context() {
230                                Some(scope) => scope,
231                                None => RequestHookContext::from_request(&request)?,
232                            };
233                            scope.path = Some("virtual:".into());
234                            scope.request = request.clone();
235                            scope.query = request.query.clone();
236                            scope.body = request.input_field_value()?;
237                            scope.operation_id =
238                                route.as_ref().map(|route| route.operation_id.clone());
239                            with_request_hook_context_value(scope, future).await
240                        } else {
241                            future.await
242                        }
243                    }
244                    Err(error) => Err(error),
245                }
246            },
247        )
248        .await;
249        let mut response = match result {
250            Ok(response) => response,
251            Err(error) if error.is_api_error() => error.to_auth_response(),
252            Err(error) => return Err(error),
253        };
254        context
255            .session_manager()
256            .finish_response(&internal_req, &mut response)?;
257        if let Some(hook) = &self.hooks.after {
258            let result = crate::observability::instrumentation::with_endpoint_hook(
259                &context.config,
260                &internal_req,
261                "after",
262                "user",
263                hook.after(&internal_req, &mut response, context),
264            )
265            .await;
266            apply_after_result(result, &internal_req, &mut response)?;
267        }
268        for plugin in self.plugins.iter() {
269            let result = plugin
270                .after_request(&internal_req, &mut response, context)
271                .await;
272            apply_after_result(result, &internal_req, &mut response)?;
273        }
274        if response.is_api_error() && !http {
275            response.capture_error_headers(response.headers.clone());
276            Err(response.into())
277        } else {
278            Ok(response)
279        }
280    }
281}
282fn endpoint_error(error: AuthError, request: &AuthRequest) -> AuthResult<AuthError> {
283    if !error.is_api_error() {
284        return Ok(error);
285    }
286    Ok(error.capture_endpoint_headers(request.take_response_headers()?))
287}
288
289fn apply_before_action(
290    action: Option<BeforeRequestAction>,
291    internal: &mut AuthRequest,
292    original: &mut AuthRequest,
293    input_patch: &mut crate::endpoint_input::EndpointInputPatch,
294) -> AuthResult<Option<AuthResponse>> {
295    match action {
296        Some(BeforeRequestAction::Respond(mut response)) => {
297            response.headers.merge(internal.take_response_headers()?);
298            return Ok(Some(response));
299        }
300        Some(BeforeRequestAction::MergeContext(patch)) => input_patch.merge(patch),
301        Some(BeforeRequestAction::ReplaceBody(body)) => {
302            internal.body = Some(body.clone());
303            original.body = Some(body);
304            let raw_body = internal.input_body()?;
305            internal.set_endpoint_body(crate::endpoint_input::ValidatedBody::unvalidated(raw_body));
306            update_request_hook_context(internal)?;
307        }
308        Some(BeforeRequestAction::InjectSession { session }) => {
309            internal.set_virtual_session(*session)
310        }
311        None => (),
312    }
313    Ok(None)
314}
315fn apply_after_result(
316    result: AuthResult<()>,
317    request: &AuthRequest,
318    response: &mut AuthResponse,
319) -> AuthResult<()> {
320    match result {
321        Ok(()) => response.headers.merge(request.take_response_headers()?),
322        Err(error) if error.is_api_error() => {
323            response.headers.merge(request.take_response_headers()?);
324            response.replace_returned(error.to_auth_response());
325        }
326        Err(error) => return Err(error),
327    }
328    Ok(())
329}
330
331impl<S: AuthSchema> AuthContext<S> {
332    /// Invoke a native endpoint with the instance's shared hook and validation pipeline.
333    pub async fn dispatch_native<F, Fut>(
334        &self,
335        source: crate::NativeRequest<'_>,
336        route: AuthRoute,
337        body: Option<serde_json::Value>,
338        query: Option<serde_json::Value>,
339        handler: F,
340    ) -> AuthResult<AuthResponse>
341    where
342        F: FnOnce(AuthRequest, Arc<AuthContext<S>>) -> Fut + Send,
343        Fut: Future<Output = AuthResult<AuthResponse>> + Send,
344    {
345        let mut request = native_request(source, &route);
346        request.body = body.as_ref().map(serde_json::to_vec).transpose()?;
347        request.set_endpoint_body(crate::endpoint_input::ValidatedBody::unvalidated(body));
348        request.query = query;
349        self.dispatch_native_request(source, route, request, handler)
350            .await
351    }
352
353    /// Invoke a native endpoint while retaining Date, undefined, and object identities.
354    pub async fn dispatch_native_value<F, Fut>(
355        &self,
356        source: crate::NativeRequest<'_>,
357        route: AuthRoute,
358        body: crate::FieldValue,
359        query: Option<serde_json::Value>,
360        handler: F,
361    ) -> AuthResult<AuthResponse>
362    where
363        F: FnOnce(AuthRequest, Arc<AuthContext<S>>) -> Fut + Send,
364        Fut: Future<Output = AuthResult<AuthResponse>> + Send,
365    {
366        let mut request = native_request(source, &route);
367        request.set_endpoint_body(crate::endpoint_input::ValidatedBody::unvalidated_native(
368            body,
369        ));
370        request.query = query;
371        self.dispatch_native_request(source, route, request, handler)
372            .await
373    }
374
375    async fn dispatch_native_request<F, Fut>(
376        &self,
377        source: crate::NativeRequest<'_>,
378        route: AuthRoute,
379        request: AuthRequest,
380        handler: F,
381    ) -> AuthResult<AuthResponse>
382    where
383        F: FnOnce(AuthRequest, Arc<AuthContext<S>>) -> Fut + Send,
384        Fut: Future<Output = AuthResult<AuthResponse>> + Send,
385    {
386        self.with_native_context(source, |context| async move {
387            let dispatcher = context
388                .extensions
389                .get::<Arc<EndpointDispatcher<S>>>()
390                .ok_or_else(|| AuthError::config("Endpoint dispatcher is not initialized"))?;
391            dispatcher
392                .native(request, route, &context, |request| {
393                    handler(request, context.clone())
394                })
395                .await
396        })
397        .await
398    }
399}
400
401fn native_request(source: crate::NativeRequest<'_>, route: &AuthRoute) -> AuthRequest {
402    let method = source
403        .request
404        .map_or_else(|| route.method.clone(), |request| request.method.clone());
405    let mut request = AuthRequest::new(method, "/").with_optional_headers(source.headers.cloned());
406    if let Some(original) = source.request {
407        request = request.with_original_request(original.clone());
408    }
409    request
410}