alibi_core/config/
origin.rs1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum BaseUrlProtocol {
12 Http,
13 Https,
14 Auto,
15}
16
17#[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_trait]
31pub trait TrustedOriginsResolver: Send + Sync {
32 async fn resolve(&self, request: &AuthRequest) -> AuthResult<Vec<String>>;
33}
34
35#[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 #[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 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
234fn 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
262fn 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 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}