Skip to main content

better_auth_core/config/
origin.rs

1use super::AuthConfig;
2use crate::{AuthError, AuthRequest, AuthResult};
3use async_trait::async_trait;
4
5/// Protocol used when resolving a dynamic base URL. `None` on the config
6/// infers the request scheme, while trusting HTTPS (and HTTP loopback) origins.
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub enum BaseUrlProtocol {
9    Http,
10    Https,
11    Auto,
12}
13
14/// Better Auth 1.7.6 dynamic `baseURL` policy. Hosts include ports; `*` and `?`
15/// patterns are supported. A fallback is used when the source host is absent
16/// or outside the allowlist, and never makes that source host trusted.
17#[derive(Clone, Debug)]
18pub struct DynamicBaseUrl {
19    pub allowed_hosts: Vec<String>,
20    pub protocol: Option<BaseUrlProtocol>,
21    pub fallback: Option<String>,
22}
23
24/// Async application policy evaluated with the original transport request.
25/// Return additional trusted patterns; these never replace the configured
26/// base-origin policy. Errors abort dispatch before authentication side effects.
27#[async_trait]
28pub trait TrustedOriginsResolver: Send + Sync {
29    async fn resolve(&self, request: &AuthRequest) -> AuthResult<Vec<String>>;
30}
31
32/// Request-dependent account-linking trust policy. Initialization passes `None`;
33/// HTTP dispatch passes the original request before hooks and authority checks.
34/// This selects trusted provider IDs, not provider factories or credentials.
35/// Initialization failures propagate from the builder. Request failures abort
36/// before routing as an empty HTTP 500, including explicit API errors thrown
37/// at this stage; the private cause is discarded rather than logged.
38#[async_trait]
39pub trait TrustedProvidersResolver: Send + Sync {
40    async fn resolve(&self, request: Option<&AuthRequest>) -> AuthResult<Vec<String>>;
41}
42
43impl std::fmt::Debug for dyn TrustedProvidersResolver {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.write_str("TrustedProvidersResolver")
46    }
47}
48
49impl AuthConfig {
50    #[must_use]
51    pub fn dynamic_base_url(mut self, config: DynamicBaseUrl) -> Self {
52        self.dynamic_base_url = Some(config);
53        self
54    }
55
56    #[must_use]
57    pub fn trusted_origins_resolver(
58        mut self,
59        resolver: impl TrustedOriginsResolver + 'static,
60    ) -> Self {
61        self.trusted_origins_resolver = Some(std::sync::Arc::new(resolver));
62        self
63    }
64
65    /// Replace static account-linking trust with an async application policy.
66    #[must_use]
67    pub fn trusted_providers_resolver(
68        mut self,
69        resolver: impl TrustedProvidersResolver + 'static,
70    ) -> Self {
71        self.account.account_linking.trusted_providers_resolver =
72            Some(std::sync::Arc::new(resolver));
73        self
74    }
75
76    /// Resolve the URL and origin policy without mutating the shared instance.
77    ///
78    /// # Errors
79    /// Fails when no permitted host or fallback exists, the fallback is invalid,
80    /// or an application trust resolver fails.
81    pub async fn resolve_request(&self, request: &AuthRequest) -> AuthResult<Self> {
82        let mut config = self.clone();
83        if let Some(dynamic) = &self.dynamic_base_url {
84            let forwarded = self.advanced.trust_forwarded_host;
85            let host = forwarded
86                .then(|| header(request, "x-forwarded-host"))
87                .flatten()
88                .filter(|host| valid_host(host))
89                .or_else(|| header(request, "host").filter(|host| valid_host(host)))
90                .map(str::to_owned)
91                .or_else(|| {
92                    request.url().and_then(|url| {
93                        url.host_str().map(|_| {
94                            url[url::Position::BeforeHost..url::Position::AfterPort].to_owned()
95                        })
96                    })
97                });
98            let accepted = host.as_deref().filter(|host| {
99                dynamic.allowed_hosts.iter().any(|pattern| {
100                    wildcard(
101                        &normalize_host_pattern(pattern),
102                        &normalize_host_pattern(host),
103                    )
104                })
105            });
106            config.base_url = if let Some(host) = accepted {
107                let protocol = match dynamic.protocol {
108                    Some(BaseUrlProtocol::Http) => "http",
109                    Some(BaseUrlProtocol::Https) => "https",
110                    _ => forwarded
111                        .then(|| header(request, "x-forwarded-proto"))
112                        .flatten()
113                        .filter(|value| matches!(*value, "http" | "https"))
114                        .or_else(|| {
115                            request
116                                .url()
117                                .map(url::Url::scheme)
118                                .filter(|value| matches!(*value, "http" | "https"))
119                        })
120                        .unwrap_or_else(|| if loopback(host) { "http" } else { "https" }),
121                };
122                format!("{protocol}://{host}")
123            } else {
124                let fallback = dynamic.fallback.as_ref().ok_or_else(|| AuthError::config("Could not resolve base URL: host is not allowed and no fallback is configured"))?;
125                let parsed = url::Url::parse(fallback)
126                    .map_err(|_| AuthError::config("Invalid base URL fallback"))?;
127                if !matches!(parsed.scheme(), "http" | "https") {
128                    return Err(AuthError::config(
129                        "Base URL fallback must use HTTP or HTTPS",
130                    ));
131                }
132                fallback.clone()
133            };
134            config.session.cookie_secure = config.base_url.starts_with("https://");
135            if self.advanced.cross_sub_domain_cookies.is_some()
136                && self.advanced.use_secure_cookies.is_none()
137            {
138                config.advanced.use_secure_cookies = Some(config.session.cookie_secure);
139            }
140            config.session.cookie_name =
141                crate::utils::cookie_utils::related_cookie_name(&config, "session_token");
142            for host in &dynamic.allowed_hosts {
143                if host.contains("://") {
144                    config.trusted_origins.push(host.clone());
145                } else {
146                    if dynamic.protocol != Some(BaseUrlProtocol::Http) {
147                        config.trusted_origins.push(format!("https://{host}"));
148                    }
149                    if matches!(
150                        dynamic.protocol,
151                        Some(BaseUrlProtocol::Http | BaseUrlProtocol::Auto)
152                    ) || trusted_loopback(host)
153                    {
154                        config.trusted_origins.push(format!("http://{host}"));
155                    }
156                }
157            }
158            if let Some(fallback) = &dynamic.fallback
159                && let Some(origin) = super::extract_origin(fallback)
160            {
161                config.trusted_origins.push(origin);
162            }
163        }
164        if let Some(resolver) = &self.trusted_origins_resolver {
165            config.trusted_origins.extend(
166                resolver
167                    .resolve(request)
168                    .await?
169                    .into_iter()
170                    .filter(|origin| !origin.is_empty()),
171            );
172        }
173        if let Some(resolver) = &self.account.account_linking.trusted_providers_resolver {
174            config.account.account_linking.trusted_providers = resolver
175                .resolve(Some(request))
176                .await
177                .map_err(|_| {
178                    AuthError::CallbackFailure(Box::new(AuthError::internal(
179                        "Trusted provider resolution failed",
180                    )))
181                })?
182                .into_iter()
183                .filter(|provider| !provider.is_empty())
184                .collect();
185        }
186        Ok(config)
187    }
188}
189
190fn header<'a>(request: &'a AuthRequest, name: &str) -> Option<&'a str> {
191    request
192        .headers
193        .iter()
194        .find_map(|(key, value)| key.eq_ignore_ascii_case(name).then_some(value.as_str()))
195}
196
197fn normalize_host_pattern(value: &str) -> String {
198    value
199        .strip_prefix("https://")
200        .or_else(|| value.strip_prefix("http://"))
201        .unwrap_or(value)
202        .split('/')
203        .next()
204        .unwrap_or_default()
205        .to_ascii_lowercase()
206}
207
208fn loopback(host: &str) -> bool {
209    let host = normalize_host_pattern(host);
210    let hostname = host
211        .trim_start_matches('[')
212        .split(']')
213        .next()
214        .unwrap_or(&host);
215    let hostname = if hostname.contains("::") {
216        hostname
217    } else {
218        hostname.split(':').next().unwrap_or(hostname)
219    };
220    hostname == "localhost"
221        || hostname.ends_with(".localhost")
222        || hostname == "::1"
223        || hostname.starts_with("127.")
224}
225
226// Trusted-origin generation uses the server classifier, rather than the
227// deliberately permissive dev-scheme heuristic above (e.g. 127.example.test).
228fn trusted_loopback(host: &str) -> bool {
229    let host = host.trim().to_ascii_lowercase();
230    let hostname = if let Some(bracketed) = host.strip_prefix('[') {
231        bracketed.split(']').next().unwrap_or(bracketed)
232    } else if host.matches(':').count() == 1 {
233        host.split(':').next().unwrap_or(&host)
234    } else {
235        &host
236    };
237    let hostname = hostname
238        .split('%')
239        .next()
240        .unwrap_or(hostname)
241        .trim_end_matches('.');
242    hostname == "localhost"
243        || hostname.ends_with(".localhost")
244        || hostname
245            .parse::<std::net::IpAddr>()
246            .is_ok_and(|ip| match ip {
247                std::net::IpAddr::V4(ip) => ip.is_loopback(),
248                std::net::IpAddr::V6(ip) => {
249                    ip.is_loopback() || ip.to_ipv4_mapped().is_some_and(|ip| ip.is_loopback())
250                }
251            })
252}
253
254fn valid_host(host: &str) -> bool {
255    // Source validateProxyHeader: DNS labels, IPv4 or bracketed IPv6 and a
256    // one-to-five digit port; no whitespace, userinfo, path or delimiter tricks.
257    if host.contains("..") {
258        return false;
259    }
260    regex::Regex::new(r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*|(?:\d{1,3}\.){3}\d{1,3}|\[[0-9a-fA-F:]+\])(?::[0-9]{1,5})?$")
261        .is_ok_and(|pattern| pattern.is_match(host))
262}
263
264// Source wildcardMatch defaults to slash/backslash separators. `**` is
265// recursive only as a complete segment; ordinary `*` and `?` stay within it.
266fn wildcard(pattern: &str, value: &str) -> bool {
267    let separator = r"[/\\]";
268    let wildcard = r"[^/\\]";
269    let segments: Vec<_> = pattern.split('/').collect();
270    let mut expression = String::from("\\A");
271    for (index, segment) in segments.iter().enumerate() {
272        if segment.is_empty() && index > 0 {
273            continue;
274        }
275        let current_separator = if index + 1 == segments.len() {
276            format!("{separator}*?")
277        } else if segments.get(index + 1) != Some(&"**") {
278            format!("{separator}+?")
279        } else {
280            String::new()
281        };
282        if *segment == "**" {
283            if !current_separator.is_empty() {
284                if index > 0 {
285                    expression.push_str(&current_separator);
286                }
287                expression.push_str(&format!("(?:{wildcard}*?{current_separator})*?"));
288            }
289            continue;
290        }
291        let mut chars = segment.chars();
292        while let Some(character) = chars.next() {
293            match character {
294                '*' => expression.push_str(&format!("{wildcard}*?")),
295                '?' => expression.push_str(wildcard),
296                '\\' => {
297                    if let Some(escaped) = chars.next() {
298                        expression.push_str(&regex::escape(&escaped.to_string()));
299                    }
300                }
301                literal => expression.push_str(&regex::escape(&literal.to_string())),
302            }
303        }
304        expression.push_str(&current_separator);
305    }
306    expression.push_str("\\z");
307    regex::Regex::new(&expression).is_ok_and(|compiled| compiled.is_match(value))
308}
309
310pub(super) fn matches_origin(value: &str, pattern: &str) -> bool {
311    if value.starts_with('/') {
312        return false;
313    }
314    let parsed = url::Url::parse(value).ok();
315    if pattern.contains(['*', '?']) {
316        if pattern.contains("://") {
317            let origin = parsed
318                .as_ref()
319                .filter(|url| matches!(url.origin(), url::Origin::Tuple(..)))
320                .map(|url| url.origin().ascii_serialization());
321            return wildcard(pattern, origin.as_deref().unwrap_or(value));
322        }
323        return parsed.is_some_and(|url| {
324            url.host_str().is_some()
325                && wildcard(
326                    pattern,
327                    &url[url::Position::BeforeHost..url::Position::AfterPort],
328                )
329        });
330    }
331    if parsed
332        .as_ref()
333        .is_none_or(|url| matches!(url.scheme(), "http" | "https"))
334    {
335        return parsed.is_some_and(|url| url.origin().ascii_serialization() == pattern);
336    }
337    let (Some((scheme, authority, path)), Some((pattern_scheme, pattern_authority, pattern_path))) =
338        (custom_parts(value), custom_parts(pattern))
339    else {
340        return false;
341    };
342    scheme == pattern_scheme
343        && (pattern_authority.is_empty() || authority == pattern_authority)
344        && (pattern_path.is_empty()
345            || path == pattern_path
346            || path.starts_with(&format!("{pattern_path}/")))
347}
348
349fn custom_parts(value: &str) -> Option<(String, String, String)> {
350    if value
351        .chars()
352        .any(|c| matches!(c, '\u{0000}'..='\u{001f}' | '\u{007f}'..='\u{009f}'))
353    {
354        return None;
355    }
356    let (scheme, mut rest) = value.split_once(':')?;
357    if scheme.is_empty() {
358        return None;
359    }
360    let mut authority = "";
361    if let Some(after) = rest.strip_prefix("//") {
362        let end = after.find(['/', '?', '#']).unwrap_or(after.len());
363        authority = after.get(..end)?;
364        rest = after.get(end..)?;
365    }
366    let path = rest.split(['?', '#']).next().unwrap_or_default();
367    // decodeURIComponent leaves the entire input unchanged on invalid escapes.
368    let valid_escapes = path.as_bytes().iter().enumerate().all(|(i, c)| {
369        *c != b'%'
370            || path
371                .as_bytes()
372                .get(i + 1)
373                .is_some_and(u8::is_ascii_hexdigit)
374                && path
375                    .as_bytes()
376                    .get(i + 2)
377                    .is_some_and(u8::is_ascii_hexdigit)
378    });
379    let decoded = if valid_escapes {
380        percent_encoding::percent_decode_str(path)
381            .decode_utf8()
382            .ok()
383    } else {
384        None
385    };
386    let mut segments = Vec::new();
387    for segment in decoded.as_deref().unwrap_or(path).split('/') {
388        match segment {
389            ".." => {
390                _ = segments.pop();
391            }
392            "." | "" => {}
393            value => segments.push(value),
394        }
395    }
396    let path = if segments.is_empty() {
397        String::new()
398    } else {
399        format!("/{}", segments.join("/"))
400    };
401    Some((
402        scheme.to_ascii_lowercase(),
403        authority.to_ascii_lowercase(),
404        path,
405    ))
406}