Skip to main content

better_auth_api/plugins/
email_password.rs

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
26/// Email and password authentication plugin
27pub struct EmailPasswordPlugin {
28    config: EmailPasswordConfig,
29    /// Optional reference to the email-verification plugin so that
30    /// `send_on_sign_in` can be triggered during the sign-in flow.
31    email_verification: Option<Arc<EmailVerificationPlugin>>,
32}
33
34#[derive(Clone)]
35pub struct EmailPasswordConfig {
36    pub enable_signup: bool,
37    /// Enable the upstream username plugin behavior. Requires both username fields.
38    pub username: bool,
39    pub require_email_verification: bool,
40    pub password_min_length: Option<usize>,
41    /// Maximum password length (default: 128).
42    pub password_max_length: Option<usize>,
43    /// Whether to automatically sign in the user after sign-up (default: true).
44    /// With `Some(false)`, sign-up returns the user without creating a session.
45    pub auto_sign_in: Option<bool>,
46    /// Custom password hasher. When `None`, the default scrypt hasher is used.
47    pub password_hasher: Option<Arc<dyn PasswordHasher>>,
48    /// Notify the application after a protected duplicate signup hashes its password.
49    pub on_existing_user_sign_up: Option<Arc<dyn OnExistingUserSignUp>>,
50    /// Customize enumeration-safe signup responses without creating an account.
51    pub custom_synthetic_user: Option<Arc<CustomSyntheticUser>>,
52}
53
54impl EmailPasswordConfig {
55    /// Read the minimum password length. Omission uses eight characters.
56    pub fn password_min_length(&self) -> usize {
57        self.password_min_length.unwrap_or(8)
58    }
59
60    /// Read the maximum password length. Omission uses 128 characters.
61    pub fn password_max_length(&self) -> usize {
62        self.password_max_length.unwrap_or(128)
63    }
64
65    /// Read the sign-up session policy. Omission creates a session.
66    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    /// Attach an [`EmailVerificationPlugin`] so that `send_on_sign_in` is
155    /// automatically called when a user signs in with an unverified email.
156    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    /// Enable username registration, sign-in, availability checks, and updates.
167    ///
168    /// The user entity must persist `username` and `display_username`.
169    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    /// Set the notification for a duplicate signup protected against enumeration.
200    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    /// Customize synthetic users returned by enumeration-safe signup.
206    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
272// ---------------------------------------------------------------------------
273// Core functions — framework-agnostic business logic
274// ---------------------------------------------------------------------------
275
276async 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
295/// Shared sign-in finalization logic after user lookup and credential verification.
296async 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    // Send verification email on sign-in if configured
306    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
349/// Core sign-in by email.
350pub(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
433/// Core sign-in by username.
434pub(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    // Upstream reference: packages/better-auth/src/api/routes/sign-up.test.ts :: describe("sign-up with custom fields") and packages/better-auth/src/api/routes/sign-in.test.ts :: describe("sign-in"); adapted to the Rust email-password plugin behavior.
696    #[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        // Response should NOT have a Set-Cookie header
707        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        // Response body token should be null
714        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        // But the user should still be created
721        assert!(body["user"]["id"].is_string());
722    }
723
724    // Upstream reference: packages/better-auth/src/api/routes/sign-up.test.ts :: describe("sign-up with custom fields") and packages/better-auth/src/api/routes/sign-in.test.ts :: describe("sign-in"); adapted to the Rust email-password plugin behavior.
725    #[tokio::test]
726    async fn test_auto_sign_in_true_returns_session() {
727        let plugin = EmailPasswordPlugin::new(); // default auto_sign_in=true
728        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        // Response SHOULD have a Set-Cookie header
736        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        // Response body token should be a string
743        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    // Upstream reference: packages/better-auth/src/api/routes/sign-up.test.ts :: describe("sign-up with custom fields") and packages/better-auth/src/api/routes/sign-in.test.ts :: describe("sign-in"); adapted to the Rust email-password plugin behavior.
752    #[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        // Password of exactly 129 chars should be rejected
758        let long_password = format!("A1!{}", "a".repeat(126)); // 129 chars total
759        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        // Password of exactly 128 chars should be accepted
764        let ok_password = format!("A1!{}", "a".repeat(125)); // 128 chars total
765        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    // Upstream reference: packages/better-auth/src/api/routes/sign-up.test.ts :: describe("sign-up with custom fields") and packages/better-auth/src/api/routes/sign-in.test.ts :: describe("sign-in"); adapted to the Rust email-password plugin behavior.
771    #[tokio::test]
772    async fn test_custom_password_hasher() {
773        /// A simple test hasher that prefixes the password with "hashed:"
774        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        // Sign up with custom hasher
791        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        // Verify the stored hash uses our custom hasher
796        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        // Sign in should work with the custom hasher
814        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        // Sign in with wrong password should fail
829        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    // Upstream reference: packages/better-auth/src/plugins/username/index.ts :: sign-in path verifies the password once before creating a session; adapted to ensure the Rust username path does not duplicate expensive password verification.
845    #[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    // Rust-specific surface: route-table registration for the endpoint declared in
910    // packages/better-auth/src/plugins/username/index.ts :: isUsernameAvailable.
911    #[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    // Upstream reference: packages/better-auth/src/plugins/username/index.ts ::
923    // isUsernameAvailable returns `{ available: true }` when no user holds the
924    // normalized username; adapted to the Rust email-password plugin.
925    #[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    // Upstream reference: packages/better-auth/src/plugins/username/index.ts ::
949    // isUsernameAvailable returns `{ available: false }` when the adapter finds a
950    // user on the normalized username; adapted to the Rust email-password plugin.
951    #[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        // Sign up a user with a username
957        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    // Upstream reference: packages/better-auth/src/plugins/username/index.ts ::
992    // isUsernameAvailable throws UNPROCESSABLE_ENTITY with code USERNAME_TOO_SHORT
993    // below `minUsernameLength` (default 3); adapted to the Rust email-password plugin.
994    #[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    // Upstream reference: packages/better-auth/src/plugins/username/index.ts ::
1019    // isUsernameAvailable rejects usernames that fail `defaultUsernameValidator`
1020    // with UNPROCESSABLE_ENTITY; adapted to the Rust email-password plugin.
1021    #[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}