Skip to main content

better_auth_core/request_runtime/
mod.rs

1//! Request-specific URL, trust, and cookie policy.
2
3mod config;
4mod cookies;
5mod scope;
6mod url;
7
8pub use config::{BaseUrl, BaseUrlProtocol, DynamicBaseUrl, TrustedValues, TrustedValuesResolver};
9pub use cookies::{CookieSettings, ResolvedCookie};
10pub(crate) use scope::{Identity as RuntimeIdentity, spawn as spawn_with_request_context};
11
12use std::collections::HashMap;
13use std::future::Future;
14use std::sync::Arc;
15
16use self::url::Source;
17use crate::{
18    AuthConfig, AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema,
19    CookieAttributes, HttpMethod,
20};
21
22/// Original native Request and separately supplied endpoint headers.
23#[derive(Clone, Copy, Default)]
24pub struct NativeRequest<'a> {
25    pub request: Option<&'a AuthRequest>,
26    pub headers: Option<&'a HashMap<String, String>>,
27}
28
29#[derive(Clone, Default)]
30pub(crate) struct RequestRuntime {
31    identity: scope::Identity,
32    initial: Arc<tokio::sync::OnceCell<Initialized>>,
33    values: Option<RuntimeValues>,
34}
35
36#[derive(Clone)]
37struct Initialized {
38    config: Arc<AuthConfig>,
39    values: RuntimeValues,
40}
41
42#[derive(Clone)]
43struct RuntimeValues {
44    base_url: String,
45    trusted_origins: Vec<String>,
46    trusted_providers: Vec<String>,
47    is_production: bool,
48}
49
50impl RequestRuntime {
51    /// Run before plugin initialization; plugin contributions merge afterward.
52    async fn initialize(config: &mut AuthConfig) -> AuthResult<RuntimeValues> {
53        if let BaseUrl::Dynamic(policy) = &config.base_url
54            && policy.allowed_hosts.is_empty()
55        {
56            return Err(AuthError::config(
57                "baseURL.allowedHosts cannot be empty. Provide at least one allowed host pattern (e.g., [\"myapp.com\", \"*.vercel.app\"]).",
58            ));
59        }
60        let base_url = match &config.base_url {
61            BaseUrl::Dynamic(_) => String::new(),
62            BaseUrl::Auto | BaseUrl::Static(_) => {
63                let environment = url::environment_base_url()?;
64                let value = url::static_url(
65                    config.base_url.as_static(),
66                    &config.base_path,
67                    None,
68                    environment.as_deref(),
69                    false,
70                )
71                .map_err(AuthError::config)?
72                .unwrap_or_default();
73                // An omitted static URL becomes a string, including the empty string.
74                config.base_url = BaseUrl::Static(url::origin(&value).unwrap_or_default());
75                value
76            }
77        };
78        if config.base_path.is_empty() {
79            config.base_path = "/api/auth".to_owned();
80        }
81        let is_production = url::environment("NODE_ENV")?.as_deref() == Some("production");
82        let cookies = CookieSettings::from_config(config, is_production);
83        if config
84            .advanced
85            .cross_sub_domain_cookies
86            .as_ref()
87            .is_some_and(|policy| {
88                policy.enabled() && policy.domain.as_deref().is_none_or(str::is_empty)
89            })
90            && !matches!(config.base_url, BaseUrl::Dynamic(_))
91            && config.base_url.as_static().is_none_or(str::is_empty)
92        {
93            return Err(AuthError::config(
94                "baseURL is required when crossSubdomainCookies are enabled.",
95            ));
96        }
97        config.install_cookie_settings(cookies.clone());
98        let trusted_origins = origins(config, &config.base_url, None).await?;
99        let trusted_providers = config
100            .account
101            .account_linking
102            .trusted_provider_values()
103            .resolve(None)
104            .await?;
105        Ok(RuntimeValues {
106            base_url,
107            trusted_origins,
108            trusted_providers,
109            is_production,
110        })
111    }
112}
113
114impl<S: AuthSchema> AuthContext<S> {
115    /// Resolve the context active for this auth instance, if one is in scope.
116    pub fn current_request_context(&self) -> Option<Arc<Self>> {
117        self.request_runtime.identity.current()
118    }
119
120    pub fn is_origin_trusted(&self, origin: &str) -> bool {
121        self.trusted_origins().iter().any(|pattern| {
122            let pattern = crate::config::extract_origin(pattern).unwrap_or_default();
123            glob_match::glob_match(&pattern, origin)
124        })
125    }
126
127    pub fn is_redirect_target_trusted(&self, target: &str) -> bool {
128        crate::config::is_safe_relative_path(target)
129            || crate::config::extract_origin(target)
130                .is_some_and(|origin| self.is_origin_trusted(&origin))
131    }
132
133    /// Full resolved authentication URL, including its effective routing path.
134    pub fn base_url(&self) -> &str {
135        self.request_runtime.values.as_ref().map_or_else(
136            || self.config.base_url.as_static().unwrap_or(""),
137            |values| values.base_url.as_str(),
138        )
139    }
140
141    /// Route prefix from the resolved URL, including a configured URL path.
142    pub fn base_path(&self) -> &str {
143        let url = self.base_url();
144        url.find("://")
145            .and_then(|scheme| url.get(scheme + 3..))
146            .and_then(|authority| authority.find('/').and_then(|start| authority.get(start..)))
147            .map(|path| path.split(['?', '#']).next().unwrap_or("/"))
148            .unwrap_or(&self.config.base_path)
149    }
150
151    pub fn trusted_origins(&self) -> &[String] {
152        self.request_runtime.values.as_ref().map_or_else(
153            || {
154                self.config
155                    .trusted_origin_values()
156                    .as_static()
157                    .unwrap_or(&[])
158            },
159            |values| values.trusted_origins.as_slice(),
160        )
161    }
162
163    pub(crate) fn is_production(&self) -> AuthResult<bool> {
164        Ok(self.runtime_values()?.is_production)
165    }
166
167    pub fn trusted_providers(&self) -> &[String] {
168        self.request_runtime.values.as_ref().map_or_else(
169            || {
170                self.config
171                    .account
172                    .account_linking
173                    .trusted_provider_values()
174                    .as_static()
175                    .unwrap_or(&[])
176            },
177            |values| values.trusted_providers.as_slice(),
178        )
179    }
180
181    pub fn create_auth_cookie(
182        &self,
183        name: &str,
184        attributes: CookieAttributes,
185    ) -> AuthResult<ResolvedCookie> {
186        Ok(self.config.auth_cookie(name, attributes))
187    }
188
189    /// Initialize options once for this auth instance before plugin setup or native use.
190    pub async fn initialize_request_context(&self) -> AuthResult<Arc<Self>> {
191        if self.request_runtime.values.is_some() {
192            return Ok(Arc::new(self.clone()));
193        }
194        let initial = self
195            .request_runtime
196            .initial
197            .get_or_try_init(|| async {
198                let mut config = (*self.config).clone();
199                let values = RequestRuntime::initialize(&mut config).await?;
200                Ok::<_, AuthError>(Initialized {
201                    config: Arc::new(config),
202                    values,
203                })
204            })
205            .await?;
206        let mut context = self.clone();
207        context.config = initial.config.clone();
208        context.request_runtime.values = Some(initial.values.clone());
209        Ok(Arc::new(context))
210    }
211
212    /// Resolve native URL policy and enter this auth instance's runtime scope.
213    pub async fn with_native_context<T, F, Fut>(
214        &self,
215        input: NativeRequest<'_>,
216        operation: F,
217    ) -> AuthResult<T>
218    where
219        F: FnOnce(Arc<AuthContext<S>>) -> Fut + Send,
220        Fut: Future<Output = AuthResult<T>> + Send,
221        T: Send,
222    {
223        if let Some(validation) = self.database.schema_validation() {
224            validation.check_runtime().await?;
225        }
226        let context = self.initialize_request_context().await?;
227        let resolved = if matches!(context.config.base_url, BaseUrl::Dynamic(_))
228            && context.base_url().is_empty()
229        {
230            let source = input.request.map(Source::Request).or_else(|| {
231                input
232                    .headers
233                    .filter(|headers| {
234                        url::header(headers, "host").is_some()
235                            || url::header(headers, "x-forwarded-host").is_some()
236                    })
237                    .map(Source::Headers)
238            });
239            context.resolve_dynamic(source, true).await?
240        } else {
241            context
242        };
243        scope::run(resolved.clone(), async move { operation(resolved).await }).await
244    }
245
246    /// Enter the HTTP policy branch before rate limiting, request hooks, and body decoding.
247    pub async fn with_http_context<T, F, Fut>(
248        &self,
249        request: &AuthRequest,
250        operation: F,
251    ) -> AuthResult<T>
252    where
253        F: FnOnce(Arc<AuthContext<S>>) -> Fut + Send,
254        Fut: Future<Output = AuthResult<T>> + Send,
255        T: Send,
256    {
257        let context = self.initialize_request_context().await?;
258        let resolved = if matches!(context.config.base_url, BaseUrl::Dynamic(_)) {
259            context
260                .resolve_dynamic(Some(Source::Request(request)), false)
261                .await?
262        } else {
263            let mut resolved = (*context).clone();
264            if context
265                .config
266                .base_url
267                .as_static()
268                .is_none_or(str::is_empty)
269            {
270                let environment = url::environment_base_url()?;
271                let base_url = url::static_url(
272                    None,
273                    &self.config.base_path,
274                    Some(request),
275                    environment.as_deref(),
276                    self.config.advanced.trusted_proxy_headers,
277                )
278                .map_err(AuthError::config)?
279                .ok_or_else(|| {
280                    AuthError::config(
281                        "Could not get base URL from request. Please provide a valid base URL.",
282                    )
283                })?;
284                resolved.runtime_values_mut()?.base_url = base_url.clone();
285                Arc::make_mut(&mut resolved.config).base_url =
286                    BaseUrl::Static(url::origin(&base_url).unwrap_or_default());
287            }
288            resolved.runtime_values_mut()?.trusted_origins =
289                origins(&resolved.config, &resolved.config.base_url, Some(request)).await?;
290            resolved.runtime_values_mut()?.trusted_providers = resolved
291                .config
292                .account
293                .account_linking
294                .trusted_provider_values()
295                .resolve(Some(request))
296                .await?;
297            Arc::new(resolved)
298        };
299        scope::run(resolved.clone(), async move { operation(resolved).await }).await
300    }
301
302    fn runtime_values(&self) -> AuthResult<&RuntimeValues> {
303        self.request_runtime
304            .values
305            .as_ref()
306            .ok_or_else(|| AuthError::internal("Request context is not initialized"))
307    }
308
309    fn runtime_values_mut(&mut self) -> AuthResult<&mut RuntimeValues> {
310        self.request_runtime
311            .values
312            .as_mut()
313            .ok_or_else(|| AuthError::internal("Request context is not initialized"))
314    }
315
316    pub(crate) fn runtime_identity(&self) -> RuntimeIdentity {
317        self.request_runtime.identity.clone()
318    }
319
320    /// The origin middleware calls functional origins again after HTTP resolution.
321    pub async fn csrf_trusted_origins(&self, request: &AuthRequest) -> AuthResult<Vec<String>> {
322        let mut values = self.trusted_origins().to_vec();
323        if self.config.trusted_origin_values().is_dynamic() {
324            values.extend(
325                self.config
326                    .trusted_origin_values()
327                    .resolve(Some(request))
328                    .await?,
329            );
330        }
331        Ok(values)
332    }
333
334    async fn resolve_dynamic(
335        &self,
336        source: Option<Source<'_>>,
337        native: bool,
338    ) -> AuthResult<Arc<Self>> {
339        let BaseUrl::Dynamic(policy) = &self.config.base_url else {
340            return Err(AuthError::internal(
341                "Dynamic context resolution requires a dynamic base URL",
342            ));
343        };
344        if native && source.is_none() && policy.fallback.as_deref().is_none_or(str::is_empty) {
345            return Err(native_url_error("Dynamic baseURL could not be resolved for this direct auth.api call. Pass `headers: request.headers` (or `request`) to the call, or add `fallback` to your baseURL config.".to_owned()));
346        }
347        let base_url = url::dynamic_url(
348            policy,
349            &self.config.base_path,
350            source,
351            self.config.advanced.trusted_proxy_headers,
352        )
353        .map_err(|message| {
354            if native {
355                native_url_error(message)
356            } else {
357                AuthError::config(message)
358            }
359        })?;
360        let synthetic;
361        let request = match source {
362            Some(Source::Request(request)) => Some(request),
363            Some(Source::Headers(headers)) => {
364                let url = ::url::Url::parse(&base_url)
365                    .map_err(|error| AuthError::config(error.to_string()))?;
366                synthetic = AuthRequest::new(HttpMethod::Get, url.path())
367                    .with_optional_headers(Some(headers.clone()))
368                    .with_url(url);
369                Some(&synthetic)
370            }
371            None => None,
372        };
373        let mut resolved = self.clone();
374        resolved.runtime_values_mut()?.base_url = base_url.clone();
375        Arc::make_mut(&mut resolved.config).base_url =
376            BaseUrl::Static(url::origin(&base_url).unwrap_or_default());
377        // The source policy still contributes every allowed host, not only this request's host.
378        resolved.runtime_values_mut()?.trusted_origins =
379            origins(&resolved.config, &self.config.base_url, request).await?;
380        resolved.runtime_values_mut()?.trusted_providers = resolved
381            .config
382            .account
383            .account_linking
384            .trusted_provider_values()
385            .resolve(request)
386            .await?;
387        if self
388            .config
389            .advanced
390            .cross_sub_domain_cookies
391            .as_ref()
392            .is_some_and(|policy| policy.enabled())
393        {
394            let cookies =
395                CookieSettings::from_config(&resolved.config, self.runtime_values()?.is_production);
396            Arc::make_mut(&mut resolved.config).install_cookie_settings(cookies);
397        }
398        Ok(Arc::new(resolved))
399    }
400}
401
402async fn origins(
403    config: &AuthConfig,
404    base_url: &BaseUrl,
405    request: Option<&AuthRequest>,
406) -> AuthResult<Vec<String>> {
407    let mut values = url::base_origins(base_url);
408    values.extend(config.trusted_origin_values().resolve(request).await?);
409    if let Some(environment) = url::environment("BETTER_AUTH_TRUSTED_ORIGINS")? {
410        values.extend(environment.split(',').map(str::to_owned));
411    }
412    values.retain(|value| !value.is_empty());
413    Ok(values)
414}
415
416fn native_url_error(message: String) -> AuthError {
417    match AuthResponse::json(500, &serde_json::json!({ "message": message })) {
418        Ok(response) => response.into(),
419        Err(error) => error.into(),
420    }
421}
422
423pub(crate) fn current_logger() -> Option<crate::observability::LoggerConfig> {
424    scope::current_logger()
425}