1mod api_error;
2mod error_page;
3pub use api_error::{
4 ApiErrorConfig, ErrorPageColors, ErrorPageCustomization, ErrorPageFont, ErrorPageSize,
5};
6
7pub use crate::request_runtime::{
8 BaseUrl, BaseUrlProtocol, DynamicBaseUrl, TrustedValues, TrustedValuesResolver,
9};
10mod cookie_cache;
11mod scalars;
12mod secrets;
13mod storage;
14mod verification;
15use crate::email::EmailProvider;
16use crate::error::{AuthError, AuthResult};
17pub use crate::user_fields::{
18 FieldTransforms, UserConfig, UserFieldConfig, UserFieldFactory, UserFieldReference,
19 UserFieldTransform, UserFieldType, UserFieldValidator,
20};
21use chrono::Duration;
22pub use cookie_cache::{CookieCacheVersion, CookieCacheVersionCallback};
23pub(crate) use secrets::parse_secret_version;
24pub use secrets::{SecretKey, VersionedSecret};
25use std::collections::HashMap;
26use std::sync::Arc;
27pub use verification::{
28 VerificationIdentifierConfig, VerificationIdentifierHasher, VerificationIdentifierStorage,
29};
30
31pub mod core_paths {
37 pub const OK: &str = "/ok";
38 pub const ERROR: &str = "/error";
39 pub const HEALTH: &str = "/health";
40 pub const OPENAPI_SPEC: &str = "/__test/openapi.json";
41 pub const UPDATE_USER: &str = "/update-user";
42 pub const DELETE_USER: &str = "/delete-user";
43 pub const CHANGE_EMAIL: &str = "/change-email";
44 pub const DELETE_USER_CALLBACK: &str = "/delete-user/callback";
45
46 pub use super::error_page::{error_page_html, error_page_html_with_description};
47}
48
49#[derive(Clone)]
51pub struct AuthConfig {
52 pub logger: crate::observability::LoggerConfig,
54 pub experimental: crate::observability::ExperimentalConfig,
56 pub telemetry: crate::observability::TelemetryConfig,
58 pub(crate) resolved_cookies: Option<crate::request_runtime::CookieSettings>,
59 pub(crate) session_input_fields: Option<UserConfig>,
60 pub secret: String,
62
63 pub secrets: Option<Vec<VersionedSecret>>,
66
67 pub app_name: String,
71
72 pub base_url: BaseUrl,
74
75 pub base_path: String,
83
84 pub trusted_origins: Option<TrustedValues>,
90
91 pub disabled_paths: Vec<String>,
96 pub api_error: ApiErrorConfig,
98 pub session: SessionConfig,
100 pub verification: VerificationConfig,
102 pub user: UserConfig,
104
105 pub jwt: JwtConfig,
107
108 pub password: PasswordConfig,
110
111 pub account: AccountConfig,
113
114 pub email_provider: Option<Arc<dyn EmailProvider>>,
116
117 pub advanced: AdvancedConfig,
119}
120
121#[derive(Debug, Clone, Default)]
123pub struct AccountConfig {
124 pub additional_fields: indexmap::IndexMap<String, crate::user_fields::UserFieldConfig>,
126 pub update_account_on_sign_in: Option<bool>,
128 pub account_linking: AccountLinkingConfig,
130 pub encrypt_oauth_tokens: Option<bool>,
132 pub store_account_cookie: Option<bool>,
134 pub store_state_strategy: Option<OAuthStateStrategy>,
136 pub skip_state_cookie_check: bool,
140}
141
142#[derive(Debug, Clone)]
144pub struct AccountLinkingConfig {
145 pub enabled: Option<bool>,
147 pub trusted_providers: Option<TrustedValues>,
149 pub allow_different_emails: bool,
151 pub allow_unlinking_all: Option<bool>,
153 pub disable_implicit_linking: bool,
155 pub require_local_email_verified: bool,
158 pub update_user_info_on_link: Option<bool>,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
164pub enum OAuthStateStrategy {
165 #[default]
167 Cookie,
168 Database,
170}
171
172#[derive(Debug, Clone, Default)]
174pub struct VerificationConfig {
175 pub additional_fields: indexmap::IndexMap<String, crate::user_fields::UserFieldConfig>,
177 pub store_identifier: VerificationIdentifierConfig,
179 pub store_in_database: bool,
181 pub disable_cleanup: Option<bool>,
183}
184
185#[derive(Clone, Default)]
187pub struct SessionConfig {
188 pub store_session_in_database: Option<bool>,
190 pub preserve_session_in_database: Option<bool>,
192
193 pub expires_in: Option<Duration>,
195
196 pub update_age: Option<Duration>,
201
202 pub disable_session_refresh: Option<bool>,
204
205 pub defer_session_refresh: bool,
208
209 pub fresh_age: Option<Duration>,
212
213 pub cookie_cache: Option<CookieCacheConfig>,
218
219 pub bearer: Option<BearerConfig>,
222
223 pub additional_fields: Option<indexmap::IndexMap<String, SessionFieldConfig>>,
226}
227
228pub type SessionFieldConfig = UserFieldConfig;
230
231impl SessionConfig {
232 pub fn fields(&self) -> &indexmap::IndexMap<String, SessionFieldConfig> {
234 crate::user_fields::fields_or_empty(&self.additional_fields)
235 }
236
237 pub fn fields_mut(&mut self) -> &mut indexmap::IndexMap<String, SessionFieldConfig> {
239 self.additional_fields.get_or_insert_default()
240 }
241
242 pub fn field_schema(&self) -> UserConfig {
243 UserConfig {
244 additional_fields: self.additional_fields.clone(),
245 }
246 }
247
248 #[doc(hidden)]
250 pub fn adapter_schema(&self) -> UserConfig {
251 self.field_schema().adapter_fields(&[
252 "expiresAt",
253 "token",
254 "createdAt",
255 "updatedAt",
256 "ipAddress",
257 "userAgent",
258 "userId",
259 ])
260 }
261
262 pub fn default_fields(&self) -> AuthResult<crate::FieldMap> {
264 session_defaults(&self.field_schema())
265 }
266}
267
268fn session_defaults(schema: &UserConfig) -> AuthResult<crate::FieldMap> {
269 let mut defaults = crate::FieldMap::new();
270 for (name, field) in schema
271 .ordered_fields(&[])
272 .into_iter()
273 .filter(|(_, field)| field.has_storage_default())
274 {
275 if let Some(value) = field.default_value()? {
276 let _ = defaults.insert(name.to_owned(), value);
277 }
278 }
279 Ok(defaults)
280}
281
282#[derive(Debug, Clone, Default)]
284pub struct BearerConfig {
285 pub require_signature: bool,
287}
288
289#[derive(Debug, Clone)]
291pub struct JwtConfig {
292 pub expires_in: Duration,
294
295 pub algorithm: String,
297
298 pub issuer: Option<String>,
300
301 pub audience: Option<String>,
303}
304
305#[derive(Debug, Clone)]
307pub struct PasswordConfig {
308 pub min_length: usize,
310
311 pub max_length: usize,
313
314 pub require_uppercase: bool,
316
317 pub require_lowercase: bool,
319
320 pub require_numbers: bool,
322
323 pub require_special: bool,
325
326 pub argon2_config: Argon2Config,
328}
329
330#[derive(Debug, Clone)]
332pub struct Argon2Config {
333 pub memory_cost: u32,
334 pub time_cost: u32,
335 pub parallelism: u32,
336}
337
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub enum SameSite {
340 Strict,
341 Lax,
342 None,
343}
344
345#[derive(Debug, Clone)]
350pub struct CookieCacheConfig {
351 pub enabled: Option<bool>,
353 pub max_age: Option<Duration>,
355 pub strategy: Option<CookieCacheStrategy>,
357 pub refresh: Option<CookieCacheRefresh>,
359 pub version: CookieCacheVersion,
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum CookieCacheRefresh {
366 Disabled,
368 Enabled,
370 After(Duration),
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub enum CookieCacheStrategy {
377 Compact,
379 Jwt,
381 Jwe,
383}
384
385impl Default for CookieCacheConfig {
386 fn default() -> Self {
387 Self {
388 enabled: None,
389 max_age: None,
390 strategy: None,
391 refresh: None,
392 version: "1".into(),
393 }
394 }
395}
396
397impl CookieCacheConfig {
398 pub fn enabled(&self) -> bool {
400 self.enabled.unwrap_or(false)
401 }
402 pub fn max_age(&self) -> Duration {
404 self.max_age
405 .filter(|age| !age.is_zero())
406 .unwrap_or_else(|| Duration::minutes(5))
407 }
408 pub fn strategy(&self) -> CookieCacheStrategy {
410 self.strategy.unwrap_or(CookieCacheStrategy::Compact)
411 }
412 pub fn refresh_age(&self) -> Option<Duration> {
414 match self.refresh {
415 None | Some(CookieCacheRefresh::Disabled) => None,
416 Some(CookieCacheRefresh::After(age)) => Some(age),
417 Some(CookieCacheRefresh::Enabled) => {
418 let age = self.max_age().as_seconds_f64();
419 Some(Duration::seconds((age * 0.2).floor() as i64))
420 }
421 }
422 }
423}
424
425impl AccountConfig {
426 pub fn store_account_cookie(&self) -> bool {
428 self.store_account_cookie.unwrap_or(false)
429 }
430 pub fn store_state_strategy(&self) -> OAuthStateStrategy {
432 self.store_state_strategy
433 .unwrap_or(OAuthStateStrategy::Database)
434 }
435}
436
437impl Default for AccountLinkingConfig {
438 fn default() -> Self {
439 Self {
440 enabled: None,
441 trusted_providers: None,
442 allow_different_emails: false,
443 allow_unlinking_all: None,
444 disable_implicit_linking: false,
445 require_local_email_verified: true,
446 update_user_info_on_link: None,
447 }
448 }
449}
450
451impl std::fmt::Display for SameSite {
452 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
453 match self {
454 SameSite::Strict => f.write_str("Strict"),
455 SameSite::Lax => f.write_str("Lax"),
456 SameSite::None => f.write_str("None"),
457 }
458 }
459}
460
461#[derive(Debug, Clone, Default)]
465pub struct AdvancedConfig {
466 pub background_tasks: Option<crate::background::BackgroundTasks>,
468 pub generate_id: Option<crate::id::IdGenerator>,
470 pub skip_trailing_slashes: bool,
473
474 pub ip_address: IpAddressConfig,
476
477 pub disable_csrf_check: Option<bool>,
479
480 pub disable_origin_check: bool,
485
486 pub cross_sub_domain_cookies: Option<CrossSubDomainConfig>,
488
489 pub cookies: Option<HashMap<String, CookieOverride>>,
494
495 pub default_cookie_attributes: CookieAttributes,
498
499 pub cookie_prefix: Option<String>,
502
503 pub database: AdvancedDatabaseConfig,
505
506 pub trusted_proxy_headers: bool,
508 pub use_secure_cookies: Option<bool>,
510}
511
512impl AdvancedConfig {
513 pub fn generate_id(
514 &self,
515 model: &str,
516 size: Option<usize>,
517 ) -> crate::AuthResult<Option<String>> {
518 let request = crate::id::IdGenerationRequest { model, size };
519 match &self.generate_id {
520 Some(generator) => generator.generate(request),
521 None => self.database.generate_id().generate(request),
522 }
523 }
524
525 pub(crate) fn csrf_check_disabled(&self) -> bool {
526 self.disable_csrf_check.unwrap_or(self.disable_origin_check)
527 }
528}
529
530#[derive(Debug, Clone)]
532pub struct IpAddressConfig {
533 pub headers: Option<Vec<String>>,
536
537 pub disable_ip_tracking: Option<bool>,
539 pub ipv6_subnet: f64,
541 pub trusted_proxies: Vec<String>,
543}
544
545#[derive(Debug, Clone, Default)]
547pub struct CrossSubDomainConfig {
548 pub enabled: Option<bool>,
550 pub additional_cookies: Option<Vec<String>>,
553 pub domain: Option<String>,
555}
556
557#[derive(Debug, Clone, Default)]
559pub struct CookieAttributes {
560 pub secure: Option<bool>,
562 pub partitioned: Option<bool>,
564 pub http_only: Option<bool>,
566 pub same_site: Option<SameSite>,
568 pub path: Option<String>,
570 pub max_age: Option<f64>,
573 pub expires: Option<chrono::DateTime<chrono::Utc>>,
576 pub domain: Option<String>,
578}
579
580#[derive(Debug, Clone, Default)]
582pub struct CookieOverride {
583 pub name: Option<String>,
585 pub attributes: CookieAttributes,
587}
588
589#[derive(Debug, Clone, Default)]
591pub struct AdvancedDatabaseConfig {
592 pub validate_schema: Option<bool>,
595
596 pub generate_id: Option<crate::id::IdGeneration>,
598 pub default_find_many_limit: Option<f64>,
600 pub joins: Option<bool>,
602}
603impl Default for AuthConfig {
604 fn default() -> Self {
605 Self {
606 logger: Default::default(),
607 experimental: Default::default(),
608 telemetry: Default::default(),
609 resolved_cookies: None,
610 session_input_fields: None,
611 secret: String::new(),
612 secrets: None,
613 app_name: "Better Auth".to_string(),
614 base_url: BaseUrl::Auto,
615 base_path: "/api/auth".to_string(),
616 trusted_origins: None,
617 disabled_paths: Vec::new(),
618 api_error: ApiErrorConfig::default(),
619 session: SessionConfig::default(),
620 verification: VerificationConfig::default(),
621 user: UserConfig::default(),
622 jwt: JwtConfig::default(),
623 password: PasswordConfig::default(),
624 account: AccountConfig::default(),
625 email_provider: None,
626 advanced: AdvancedConfig::default(),
627 }
628 }
629}
630
631impl Default for IpAddressConfig {
632 fn default() -> Self {
633 Self {
634 headers: None,
635 disable_ip_tracking: None,
636 ipv6_subnet: 64.0,
637 trusted_proxies: Vec::new(),
638 }
639 }
640}
641
642impl AdvancedDatabaseConfig {
643 pub fn find_many_limit(&self) -> f64 {
645 self.default_find_many_limit.unwrap_or(100.0)
646 }
647}
648
649impl Default for JwtConfig {
650 fn default() -> Self {
651 Self {
652 expires_in: Duration::hours(24), algorithm: "HS256".to_string(),
654 issuer: None,
655 audience: None,
656 }
657 }
658}
659
660impl Default for PasswordConfig {
661 fn default() -> Self {
662 Self {
663 min_length: 8,
664 max_length: 128,
665 require_uppercase: false,
666 require_lowercase: false,
667 require_numbers: false,
668 require_special: false,
669 argon2_config: Argon2Config::default(),
670 }
671 }
672}
673
674impl Default for Argon2Config {
675 fn default() -> Self {
676 Self {
677 memory_cost: 4096, time_cost: 3, parallelism: 1, }
681 }
682}
683
684impl AuthConfig {
685 #[doc(hidden)]
687 pub fn session_default_fields(&self) -> AuthResult<crate::FieldMap> {
688 self.session_input_fields
689 .as_ref()
690 .map_or_else(|| self.session.default_fields(), session_defaults)
691 }
692
693 pub fn secrets(mut self, keys: Vec<VersionedSecret>) -> Self {
695 self.secrets = Some(keys);
696 self
697 }
698
699 pub fn signing_secret(&self) -> &str {
700 self.secrets
701 .as_deref()
702 .and_then(|keys| keys.first())
703 .map_or(&self.secret, |key| key.value.as_str())
704 }
705
706 pub fn encryption_secret(&self) -> SecretKey<'_> {
707 match self.secrets.as_deref() {
708 Some(keys) => SecretKey::Versioned {
709 keys,
710 legacy_secret: (!self.secret.is_empty() && self.secret != secrets::DEFAULT_SECRET)
711 .then_some(self.secret.as_str()),
712 },
713 None => SecretKey::Single(&self.secret),
714 }
715 }
716
717 pub fn new(secret: impl Into<String>) -> Self {
718 Self {
719 secret: secret.into(),
720 ..Default::default()
721 }
722 }
723
724 pub fn app_name(mut self, name: impl Into<String>) -> Self {
726 self.app_name = name.into();
727 self
728 }
729
730 pub fn base_url(mut self, url: impl Into<BaseUrl>) -> Self {
732 self.base_url = url.into();
733 self
734 }
735
736 pub fn account(mut self, account: AccountConfig) -> Self {
737 self.account = account;
738 self
739 }
740
741 pub fn base_path(mut self, path: impl Into<String>) -> Self {
743 self.base_path = path.into();
744 self
745 }
746
747 pub fn trusted_origin(mut self, origin: impl Into<String>) -> Self {
749 self.trusted_origins = Some(TrustedValues::merge(
750 self.trusted_origins
751 .into_iter()
752 .chain([vec![origin.into()].into()])
753 .collect(),
754 ));
755 self
756 }
757
758 pub fn trusted_origins(mut self, origins: impl Into<TrustedValues>) -> Self {
760 self.trusted_origins = Some(origins.into());
761 self
762 }
763
764 pub fn disabled_path(mut self, path: impl Into<String>) -> Self {
766 self.disabled_paths.push(path.into());
767 self
768 }
769
770 pub fn disabled_paths(mut self, paths: Vec<String>) -> Self {
772 self.disabled_paths = paths;
773 self
774 }
775
776 pub fn session_expires_in(mut self, duration: Duration) -> Self {
778 self.session.expires_in = Some(duration);
779 self
780 }
781
782 pub fn session_update_age(mut self, duration: Duration) -> Self {
783 self.session.update_age = Some(duration);
784 self
785 }
786
787 pub fn disable_session_refresh(mut self, disabled: bool) -> Self {
788 self.session.disable_session_refresh = Some(disabled);
789 self
790 }
791
792 pub fn session_fresh_age(mut self, duration: Duration) -> Self {
793 self.session.fresh_age = Some(duration);
794 self
795 }
796
797 pub fn session_cookie_cache(mut self, config: CookieCacheConfig) -> Self {
799 self.session.cookie_cache = Some(config);
800 self
801 }
802
803 pub fn jwt_expires_in(mut self, duration: Duration) -> Self {
805 self.jwt.expires_in = duration;
806 self
807 }
808
809 pub fn password_min_length(mut self, length: usize) -> Self {
811 self.password.min_length = length;
812 self
813 }
814
815 pub fn password_max_length(mut self, length: usize) -> Self {
817 self.password.max_length = length;
818 self
819 }
820
821 pub fn advanced(mut self, advanced: AdvancedConfig) -> Self {
822 self.advanced = advanced;
823 self
824 }
825
826 pub fn cookie_prefix(mut self, prefix: impl Into<String>) -> Self {
827 self.advanced.cookie_prefix = Some(prefix.into());
828 self
829 }
830
831 pub fn disable_csrf_check(mut self, disabled: bool) -> Self {
832 self.advanced.disable_csrf_check = Some(disabled);
833 self
834 }
835
836 pub fn disable_origin_check(mut self, disabled: bool) -> Self {
837 self.advanced.disable_origin_check = disabled;
838 self
839 }
840
841 pub fn cross_sub_domain_cookies(mut self, domain: impl Into<String>) -> Self {
842 self.advanced.cross_sub_domain_cookies = Some(CrossSubDomainConfig {
843 domain: Some(domain.into()),
844 enabled: Some(true),
845 ..Default::default()
846 });
847 self
848 }
849
850 pub fn is_origin_trusted(&self, origin: &str) -> bool {
860 if let Some(base_origin) = self.base_url.as_static().and_then(extract_origin)
862 && origin == base_origin
863 {
864 return true;
865 }
866 self.trusted_origin_values()
868 .as_static()
869 .unwrap_or(&[])
870 .iter()
871 .any(|pattern| {
872 let pattern_origin = extract_origin(pattern).unwrap_or_default();
873 glob_match::glob_match(&pattern_origin, origin)
874 })
875 }
876
877 pub fn is_redirect_target_trusted(&self, url: &str) -> bool {
886 if is_safe_relative_path(url) {
887 return true;
888 }
889 extract_origin(url).is_some_and(|origin| self.is_origin_trusted(&origin))
890 }
891
892 pub fn is_path_disabled(&self, path: &str) -> bool {
894 self.disabled_paths.iter().any(|disabled| disabled == path)
895 }
896 pub fn validate(&self) -> Result<(), AuthError> {
897 if self.secrets.is_some() {
898 return self.encryption_secret().validate(&self.logger);
899 }
900 if self.secret.is_empty() {
901 return Err(AuthError::config("Secret key cannot be empty"));
902 }
903
904 secrets::warn_secret_strength(&self.secret, &self.logger);
905 Ok(())
906 }
907}
908
909pub fn is_safe_relative_path(value: &str) -> bool {
917 if !value.starts_with('/') || value.starts_with("//") || value.contains('\\') {
918 return false;
919 }
920
921 let tail = &value[1..];
922 let lower = tail.to_ascii_lowercase();
923 !lower.starts_with("%2f") && !lower.starts_with("%5c")
924}
925
926pub fn extract_origin(url: &str) -> Option<String> {
933 let scheme_end = url.find("://")?;
934 let rest = &url[scheme_end + 3..];
935 let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
936 let origin = format!("{}{}", &url[..scheme_end + 3], &rest[..host_end]);
937 Some(origin)
938}
939
940#[cfg(test)]
941mod tests {
942 use super::*;
943
944 #[test]
948 fn extract_origin_with_path() {
949 assert_eq!(
950 extract_origin("https://example.com/path"),
951 Some("https://example.com".to_string())
952 );
953 }
954
955 #[test]
957 fn extract_origin_without_path() {
958 assert_eq!(
959 extract_origin("https://example.com"),
960 Some("https://example.com".to_string())
961 );
962 }
963
964 #[test]
966 fn extract_origin_with_port() {
967 assert_eq!(
968 extract_origin("http://localhost:3000/api"),
969 Some("http://localhost:3000".to_string())
970 );
971 }
972
973 #[test]
975 fn extract_origin_with_query() {
976 assert_eq!(
977 extract_origin("https://example.com?foo=bar"),
978 Some("https://example.com".to_string())
979 );
980 }
981
982 #[test]
984 fn extract_origin_with_fragment() {
985 assert_eq!(
986 extract_origin("https://example.com#fragment"),
987 Some("https://example.com".to_string())
988 );
989 }
990
991 #[test]
993 fn extract_origin_no_scheme() {
994 assert_eq!(extract_origin("example.com"), None);
995 }
996
997 #[test]
1001 fn new_config_sets_secret() {
1002 let cfg = AuthConfig::new("a]secret-that-is-at-least-32-characters-long");
1003 assert_eq!(cfg.secret, "a]secret-that-is-at-least-32-characters-long");
1004 }
1005
1006 #[test]
1008 fn new_config_uses_defaults() {
1009 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567");
1010 assert_eq!(cfg.app_name, "Better Auth");
1011 assert!(matches!(cfg.base_url, BaseUrl::Auto));
1012 assert_eq!(cfg.base_path, "/api/auth");
1013 assert!(cfg.trusted_origins.is_none());
1014 }
1015
1016 #[test]
1020 fn base_url_sets_cookie_secure_for_https() {
1021 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1022 assert_eq!(
1023 cfg.auth_cookie("session_token", Default::default())
1024 .attributes
1025 .secure,
1026 Some(true)
1027 );
1028 }
1029
1030 #[test]
1032 fn base_url_clears_cookie_secure_for_http() {
1033 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1034 .base_url("https://myapp.com")
1035 .base_url("http://localhost:3000");
1036 assert_eq!(
1037 cfg.auth_cookie("session_token", Default::default())
1038 .attributes
1039 .secure,
1040 Some(false)
1041 );
1042 }
1043
1044 #[test]
1046 fn builder_chaining() {
1047 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1048 .app_name("MyApp")
1049 .base_path("/auth")
1050 .password_min_length(12)
1051 .disable_csrf_check(true)
1052 .disable_origin_check(true)
1053 .cookie_prefix("myapp");
1054
1055 assert_eq!(cfg.app_name, "MyApp");
1056 assert_eq!(cfg.base_path, "/auth");
1057 assert_eq!(cfg.password.min_length, 12);
1058 assert_eq!(cfg.advanced.disable_csrf_check, Some(true));
1059 assert!(cfg.advanced.disable_origin_check);
1060 assert_eq!(cfg.advanced.cookie_prefix, Some("myapp".to_string()));
1061 }
1062
1063 #[test]
1065 fn trusted_origin_appends() {
1066 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1067 .trusted_origin("https://a.com")
1068 .trusted_origin("https://b.com");
1069 assert_eq!(cfg.trusted_origin_values().as_static().unwrap().len(), 2);
1070 }
1071
1072 #[test]
1074 fn trusted_origins_replaces() {
1075 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1076 .trusted_origin("https://old.com")
1077 .trusted_origins(vec!["https://new.com".to_string()]);
1078 assert_eq!(
1079 cfg.trusted_origin_values().as_static().unwrap(),
1080 &["https://new.com"]
1081 );
1082 }
1083
1084 #[test]
1086 fn disabled_path_appends() {
1087 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1088 .disabled_path("/admin")
1089 .disabled_path("/debug");
1090 assert_eq!(cfg.disabled_paths.len(), 2);
1091 }
1092
1093 #[test]
1095 fn disabled_paths_replaces() {
1096 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1097 .disabled_path("/old")
1098 .disabled_paths(vec!["/new".to_string()]);
1099 assert_eq!(cfg.disabled_paths, vec!["/new"]);
1100 }
1101
1102 #[test]
1106 fn is_origin_trusted_matches_base_url() {
1107 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1108 assert!(cfg.is_origin_trusted("https://myapp.com"));
1109 }
1110
1111 #[test]
1113 fn is_origin_trusted_rejects_unknown() {
1114 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1115 assert!(!cfg.is_origin_trusted("https://evil.com"));
1116 }
1117
1118 #[test]
1120 fn is_origin_trusted_glob_pattern() {
1121 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1122 .trusted_origin("https://*.example.com");
1123 assert!(cfg.is_origin_trusted("https://sub.example.com"));
1124 assert!(!cfg.is_origin_trusted("https://other.com"));
1125 }
1126
1127 #[test]
1131 fn redirect_target_allows_relative_path() {
1132 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1133 assert!(cfg.is_redirect_target_trusted("/dashboard"));
1134 assert!(cfg.is_redirect_target_trusted("/callback?foo=bar"));
1135 }
1136
1137 #[test]
1139 fn redirect_target_allows_same_origin() {
1140 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1141 assert!(cfg.is_redirect_target_trusted("https://myapp.com/verified"));
1142 }
1143
1144 #[test]
1146 fn redirect_target_allows_trusted_origin() {
1147 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1148 .base_url("https://myapp.com")
1149 .trusted_origin("https://trusted.com");
1150 assert!(cfg.is_redirect_target_trusted("https://trusted.com/path"));
1151 }
1152
1153 #[test]
1155 fn redirect_target_rejects_untrusted_origin() {
1156 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1157 assert!(!cfg.is_redirect_target_trusted("https://evil.com/phish"));
1158 }
1159
1160 #[test]
1162 fn redirect_target_rejects_protocol_relative() {
1163 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1164 assert!(!cfg.is_redirect_target_trusted("//evil.com"));
1165 }
1166
1167 #[test]
1171 fn is_path_disabled_matches() {
1172 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").disabled_path("/admin");
1173 assert!(cfg.is_path_disabled("/admin"));
1174 assert!(!cfg.is_path_disabled("/user"));
1175 }
1176
1177 #[test]
1181 fn validate_rejects_empty_secret() {
1182 let cfg = AuthConfig::default();
1183 assert!(cfg.validate().is_err());
1184 }
1185
1186 #[test]
1188 fn validate_accepts_short_secret_with_warning() {
1189 let cfg = AuthConfig::new("short");
1190 assert!(cfg.validate().is_ok());
1191 }
1192
1193 #[test]
1195 fn validate_accepts_valid_secret() {
1196 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567");
1197 assert!(cfg.validate().is_ok());
1198 }
1199
1200 #[test]
1204 fn session_config_defaults() {
1205 let s = SessionConfig::default();
1206 assert_eq!(s.expires_in, None);
1207 assert_eq!(s.expires_in(), Duration::hours(24 * 7));
1208 assert_eq!(s.update_age, None);
1209 assert_eq!(s.update_age(), Duration::hours(24));
1210 assert!(!s.disable_session_refresh());
1211 let cookie = AuthConfig::default().auth_cookie("session_token", Default::default());
1212 assert_eq!(cookie.name, "better-auth.session_token");
1213 assert_eq!(cookie.attributes.http_only, Some(true));
1214 assert_eq!(cookie.attributes.same_site, Some(SameSite::Lax));
1215 }
1216
1217 #[test]
1219 fn jwt_config_defaults() {
1220 let j = JwtConfig::default();
1221 assert_eq!(j.expires_in, Duration::hours(24));
1222 assert_eq!(j.algorithm, "HS256");
1223 }
1224
1225 #[test]
1227 fn password_config_defaults() {
1228 let p = PasswordConfig::default();
1229 assert_eq!(p.min_length, 8);
1230 assert!(!p.require_uppercase);
1231 }
1232
1233 #[test]
1235 fn same_site_display() {
1236 assert_eq!(SameSite::Strict.to_string(), "Strict");
1237 assert_eq!(SameSite::Lax.to_string(), "Lax");
1238 assert_eq!(SameSite::None.to_string(), "None");
1239 }
1240
1241 #[test]
1243 fn cookie_cache_config_defaults() {
1244 let c = CookieCacheConfig::default();
1245 assert!(!c.enabled());
1246 assert_eq!(c.max_age(), Duration::minutes(5));
1247 assert_eq!(c.strategy(), CookieCacheStrategy::Compact);
1248 }
1249
1250 #[test]
1252 fn account_config_defaults() {
1253 let a = AccountConfig::default();
1254 assert!(a.update_account_on_sign_in());
1255 assert!(!a.encrypt_oauth_tokens());
1256 assert!(a.account_linking.enabled());
1257 }
1258
1259 #[test]
1261 fn core_paths_error_page() {
1262 let html = core_paths::error_page_html("TEST_ERROR");
1263 assert!(html.contains("TEST_ERROR"));
1264 assert!(html.contains("Ask AI"));
1265 assert!(html.contains("<title>Error</title>"));
1266 }
1267
1268 #[test]
1270 fn error_page_sanitizes_script_tag() {
1271 let html = core_paths::error_page_html("<script>alert(1)</script>");
1272 assert!(html.contains("UNKNOWN"));
1273 assert!(!html.contains("<script>"));
1274 }
1275
1276 #[test]
1278 fn error_page_allows_valid_codes() {
1279 assert!(core_paths::error_page_html("SOME_ERROR-CODE").contains("SOME_ERROR-CODE"));
1280 assert!(core_paths::error_page_html("it's").contains("it's"));
1281 }
1282
1283 #[test]
1287 fn session_builder_methods() {
1288 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1289 .session_expires_in(Duration::hours(1))
1290 .session_update_age(Duration::minutes(30))
1291 .disable_session_refresh(true)
1292 .session_fresh_age(Duration::minutes(5));
1293
1294 assert_eq!(cfg.session.expires_in(), Duration::hours(1));
1295 assert_eq!(cfg.session.update_age, Some(Duration::minutes(30)));
1296 assert!(cfg.session.disable_session_refresh());
1297 assert_eq!(cfg.session.fresh_age, Some(Duration::minutes(5)));
1298 }
1299
1300 #[test]
1302 fn session_cookie_cache_builder() {
1303 let cache = CookieCacheConfig {
1304 enabled: Some(true),
1305 max_age: Some(Duration::minutes(10)),
1306 strategy: Some(CookieCacheStrategy::Jwt),
1307 ..Default::default()
1308 };
1309 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").session_cookie_cache(cache);
1310
1311 let cc = cfg.session.cookie_cache.as_ref();
1312 assert!(cc.is_some());
1313 let cc = cc.unwrap();
1314 assert!(cc.enabled());
1315 assert_eq!(cc.strategy(), CookieCacheStrategy::Jwt);
1316 }
1317
1318 #[test]
1320 fn cross_sub_domain_cookies_builder() {
1321 let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1322 .cross_sub_domain_cookies(".example.com");
1323 let csd = cfg.advanced.cross_sub_domain_cookies.as_ref();
1324 assert!(csd.is_some());
1325 assert_eq!(csd.unwrap().domain.as_deref(), Some(".example.com"));
1326 }
1327
1328 #[test]
1330 fn advanced_database_defaults() {
1331 let d = AdvancedDatabaseConfig::default();
1332 assert_eq!(d.default_find_many_limit, None);
1333 assert!(d.generate_id.is_none());
1334 }
1335
1336 #[test]
1338 fn ip_address_config_defaults() {
1339 let ip = IpAddressConfig::default();
1340 assert_eq!(ip.headers, None);
1341 assert_eq!(ip.disable_ip_tracking, None);
1342 }
1343}