1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::sync::Arc;
4
5use better_auth_core::entity::{AuthSession, AuthUser};
6use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
7use better_auth_core::{AuthError, AuthResult};
8use better_auth_core::{AuthRequest, AuthResponse, HttpMethod, RequestMeta};
9
10use super::email_verification::EmailVerificationPlugin;
11use better_auth_core::utils::password::{self as password_utils, PasswordHasher};
12use better_auth_core::wire::UserView;
13
14use crate::plugins::helpers::{SessionIssueError, issue_selected_user_session};
15
16mod callbacks;
17mod request;
18mod signup;
19use super::username::request::SignInUsernameRequest;
20pub use callbacks::EmailPasswordCallbacks;
21use signup::sign_up_core;
22pub use signup::{CustomSyntheticUser, OnExistingUserSignUp, SyntheticUserInput};
23
24const MESSAGE_EMAIL_NOT_VERIFIED: &str = "Email not verified";
25
26pub struct EmailPasswordPlugin {
28 config: EmailPasswordConfig,
29 email_verification: Option<Arc<EmailVerificationPlugin>>,
32}
33
34#[derive(Clone)]
35pub struct EmailPasswordConfig {
36 pub enable_signup: bool,
37 pub username: bool,
39 pub require_email_verification: bool,
40 pub password_min_length: Option<usize>,
41 pub password_max_length: Option<usize>,
43 pub auto_sign_in: Option<bool>,
46 pub password_hasher: Option<Arc<dyn PasswordHasher>>,
48 pub on_existing_user_sign_up: Option<Arc<dyn OnExistingUserSignUp>>,
50 pub custom_synthetic_user: Option<Arc<CustomSyntheticUser>>,
52}
53
54impl EmailPasswordConfig {
55 pub fn password_min_length(&self) -> usize {
57 self.password_min_length.unwrap_or(8)
58 }
59
60 pub fn password_max_length(&self) -> usize {
62 self.password_max_length.unwrap_or(128)
63 }
64
65 pub fn auto_sign_in(&self) -> bool {
67 self.auto_sign_in.unwrap_or(true)
68 }
69}
70
71impl std::fmt::Debug for EmailPasswordConfig {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.debug_struct("EmailPasswordConfig")
74 .field("enable_signup", &self.enable_signup)
75 .field("username", &self.username)
76 .field(
77 "require_email_verification",
78 &self.require_email_verification,
79 )
80 .field("password_min_length", &self.password_min_length)
81 .field("password_max_length", &self.password_max_length)
82 .field("auto_sign_in", &self.auto_sign_in)
83 .field(
84 "password_hasher",
85 &self.password_hasher.as_ref().map(|_| "custom"),
86 )
87 .finish()
88 }
89}
90
91#[derive(Debug, Clone, Deserialize)]
92pub(crate) struct SignUpRequest {
93 name: String,
94 email: String,
95 password: String,
96 image: Option<String>,
97 #[serde(rename = "callbackURL")]
98 callback_url: Option<String>,
99 #[serde(rename = "rememberMe")]
100 remember_me: Option<bool>,
101 #[serde(flatten)]
102 additional_fields: serde_json::Map<String, serde_json::Value>,
103}
104
105#[derive(Debug, Clone, Deserialize)]
106pub(crate) struct SignInRequest {
107 email: String,
108 password: String,
109 #[serde(rename = "callbackURL")]
110 callback_url: Option<String>,
111 #[serde(rename = "rememberMe")]
112 remember_me: Option<bool>,
113}
114
115#[derive(Debug, Serialize)]
116pub(crate) struct SignUpResponse {
117 token: Option<String>,
118 #[serde(with = "better_auth_core::field_value::serde::map")]
119 user: better_auth_core::FieldMap,
120}
121
122#[derive(Debug, Serialize)]
123pub(crate) struct SignInResponse<U: Serialize> {
124 redirect: bool,
125 token: String,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 url: Option<String>,
128 user: U,
129}
130
131pub(crate) struct SignInCoreResult<U: Serialize> {
132 pub response: SignInResponse<U>,
133}
134
135impl EmailPasswordPlugin {
136 #[expect(
137 clippy::new_without_default,
138 reason = "plugin construction is intentionally explicit"
139 )]
140 pub fn new() -> Self {
141 Self {
142 config: EmailPasswordConfig::default(),
143 email_verification: None,
144 }
145 }
146
147 pub fn with_config(config: EmailPasswordConfig) -> Self {
148 Self {
149 config,
150 email_verification: None,
151 }
152 }
153
154 pub fn with_email_verification(mut self, plugin: Arc<EmailVerificationPlugin>) -> Self {
157 self.email_verification = Some(plugin);
158 self
159 }
160
161 pub fn enable_signup(mut self, enable: bool) -> Self {
162 self.config.enable_signup = enable;
163 self
164 }
165
166 pub fn username(mut self, enabled: bool) -> Self {
170 self.config.username = enabled;
171 self
172 }
173
174 pub fn require_email_verification(mut self, require: bool) -> Self {
175 self.config.require_email_verification = require;
176 self
177 }
178
179 pub fn password_min_length(mut self, length: usize) -> Self {
180 self.config.password_min_length = Some(length);
181 self
182 }
183
184 pub fn password_max_length(mut self, length: usize) -> Self {
185 self.config.password_max_length = Some(length);
186 self
187 }
188
189 pub fn auto_sign_in(mut self, auto: bool) -> Self {
190 self.config.auto_sign_in = Some(auto);
191 self
192 }
193
194 pub fn password_hasher(mut self, hasher: Arc<dyn PasswordHasher>) -> Self {
195 self.config.password_hasher = Some(hasher);
196 self
197 }
198
199 pub fn on_existing_user_sign_up(mut self, callback: Arc<dyn OnExistingUserSignUp>) -> Self {
201 self.config.on_existing_user_sign_up = Some(callback);
202 self
203 }
204
205 pub fn custom_synthetic_user(mut self, callback: Arc<CustomSyntheticUser>) -> Self {
207 self.config.custom_synthetic_user = Some(callback);
208 self
209 }
210
211 async fn handle_sign_up(
212 &self,
213 req: &AuthRequest,
214 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
215 ) -> AuthResult<AuthResponse> {
216 let endpoint_body: serde_json::Value = req
217 .body_as_json()
218 .map_err(|error| AuthError::bad_request(format!("Invalid JSON: {error}")))?;
219 let signup_req = request::sign_up(req)?;
220 request::form_csrf(req, ctx).await?;
221
222 let response = sign_up_core(&signup_req, endpoint_body, &self.config, req, ctx).await?;
223
224 Ok(AuthResponse::json(200, &response)?)
225 }
226
227 async fn handle_sign_in(
228 &self,
229 req: &AuthRequest,
230 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
231 ) -> AuthResult<AuthResponse> {
232 let signin_req = request::sign_in(req)?;
233 request::form_csrf(req, ctx).await?;
234 if !super::json_body::valid_email(&signin_req.email)? {
235 return Err(AuthError::bad_request("Invalid email"));
236 }
237
238 let meta = RequestMeta::from_request_with_config(req, &ctx.config.advanced.ip_address);
239 let result = sign_in_core(
240 req,
241 &signin_req,
242 &self.config,
243 self.email_verification.as_deref(),
244 &meta,
245 ctx,
246 )
247 .await?;
248 Ok(AuthResponse::json(200, &result.response)?)
249 }
250
251 async fn handle_sign_in_username(
252 &self,
253 req: &AuthRequest,
254 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
255 ) -> AuthResult<AuthResponse> {
256 super::username::UsernamePlugin::default()
257 .sign_in_with_verification(req, ctx, self.email_verification.as_deref())
258 .await
259 }
260
261 async fn handle_is_username_available(
262 &self,
263 req: &AuthRequest,
264 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
265 ) -> AuthResult<AuthResponse> {
266 super::username::UsernamePlugin::default()
267 .available(req, ctx)
268 .await
269 }
270}
271
272async fn load_credential_password_hash(
277 user: &impl AuthUser,
278 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
279) -> AuthResult<String> {
280 super::helpers::get_credential_password_hash(ctx, user)
281 .await?
282 .ok_or(AuthError::InvalidCredentials)
283}
284
285async fn verify_user_password(
286 user: &impl AuthUser,
287 password: &str,
288 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
289) -> AuthResult<()> {
290 let stored_hash = load_credential_password_hash(user, ctx).await?;
291 password_utils::verify_password(ctx.password_policy.hasher.as_ref(), password, &stored_hash)
292 .await
293}
294
295async fn finalize_sign_in_with_user_core(
297 req: &AuthRequest,
298 user: impl AuthUser,
299 remember_me: Option<bool>,
300 email_verification: Option<&EmailVerificationPlugin>,
301 callback_url: Option<&str>,
302 meta: &RequestMeta,
303 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
304) -> AuthResult<SignInCoreResult<UserView>> {
305 if let Some(ev) = email_verification
307 && let Err(e) = ev
308 .send_verification_on_sign_in_with_request(&user, callback_url, Some(req), ctx)
309 .await
310 {
311 better_auth_core::observability::logger::current().warn(
312 "Failed to send verification email on sign-in",
313 &[better_auth_core::observability::LogArgument::Error(&e)],
314 );
315 }
316
317 let expires_in = if remember_me == Some(false) {
318 chrono::Duration::days(1)
319 } else {
320 ctx.config.session.expires_in()
321 };
322 let user = ctx.internal_user_view(&user).await?;
323 let issued = issue_selected_user_session(
324 ctx,
325 better_auth_core::FieldMap::from(user.clone()).into(),
326 meta,
327 expires_in,
328 )
329 .await
330 .map_err(SessionIssueError::into_auth_error)?;
331 let token = issued.session.token().to_string();
332 let manager = ctx.session_manager();
333 manager
334 .set_native_session_cookie(req, issued, Some(remember_me == Some(false)))
335 .await?;
336
337 if let Some(callback) = callback_url.filter(|url| !url.is_empty()) {
338 req.set_response_header("Location", callback)?;
339 }
340 let response = SignInResponse {
341 redirect: callback_url.is_some_and(|url| !url.is_empty()),
342 token: token.clone(),
343 url: callback_url.map(str::to_owned),
344 user: ctx.user_view(&user).await?,
345 };
346 Ok(SignInCoreResult { response })
347}
348
349pub(crate) async fn sign_in_core(
351 req: &AuthRequest,
352 body: &SignInRequest,
353 config: &EmailPasswordConfig,
354 email_verification: Option<&EmailVerificationPlugin>,
355 meta: &RequestMeta,
356 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
357) -> AuthResult<SignInCoreResult<UserView>> {
358 ctx.password_policy.validate_max_length(&body.password)?;
359 let verification = EmailVerificationPlugin::from_context(ctx);
360 let email_verification = email_verification.or(verification.as_ref());
361 let Some(record) = ctx.database.get_user_with_accounts(&body.email).await? else {
362 _ = password_utils::hash_password(ctx.password_policy.hasher.as_ref(), &body.password)
363 .await?;
364 return Err(AuthError::InvalidCredentials);
365 };
366
367 let user = record.user;
368 let better_auth_core::store::JoinValue::Many(accounts) = record.accounts else {
369 return Err(AuthError::internal("accounts.find is not a function"));
370 };
371 let credential = accounts
372 .into_iter()
373 .find(|account| account.provider_id == "credential" && account.account_id == user.id);
374 let hash = credential
375 .map(|account| {
376 if account.password.is_truthy()? {
377 account
378 .password
379 .typed()
380 .cloned()
381 .map(|value| value.unwrap_or_default())
382 } else {
383 Ok(String::new())
384 }
385 })
386 .transpose()?
387 .ok_or(AuthError::InvalidCredentials);
388 let stored_hash = match hash {
389 Ok(hash) if !hash.is_empty() => hash,
390 Ok(_) | Err(AuthError::InvalidCredentials) => {
391 _ = password_utils::hash_password(ctx.password_policy.hasher.as_ref(), &body.password)
392 .await?;
393 return Err(AuthError::InvalidCredentials);
394 }
395 Err(error) => return Err(error),
396 };
397 password_utils::verify_password(
398 ctx.password_policy.hasher.as_ref(),
399 &body.password,
400 &stored_hash,
401 )
402 .await?;
403
404 if (config.require_email_verification
405 || email_verification.is_some_and(EmailVerificationPlugin::is_verification_required))
406 && !user.email_verified()
407 {
408 if let Some(verification) = email_verification {
409 verification
410 .send_verification_on_sign_in_with_request(
411 &user,
412 body.callback_url.as_deref(),
413 Some(req),
414 ctx,
415 )
416 .await?;
417 }
418 return Err(AuthError::forbidden(MESSAGE_EMAIL_NOT_VERIFIED));
419 }
420
421 finalize_sign_in_with_user_core(
422 req,
423 user,
424 body.remember_me,
425 email_verification,
426 body.callback_url.as_deref(),
427 meta,
428 ctx,
429 )
430 .await
431}
432
433pub(crate) async fn sign_in_username_core(
435 req: &AuthRequest,
436 body: &SignInUsernameRequest,
437 normalized_username: &str,
438 config: &EmailPasswordConfig,
439 email_verification: Option<&EmailVerificationPlugin>,
440 meta: &RequestMeta,
441 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
442) -> Result<SignInCoreResult<UserView>, SignInUsernameFailure> {
443 ctx.password_policy
444 .validate_max_length(&body.password)
445 .map_err(SignInUsernameFailure::Auth)?;
446 let verification = EmailVerificationPlugin::from_context(ctx);
447 let email_verification = email_verification.or(verification.as_ref());
448 let Some(user) = ctx
449 .database
450 .get_user_by_username(normalized_username)
451 .await
452 .map_err(SignInUsernameFailure::Auth)?
453 else {
454 let _ = password_utils::hash_password(ctx.password_policy.hasher.as_ref(), &body.password)
455 .await
456 .map_err(SignInUsernameFailure::Auth)?;
457 return Err(SignInUsernameFailure::InvalidUsernameOrPassword);
458 };
459
460 verify_user_password(&user, &body.password, ctx)
461 .await
462 .map_err(|error| match error {
463 AuthError::InvalidCredentials => SignInUsernameFailure::InvalidUsernameOrPassword,
464 other => SignInUsernameFailure::Auth(other),
465 })?;
466
467 if (config.require_email_verification
468 || email_verification.is_some_and(EmailVerificationPlugin::is_verification_required))
469 && !user.email_verified()
470 {
471 if let Some(ev) = email_verification {
472 ev.send_verification_on_sign_in_with_request(
473 &user,
474 body.callback_url.as_deref(),
475 Some(req),
476 ctx,
477 )
478 .await
479 .map_err(SignInUsernameFailure::Auth)?;
480 }
481 return Err(SignInUsernameFailure::EmailNotVerified);
482 }
483
484 finalize_sign_in_with_user_core(
485 req,
486 user,
487 body.remember_me,
488 None,
489 body.callback_url.as_deref(),
490 meta,
491 ctx,
492 )
493 .await
494 .map_err(SignInUsernameFailure::Auth)
495}
496
497pub(crate) enum SignInUsernameFailure {
498 InvalidUsernameOrPassword,
499 EmailNotVerified,
500 Auth(AuthError),
501}
502
503impl Default for EmailPasswordConfig {
504 fn default() -> Self {
505 Self {
506 enable_signup: true,
507 username: false,
508 require_email_verification: false,
509 password_min_length: None,
510 password_max_length: None,
511 auto_sign_in: None,
512 password_hasher: None,
513 on_existing_user_sign_up: None,
514 custom_synthetic_user: None,
515 }
516 }
517}
518
519#[async_trait]
520impl<S: better_auth_core::AuthSchema> AuthPlugin<S> for EmailPasswordPlugin {
521 fn telemetry(&self, options: &mut better_auth_core::observability::telemetry::PluginTelemetry) {
522 if let Some(verification) = &self.email_verification {
523 <EmailVerificationPlugin as AuthPlugin<S>>::telemetry(verification, options);
524 }
525 let options = &mut options.email_and_password;
526 options.enabled = true;
527 options.disable_sign_up = !self.config.enable_signup;
528 options.require_email_verification = self.config.require_email_verification;
529 options.min_password_length = self.config.password_min_length;
530 options.max_password_length = self.config.password_max_length;
531 options.auto_sign_in = self.config.auto_sign_in == Some(true);
532 options.password.hash |= self.config.password_hasher.is_some();
533 options.password.verify |= self.config.password_hasher.is_some();
534 }
535
536 fn name(&self) -> &'static str {
537 "email-password"
538 }
539
540 fn telemetry_plugin_id(&self) -> Option<&'static str> {
541 None
542 }
543
544 fn openapi(&self) -> AuthResult<better_auth_core::openapi::OpenApiPluginMetadata> {
545 if self.config.username {
546 <crate::plugins::username::UsernamePlugin as AuthPlugin<S>>::openapi(&Default::default())
547 } else {
548 better_auth_core::openapi::OpenApiPluginMetadata::from_routes(
549 <Self as better_auth_core::AuthPlugin<S>>::name(self),
550 <Self as better_auth_core::AuthPlugin<S>>::routes(self),
551 )
552 }
553 }
554
555 fn password_hasher(&self) -> Option<Arc<dyn PasswordHasher>> {
556 self.config.password_hasher.clone()
557 }
558
559 async fn on_init(&self, ctx: &mut better_auth_core::AuthInitContext<S>) -> AuthResult<()> {
560 ctx.password_policy.min_length = self.config.password_min_length();
561 ctx.password_policy.max_length = self.config.password_max_length();
562 ctx.extensions.insert(self.config.clone());
563 if self.config.username {
564 <super::username::UsernamePlugin as AuthPlugin<S>>::on_init(
565 &super::username::UsernamePlugin::default(),
566 ctx,
567 )
568 .await?;
569 }
570 Ok(())
571 }
572
573 fn routes(&self) -> Vec<AuthRoute> {
574 let mut routes = vec![
575 AuthRoute::post("/sign-in/email", "signInEmail")
576 .body_validator(request::sign_in_body)
577 .allowed_media_types(&["application/x-www-form-urlencoded", "application/json"]),
578 ];
579 if self.config.username {
580 routes.push(AuthRoute::post("/sign-in/username", "signInUsername"));
581 routes.push(AuthRoute::post(
582 "/is-username-available",
583 "isUsernameAvailable",
584 ));
585 }
586
587 if self.config.enable_signup {
588 routes.push(
589 AuthRoute::post("/sign-up/email", "signUpWithEmailAndPassword")
590 .body_validator(request::sign_up_body)
591 .allowed_media_types(&[
592 "application/x-www-form-urlencoded",
593 "application/json",
594 ]),
595 );
596 }
597
598 routes
599 }
600
601 async fn before_request(
602 &self,
603 req: &AuthRequest,
604 ctx: &AuthContext<S>,
605 ) -> AuthResult<Option<better_auth_core::BeforeRequestAction>> {
606 if self.config.username {
607 super::username::UsernamePlugin::default()
608 .before_endpoint(req, ctx)
609 .await
610 } else {
611 Ok(None)
612 }
613 }
614
615 async fn on_request(
616 &self,
617 req: &AuthRequest,
618 ctx: &AuthContext<S>,
619 ) -> AuthResult<Option<AuthResponse>> {
620 match (req.method(), req.path()) {
621 (HttpMethod::Post, "/sign-up/email") if self.config.enable_signup => {
622 Ok(Some(self.handle_sign_up(req, ctx).await?))
623 }
624 (HttpMethod::Post, "/sign-in/email") => Ok(Some(self.handle_sign_in(req, ctx).await?)),
625 (HttpMethod::Post, "/sign-in/username") if self.config.username => {
626 Ok(Some(self.handle_sign_in_username(req, ctx).await?))
627 }
628 (HttpMethod::Post, "/is-username-available") if self.config.username => {
629 Ok(Some(self.handle_is_username_available(req, ctx).await?))
630 }
631 _ => Ok(None),
632 }
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use crate::plugins::test_helpers;
640 use better_auth_core::AuthContext;
641 use better_auth_core::config::AuthConfig;
642 use std::collections::HashMap;
643 use std::sync::Arc;
644 use std::sync::atomic::{AtomicUsize, Ordering};
645
646 type TestSchema =
647 better_auth_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
648
649 async fn create_test_context(plugin: &EmailPasswordPlugin) -> Arc<AuthContext<TestSchema>> {
650 let config = AuthConfig::new("test-secret-key-at-least-32-chars-long");
651 let config = Arc::new(config);
652 let database = crate::plugins::test_helpers::create_test_database().await;
653 let mut init = better_auth_core::AuthInitContext::new(config.clone(), database.clone());
654 init.password_policy.hasher =
655 <EmailPasswordPlugin as AuthPlugin<TestSchema>>::password_hasher(plugin);
656 plugin.on_init(&mut init).await.unwrap();
657 let parts = init.into_parts();
658 let (adapter_config, endpoint_config, model_fields) =
659 parts.plugin_fields.clone().resolve(&config);
660 let database = database
661 .with_runtime(
662 Arc::new(adapter_config),
663 parts.database_hooks.clone(),
664 model_fields,
665 )
666 .unwrap();
667 let mut context = AuthContext::new(Arc::new(endpoint_config), database);
668 parts.apply_request_runtime(&mut context);
669 context.extensions = parts.extensions;
670 context.email_verification_policy = parts.email_verification_policy;
671 context.email_provider = parts.email_provider;
672 context.secondary_storage = parts.secondary_storage;
673 context.password_policy = parts.password_policy;
674 context.metadata = parts.metadata;
675 let context = Arc::new(context);
676 parts.runtime.bind(&context).unwrap();
677 context
678 }
679
680 fn create_signup_request(email: &str, password: &str) -> AuthRequest {
681 let body = serde_json::json!({
682 "name": "Test User",
683 "email": email,
684 "password": password,
685 });
686 AuthRequest::from_parts(
687 HttpMethod::Post,
688 "/sign-up/email".to_string(),
689 HashMap::new(),
690 Some(body.to_string().into_bytes()),
691 None,
692 )
693 }
694
695 #[tokio::test]
697 async fn test_auto_sign_in_false_returns_no_session() {
698 let plugin = EmailPasswordPlugin::new().auto_sign_in(false);
699 let ctx = create_test_context(&plugin).await;
700
701 let req = create_signup_request("auto@example.com", "Password123!");
702 let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
703 let response = test_helpers::finalize_response(&ctx, &req, response);
704 assert_eq!(response.status, 200);
705
706 let has_cookie = response
708 .headers
709 .iter()
710 .any(|(k, _)| k.eq_ignore_ascii_case("Set-Cookie"));
711 assert!(!has_cookie, "auto_sign_in=false should not set a cookie");
712
713 let body: serde_json::Value =
715 serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
716 assert!(
717 body["token"].is_null(),
718 "auto_sign_in=false should return null token"
719 );
720 assert!(body["user"]["id"].is_string());
722 }
723
724 #[tokio::test]
726 async fn test_auto_sign_in_true_returns_session() {
727 let plugin = EmailPasswordPlugin::new(); let ctx = create_test_context(&plugin).await;
729
730 let req = create_signup_request("autotrue@example.com", "Password123!");
731 let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
732 let response = test_helpers::finalize_response(&ctx, &req, response);
733 assert_eq!(response.status, 200);
734
735 let has_cookie = response
737 .headers
738 .iter()
739 .any(|(k, _)| k.eq_ignore_ascii_case("Set-Cookie"));
740 assert!(has_cookie, "auto_sign_in=true should set a cookie");
741
742 let body: serde_json::Value =
744 serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
745 assert!(
746 body["token"].is_string(),
747 "auto_sign_in=true should return a session token"
748 );
749 }
750
751 #[tokio::test]
753 async fn test_password_max_length_rejection() {
754 let plugin = EmailPasswordPlugin::new().password_max_length(128);
755 let ctx = create_test_context(&plugin).await;
756
757 let long_password = format!("A1!{}", "a".repeat(126)); let req = create_signup_request("long@example.com", &long_password);
760 let err = plugin.handle_sign_up(&req, &ctx).await.unwrap_err();
761 assert_eq!(err.status_code(), 400);
762
763 let ok_password = format!("A1!{}", "a".repeat(125)); let req = create_signup_request("ok@example.com", &ok_password);
766 let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
767 assert_eq!(response.status, 200);
768 }
769
770 #[tokio::test]
772 async fn test_custom_password_hasher() {
773 struct TestHasher;
775
776 #[async_trait]
777 impl PasswordHasher for TestHasher {
778 async fn hash(&self, password: &str) -> AuthResult<String> {
779 Ok(format!("hashed:{}", password))
780 }
781 async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
782 Ok(hash == format!("hashed:{}", password))
783 }
784 }
785
786 let hasher: Arc<dyn PasswordHasher> = Arc::new(TestHasher);
787 let plugin = EmailPasswordPlugin::new().password_hasher(hasher);
788 let ctx = create_test_context(&plugin).await;
789
790 let req = create_signup_request("hasher@example.com", "Password123!");
792 let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
793 assert_eq!(response.status, 200);
794
795 let user = ctx
797 .database
798 .get_user_by_email("hasher@example.com")
799 .await
800 .unwrap()
801 .unwrap();
802 let stored_hash = ctx
803 .database
804 .get_user_accounts(user.id().typed().unwrap())
805 .await
806 .unwrap()
807 .into_iter()
808 .find(|account| account.provider_id == "credential")
809 .and_then(|account| account.password.typed().unwrap().clone())
810 .expect("credential account should store hashed password");
811 assert_eq!(stored_hash, "hashed:Password123!");
812
813 let signin_body = serde_json::json!({
815 "email": "hasher@example.com",
816 "password": "Password123!",
817 });
818 let signin_req = AuthRequest::from_parts(
819 HttpMethod::Post,
820 "/sign-in/email".to_string(),
821 HashMap::new(),
822 Some(signin_body.to_string().into_bytes()),
823 None,
824 );
825 let response = plugin.handle_sign_in(&signin_req, &ctx).await.unwrap();
826 assert_eq!(response.status, 200);
827
828 let bad_body = serde_json::json!({
830 "email": "hasher@example.com",
831 "password": "WrongPassword!",
832 });
833 let bad_req = AuthRequest::from_parts(
834 HttpMethod::Post,
835 "/sign-in/email".to_string(),
836 HashMap::new(),
837 Some(bad_body.to_string().into_bytes()),
838 None,
839 );
840 let err = plugin.handle_sign_in(&bad_req, &ctx).await.unwrap_err();
841 assert_eq!(err.to_string(), AuthError::InvalidCredentials.to_string());
842 }
843
844 #[tokio::test]
846 async fn test_sign_in_username_verifies_password_once() {
847 struct CountingHasher {
848 verify_calls: Arc<AtomicUsize>,
849 }
850
851 #[async_trait]
852 impl PasswordHasher for CountingHasher {
853 async fn hash(&self, password: &str) -> AuthResult<String> {
854 Ok(format!("hashed:{password}"))
855 }
856
857 async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
858 self.verify_calls.fetch_add(1, Ordering::SeqCst);
859 Ok(hash == format!("hashed:{password}"))
860 }
861 }
862
863 let verify_calls = Arc::new(AtomicUsize::new(0));
864 let hasher: Arc<dyn PasswordHasher> = Arc::new(CountingHasher {
865 verify_calls: verify_calls.clone(),
866 });
867 let plugin = EmailPasswordPlugin::new()
868 .username(true)
869 .password_hasher(hasher);
870 let ctx = create_test_context(&plugin).await;
871
872 let signup_body = serde_json::json!({
873 "email": "username-counter@example.com",
874 "password": "Password123!",
875 "name": "Counter User",
876 "username": "Counter_User",
877 });
878 let signup_req = AuthRequest::from_parts(
879 HttpMethod::Post,
880 "/sign-up/email".to_string(),
881 HashMap::new(),
882 Some(signup_body.to_string().into_bytes()),
883 None,
884 );
885 let signup_response = plugin.handle_sign_up(&signup_req, &ctx).await.unwrap();
886 assert_eq!(signup_response.status, 200);
887
888 verify_calls.store(0, Ordering::SeqCst);
889
890 let signin_body = serde_json::json!({
891 "username": "COUNTER_USER",
892 "password": "Password123!",
893 });
894 let signin_req = AuthRequest::from_parts(
895 HttpMethod::Post,
896 "/sign-in/username".to_string(),
897 HashMap::from([("content-type".into(), "application/json".into())]),
898 Some(signin_body.to_string().into_bytes()),
899 None,
900 );
901 let signin_response = plugin
902 .handle_sign_in_username(&signin_req, &ctx)
903 .await
904 .unwrap();
905 assert_eq!(signin_response.status, 200);
906 assert_eq!(verify_calls.load(Ordering::SeqCst), 1);
907 }
908
909 #[tokio::test]
912 async fn test_is_username_available_route_registered() {
913 let plugin = EmailPasswordPlugin::new().username(true);
914 let routes =
915 <EmailPasswordPlugin as better_auth_core::AuthPlugin<TestSchema>>::routes(&plugin);
916 assert!(
917 routes.iter().any(|r| r.path == "/is-username-available"),
918 "route /is-username-available should be registered"
919 );
920 }
921
922 #[tokio::test]
926 async fn test_is_username_available_fresh() {
927 let plugin = EmailPasswordPlugin::new().username(true);
928 let ctx = create_test_context(&plugin).await;
929
930 let body = serde_json::json!({ "username": "fresh_user" });
931 let req = AuthRequest::from_parts(
932 HttpMethod::Post,
933 "/is-username-available".to_string(),
934 HashMap::from([("content-type".into(), "application/json".into())]),
935 Some(body.to_string().into_bytes()),
936 None,
937 );
938 let response = plugin
939 .handle_is_username_available(&req, &ctx)
940 .await
941 .unwrap();
942 assert_eq!(response.status, 200);
943 let json: serde_json::Value =
944 serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
945 assert_eq!(json["available"], true);
946 }
947
948 #[tokio::test]
952 async fn test_is_username_available_taken() {
953 let plugin = EmailPasswordPlugin::new().username(true);
954 let ctx = create_test_context(&plugin).await;
955
956 let signup_body = serde_json::json!({
958 "name": "Taken User",
959 "email": "taken@example.com",
960 "password": "Password123!",
961 "username": "taken_user",
962 });
963 let signup_req = AuthRequest::from_parts(
964 HttpMethod::Post,
965 "/sign-up/email".to_string(),
966 HashMap::new(),
967 Some(signup_body.to_string().into_bytes()),
968 None,
969 );
970 let resp = plugin.handle_sign_up(&signup_req, &ctx).await.unwrap();
971 assert_eq!(resp.status, 200);
972
973 let body = serde_json::json!({ "username": "taken_user" });
974 let req = AuthRequest::from_parts(
975 HttpMethod::Post,
976 "/is-username-available".to_string(),
977 HashMap::from([("content-type".into(), "application/json".into())]),
978 Some(body.to_string().into_bytes()),
979 None,
980 );
981 let response = plugin
982 .handle_is_username_available(&req, &ctx)
983 .await
984 .unwrap();
985 assert_eq!(response.status, 200);
986 let json: serde_json::Value =
987 serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
988 assert_eq!(json["available"], false);
989 }
990
991 #[tokio::test]
995 async fn test_is_username_available_too_short() {
996 let plugin = EmailPasswordPlugin::new().username(true);
997 let ctx = create_test_context(&plugin).await;
998
999 let body = serde_json::json!({ "username": "ab" });
1000 let req = AuthRequest::from_parts(
1001 HttpMethod::Post,
1002 "/is-username-available".to_string(),
1003 HashMap::from([("content-type".into(), "application/json".into())]),
1004 Some(body.to_string().into_bytes()),
1005 None,
1006 );
1007 let response = plugin
1008 .handle_is_username_available(&req, &ctx)
1009 .await
1010 .unwrap_err()
1011 .to_auth_response();
1012 assert_eq!(response.status, 422);
1013 let json: serde_json::Value =
1014 serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
1015 assert_eq!(json["code"], "USERNAME_TOO_SHORT");
1016 }
1017
1018 #[tokio::test]
1022 async fn test_is_username_available_invalid_chars() {
1023 let plugin = EmailPasswordPlugin::new().username(true);
1024 let ctx = create_test_context(&plugin).await;
1025
1026 let body = serde_json::json!({ "username": "bad user!" });
1027 let req = AuthRequest::from_parts(
1028 HttpMethod::Post,
1029 "/is-username-available".to_string(),
1030 HashMap::from([("content-type".into(), "application/json".into())]),
1031 Some(body.to_string().into_bytes()),
1032 None,
1033 );
1034 let response = plugin
1035 .handle_is_username_available(&req, &ctx)
1036 .await
1037 .unwrap_err()
1038 .to_auth_response();
1039 assert_eq!(response.status, 422);
1040 let json: serde_json::Value =
1041 serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
1042 assert_eq!(json["code"], "INVALID_USERNAME");
1043 }
1044}