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().ok_or_else(|| AuthError::config("Could not resolve base URL: host is not allowed and no fallback is configured"))?;
128                let parsed = url::Url::parse(fallback)
129                    .map_err(|_| AuthError::config("Invalid base URL fallback"))?;
130                if !matches!(parsed.scheme(), "http" | "https") {
131                    return Err(AuthError::config(
132                        "Base URL fallback must use HTTP or HTTPS",
133                    ));
134                }
135                fallback.clone()
136            };
137            config.session.cookie_secure = config.base_url.starts_with("https://");
138            if self.advanced.cross_sub_domain_cookies.is_some()
139                && self.advanced.use_secure_cookies.is_none()
140            {
141                config.advanced.use_secure_cookies = Some(config.session.cookie_secure);
142            }
143            config.session.cookie_name =
144                crate::utils::cookie_utils::related_cookie_name(&config, "session_token");
145            for host in &dynamic.allowed_hosts {
146                if host.contains("://") {
147                    config.trusted_origins.push(host.clone());
148                } else {
149                    if dynamic.protocol != Some(BaseUrlProtocol::Http) {
150                        config.trusted_origins.push(format!("https://{host}"));
151                    }
152                    if matches!(
153                        dynamic.protocol,
154                        Some(BaseUrlProtocol::Http | BaseUrlProtocol::Auto)
155                    ) || trusted_loopback(host)
156                    {
157                        config.trusted_origins.push(format!("http://{host}"));
158                    }
159                }
160            }
161            if let Some(fallback) = &dynamic.fallback
162                && let Some(origin) = super::extract_origin(fallback)
163            {
164                config.trusted_origins.push(origin);
165            }
166        }
167        if let Some(resolver) = &self.trusted_origins_resolver {
168            config.trusted_origins.extend(
169                resolver
170                    .resolve(request)
171                    .await?
172                    .into_iter()
173                    .filter(|origin| !origin.is_empty()),
174            );
175        }
176        if let Some(resolver) = &self.account.account_linking.trusted_providers_resolver {
177            config.account.account_linking.trusted_providers = resolver
178                .resolve(Some(request))
179                .await
180                .map_err(|_| {
181                    AuthError::CallbackFailure(Box::new(AuthError::internal(
182                        "Trusted provider resolution failed",
183                    )))
184                })?
185                .into_iter()
186                .filter(|provider| !provider.is_empty())
187                .collect();
188        }
189        Ok(config)
190    }
191}
192
193fn header<'a>(request: &'a AuthRequest, name: &str) -> Option<&'a str> {
194    request
195        .headers
196        .iter()
197        .find_map(|(key, value)| key.eq_ignore_ascii_case(name).then_some(value.as_str()))
198}
199
200fn normalize_host_pattern(value: &str) -> String {
201    value
202        .strip_prefix("https://")
203        .or_else(|| value.strip_prefix("http://"))
204        .unwrap_or(value)
205        .split('/')
206        .next()
207        .unwrap_or_default()
208        .to_ascii_lowercase()
209}
210
211fn loopback(host: &str) -> bool {
212    let host = normalize_host_pattern(host);
213    let hostname = host
214        .trim_start_matches('[')
215        .split(']')
216        .next()
217        .unwrap_or(&host);
218    let hostname = if hostname.contains("::") {
219        hostname
220    } else {
221        hostname.split(':').next().unwrap_or(hostname)
222    };
223    hostname == "localhost"
224        || hostname.ends_with(".localhost")
225        || hostname == "::1"
226        || hostname.starts_with("127.")
227}
228
229// Trusted-origin generation uses the server classifier, rather than the
230// deliberately permissive dev-scheme heuristic above (e.g. 127.example.test).
231fn trusted_loopback(host: &str) -> bool {
232    let host = host.trim().to_ascii_lowercase();
233    let hostname = if let Some(bracketed) = host.strip_prefix('[') {
234        bracketed.split(']').next().unwrap_or(bracketed)
235    } else if host.matches(':').count() == 1 {
236        host.split(':').next().unwrap_or(&host)
237    } else {
238        &host
239    };
240    let hostname = hostname
241        .split('%')
242        .next()
243        .unwrap_or(hostname)
244        .trim_end_matches('.');
245    hostname == "localhost"
246        || hostname.ends_with(".localhost")
247        || hostname
248            .parse::<std::net::IpAddr>()
249            .is_ok_and(|ip| match ip {
250                std::net::IpAddr::V4(ip) => ip.is_loopback(),
251                std::net::IpAddr::V6(ip) => {
252                    ip.is_loopback() || ip.to_ipv4_mapped().is_some_and(|ip| ip.is_loopback())
253                }
254            })
255}
256
257/// Source `validateProxyHeader`: DNS labels, IPv4 or bracketed IPv6 and a
258/// one-to-five digit port; no whitespace, userinfo, path or delimiter tricks.
259fn valid_host(host: &str) -> bool {
260    static HOST: LazyLock<Option<Regex>> = LazyLock::new(|| {
261        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})?$")
262            .ok()
263    });
264    !host.contains("..") && HOST.as_ref().is_some_and(|pattern| pattern.is_match(host))
265}
266
267pub(super) fn matches_origin(value: &str, pattern: &str) -> bool {
268    if value.starts_with('/') {
269        return false;
270    }
271    let parsed = url::Url::parse(value).ok();
272    if pattern.contains(['*', '?']) {
273        if pattern.contains("://") {
274            let origin = parsed
275                .as_ref()
276                .filter(|url| matches!(url.origin(), url::Origin::Tuple(..)))
277                .map(|url| url.origin().ascii_serialization());
278            return wildcard::matches(pattern, origin.as_deref().unwrap_or(value));
279        }
280        return parsed.is_some_and(|url| {
281            url.host_str().is_some()
282                && wildcard::matches(
283                    pattern,
284                    &url[url::Position::BeforeHost..url::Position::AfterPort],
285                )
286        });
287    }
288    if parsed
289        .as_ref()
290        .is_none_or(|url| matches!(url.scheme(), "http" | "https"))
291    {
292        return parsed.is_some_and(|url| url.origin().ascii_serialization() == pattern);
293    }
294    let (Some((scheme, authority, path)), Some((pattern_scheme, pattern_authority, pattern_path))) =
295        (custom_parts(value), custom_parts(pattern))
296    else {
297        return false;
298    };
299    scheme == pattern_scheme
300        && (pattern_authority.is_empty() || authority == pattern_authority)
301        && (pattern_path.is_empty()
302            || path == pattern_path
303            || path.starts_with(&format!("{pattern_path}/")))
304}
305
306fn custom_parts(value: &str) -> Option<(String, String, String)> {
307    if value
308        .chars()
309        .any(|c| matches!(c, '\u{0000}'..='\u{001f}' | '\u{007f}'..='\u{009f}'))
310    {
311        return None;
312    }
313    let (scheme, mut rest) = value.split_once(':')?;
314    if scheme.is_empty() {
315        return None;
316    }
317    let mut authority = "";
318    if let Some(after) = rest.strip_prefix("//") {
319        let end = after.find(['/', '?', '#']).unwrap_or(after.len());
320        authority = after.get(..end)?;
321        rest = after.get(end..)?;
322    }
323    let path = rest.split(['?', '#']).next().unwrap_or_default();
324    // decodeURIComponent leaves the entire input unchanged on invalid escapes.
325    let valid_escapes = path.as_bytes().iter().enumerate().all(|(i, c)| {
326        *c != b'%'
327            || path
328                .as_bytes()
329                .get(i + 1)
330                .is_some_and(u8::is_ascii_hexdigit)
331                && path
332                    .as_bytes()
333                    .get(i + 2)
334                    .is_some_and(u8::is_ascii_hexdigit)
335    });
336    let decoded = if valid_escapes {
337        percent_encoding::percent_decode_str(path)
338            .decode_utf8()
339            .ok()
340    } else {
341        None
342    };
343    let mut segments = Vec::new();
344    for segment in decoded.as_deref().unwrap_or(path).split('/') {
345        match segment {
346            ".." => {
347                _ = segments.pop();
348            }
349            "." | "" => {}
350            value => segments.push(value),
351        }
352    }
353    let path = if segments.is_empty() {
354        String::new()
355    } else {
356        format!("/{}", segments.join("/"))
357    };
358    Some((
359        scheme.to_ascii_lowercase(),
360        authority.to_ascii_lowercase(),
361        path,
362    ))
363}