better_auth_core/config/
origin.rs1use super::AuthConfig;
2use crate::{AuthError, AuthRequest, AuthResult};
3use async_trait::async_trait;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub enum BaseUrlProtocol {
9 Http,
10 Https,
11 Auto,
12}
13
14#[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_trait]
28pub trait TrustedOriginsResolver: Send + Sync {
29 async fn resolve(&self, request: &AuthRequest) -> AuthResult<Vec<String>>;
30}
31
32#[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 #[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 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
226fn 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 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
264fn 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(¤t_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(®ex::escape(&escaped.to_string()));
299 }
300 }
301 literal => expression.push_str(®ex::escape(&literal.to_string())),
302 }
303 }
304 expression.push_str(¤t_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 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}