Skip to main content

alibi_core/config/
origin.rs

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