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().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
229fn 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
257fn 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 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}