1use crate::email::EmailProvider;
2use crate::error::AuthError;
3use chrono::Duration;
4use std::collections::HashMap;
5use std::sync::Arc;
6
7pub 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 = "/__test/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 fn valid_error_code(input: &str) -> bool {
23 !input.is_empty()
24 && input
25 .chars()
26 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '\'')
27 }
28
29 fn is_preserved_entity(input: &str) -> bool {
30 input.starts_with("amp;")
31 || input.starts_with("lt;")
32 || input.starts_with("gt;")
33 || input.starts_with("quot;")
34 || input.starts_with("#39;")
35 || input.strip_prefix("#x").is_some_and(|hex| {
36 let Some(hex) = hex.strip_suffix(';') else {
37 return false;
38 };
39 !hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit())
40 })
41 || input.strip_prefix('#').is_some_and(|digits| {
42 let Some(digits) = digits.strip_suffix(';') else {
43 return false;
44 };
45 !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
46 })
47 }
48
49 fn sanitize_html(input: &str) -> String {
50 let mut out = String::with_capacity(input.len());
51
52 for (idx, ch) in input.char_indices() {
53 match ch {
54 '<' => out.push_str("<"),
55 '>' => out.push_str(">"),
56 '"' => out.push_str("""),
57 '\'' => out.push_str("'"),
58 '&' => {
59 let rest = &input[idx + ch.len_utf8()..];
60 if is_preserved_entity(rest) {
61 out.push('&');
62 } else {
63 out.push_str("&");
64 }
65 }
66 _ => out.push(ch),
67 }
68 }
69
70 out
71 }
72
73 fn default_error_description(code: &str) -> String {
74 format!(
75 "We encountered an unexpected error. Please try again or return to the home page. If you're a developer, you can find more information about the error <a href='https://better-auth.com/docs/reference/errors/{code}' target='_blank' rel=\"noopener noreferrer\" style='color: var(--foreground); text-decoration: underline;'>here</a>."
76 )
77 }
78
79 pub fn error_page_html(error_code: &str) -> String {
83 error_page_html_with_description(error_code, None)
84 }
85
86 pub fn error_page_html_with_description(
89 error_code: &str,
90 error_description: Option<&str>,
91 ) -> String {
92 let safe_code = if valid_error_code(error_code) {
93 error_code
94 } else {
95 "UNKNOWN"
96 };
97 let description = error_description
98 .map(sanitize_html)
99 .unwrap_or_else(|| default_error_description(safe_code));
100 let ask_ai_query = format!("What%20does%20the%20error%20code%20{safe_code}%20mean%3F");
101
102 format!(
103 r#"<!DOCTYPE html>
104<html lang="en">
105 <head>
106 <meta charset="UTF-8" />
107 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
108 <title>Error</title>
109 <style>
110 * {{
111 box-sizing: border-box;
112 }}
113 body {{
114 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
115 background: var(--background);
116 color: var(--foreground);
117 margin: 0;
118 }}
119 :root,
120 :host {{
121 --spacing: 0.25rem;
122 --container-md: 28rem;
123 --text-sm: 0.875rem;
124 --text-sm--line-height: calc(1.25 / 0.875);
125 --text-2xl: 1.5rem;
126 --text-2xl--line-height: calc(2 / 1.5);
127 --text-4xl: 2.25rem;
128 --text-4xl--line-height: calc(2.5 / 2.25);
129 --text-6xl: 3rem;
130 --text-6xl--line-height: 1;
131 --font-weight-medium: 500;
132 --font-weight-semibold: 600;
133 --font-weight-bold: 700;
134 --default-transition-duration: 150ms;
135 --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
136 --radius: 0.625rem;
137 --default-mono-font-family: var(--font-geist-mono);
138 --primary: black;
139 --primary-foreground: white;
140 --background: white;
141 --foreground: oklch(0.271 0 0);
142 --border: oklch(0.89 0 0);
143 --destructive: oklch(0.55 0.15 25.723);
144 --muted-foreground: oklch(0.545 0 0);
145 --corner-border: #404040;
146 }}
147
148 button, .btn {{
149 cursor: pointer;
150 background: none;
151 border: none;
152 color: inherit;
153 font: inherit;
154 transition: all var(--default-transition-duration)
155 var(--default-transition-timing-function);
156 }}
157 button:hover, .btn:hover {{
158 opacity: 0.8;
159 }}
160
161 @media (prefers-color-scheme: dark) {{
162 :root,
163 :host {{
164 --primary: white;
165 --primary-foreground: black;
166 --background: oklch(0.15 0 0);
167 --foreground: oklch(0.98 0 0);
168 --border: oklch(0.27 0 0);
169 --destructive: oklch(0.65 0.15 25.723);
170 --muted-foreground: oklch(0.65 0 0);
171 --corner-border: #a0a0a0;
172 }}
173 }}
174 @media (max-width: 640px) {{
175 :root, :host {{
176 --text-6xl: 2.5rem;
177 --text-2xl: 1.25rem;
178 --text-sm: 0.8125rem;
179 }}
180 }}
181 @media (max-width: 480px) {{
182 :root, :host {{
183 --text-6xl: 2rem;
184 --text-2xl: 1.125rem;
185 }}
186 }}
187 </style>
188 </head>
189 <body style="width: 100vw; min-height: 100vh; overflow-x: hidden; overflow-y: auto;">
190 <div
191 style="
192 display: flex;
193 flex-direction: column;
194 align-items: center;
195 justify-content: center;
196 gap: 1.5rem;
197 position: relative;
198 width: 100%;
199 min-height: 100vh;
200 padding: 1rem;
201 "
202 >
203
204 <div
205 style="
206 position: absolute;
207 inset: 0;
208 background-image: linear-gradient(to right, var(--border) 1px, transparent 1px),
209 linear-gradient(to bottom, var(--border) 1px, transparent 1px);
210 background-size: 40px 40px;
211 opacity: 0.6;
212 pointer-events: none;
213 width: 100vw;
214 height: 100vh;
215 "
216 ></div>
217 <div
218 style="
219 position: absolute;
220 inset: 0;
221 display: flex;
222 align-items: center;
223 justify-content: center;
224 background: var(--background);
225 mask-image: radial-gradient(ellipse at center, transparent 20%, black);
226 -webkit-mask-image: radial-gradient(ellipse at center, transparent 20%, black);
227 pointer-events: none;
228 "
229 ></div>
230
231
232<div
233 style="
234 position: relative;
235 z-index: 10;
236 border: 2px solid var(--border);
237 background: var(--background);
238 padding: 1.5rem;
239 max-width: 42rem;
240 width: 100%;
241 "
242>
243
244 <!-- Corner decorations -->
245 <div
246 style="
247 position: absolute;
248 top: -2px;
249 left: -2px;
250 width: 2rem;
251 height: 2rem;
252 border-top: 4px solid var(--corner-border);
253 border-left: 4px solid var(--corner-border);
254 "
255 ></div>
256 <div
257 style="
258 position: absolute;
259 top: -2px;
260 right: -2px;
261 width: 2rem;
262 height: 2rem;
263 border-top: 4px solid var(--corner-border);
264 border-right: 4px solid var(--corner-border);
265 "
266 ></div>
267
268 <div
269 style="
270 position: absolute;
271 bottom: -2px;
272 left: -2px;
273 width: 2rem;
274 height: 2rem;
275 border-bottom: 4px solid var(--corner-border);
276 border-left: 4px solid var(--corner-border);
277 "
278 ></div>
279 <div
280 style="
281 position: absolute;
282 bottom: -2px;
283 right: -2px;
284 width: 2rem;
285 height: 2rem;
286 border-bottom: 4px solid var(--corner-border);
287 border-right: 4px solid var(--corner-border);
288 "
289 ></div>
290
291 <div style="text-align: center; margin-bottom: 1.5rem;">
292 <div style="margin-bottom: 1.5rem;">
293 <div
294 style="
295 display: inline-block;
296 border: 2px solid var(--destructive);
297 padding: 0.375rem 1rem;
298 "
299 >
300 <h1
301 style="
302 font-size: var(--text-6xl);
303 font-weight: var(--font-weight-semibold);
304 color: var(--foreground);
305 letter-spacing: -0.02em;
306 margin: 0;
307 "
308 >
309 ERROR
310 </h1>
311 </div>
312 <div
313 style="
314 height: 2px;
315 background-color: var(--border);
316 width: calc(100% + 3rem);
317 margin-left: -1.5rem;
318 margin-top: 1.5rem;
319 "
320 ></div>
321 </div>
322
323 <h2
324 style="
325 font-size: var(--text-2xl);
326 font-weight: var(--font-weight-semibold);
327 color: var(--foreground);
328 margin: 0 0 1rem;
329 "
330 >
331 Something went wrong
332 </h2>
333
334 <div
335 style="
336 display: inline-flex;
337 align-items: center;
338 gap: 0.5rem;
339 border: 2px solid var(--border);
340 background-color: var(--muted);
341 padding: 0.375rem 0.75rem;
342 margin: 0 0 1rem;
343 flex-wrap: wrap;
344 justify-content: center;
345 "
346 >
347 <span
348 style="
349 font-size: 0.75rem;
350 color: var(--muted-foreground);
351 font-weight: var(--font-weight-semibold);
352 "
353 >
354 CODE:
355 </span>
356 <span
357 style="
358 font-size: var(--text-sm);
359 font-family: var(--default-mono-font-family, monospace);
360 color: var(--foreground);
361 word-break: break-all;
362 "
363 >
364 {safe_code}
365 </span>
366 </div>
367
368 <p
369 style="
370 color: var(--muted-foreground);
371 max-width: 28rem;
372 margin: 0 auto;
373 font-size: var(--text-sm);
374 line-height: 1.5;
375 text-wrap: pretty;
376 "
377 >
378 {description}
379 </p>
380 </div>
381
382 <div
383 style="
384 display: flex;
385 gap: 0.75rem;
386 margin-top: 1.5rem;
387 justify-content: center;
388 flex-wrap: wrap;
389 "
390 >
391 <a
392 href="/"
393 style="
394 text-decoration: none;
395 "
396 >
397 <div
398 style="
399 border: 2px solid var(--border);
400 background: var(--primary);
401 color: var(--primary-foreground);
402 padding: 0.5rem 1rem;
403 border-radius: 0;
404 white-space: nowrap;
405 "
406 class="btn"
407 >
408 Go Home
409 </div>
410 </a>
411 <a
412 href="https://better-auth.com/docs/reference/errors/{safe_code}?askai={ask_ai_query}"
413 target="_blank"
414 rel="noopener noreferrer"
415 style="
416 text-decoration: none;
417 "
418 >
419 <div
420 style="
421 border: 2px solid var(--border);
422 background: transparent;
423 color: var(--foreground);
424 padding: 0.5rem 1rem;
425 border-radius: 0;
426 white-space: nowrap;
427 "
428 class="btn"
429 >
430 Ask AI
431 </div>
432 </a>
433 </div>
434 </div>
435 </div>
436 </body>
437</html>"#
438 )
439 }
440}
441
442#[derive(Clone)]
444pub struct AuthConfig {
445 pub secret: String,
447
448 pub app_name: String,
452
453 pub base_url: String,
455
456 pub base_path: String,
464
465 pub trusted_origins: Vec<String>,
471
472 pub disabled_paths: Vec<String>,
477 pub session: SessionConfig,
479
480 pub jwt: JwtConfig,
482
483 pub password: PasswordConfig,
485
486 pub account: AccountConfig,
488
489 pub email_provider: Option<Arc<dyn EmailProvider>>,
491
492 pub advanced: AdvancedConfig,
494}
495
496#[derive(Debug, Clone)]
498pub struct AccountConfig {
499 pub update_account_on_sign_in: bool,
501 pub account_linking: AccountLinkingConfig,
503 pub encrypt_oauth_tokens: bool,
505 pub store_account_cookie: bool,
507 pub store_state_strategy: OAuthStateStrategy,
509 pub skip_state_cookie_check: bool,
513}
514
515#[derive(Debug, Clone)]
517pub struct AccountLinkingConfig {
518 pub enabled: bool,
520 pub trusted_providers: Vec<String>,
522 pub allow_different_emails: bool,
524 pub allow_unlinking_all: bool,
526 pub disable_implicit_linking: bool,
528 pub update_user_info_on_link: bool,
530}
531
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
534pub enum OAuthStateStrategy {
535 #[default]
537 Cookie,
538 Database,
540}
541
542#[derive(Debug, Clone)]
544pub struct SessionConfig {
545 pub expires_in: Duration,
547
548 pub update_age: Option<Duration>,
554
555 pub disable_session_refresh: bool,
557
558 pub fresh_age: Option<Duration>,
561
562 pub cookie_name: String,
564
565 pub cookie_secure: bool,
567 pub cookie_http_only: bool,
568 pub cookie_same_site: SameSite,
569
570 pub cookie_cache: Option<CookieCacheConfig>,
575}
576
577#[derive(Debug, Clone)]
579pub struct JwtConfig {
580 pub expires_in: Duration,
582
583 pub algorithm: String,
585
586 pub issuer: Option<String>,
588
589 pub audience: Option<String>,
591}
592
593#[derive(Debug, Clone)]
595pub struct PasswordConfig {
596 pub min_length: usize,
598
599 pub require_uppercase: bool,
601
602 pub require_lowercase: bool,
604
605 pub require_numbers: bool,
607
608 pub require_special: bool,
610
611 pub argon2_config: Argon2Config,
613}
614
615#[derive(Debug, Clone)]
617pub struct Argon2Config {
618 pub memory_cost: u32,
619 pub time_cost: u32,
620 pub parallelism: u32,
621}
622
623#[derive(Debug, Clone, PartialEq, Eq)]
624pub enum SameSite {
625 Strict,
626 Lax,
627 None,
628}
629
630#[derive(Debug, Clone)]
635pub struct CookieCacheConfig {
636 pub enabled: bool,
638
639 pub max_age: Duration,
643
644 pub strategy: CookieCacheStrategy,
646}
647
648#[derive(Debug, Clone, PartialEq, Eq)]
650pub enum CookieCacheStrategy {
651 Compact,
653 Jwt,
655 Jwe,
657}
658
659impl Default for CookieCacheConfig {
660 fn default() -> Self {
661 Self {
662 enabled: false,
663 max_age: Duration::minutes(5),
664 strategy: CookieCacheStrategy::Compact,
665 }
666 }
667}
668
669impl Default for AccountConfig {
670 fn default() -> Self {
671 Self {
672 update_account_on_sign_in: true,
673 account_linking: AccountLinkingConfig::default(),
674 encrypt_oauth_tokens: false,
675 store_account_cookie: false,
676 store_state_strategy: OAuthStateStrategy::Database,
677 skip_state_cookie_check: false,
678 }
679 }
680}
681
682impl Default for AccountLinkingConfig {
683 fn default() -> Self {
684 Self {
685 enabled: true,
686 trusted_providers: Vec::new(),
687 allow_different_emails: false,
688 allow_unlinking_all: false,
689 disable_implicit_linking: false,
690 update_user_info_on_link: false,
691 }
692 }
693}
694
695impl std::fmt::Display for SameSite {
696 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697 match self {
698 SameSite::Strict => f.write_str("Strict"),
699 SameSite::Lax => f.write_str("Lax"),
700 SameSite::None => f.write_str("None"),
701 }
702 }
703}
704
705#[derive(Debug, Clone, Default)]
709pub struct AdvancedConfig {
710 pub ip_address: IpAddressConfig,
712
713 pub disable_csrf_check: bool,
715
716 pub disable_origin_check: bool,
721
722 pub cross_sub_domain_cookies: Option<CrossSubDomainConfig>,
724
725 pub cookies: HashMap<String, CookieOverride>,
730
731 pub default_cookie_attributes: CookieAttributes,
734
735 pub cookie_prefix: Option<String>,
738
739 pub database: AdvancedDatabaseConfig,
741
742 pub trusted_proxy_headers: Vec<String>,
745}
746
747#[derive(Debug, Clone)]
749pub struct IpAddressConfig {
750 pub headers: Vec<String>,
753
754 pub disable_ip_tracking: bool,
756}
757
758#[derive(Debug, Clone)]
760pub struct CrossSubDomainConfig {
761 pub domain: String,
763}
764
765#[derive(Debug, Clone, Default)]
767pub struct CookieAttributes {
768 pub secure: Option<bool>,
770 pub http_only: Option<bool>,
772 pub same_site: Option<SameSite>,
774 pub path: Option<String>,
776 pub max_age: Option<i64>,
778 pub domain: Option<String>,
780}
781
782#[derive(Debug, Clone, Default)]
784pub struct CookieOverride {
785 pub name: Option<String>,
787 pub attributes: CookieAttributes,
789}
790
791#[derive(Debug, Clone)]
793pub struct AdvancedDatabaseConfig {
794 pub default_find_many_limit: usize,
796
797 pub use_number_id: bool,
800}
801impl Default for AuthConfig {
802 fn default() -> Self {
803 Self {
804 secret: String::new(),
805 app_name: "Better Auth".to_string(),
806 base_url: "http://localhost:3000".to_string(),
807 base_path: "/api/auth".to_string(),
808 trusted_origins: Vec::new(),
809 disabled_paths: Vec::new(),
810 session: SessionConfig::default(),
811 jwt: JwtConfig::default(),
812 password: PasswordConfig::default(),
813 account: AccountConfig::default(),
814 email_provider: None,
815 advanced: AdvancedConfig::default(),
816 }
817 }
818}
819
820impl Default for SessionConfig {
821 fn default() -> Self {
822 Self {
823 expires_in: Duration::hours(24 * 7), update_age: Some(Duration::hours(24)), disable_session_refresh: false,
826 fresh_age: None,
827 cookie_name: "better-auth.session_token".to_string(),
828 cookie_secure: false,
831 cookie_http_only: true,
832 cookie_same_site: SameSite::Lax,
833 cookie_cache: None,
834 }
835 }
836}
837
838impl Default for IpAddressConfig {
839 fn default() -> Self {
840 Self {
841 headers: vec!["x-forwarded-for".to_string(), "x-real-ip".to_string()],
842 disable_ip_tracking: false,
843 }
844 }
845}
846
847impl Default for AdvancedDatabaseConfig {
848 fn default() -> Self {
849 Self {
850 default_find_many_limit: 100,
851 use_number_id: false,
852 }
853 }
854}
855
856impl Default for JwtConfig {
857 fn default() -> Self {
858 Self {
859 expires_in: Duration::hours(24), algorithm: "HS256".to_string(),
861 issuer: None,
862 audience: None,
863 }
864 }
865}
866
867impl Default for PasswordConfig {
868 fn default() -> Self {
869 Self {
870 min_length: 8,
871 require_uppercase: false,
872 require_lowercase: false,
873 require_numbers: false,
874 require_special: false,
875 argon2_config: Argon2Config::default(),
876 }
877 }
878}
879
880impl Default for Argon2Config {
881 fn default() -> Self {
882 Self {
883 memory_cost: 4096, time_cost: 3, parallelism: 1, }
887 }
888}
889
890impl AuthConfig {
891 pub fn new(secret: impl Into<String>) -> Self {
892 Self {
893 secret: secret.into(),
894 ..Default::default()
895 }
896 }
897
898 pub fn app_name(mut self, name: impl Into<String>) -> Self {
900 self.app_name = name.into();
901 self
902 }
903
904 pub fn base_url(mut self, url: impl Into<String>) -> Self {
909 self.base_url = url.into();
910 self.session.cookie_secure = self.base_url.starts_with("https://");
911 self
912 }
913
914 pub fn account(mut self, account: AccountConfig) -> Self {
915 self.account = account;
916 self
917 }
918
919 pub fn base_path(mut self, path: impl Into<String>) -> Self {
921 self.base_path = path.into();
922 self
923 }
924
925 pub fn trusted_origin(mut self, origin: impl Into<String>) -> Self {
927 self.trusted_origins.push(origin.into());
928 self
929 }
930
931 pub fn trusted_origins(mut self, origins: Vec<String>) -> Self {
933 self.trusted_origins = origins;
934 self
935 }
936
937 pub fn disabled_path(mut self, path: impl Into<String>) -> Self {
939 self.disabled_paths.push(path.into());
940 self
941 }
942
943 pub fn disabled_paths(mut self, paths: Vec<String>) -> Self {
945 self.disabled_paths = paths;
946 self
947 }
948
949 pub fn session_expires_in(mut self, duration: Duration) -> Self {
951 self.session.expires_in = duration;
952 self
953 }
954
955 pub fn session_update_age(mut self, duration: Duration) -> Self {
956 self.session.update_age = Some(duration);
957 self
958 }
959
960 pub fn disable_session_refresh(mut self, disabled: bool) -> Self {
961 self.session.disable_session_refresh = disabled;
962 self
963 }
964
965 pub fn session_fresh_age(mut self, duration: Duration) -> Self {
966 self.session.fresh_age = Some(duration);
967 self
968 }
969
970 pub fn session_cookie_cache(mut self, config: CookieCacheConfig) -> Self {
972 self.session.cookie_cache = Some(config);
973 self
974 }
975
976 pub fn jwt_expires_in(mut self, duration: Duration) -> Self {
978 self.jwt.expires_in = duration;
979 self
980 }
981
982 pub fn password_min_length(mut self, length: usize) -> Self {
984 self.password.min_length = length;
985 self
986 }
987
988 pub fn advanced(mut self, advanced: AdvancedConfig) -> Self {
989 self.advanced = advanced;
990 self
991 }
992
993 pub fn cookie_prefix(mut self, prefix: impl Into<String>) -> Self {
994 self.advanced.cookie_prefix = Some(prefix.into());
995 self
996 }
997
998 pub fn disable_csrf_check(mut self, disabled: bool) -> Self {
999 self.advanced.disable_csrf_check = disabled;
1000 self
1001 }
1002
1003 pub fn disable_origin_check(mut self, disabled: bool) -> Self {
1004 self.advanced.disable_origin_check = disabled;
1005 self
1006 }
1007
1008 pub fn cross_sub_domain_cookies(mut self, domain: impl Into<String>) -> Self {
1009 self.advanced.cross_sub_domain_cookies = Some(CrossSubDomainConfig {
1010 domain: domain.into(),
1011 });
1012 self
1013 }
1014
1015 pub fn is_origin_trusted(&self, origin: &str) -> bool {
1025 if let Some(base_origin) = extract_origin(&self.base_url)
1027 && origin == base_origin
1028 {
1029 return true;
1030 }
1031 self.trusted_origins.iter().any(|pattern| {
1033 let pattern_origin = extract_origin(pattern).unwrap_or_default();
1034 glob_match::glob_match(&pattern_origin, origin)
1035 })
1036 }
1037
1038 pub fn is_redirect_target_trusted(&self, url: &str) -> bool {
1047 if is_safe_relative_path(url) {
1048 return true;
1049 }
1050 extract_origin(url).is_some_and(|origin| self.is_origin_trusted(&origin))
1051 }
1052
1053 pub fn is_path_disabled(&self, path: &str) -> bool {
1055 self.disabled_paths.iter().any(|disabled| disabled == path)
1056 }
1057 pub fn validate(&self) -> Result<(), AuthError> {
1058 if self.secret.is_empty() {
1059 return Err(AuthError::config("Secret key cannot be empty"));
1060 }
1061
1062 if self.secret.len() < 32 {
1063 return Err(AuthError::config(
1064 "Secret key must be at least 32 characters",
1065 ));
1066 }
1067
1068 Ok(())
1069 }
1070}
1071
1072pub fn is_safe_relative_path(value: &str) -> bool {
1080 if !value.starts_with('/') || value.starts_with("//") || value.contains('\\') {
1081 return false;
1082 }
1083
1084 let tail = &value[1..];
1085 let lower = tail.to_ascii_lowercase();
1086 !lower.starts_with("%2f") && !lower.starts_with("%5c")
1087}
1088
1089pub fn extract_origin(url: &str) -> Option<String> {
1096 let scheme_end = url.find("://")?;
1097 let rest = &url[scheme_end + 3..];
1098 let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1099 let origin = format!("{}{}", &url[..scheme_end + 3], &rest[..host_end]);
1100 Some(origin)
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105 use super::*;
1106
1107 #[test]
1111 fn extract_origin_with_path() {
1112 assert_eq!(
1113 extract_origin("https://example.com/path"),
1114 Some("https://example.com".to_string())
1115 );
1116 }
1117
1118 #[test]
1120 fn extract_origin_without_path() {
1121 assert_eq!(
1122 extract_origin("https://example.com"),
1123 Some("https://example.com".to_string())
1124 );
1125 }
1126
1127 #[test]
1129 fn extract_origin_with_port() {
1130 assert_eq!(
1131 extract_origin("http://localhost:3000/api"),
1132 Some("http://localhost:3000".to_string())
1133 );
1134 }
1135
1136 #[test]
1138 fn extract_origin_with_query() {
1139 assert_eq!(
1140 extract_origin("https://example.com?foo=bar"),
1141 Some("https://example.com".to_string())
1142 );
1143 }
1144
1145 #[test]
1147 fn extract_origin_with_fragment() {
1148 assert_eq!(
1149 extract_origin("https://example.com#fragment"),
1150 Some("https://example.com".to_string())
1151 );
1152 }
1153
1154 #[test]
1156 fn extract_origin_no_scheme() {
1157 assert_eq!(extract_origin("example.com"), None);
1158 }
1159
1160 #[test]
1164 fn new_config_sets_secret() {
1165 let cfg = AuthConfig::new("a]secret-that-is-at-least-32-characters-long");
1166 assert_eq!(cfg.secret, "a]secret-that-is-at-least-32-characters-long");
1167 }
1168
1169 #[test]
1171 fn new_config_uses_defaults() {
1172 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567");
1173 assert_eq!(cfg.app_name, "Better Auth");
1174 assert_eq!(cfg.base_url, "http://localhost:3000");
1175 assert_eq!(cfg.base_path, "/api/auth");
1176 assert!(cfg.trusted_origins.is_empty());
1177 }
1178
1179 #[test]
1183 fn base_url_sets_cookie_secure_for_https() {
1184 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1185 assert!(cfg.session.cookie_secure);
1186 }
1187
1188 #[test]
1190 fn base_url_clears_cookie_secure_for_http() {
1191 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1192 .base_url("https://myapp.com")
1193 .base_url("http://localhost:3000");
1194 assert!(!cfg.session.cookie_secure);
1195 }
1196
1197 #[test]
1199 fn builder_chaining() {
1200 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1201 .app_name("MyApp")
1202 .base_path("/auth")
1203 .password_min_length(12)
1204 .disable_csrf_check(true)
1205 .disable_origin_check(true)
1206 .cookie_prefix("myapp");
1207
1208 assert_eq!(cfg.app_name, "MyApp");
1209 assert_eq!(cfg.base_path, "/auth");
1210 assert_eq!(cfg.password.min_length, 12);
1211 assert!(cfg.advanced.disable_csrf_check);
1212 assert!(cfg.advanced.disable_origin_check);
1213 assert_eq!(cfg.advanced.cookie_prefix, Some("myapp".to_string()));
1214 }
1215
1216 #[test]
1218 fn trusted_origin_appends() {
1219 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1220 .trusted_origin("https://a.com")
1221 .trusted_origin("https://b.com");
1222 assert_eq!(cfg.trusted_origins.len(), 2);
1223 }
1224
1225 #[test]
1227 fn trusted_origins_replaces() {
1228 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1229 .trusted_origin("https://old.com")
1230 .trusted_origins(vec!["https://new.com".to_string()]);
1231 assert_eq!(cfg.trusted_origins, vec!["https://new.com"]);
1232 }
1233
1234 #[test]
1236 fn disabled_path_appends() {
1237 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1238 .disabled_path("/admin")
1239 .disabled_path("/debug");
1240 assert_eq!(cfg.disabled_paths.len(), 2);
1241 }
1242
1243 #[test]
1245 fn disabled_paths_replaces() {
1246 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1247 .disabled_path("/old")
1248 .disabled_paths(vec!["/new".to_string()]);
1249 assert_eq!(cfg.disabled_paths, vec!["/new"]);
1250 }
1251
1252 #[test]
1256 fn is_origin_trusted_matches_base_url() {
1257 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1258 assert!(cfg.is_origin_trusted("https://myapp.com"));
1259 }
1260
1261 #[test]
1263 fn is_origin_trusted_rejects_unknown() {
1264 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1265 assert!(!cfg.is_origin_trusted("https://evil.com"));
1266 }
1267
1268 #[test]
1270 fn is_origin_trusted_glob_pattern() {
1271 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1272 .trusted_origin("https://*.example.com");
1273 assert!(cfg.is_origin_trusted("https://sub.example.com"));
1274 assert!(!cfg.is_origin_trusted("https://other.com"));
1275 }
1276
1277 #[test]
1281 fn redirect_target_allows_relative_path() {
1282 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1283 assert!(cfg.is_redirect_target_trusted("/dashboard"));
1284 assert!(cfg.is_redirect_target_trusted("/callback?foo=bar"));
1285 }
1286
1287 #[test]
1289 fn redirect_target_allows_same_origin() {
1290 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1291 assert!(cfg.is_redirect_target_trusted("https://myapp.com/verified"));
1292 }
1293
1294 #[test]
1296 fn redirect_target_allows_trusted_origin() {
1297 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1298 .base_url("https://myapp.com")
1299 .trusted_origin("https://trusted.com");
1300 assert!(cfg.is_redirect_target_trusted("https://trusted.com/path"));
1301 }
1302
1303 #[test]
1305 fn redirect_target_rejects_untrusted_origin() {
1306 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1307 assert!(!cfg.is_redirect_target_trusted("https://evil.com/phish"));
1308 }
1309
1310 #[test]
1312 fn redirect_target_rejects_protocol_relative() {
1313 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1314 assert!(!cfg.is_redirect_target_trusted("//evil.com"));
1315 }
1316
1317 #[test]
1321 fn is_path_disabled_matches() {
1322 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").disabled_path("/admin");
1323 assert!(cfg.is_path_disabled("/admin"));
1324 assert!(!cfg.is_path_disabled("/user"));
1325 }
1326
1327 #[test]
1331 fn validate_rejects_empty_secret() {
1332 let cfg = AuthConfig::default();
1333 assert!(cfg.validate().is_err());
1334 }
1335
1336 #[test]
1338 fn validate_rejects_short_secret() {
1339 let cfg = AuthConfig::new("short");
1340 assert!(cfg.validate().is_err());
1341 }
1342
1343 #[test]
1345 fn validate_accepts_valid_secret() {
1346 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567");
1347 assert!(cfg.validate().is_ok());
1348 }
1349
1350 #[test]
1354 fn session_config_defaults() {
1355 let s = SessionConfig::default();
1356 assert_eq!(s.expires_in, Duration::hours(24 * 7));
1357 assert_eq!(s.update_age, Some(Duration::hours(24)));
1358 assert!(!s.disable_session_refresh);
1359 assert_eq!(s.cookie_name, "better-auth.session_token");
1360 assert!(s.cookie_http_only);
1361 assert_eq!(s.cookie_same_site, SameSite::Lax);
1362 }
1363
1364 #[test]
1366 fn jwt_config_defaults() {
1367 let j = JwtConfig::default();
1368 assert_eq!(j.expires_in, Duration::hours(24));
1369 assert_eq!(j.algorithm, "HS256");
1370 }
1371
1372 #[test]
1374 fn password_config_defaults() {
1375 let p = PasswordConfig::default();
1376 assert_eq!(p.min_length, 8);
1377 assert!(!p.require_uppercase);
1378 }
1379
1380 #[test]
1382 fn same_site_display() {
1383 assert_eq!(SameSite::Strict.to_string(), "Strict");
1384 assert_eq!(SameSite::Lax.to_string(), "Lax");
1385 assert_eq!(SameSite::None.to_string(), "None");
1386 }
1387
1388 #[test]
1390 fn cookie_cache_config_defaults() {
1391 let c = CookieCacheConfig::default();
1392 assert!(!c.enabled);
1393 assert_eq!(c.max_age, Duration::minutes(5));
1394 assert_eq!(c.strategy, CookieCacheStrategy::Compact);
1395 }
1396
1397 #[test]
1399 fn account_config_defaults() {
1400 let a = AccountConfig::default();
1401 assert!(a.update_account_on_sign_in);
1402 assert!(!a.encrypt_oauth_tokens);
1403 assert!(a.account_linking.enabled);
1404 }
1405
1406 #[test]
1408 fn core_paths_error_page() {
1409 let html = core_paths::error_page_html("TEST_ERROR");
1410 assert!(html.contains("TEST_ERROR"));
1411 assert!(html.contains("Ask AI"));
1412 assert!(html.contains("<title>Error</title>"));
1413 }
1414
1415 #[test]
1417 fn error_page_sanitizes_script_tag() {
1418 let html = core_paths::error_page_html("<script>alert(1)</script>");
1419 assert!(html.contains("UNKNOWN"));
1420 assert!(!html.contains("<script>"));
1421 }
1422
1423 #[test]
1425 fn error_page_allows_valid_codes() {
1426 assert!(core_paths::error_page_html("SOME_ERROR-CODE").contains("SOME_ERROR-CODE"));
1427 assert!(core_paths::error_page_html("it's").contains("it's"));
1428 }
1429
1430 #[test]
1434 fn session_builder_methods() {
1435 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1436 .session_expires_in(Duration::hours(1))
1437 .session_update_age(Duration::minutes(30))
1438 .disable_session_refresh(true)
1439 .session_fresh_age(Duration::minutes(5));
1440
1441 assert_eq!(cfg.session.expires_in, Duration::hours(1));
1442 assert_eq!(cfg.session.update_age, Some(Duration::minutes(30)));
1443 assert!(cfg.session.disable_session_refresh);
1444 assert_eq!(cfg.session.fresh_age, Some(Duration::minutes(5)));
1445 }
1446
1447 #[test]
1449 fn session_cookie_cache_builder() {
1450 let cache = CookieCacheConfig {
1451 enabled: true,
1452 max_age: Duration::minutes(10),
1453 strategy: CookieCacheStrategy::Jwt,
1454 };
1455 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").session_cookie_cache(cache);
1456
1457 let cc = cfg.session.cookie_cache.as_ref();
1458 assert!(cc.is_some());
1459 let cc = cc.unwrap();
1460 assert!(cc.enabled);
1461 assert_eq!(cc.strategy, CookieCacheStrategy::Jwt);
1462 }
1463
1464 #[test]
1466 fn cross_sub_domain_cookies_builder() {
1467 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1468 .cross_sub_domain_cookies(".example.com");
1469 let csd = cfg.advanced.cross_sub_domain_cookies.as_ref();
1470 assert!(csd.is_some());
1471 assert_eq!(csd.unwrap().domain, ".example.com");
1472 }
1473
1474 #[test]
1476 fn advanced_database_defaults() {
1477 let d = AdvancedDatabaseConfig::default();
1478 assert_eq!(d.default_find_many_limit, 100);
1479 assert!(!d.use_number_id);
1480 }
1481
1482 #[test]
1484 fn ip_address_config_defaults() {
1485 let ip = IpAddressConfig::default();
1486 assert_eq!(ip.headers, vec!["x-forwarded-for", "x-real-ip"]);
1487 assert!(!ip.disable_ip_tracking);
1488 }
1489}