better_auth_core/config.rs
1use crate::email::EmailProvider;
2use crate::error::AuthError;
3use chrono::Duration;
4use std::collections::HashMap;
5use std::sync::Arc;
6
7/// Well-known core route paths.
8///
9/// These constants are the single source of truth for route paths used by both
10/// the core request dispatcher (`handle_core_request`) and framework-specific
11/// routers (e.g. Axum) so that path strings are never duplicated.
12pub mod core_paths {
13 pub const OK: &str = "/ok";
14 pub const ERROR: &str = "/error";
15 pub const HEALTH: &str = "/health";
16 pub const OPENAPI_SPEC: &str = "/reference/openapi.json";
17 pub const UPDATE_USER: &str = "/update-user";
18 pub const DELETE_USER: &str = "/delete-user";
19 pub const CHANGE_EMAIL: &str = "/change-email";
20 pub const DELETE_USER_CALLBACK: &str = "/delete-user/callback";
21}
22
23/// Main configuration for BetterAuth
24#[derive(Clone)]
25pub struct AuthConfig {
26 /// Secret key for signing tokens and sessions
27 pub secret: String,
28
29 /// Application name, used for cookie prefixes, email templates, etc.
30 ///
31 /// Defaults to `"Better Auth"`.
32 pub app_name: String,
33
34 /// Base URL for the authentication service (e.g. `"http://localhost:3000"`).
35 pub base_url: String,
36
37 /// Base path where the auth routes are mounted.
38 ///
39 /// All routes handled by BetterAuth will be prefixed with this path.
40 /// For example, with the default `"/api/auth"`, the sign-in route becomes
41 /// `"/api/auth/sign-in/email"`.
42 ///
43 /// Defaults to `"/api/auth"`.
44 pub base_path: String,
45
46 /// Origins that are trusted for CSRF and other cross-origin checks.
47 ///
48 /// Supports glob patterns (e.g. `"https://*.example.com"`).
49 /// These are shared across all middleware that needs origin validation
50 /// (CSRF, CORS, etc.).
51 pub trusted_origins: Vec<String>,
52
53 /// Paths that should be disabled (skipped) by the router.
54 ///
55 /// Any request whose path matches an entry in this list will receive
56 /// a 404 response, even if a handler is registered for it.
57 pub disabled_paths: Vec<String>,
58 /// Session configuration
59 pub session: SessionConfig,
60
61 /// JWT configuration
62 pub jwt: JwtConfig,
63
64 /// Password configuration
65 pub password: PasswordConfig,
66
67 /// Account configuration (linking, token encryption, etc.)
68 pub account: AccountConfig,
69
70 /// Email provider for sending emails (verification, password reset, etc.)
71 pub email_provider: Option<Arc<dyn EmailProvider>>,
72
73 /// Advanced configuration options
74 pub advanced: AdvancedConfig,
75}
76
77/// Account-level configuration: linking, token encryption, sign-in behavior.
78#[derive(Debug, Clone)]
79pub struct AccountConfig {
80 /// Update OAuth tokens on every sign-in (default: true)
81 pub update_account_on_sign_in: bool,
82 /// Account linking settings
83 pub account_linking: AccountLinkingConfig,
84 /// Encrypt OAuth tokens at rest (default: false)
85 pub encrypt_oauth_tokens: bool,
86}
87
88/// Settings that control how OAuth accounts are linked to existing users.
89#[derive(Debug, Clone)]
90pub struct AccountLinkingConfig {
91 /// Enable account linking (default: true)
92 pub enabled: bool,
93 /// Trusted providers that can auto-link (default: empty = all trusted)
94 pub trusted_providers: Vec<String>,
95 /// Allow linking accounts with different emails (default: false) - SECURITY WARNING
96 pub allow_different_emails: bool,
97 /// Allow unlinking all accounts (default: false)
98 pub allow_unlinking_all: bool,
99 /// Update user info when a new account is linked (default: false)
100 pub update_user_info_on_link: bool,
101}
102
103/// Session-specific configuration
104#[derive(Debug, Clone)]
105pub struct SessionConfig {
106 /// Session expiration duration
107 pub expires_in: Duration,
108
109 /// How often to refresh the session expiry (as a Duration).
110 ///
111 /// When set, session expiry is only updated if the session is older than
112 /// this duration since the last update. When `None`, every request
113 /// refreshes the session (equivalent to the old `update_age: true`).
114 pub update_age: Option<Duration>,
115
116 /// If `true`, sessions are never automatically refreshed on access.
117 pub disable_session_refresh: bool,
118
119 /// Session freshness window. A session younger than this is considered
120 /// "fresh" (useful for step-up auth or sensitive operations).
121 pub fresh_age: Option<Duration>,
122
123 /// Cookie name for session token
124 pub cookie_name: String,
125
126 /// Cookie settings
127 pub cookie_secure: bool,
128 pub cookie_http_only: bool,
129 pub cookie_same_site: SameSite,
130
131 /// Optional cookie-based session cache to avoid DB lookups.
132 ///
133 /// When enabled, session data is cached in a signed/encrypted cookie.
134 /// `SessionManager` checks the cookie cache before hitting the database.
135 pub cookie_cache: Option<CookieCacheConfig>,
136}
137
138/// JWT configuration
139#[derive(Debug, Clone)]
140pub struct JwtConfig {
141 /// JWT expiration duration
142 pub expires_in: Duration,
143
144 /// JWT algorithm
145 pub algorithm: String,
146
147 /// Issuer claim
148 pub issuer: Option<String>,
149
150 /// Audience claim
151 pub audience: Option<String>,
152}
153
154/// Password hashing configuration
155#[derive(Debug, Clone)]
156pub struct PasswordConfig {
157 /// Minimum password length
158 pub min_length: usize,
159
160 /// Require uppercase letters
161 pub require_uppercase: bool,
162
163 /// Require lowercase letters
164 pub require_lowercase: bool,
165
166 /// Require numbers
167 pub require_numbers: bool,
168
169 /// Require special characters
170 pub require_special: bool,
171
172 /// Argon2 configuration
173 pub argon2_config: Argon2Config,
174}
175
176/// Argon2 hashing configuration
177#[derive(Debug, Clone)]
178pub struct Argon2Config {
179 pub memory_cost: u32,
180 pub time_cost: u32,
181 pub parallelism: u32,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub enum SameSite {
186 Strict,
187 Lax,
188 None,
189}
190
191/// Configuration for cookie-based session caching.
192///
193/// When enabled, session data is stored in a signed or encrypted cookie so that
194/// subsequent requests can skip the database lookup.
195#[derive(Debug, Clone)]
196pub struct CookieCacheConfig {
197 /// Whether the cookie cache is active.
198 pub enabled: bool,
199
200 /// Maximum age of the cached cookie before a fresh DB lookup is required.
201 ///
202 /// Default: 5 minutes.
203 pub max_age: Duration,
204
205 /// Strategy used to protect the cached cookie value.
206 pub strategy: CookieCacheStrategy,
207}
208
209/// Strategy for signing / encrypting the cookie cache.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub enum CookieCacheStrategy {
212 /// Base64url-encoded payload + HMAC-SHA256 signature.
213 Compact,
214 /// Standard JWT with HMAC signing.
215 Jwt,
216 /// JWE with AES-256-GCM encryption.
217 Jwe,
218}
219
220impl Default for CookieCacheConfig {
221 fn default() -> Self {
222 Self {
223 enabled: false,
224 max_age: Duration::minutes(5),
225 strategy: CookieCacheStrategy::Compact,
226 }
227 }
228}
229
230impl Default for AccountConfig {
231 fn default() -> Self {
232 Self {
233 update_account_on_sign_in: true,
234 account_linking: AccountLinkingConfig::default(),
235 encrypt_oauth_tokens: false,
236 }
237 }
238}
239
240impl Default for AccountLinkingConfig {
241 fn default() -> Self {
242 Self {
243 enabled: true,
244 trusted_providers: Vec::new(),
245 allow_different_emails: false,
246 allow_unlinking_all: false,
247 update_user_info_on_link: false,
248 }
249 }
250}
251
252impl std::fmt::Display for SameSite {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 match self {
255 SameSite::Strict => f.write_str("Strict"),
256 SameSite::Lax => f.write_str("Lax"),
257 SameSite::None => f.write_str("None"),
258 }
259 }
260}
261
262// ── Advanced configuration ──────────────────────────────────────────────
263
264/// Advanced configuration options (mirrors TS `advanced` block).
265#[derive(Debug, Clone, Default)]
266pub struct AdvancedConfig {
267 /// IP address extraction configuration.
268 pub ip_address: IpAddressConfig,
269
270 /// If `true`, the CSRF-check middleware is disabled.
271 pub disable_csrf_check: bool,
272
273 /// If `true`, the Origin header check is skipped.
274 pub disable_origin_check: bool,
275
276 /// Cross-subdomain cookie sharing configuration.
277 pub cross_sub_domain_cookies: Option<CrossSubDomainConfig>,
278
279 /// Per-cookie-name overrides (name, attributes, prefix).
280 ///
281 /// Keys are the *logical* cookie names (e.g. `"session_token"`,
282 /// `"csrf_token"`). Values specify the attributes to override.
283 pub cookies: HashMap<String, CookieOverride>,
284
285 /// Default cookie attributes applied to *every* cookie the library sets
286 /// (individual overrides in `cookies` take precedence).
287 pub default_cookie_attributes: CookieAttributes,
288
289 /// Optional prefix prepended to every cookie name (e.g. `"myapp"` →
290 /// `"myapp.session_token"`).
291 pub cookie_prefix: Option<String>,
292
293 /// Database-related advanced options.
294 pub database: AdvancedDatabaseConfig,
295
296 /// List of header names the framework trusts for extracting the
297 /// client's real IP when behind a proxy (e.g. `X-Forwarded-For`).
298 pub trusted_proxy_headers: Vec<String>,
299}
300
301/// IP-address extraction configuration.
302#[derive(Debug, Clone)]
303pub struct IpAddressConfig {
304 /// Ordered list of headers to check for the client IP.
305 /// Defaults to `["x-forwarded-for", "x-real-ip"]`.
306 pub headers: Vec<String>,
307
308 /// If `true`, IP tracking is entirely disabled (no IP stored in sessions).
309 pub disable_ip_tracking: bool,
310}
311
312/// Configuration for sharing cookies across sub-domains.
313#[derive(Debug, Clone)]
314pub struct CrossSubDomainConfig {
315 /// The parent domain (e.g. `".example.com"`).
316 pub domain: String,
317}
318
319/// Overridable cookie attributes.
320#[derive(Debug, Clone, Default)]
321pub struct CookieAttributes {
322 /// Override `Secure` flag.
323 pub secure: Option<bool>,
324 /// Override `HttpOnly` flag.
325 pub http_only: Option<bool>,
326 /// Override `SameSite` policy.
327 pub same_site: Option<SameSite>,
328 /// Override `Path`.
329 pub path: Option<String>,
330 /// Override `Max-Age` (seconds).
331 pub max_age: Option<i64>,
332 /// Override cookie `Domain`.
333 pub domain: Option<String>,
334}
335
336/// Per-cookie override entry.
337#[derive(Debug, Clone, Default)]
338pub struct CookieOverride {
339 /// Custom name to use instead of the logical name.
340 pub name: Option<String>,
341 /// Attribute overrides for this cookie.
342 pub attributes: CookieAttributes,
343}
344
345/// Database-related advanced options.
346#[derive(Debug, Clone)]
347pub struct AdvancedDatabaseConfig {
348 /// Default `LIMIT` for "find many" queries.
349 pub default_find_many_limit: usize,
350
351 /// If `true`, auto-generated IDs will be numeric (auto-increment style)
352 /// rather than UUIDs.
353 pub use_number_id: bool,
354}
355impl Default for AuthConfig {
356 fn default() -> Self {
357 Self {
358 secret: String::new(),
359 app_name: "Better Auth".to_string(),
360 base_url: "http://localhost:3000".to_string(),
361 base_path: "/api/auth".to_string(),
362 trusted_origins: Vec::new(),
363 disabled_paths: Vec::new(),
364 session: SessionConfig::default(),
365 jwt: JwtConfig::default(),
366 password: PasswordConfig::default(),
367 account: AccountConfig::default(),
368 email_provider: None,
369 advanced: AdvancedConfig::default(),
370 }
371 }
372}
373
374impl Default for SessionConfig {
375 fn default() -> Self {
376 Self {
377 expires_in: Duration::hours(24 * 7), // 7 days
378 update_age: Some(Duration::hours(24)), // refresh once per day
379 disable_session_refresh: false,
380 fresh_age: None,
381 cookie_name: "better-auth.session-token".to_string(),
382 cookie_secure: true,
383 cookie_http_only: true,
384 cookie_same_site: SameSite::Lax,
385 cookie_cache: None,
386 }
387 }
388}
389
390impl Default for IpAddressConfig {
391 fn default() -> Self {
392 Self {
393 headers: vec!["x-forwarded-for".to_string(), "x-real-ip".to_string()],
394 disable_ip_tracking: false,
395 }
396 }
397}
398
399impl Default for AdvancedDatabaseConfig {
400 fn default() -> Self {
401 Self {
402 default_find_many_limit: 100,
403 use_number_id: false,
404 }
405 }
406}
407
408impl Default for JwtConfig {
409 fn default() -> Self {
410 Self {
411 expires_in: Duration::hours(24), // 1 day
412 algorithm: "HS256".to_string(),
413 issuer: None,
414 audience: None,
415 }
416 }
417}
418
419impl Default for PasswordConfig {
420 fn default() -> Self {
421 Self {
422 min_length: 8,
423 require_uppercase: false,
424 require_lowercase: false,
425 require_numbers: false,
426 require_special: false,
427 argon2_config: Argon2Config::default(),
428 }
429 }
430}
431
432impl Default for Argon2Config {
433 fn default() -> Self {
434 Self {
435 memory_cost: 4096, // 4MB
436 time_cost: 3, // 3 iterations
437 parallelism: 1, // 1 thread
438 }
439 }
440}
441
442impl AuthConfig {
443 pub fn new(secret: impl Into<String>) -> Self {
444 Self {
445 secret: secret.into(),
446 ..Default::default()
447 }
448 }
449
450 /// Set the application name.
451 pub fn app_name(mut self, name: impl Into<String>) -> Self {
452 self.app_name = name.into();
453 self
454 }
455
456 /// Set the base URL (e.g. `"https://myapp.com"`).
457 pub fn base_url(mut self, url: impl Into<String>) -> Self {
458 self.base_url = url.into();
459 self
460 }
461
462 pub fn account(mut self, account: AccountConfig) -> Self {
463 self.account = account;
464 self
465 }
466
467 /// Set the base path where auth routes are mounted.
468 pub fn base_path(mut self, path: impl Into<String>) -> Self {
469 self.base_path = path.into();
470 self
471 }
472
473 /// Add a trusted origin. Supports glob patterns (e.g. `"https://*.example.com"`).
474 pub fn trusted_origin(mut self, origin: impl Into<String>) -> Self {
475 self.trusted_origins.push(origin.into());
476 self
477 }
478
479 /// Set all trusted origins at once.
480 pub fn trusted_origins(mut self, origins: Vec<String>) -> Self {
481 self.trusted_origins = origins;
482 self
483 }
484
485 /// Add a path to the disabled paths list.
486 pub fn disabled_path(mut self, path: impl Into<String>) -> Self {
487 self.disabled_paths.push(path.into());
488 self
489 }
490
491 /// Set all disabled paths at once.
492 pub fn disabled_paths(mut self, paths: Vec<String>) -> Self {
493 self.disabled_paths = paths;
494 self
495 }
496
497 /// Set the session expiration duration.
498 pub fn session_expires_in(mut self, duration: Duration) -> Self {
499 self.session.expires_in = duration;
500 self
501 }
502
503 pub fn session_update_age(mut self, duration: Duration) -> Self {
504 self.session.update_age = Some(duration);
505 self
506 }
507
508 pub fn disable_session_refresh(mut self, disabled: bool) -> Self {
509 self.session.disable_session_refresh = disabled;
510 self
511 }
512
513 pub fn session_fresh_age(mut self, duration: Duration) -> Self {
514 self.session.fresh_age = Some(duration);
515 self
516 }
517
518 /// Set the cookie cache configuration for sessions.
519 pub fn session_cookie_cache(mut self, config: CookieCacheConfig) -> Self {
520 self.session.cookie_cache = Some(config);
521 self
522 }
523
524 /// Set the JWT expiration duration.
525 pub fn jwt_expires_in(mut self, duration: Duration) -> Self {
526 self.jwt.expires_in = duration;
527 self
528 }
529
530 /// Set the minimum password length.
531 pub fn password_min_length(mut self, length: usize) -> Self {
532 self.password.min_length = length;
533 self
534 }
535
536 pub fn advanced(mut self, advanced: AdvancedConfig) -> Self {
537 self.advanced = advanced;
538 self
539 }
540
541 pub fn cookie_prefix(mut self, prefix: impl Into<String>) -> Self {
542 self.advanced.cookie_prefix = Some(prefix.into());
543 self
544 }
545
546 pub fn disable_csrf_check(mut self, disabled: bool) -> Self {
547 self.advanced.disable_csrf_check = disabled;
548 self
549 }
550
551 pub fn cross_sub_domain_cookies(mut self, domain: impl Into<String>) -> Self {
552 self.advanced.cross_sub_domain_cookies = Some(CrossSubDomainConfig {
553 domain: domain.into(),
554 });
555 self
556 }
557
558 /// Check whether a given origin is trusted.
559 ///
560 /// An origin is trusted if it matches:
561 /// 1. The origin extracted from [`base_url`](Self::base_url), or
562 /// 2. Any pattern in [`trusted_origins`](Self::trusted_origins) (after
563 /// extracting the origin portion from the pattern).
564 ///
565 /// Glob patterns are supported — `*` matches any characters except `/`,
566 /// `**` matches any characters including `/`. Non-wildcard patterns
567 /// are parsed with the strict WHATWG URL parser so scheme, host, and
568 /// default port match exactly what runtime callback URLs normalise
569 /// to. Wildcard patterns fall back to naïve scheme/authority
570 /// splitting so `http://localhost:*` and `*://app.com` still work;
571 /// their non-wildcard host labels are still IDN-canonicalised.
572 pub fn is_origin_trusted(&self, origin: &str) -> bool {
573 // Check base_url origin
574 if let Some(base_origin) = extract_origin(&self.base_url)
575 && origin == base_origin
576 {
577 return true;
578 }
579 // Check trusted_origins patterns
580 self.trusted_origins.iter().any(|pattern| {
581 let pattern_origin = extract_pattern_origin(pattern);
582 glob_match::glob_match(&pattern_origin, origin)
583 })
584 }
585
586 /// Check whether a given path is disabled.
587 pub fn is_path_disabled(&self, path: &str) -> bool {
588 self.disabled_paths.iter().any(|disabled| disabled == path)
589 }
590
591 /// Check whether `target` is safe to use as the value of a server-issued
592 /// redirect (302 `Location`) or an absolute link embedded in an outgoing
593 /// email. Safe targets are:
594 ///
595 /// - a relative path starting with `/` whose second character is not
596 /// `/` or `\` (authority smuggling — `//evil.com`, `/\evil.com` —
597 /// is rejected even when the caller opts out of origin checks;
598 /// browsers normalise `\` to `/` in the authority component);
599 /// - an absolute `http`/`https` URL whose origin matches
600 /// [`base_url`](Self::base_url) or a
601 /// [`trusted_origins`](Self::trusted_origins) pattern;
602 /// - any path/URL when `advanced.disable_origin_check` is set, with
603 /// the authority-smuggling exception above.
604 ///
605 /// Other schemes (`javascript:`, `data:`, `file:`, …) are always
606 /// rejected. Prevents open-redirect via user-supplied `callbackURL`
607 /// / `redirectTo`.
608 pub fn is_redirect_target_trusted(&self, target: &str) -> bool {
609 // Reject control characters (CR/LF, NUL, TAB, etc.) and the
610 // double-quote character outright. Any of them would let the
611 // caller break out of the Location header or the surrounding
612 // email-HTML `href="..."` attribute once this value is
613 // interpolated by a downstream `format!`. WHATWG URL parsers
614 // strip most of these silently but our callers treat the string
615 // as opaque, so the guard must live here.
616 if target.chars().any(|c| c.is_control() || c == '"') {
617 return false;
618 }
619 // Authority smuggling must NEVER be accepted, even under
620 // `disable_origin_check`. That flag is an opt-out of same-origin
621 // checks, not a licence to let the caller pick the host.
622 if is_authority_smuggling(target) {
623 return false;
624 }
625 if self.advanced.disable_origin_check {
626 // Even with origin checks disabled, reject non-http(s) URLs.
627 // `javascript:`, `data:`, `file:`, and other schemes would
628 // execute in the caller's browser / expose local files if
629 // reflected into a Location header. Relative paths and
630 // well-formed http/https URLs are allowed; `extract_origin`
631 // already filters the unsafe schemes.
632 return target.starts_with('/') || extract_origin(target).is_some();
633 }
634 if target.starts_with('/') {
635 return true;
636 }
637 match extract_origin(target) {
638 Some(origin) => self.is_origin_trusted(&origin),
639 None => false,
640 }
641 }
642
643 /// Stricter variant of [`is_redirect_target_trusted`] that requires
644 /// an absolute `http`/`https` URL. Use this for `callbackURL` values
645 /// that are **embedded in an email body** or **forwarded to an OAuth
646 /// provider as `redirect_uri`** — in both contexts a relative path
647 /// produces a broken link (mail clients have no base URL to resolve
648 /// against; OAuth spec requires absolute URIs).
649 ///
650 /// For server-issued `Location` redirects (GET handlers reached via
651 /// email link clicks), relative paths are fine; use the less strict
652 /// [`is_redirect_target_trusted`] there.
653 pub fn is_absolute_trusted_callback_url(&self, target: &str) -> bool {
654 if !self.is_redirect_target_trusted(target) {
655 return false;
656 }
657 // `extract_origin` returns `Some(_)` only for well-formed http/https
658 // absolute URLs; relative paths return `None`.
659 extract_origin(target).is_some()
660 }
661
662 pub fn validate(&self) -> Result<(), AuthError> {
663 if self.secret.is_empty() {
664 return Err(AuthError::config("Secret key cannot be empty"));
665 }
666
667 if self.secret.len() < 32 {
668 return Err(AuthError::config(
669 "Secret key must be at least 32 characters",
670 ));
671 }
672
673 Ok(())
674 }
675}
676
677/// Default hard cap for request body reads.
678///
679/// Applied by the root-crate axum entry handler and by
680/// `AuthRequestExt::from_request` when no explicit limit is configured,
681/// so chunked bodies cannot exhaust memory before `BodyLimitMiddleware`
682/// runs. Matches the `BodyLimitConfig::default().max_bytes` value and
683/// upstream TypeScript `better-auth@1.4.19`.
684pub const DEFAULT_MAX_BODY_BYTES: usize = 1024 * 1024;
685
686/// Extract the origin (scheme + host + port) from a URL string.
687///
688/// For example, `"https://example.com/path"` → `"https://example.com"`.
689///
690/// Uses the WHATWG URL parser so query strings, fragments, and userinfo
691/// are stripped correctly (the hand-rolled version this replaced returned
692/// `"https://app.example.com?foo=bar"` for `"https://app.example.com?foo=bar"`,
693/// and kept userinfo, which let an `app.example.com@evil.com` authority
694/// masquerade as an app-origin URL in string comparisons).
695///
696/// Only `http` and `https` origins are returned; opaque or unusual schemes
697/// (`javascript:`, `data:`, `file:`) return `None` so they cannot sneak
698/// through the origin-comparison path.
699///
700/// This is used by [`AuthConfig::is_origin_trusted`],
701/// [`AuthConfig::is_redirect_target_trusted`], and the CSRF middleware so
702/// that origin comparison is centralised in one place.
703pub fn extract_origin(url: &str) -> Option<String> {
704 let parsed = ::url::Url::parse(url).ok()?;
705 if !matches!(parsed.scheme(), "http" | "https") {
706 return None;
707 }
708 match parsed.origin() {
709 ::url::Origin::Tuple(..) => Some(parsed.origin().ascii_serialization()),
710 ::url::Origin::Opaque(_) => None,
711 }
712}
713
714/// Naïve origin extractor used only for matching `trusted_origins`
715/// patterns. Unlike [`extract_origin`] this does not invoke a strict URL
716/// parser, so glob patterns with non-RFC characters (`*`, wildcard
717/// ports) survive. For any value an operator is likely to configure —
718/// `https://*.example.com`, `http://localhost:*`, `*://app.com` — we
719/// return the `"scheme://authority"` prefix unchanged and let
720/// `glob_match` do the final comparison.
721///
722/// Default ports (`:80` for `http`, `:443` for `https`) are stripped so
723/// that a pattern like `https://admin.example.com:443` still matches the
724/// origin produced by `extract_origin("https://admin.example.com/x")`,
725/// which is `https://admin.example.com` — `url::Url::origin()` omits
726/// default ports per the WHATWG URL spec.
727fn extract_pattern_origin(pattern: &str) -> String {
728 // When the pattern is a well-formed absolute URL with no glob
729 // wildcards, round-trip it through `extract_origin` so the result
730 // uses exactly the same normalisation (punycode host, lower-case
731 // scheme, omitted default port) as the runtime origin comparisons
732 // produced by `url::Url::parse`. Otherwise (bare hostnames, glob
733 // wildcards like `http://localhost:*`, non-http schemes, …) fall
734 // back to a naïve scheme://authority split so wildcards survive.
735 if !pattern.contains('*')
736 && let Some(canonical) = extract_origin(pattern)
737 {
738 return canonical;
739 }
740
741 let Some(scheme_end) = pattern.find("://") else {
742 return String::new();
743 };
744 let scheme = pattern[..scheme_end].to_ascii_lowercase();
745 let rest = &pattern[scheme_end + 3..];
746 let host_end = rest.find('/').unwrap_or(rest.len());
747 let authority = &rest[..host_end];
748
749 // Split host from port so IDN normalisation only runs on the host.
750 let (host, port_suffix) = match authority.rfind(':') {
751 Some(idx)
752 if authority[idx + 1..]
753 .chars()
754 .all(|c| c.is_ascii_digit() || c == '*') =>
755 {
756 (&authority[..idx], &authority[idx..])
757 }
758 _ => (authority, ""),
759 };
760
761 // IDN-canonicalise each label that does not contain a glob wildcard
762 // so `https://*.bücher.example` matches callbacks that
763 // `url::Url::parse` normalises to `https://shop.xn--bcher-kva.example`.
764 // Labels that contain `*` / `**` stay raw, otherwise we would break
765 // the glob. Purely ASCII labels pass through unchanged.
766 let canonical_host: String = host
767 .split('.')
768 .map(|label| {
769 if label.contains('*') || label.is_ascii() {
770 label.to_ascii_lowercase()
771 } else {
772 idna::domain_to_ascii(label).unwrap_or_else(|_| label.to_ascii_lowercase())
773 }
774 })
775 .collect::<Vec<_>>()
776 .join(".");
777
778 // Strip default ports for http/https to match `extract_origin`.
779 let port_suffix = match (scheme.as_str(), port_suffix) {
780 ("http", ":80") | ("https", ":443") => "",
781 _ => port_suffix,
782 };
783
784 format!("{}://{}{}", scheme, canonical_host, port_suffix)
785}
786
787/// Detect attacker-controlled authority smuggling in a redirect target.
788///
789/// Returns `true` for:
790/// - protocol-relative URLs (`//evil.com/x`) — browser resolves against
791/// the current origin's scheme but the host is caller-controlled;
792/// - `/\evil.com` and similar backslash bypasses — Chrome, Safari, and
793/// Edge follow WHATWG authority-state parsing and normalise `\` to `/`.
794///
795/// Used by [`AuthConfig::is_redirect_target_trusted`] to reject these
796/// forms even when origin checks are otherwise disabled.
797fn is_authority_smuggling(target: &str) -> bool {
798 let trimmed = target.trim_start_matches(|c: char| c.is_whitespace());
799 // Any leading backslash is suspect. Browsers normalise `\` to `/` in
800 // the authority state, so `\evil.com`, `\\evil.com`, and `\/evil.com`
801 // all resolve to different attacker-controllable targets depending on
802 // the surrounding context. Reject the whole class up-front rather
803 // than enumerating every two-character combination.
804 if trimmed.starts_with('\\') {
805 return true;
806 }
807 if trimmed.starts_with("//") {
808 return true;
809 }
810 if let Some(rest) = trimmed.strip_prefix('/')
811 && (rest.starts_with('/') || rest.starts_with('\\'))
812 {
813 return true;
814 }
815 // Percent-encoded `/` and `\` in the authority-start position let a
816 // double-decoding proxy (some nginx configurations, some CDNs) see
817 // `//evil.com` or `/\evil.com` after the first decode pass while
818 // the Rust-side string still looks like a harmless path. Defence in
819 // depth: reject the encoded forms too.
820 let encoded_bypass = ["/%2f", "/%2F", "/%5c", "/%5C", "%2f", "%2F", "%5c", "%5C"];
821 if encoded_bypass.iter().any(|p| trimmed.starts_with(p)) {
822 return true;
823 }
824 false
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830
831 fn config_with(trusted: Vec<&str>) -> AuthConfig {
832 AuthConfig {
833 base_url: "https://app.example.com".into(),
834 trusted_origins: trusted.into_iter().map(String::from).collect(),
835 ..AuthConfig::default()
836 }
837 }
838
839 #[test]
840 fn redirect_target_allows_relative_path() {
841 let cfg = config_with(vec![]);
842 assert!(cfg.is_redirect_target_trusted("/dashboard"));
843 assert!(cfg.is_redirect_target_trusted("/reset-password?token=abc"));
844 }
845
846 #[test]
847 fn redirect_target_rejects_protocol_relative() {
848 let cfg = config_with(vec![]);
849 assert!(!cfg.is_redirect_target_trusted("//evil.com/x"));
850 assert!(!cfg.is_redirect_target_trusted("//evil.com"));
851 }
852
853 #[test]
854 fn redirect_target_allows_base_url_origin() {
855 let cfg = config_with(vec![]);
856 assert!(cfg.is_redirect_target_trusted("https://app.example.com/dashboard"));
857 }
858
859 #[test]
860 fn redirect_target_allows_trusted_origin() {
861 let cfg = config_with(vec!["https://admin.example.com"]);
862 assert!(cfg.is_redirect_target_trusted("https://admin.example.com/callback"));
863 }
864
865 #[test]
866 fn redirect_target_rejects_untrusted_origin() {
867 let cfg = config_with(vec!["https://admin.example.com"]);
868 assert!(!cfg.is_redirect_target_trusted("https://evil.com/cb"));
869 }
870
871 #[test]
872 fn redirect_target_rejects_unparseable_absolute() {
873 let cfg = config_with(vec![]);
874 assert!(!cfg.is_redirect_target_trusted("javascript:alert(1)"));
875 assert!(!cfg.is_redirect_target_trusted("data:text/html,x"));
876 }
877
878 #[test]
879 fn redirect_target_bypass_does_not_cover_authority_smuggling() {
880 // `disable_origin_check` is an opt-out of same-origin checks, NOT
881 // a licence for the caller to pick the host. Protocol-relative
882 // and backslash-bypass forms must still be rejected.
883 let mut cfg = config_with(vec![]);
884 cfg.advanced.disable_origin_check = true;
885 assert!(cfg.is_redirect_target_trusted("https://evil.com/cb"));
886 assert!(cfg.is_redirect_target_trusted("/dashboard"));
887 assert!(!cfg.is_redirect_target_trusted("//evil.com"));
888 assert!(!cfg.is_redirect_target_trusted("/\\evil.com"));
889 assert!(!cfg.is_redirect_target_trusted("\\\\evil.com"));
890 }
891
892 #[test]
893 fn redirect_target_rejects_backslash_authority_bypass() {
894 // Browsers (Chrome, Safari, Edge) normalise `\` to `/` in the
895 // authority component, so `Location: /\evil.com` navigates to
896 // `//evil.com`. Must be rejected.
897 let cfg = config_with(vec![]);
898 assert!(!cfg.is_redirect_target_trusted("/\\evil.com"));
899 assert!(!cfg.is_redirect_target_trusted("/\\\\evil.com"));
900 assert!(!cfg.is_redirect_target_trusted("\\evil.com"));
901 assert!(!cfg.is_redirect_target_trusted("\\\\evil.com"));
902 assert!(!cfg.is_redirect_target_trusted("\\/evil.com"));
903 // Whitespace-padded variants browsers may strip.
904 assert!(!cfg.is_redirect_target_trusted(" //evil.com"));
905 assert!(!cfg.is_redirect_target_trusted("\t/\\evil.com"));
906 }
907
908 #[test]
909 fn redirect_target_strips_userinfo_when_comparing_origin() {
910 // `app.example.com@evil.com` — the real host is `evil.com`; the
911 // old hand-rolled parser kept the whole string as the "origin"
912 // and would silently compare against `https://app.example.com`.
913 let cfg = config_with(vec![]);
914 assert!(!cfg.is_redirect_target_trusted("https://app.example.com@evil.com/x"));
915 }
916
917 #[test]
918 fn redirect_target_allows_same_origin_with_query_and_fragment() {
919 // The old hand-rolled `extract_origin` returned the whole URL for
920 // these inputs, so legitimate same-origin URLs with `?` or `#`
921 // were silently rejected. url::Url::parse fixes this.
922 let cfg = config_with(vec![]);
923 assert!(cfg.is_redirect_target_trusted("https://app.example.com?retry=1"));
924 assert!(cfg.is_redirect_target_trusted("https://app.example.com#/route"));
925 assert!(cfg.is_redirect_target_trusted("https://app.example.com/path?x=1#y"));
926 }
927
928 #[test]
929 fn redirect_target_rejects_non_http_schemes() {
930 let cfg = config_with(vec![]);
931 assert!(!cfg.is_redirect_target_trusted("javascript:alert(1)"));
932 assert!(!cfg.is_redirect_target_trusted("data:text/html,x"));
933 assert!(!cfg.is_redirect_target_trusted("file:///etc/passwd"));
934 assert!(!cfg.is_redirect_target_trusted("ftp://example.com/"));
935 }
936
937 #[test]
938 fn redirect_target_preserves_non_default_port_in_origin_match() {
939 let cfg = config_with(vec!["https://admin.example.com:8443"]);
940 assert!(cfg.is_redirect_target_trusted("https://admin.example.com:8443/x"));
941 // Different port → not the same origin.
942 assert!(!cfg.is_redirect_target_trusted("https://admin.example.com/x"));
943 }
944
945 #[test]
946 fn redirect_target_rejects_control_chars_and_quotes() {
947 // CR/LF would split a Location header; `"` would break out of
948 // an `href="..."` attribute in the rendered email.
949 let cfg = config_with(vec![]);
950 assert!(!cfg.is_redirect_target_trusted("/path\r\nEvil-Header: x"));
951 assert!(!cfg.is_redirect_target_trusted("/path\nEvil: x"));
952 assert!(!cfg.is_redirect_target_trusted("/path\"><script>"));
953 assert!(!cfg.is_redirect_target_trusted("/path\u{0000}null"));
954 assert!(!cfg.is_redirect_target_trusted("https://app.example.com/x\r\n"));
955 }
956
957 #[test]
958 fn trusted_origins_with_explicit_default_ports_still_match() {
959 // `url::Url::origin` strips `:443` / `:80`; `extract_pattern_origin`
960 // now does the same so a `trusted_origins` entry that spells out
961 // the default port still matches callbacks that don't.
962 let cfg = config_with(vec![
963 "https://admin.example.com:443",
964 "http://legacy.example.com:80",
965 ]);
966 assert!(cfg.is_origin_trusted("https://admin.example.com"));
967 assert!(cfg.is_origin_trusted("http://legacy.example.com"));
968 assert!(cfg.is_redirect_target_trusted("https://admin.example.com/cb"));
969 assert!(cfg.is_redirect_target_trusted("http://legacy.example.com/cb"));
970 }
971
972 #[test]
973 fn redirect_target_bypass_still_rejects_dangerous_schemes() {
974 // `disable_origin_check = true` opts out of origin comparison
975 // but MUST NOT open the gate to `javascript:`, `data:`,
976 // `file:`, or other non-http schemes — the rustdoc promises
977 // those are always rejected.
978 let mut cfg = config_with(vec![]);
979 cfg.advanced.disable_origin_check = true;
980 assert!(!cfg.is_redirect_target_trusted("javascript:alert(1)"));
981 assert!(!cfg.is_redirect_target_trusted("data:text/html,<script>x</script>"));
982 assert!(!cfg.is_redirect_target_trusted("file:///etc/passwd"));
983 assert!(!cfg.is_redirect_target_trusted("ftp://example.com/"));
984 // But well-formed http(s) and relative paths still pass.
985 assert!(cfg.is_redirect_target_trusted("/dashboard"));
986 assert!(cfg.is_redirect_target_trusted("https://evil.com/cb"));
987 }
988
989 #[test]
990 fn trusted_origins_wildcard_idn_matches_punycode_callback() {
991 // A wildcard pattern with a Unicode label should still match a
992 // callback origin that `url::Url::parse` canonicalises to
993 // punycode. Wildcard labels themselves are preserved verbatim.
994 let cfg = config_with(vec!["https://*.bücher.example"]);
995 assert!(cfg.is_origin_trusted("https://shop.xn--bcher-kva.example"));
996 assert!(cfg.is_redirect_target_trusted("https://shop.xn--bcher-kva.example/path"));
997 }
998
999 #[test]
1000 fn trusted_origins_pattern_lowercases_scheme_and_host() {
1001 // `url::Url::origin()` lowercases scheme + host; patterns typed
1002 // with mixed case should still match.
1003 let cfg = config_with(vec!["HTTPS://APP.Example.COM"]);
1004 assert!(cfg.is_origin_trusted("https://app.example.com"));
1005 assert!(cfg.is_redirect_target_trusted("https://app.example.com/x"));
1006 }
1007
1008 #[test]
1009 fn trusted_origins_punycode_idn_matches_punycode_callback() {
1010 // `url::Url` converts IDN hosts to punycode. A pattern that
1011 // spells the domain in Unicode should still match a callback
1012 // URL that the parser normalises to `xn--...`.
1013 let cfg = config_with(vec!["https://bücher.example"]);
1014 assert!(cfg.is_origin_trusted("https://xn--bcher-kva.example"));
1015 assert!(cfg.is_redirect_target_trusted("https://xn--bcher-kva.example/book"));
1016 }
1017
1018 #[test]
1019 fn absolute_trusted_callback_url_rejects_relative_paths() {
1020 // Relative paths are fine for server-issued 302 Location headers
1021 // but break when embedded in an email body (mail clients have no
1022 // base URL) or forwarded to an OAuth provider as `redirect_uri`
1023 // (spec requires absolute). The stricter helper must reject
1024 // them even when the origin check would otherwise accept.
1025 let cfg = config_with(vec!["https://admin.example.com"]);
1026 // Still accepted by the looser redirect helper…
1027 assert!(cfg.is_redirect_target_trusted("/dashboard"));
1028 // …but not by the email / OAuth helper.
1029 assert!(!cfg.is_absolute_trusted_callback_url("/dashboard"));
1030 assert!(!cfg.is_absolute_trusted_callback_url("/reset?token=x"));
1031 // Absolute trusted URL passes both.
1032 assert!(cfg.is_absolute_trusted_callback_url("https://admin.example.com/cb"));
1033 // Absolute untrusted URL rejected by both.
1034 assert!(!cfg.is_absolute_trusted_callback_url("https://evil.com/cb"));
1035 // Non-http schemes rejected by both.
1036 assert!(!cfg.is_absolute_trusted_callback_url("javascript:alert(1)"));
1037 }
1038
1039 #[test]
1040 fn redirect_target_rejects_percent_encoded_authority_bypass() {
1041 // Double-decoding proxies can turn `/%2Fevil.com` into
1042 // `//evil.com` before the next hop sees it; reject the
1043 // percent-encoded forms so the extra decode pass can't
1044 // rehydrate an authority smuggler.
1045 let cfg = config_with(vec![]);
1046 assert!(!cfg.is_redirect_target_trusted("/%2Fevil.com"));
1047 assert!(!cfg.is_redirect_target_trusted("/%2fevil.com"));
1048 assert!(!cfg.is_redirect_target_trusted("/%5Cevil.com"));
1049 assert!(!cfg.is_redirect_target_trusted("/%5cevil.com"));
1050 assert!(!cfg.is_redirect_target_trusted("%2Fevil.com"));
1051 }
1052
1053 #[test]
1054 fn redirect_target_rejects_bare_backslash_under_disable_origin_check() {
1055 // `\evil.com` is normalised by browsers to path-start + "/evil.com"
1056 // — same-origin in practice, but the authority-smuggling guard's
1057 // documented contract ("even under disable_origin_check, the
1058 // caller cannot pick the host") must hold.
1059 let mut cfg = config_with(vec![]);
1060 cfg.advanced.disable_origin_check = true;
1061 assert!(!cfg.is_redirect_target_trusted("\\evil.com"));
1062 assert!(!cfg.is_redirect_target_trusted(" \\evil.com"));
1063 }
1064
1065 #[test]
1066 fn trusted_origins_supports_port_and_scheme_globs() {
1067 // Regression for switching extract_origin to url::Url::parse —
1068 // `http://localhost:*` and similar wildcard patterns must stay
1069 // usable. Strict URL parsing would reject them.
1070 let cfg = config_with(vec![
1071 "http://localhost:*",
1072 "https://*.example.com",
1073 "*://api.staging.test",
1074 ]);
1075 assert!(cfg.is_origin_trusted("http://localhost:3000"));
1076 assert!(cfg.is_origin_trusted("http://localhost:8080"));
1077 assert!(cfg.is_origin_trusted("https://app.example.com"));
1078 assert!(cfg.is_origin_trusted("http://api.staging.test"));
1079 assert!(cfg.is_origin_trusted("https://api.staging.test"));
1080 assert!(!cfg.is_origin_trusted("http://localhost.evil.com"));
1081 }
1082}