Skip to main content

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}