Skip to main content

alibi_plugins/email_password/
mod.rs

1mod config;
2mod http;
3mod signin;
4mod signup;
5mod types;
6
7use super::{email_verification::EmailVerificationPlugin, two_factor};
8use crate::helpers::{SessionIssueError, apply_default_role};
9use alibi_core::entity::{AuthAccount, AuthSession, AuthUser};
10use alibi_core::field_policy::FieldValues;
11use alibi_core::utils::cookie_utils::{
12    create_session_cookie, create_session_cookie_with_max_age, create_session_like_cookie,
13    related_cookie_name, sign_cookie_value,
14};
15use alibi_core::utils::password::{self as password_utils, PasswordHasher};
16pub use alibi_core::utils::username::{
17    UsernameConfig, UsernameNormalization, UsernameNormalizer, UsernameValidationOrder,
18    UsernameValidator,
19};
20use alibi_core::wire::UserView;
21use alibi_core::{AuthContext, AuthPlugin, AuthRoute};
22use alibi_core::{AuthError, AuthResult};
23use alibi_core::{
24    AuthRequest, AuthResponse, CreateAccount, CreateSession, CreateUser, ErrorCodeMessageResponse,
25    HttpMethod, RequestMeta,
26};
27use async_trait::async_trait;
28pub use config::EmailPasswordConfig;
29pub(crate) use config::password_length_limits;
30pub(crate) use signin::sign_in_core;
31pub(crate) use signin::sign_in_username_core;
32pub use signup::{CustomSyntheticUserCallback, ExistingUserSignupCallback, SyntheticUserContext};
33use std::io::Write;
34use std::sync::Arc;
35pub(crate) use types::SignInCoreResult;
36pub(crate) use types::SignInResponse;
37pub(crate) use types::SignUpRequest;
38pub(crate) use types::SignUpResponse;
39
40const MESSAGE_INVALID_USERNAME_OR_PASSWORD: &str = "Invalid username or password";
41
42const MESSAGE_EMAIL_NOT_VERIFIED: &str = "Email not verified";
43
44const MESSAGE_USERNAME_IS_ALREADY_TAKEN: &str = "Username is already taken. Please try another.";
45
46/// Email and password authentication plugin
47pub struct EmailPasswordPlugin {
48    config: EmailPasswordConfig,
49    /// Optional reference to the email-verification plugin so that
50    /// `send_on_sign_in` can be triggered during the sign-in flow.
51    email_verification: Option<Arc<EmailVerificationPlugin>>,
52}
53
54impl EmailPasswordPlugin {
55    #[expect(
56        clippy::new_without_default,
57        reason = "plugin construction is intentionally explicit"
58    )]
59    #[must_use]
60    pub fn new() -> Self {
61        Self {
62            config: EmailPasswordConfig::default(),
63            email_verification: None,
64        }
65    }
66
67    #[must_use]
68    pub const fn with_config(config: EmailPasswordConfig) -> Self {
69        Self {
70            config,
71            email_verification: None,
72        }
73    }
74
75    /// Attach an [`EmailVerificationPlugin`] so that `send_on_sign_in` is
76    /// automatically called when a user signs in with an unverified email.
77    #[must_use]
78    pub fn with_email_verification(mut self, plugin: Arc<EmailVerificationPlugin>) -> Self {
79        self.email_verification = Some(plugin);
80        self
81    }
82
83    #[must_use]
84    pub const fn enable_signup(mut self, enable: bool) -> Self {
85        self.config.enable_signup = enable;
86        self
87    }
88
89    #[must_use]
90    pub const fn enabled(mut self, enabled: bool) -> Self {
91        self.config.enabled = enabled;
92        self
93    }
94
95    #[must_use]
96    pub fn on_existing_user_signup(mut self, callback: Arc<ExistingUserSignupCallback>) -> Self {
97        self.config.on_existing_user_signup = Some(callback);
98        self
99    }
100
101    #[must_use]
102    pub fn custom_synthetic_user(mut self, callback: Arc<CustomSyntheticUserCallback>) -> Self {
103        self.config.custom_synthetic_user = Some(callback);
104        self
105    }
106
107    #[must_use]
108    pub const fn enable_username(mut self, enable: bool) -> Self {
109        self.config.enable_username = enable;
110        self
111    }
112
113    /// Configure the installed username plugin's validation and normalization.
114    #[must_use]
115    pub fn username_config(mut self, policy: UsernameConfig) -> Self {
116        self.config.username = policy;
117        self
118    }
119
120    #[must_use]
121    pub const fn require_email_verification(mut self, require: bool) -> Self {
122        self.config.require_email_verification = require;
123        self
124    }
125
126    #[must_use]
127    pub const fn password_min_length(mut self, length: usize) -> Self {
128        self.config.password_min_length = length;
129        self
130    }
131
132    #[must_use]
133    pub const fn password_max_length(mut self, length: usize) -> Self {
134        self.config.password_max_length = length;
135        self
136    }
137
138    #[must_use]
139    pub const fn auto_sign_in(mut self, auto: bool) -> Self {
140        self.config.auto_sign_in = auto;
141        self
142    }
143
144    #[must_use]
145    pub fn password_hasher(mut self, hasher: Arc<dyn PasswordHasher>) -> Self {
146        self.config.password_hasher = Some(hasher);
147        self
148    }
149}
150
151#[async_trait]
152impl<S: alibi_core::AuthSchema> AuthPlugin<S> for EmailPasswordPlugin {
153    route_openapi_metadata!(S);
154
155    fn name(&self) -> &'static str {
156        "email-password"
157    }
158
159    async fn on_init(&self, ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
160        let mut config = self.config.clone();
161        config.password_min_length = config.effective_min_length();
162        config.password_max_length = config.effective_max_length();
163        ctx.extensions.insert(config);
164        if self.config.enable_username {
165            ctx.extensions.insert(self.config.username.clone());
166            let policy = self.config.username.clone();
167            ctx.register_user_create_transform(move |mut data| {
168                policy.normalize_fields(
169                    &mut data.username,
170                    &mut data.display_username,
171                    &mut data.additional_fields,
172                    true,
173                )?;
174                Ok(data)
175            });
176            let policy = self.config.username.clone();
177            ctx.register_user_update_transform(move |_, mut data| {
178                policy.normalize_fields(
179                    &mut data.username,
180                    &mut data.display_username,
181                    &mut data.additional_fields,
182                    false,
183                )?;
184                Ok(data)
185            });
186        }
187        if self.config.enable_username {
188            _ = ctx
189                .metadata
190                .insert("username.enabled".into(), serde_json::Value::Bool(true));
191        }
192        Ok(())
193    }
194
195    fn user_fields(&self) -> alibi_core::field_policy::FieldConfigs {
196        if self.config.enable_username {
197            self.config.username.fields()
198        } else {
199            indexmap::IndexMap::default()
200        }
201    }
202
203    fn routes(&self) -> Vec<AuthRoute> {
204        let mut routes = vec![AuthRoute::post("/sign-in/email", "sign_in_email")];
205        if self.config.enable_username {
206            routes.extend([
207                AuthRoute::post("/sign-in/username", "sign_in_username"),
208                AuthRoute::post("/is-username-available", "is_username_available"),
209            ]);
210        }
211
212        routes.push(AuthRoute::post("/sign-up/email", "sign_up_email"));
213
214        routes
215    }
216
217    fn allowed_media_types(&self, route: &AuthRoute) -> Vec<&'static str> {
218        if matches!(route.path.as_str(), "/sign-up/email" | "/sign-in/email") {
219            vec!["application/x-www-form-urlencoded", "application/json"]
220        } else {
221            vec!["application/json"]
222        }
223    }
224
225    async fn on_request(
226        &self,
227        req: &AuthRequest,
228        ctx: &AuthContext<S>,
229    ) -> AuthResult<Option<AuthResponse>> {
230        match (req.method(), req.path()) {
231            (HttpMethod::Post, "/sign-up/email") => Ok(Some(self.handle_sign_up(req, ctx).await?)),
232            (HttpMethod::Post, "/sign-in/email") => Ok(Some(self.handle_sign_in(req, ctx).await?)),
233            (HttpMethod::Post, "/sign-in/username") if self.config.enable_username => {
234                Ok(Some(self.handle_sign_in_username(req, ctx).await?))
235            }
236            (HttpMethod::Post, "/is-username-available") if self.config.enable_username => {
237                Ok(Some(self.handle_is_username_available(req, ctx).await?))
238            }
239            _ => Ok(None),
240        }
241    }
242
243    async fn on_user_created(&self, user: &S::User, _ctx: &AuthContext<S>) -> AuthResult<()> {
244        if self.config.require_email_verification
245            && !user.email_verified()
246            && let Some(email) = user.email()
247        {
248            _ = writeln!(
249                std::io::stdout().lock(),
250                "Email verification required for user: {email}"
251            );
252        }
253        Ok(())
254    }
255}
256
257impl std::fmt::Debug for EmailPasswordPlugin {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        f.debug_struct("EmailPasswordPlugin")
260            .finish_non_exhaustive()
261    }
262}
263
264fn username_error_response(status: u16, code: &str, message: &str) -> AuthResult<AuthResponse> {
265    AuthResponse::json(
266        status,
267        &ErrorCodeMessageResponse {
268            code: Some(code.to_owned()),
269            message: message.to_owned(),
270        },
271    )
272    .map_err(AuthError::from)
273}
274
275fn create_session_cookie_for_remember_me(
276    token: &str,
277    remember_me: Option<bool>,
278    config: &alibi_core::AuthConfig,
279) -> AuthResult<String> {
280    if remember_me == Some(false) {
281        create_session_cookie_with_max_age(Some(token), None, config)
282    } else {
283        create_session_cookie(token, config)
284    }
285}
286
287fn append_dont_remember_cookie(
288    response: AuthResponse,
289    remember_me: Option<bool>,
290    config: &alibi_core::AuthConfig,
291) -> AuthResult<AuthResponse> {
292    Ok(if remember_me == Some(false) {
293        response.with_appended_header(
294            "Set-Cookie",
295            create_session_like_cookie(
296                &related_cookie_name(config, "dont_remember"),
297                &sign_cookie_value("true", config.current_secret()),
298                None,
299                config,
300            )?,
301        )
302    } else {
303        response
304    })
305}
306
307/// Core sign-up logic.
308///
309/// Returns the response and optional rendered session headers. Real automatic
310/// sign-in renders once inside the signup transaction; synthetic duplicates
311/// return no headers.
312#[expect(
313    clippy::too_many_lines,
314    reason = "Keep signup validation, persistence, and provider callbacks in compatibility order"
315)]
316pub(crate) async fn sign_up_core<S: alibi_core::AuthSchema>(
317    request: &AuthRequest,
318    body: &SignUpRequest,
319    config: &EmailPasswordConfig,
320    meta: &RequestMeta,
321    ctx: &AuthContext<S>,
322) -> AuthResult<(SignUpResponse<serde_json::Value>, Option<Vec<String>>)> {
323    if !config.enabled || !config.enable_signup {
324        return Err(AuthError::Upstream {
325            status: 400,
326            code: "EMAIL_PASSWORD_SIGN_UP_DISABLED",
327            message: "Email and password sign up is not enabled",
328        });
329    }
330
331    password_utils::validate_password(
332        &body.password,
333        config.effective_min_length(),
334        config.effective_max_length(),
335        ctx,
336    )?;
337
338    let mut input_fields = body.additional_fields.clone();
339    if config.enable_username {
340        if let Some(value) = &body.username {
341            _ = input_fields.insert(
342                "username".into(),
343                alibi_core::utils::json::JsValue::String(value.clone()),
344            );
345        }
346        if config.username.include_display_username
347            && let Some(value) = &body.display_username
348        {
349            _ = input_fields.insert(
350                "displayUsername".into(),
351                alibi_core::utils::json::JsValue::String(value.clone()),
352            );
353        }
354    }
355    let additional_fields =
356        ctx.parse_user_fields(&input_fields, true)
357            .map_err(|error| match error {
358                alibi_core::field_policy::FieldInputError::Validation { code, message } => {
359                    AuthError::Api {
360                        status: 400,
361                        code: Some(code.into()),
362                        message,
363                    }
364                }
365                alibi_core::field_policy::FieldInputError::Transform(error) => error,
366            })?;
367
368    super::last_login_method::reject_last_login_method_input(ctx, body.last_login_method.as_ref())?;
369
370    let phone_enabled = ctx
371        .get_metadata("phone-number.enabled")
372        .and_then(serde_json::Value::as_bool)
373        .unwrap_or(false);
374    if phone_enabled {
375        super::phone_number::reject_verified_input(body.phone_number_verified.as_ref())?;
376    }
377
378    if let Some(user) = ctx.database.get_user_by_email(&body.email).await? {
379        if config.require_email_verification || !config.auto_sign_in {
380            _ = ctx
381                .hash_password(config.password_hasher.as_ref(), &body.password)
382                .await?;
383            signup::notify_existing(ctx.user_view(&user), request, config, ctx).await?;
384            return signup::synthetic_response(body, config, ctx);
385        }
386        // TS returns 422 UNPROCESSABLE_ENTITY for duplicate email
387        return Err(AuthError::UnprocessableEntity(
388            "User already exists. Use another email.".to_owned(),
389        ));
390    }
391
392    let password_hash = ctx
393        .hash_password(config.password_hasher.as_ref(), &body.password)
394        .await?;
395
396    let mut create_user = CreateUser::new()
397        .with_email(&body.email)
398        .with_name(&body.name);
399    create_user.image = body.image.clone();
400    create_user.additional_fields = additional_fields;
401    create_user.email_verified = Some(false);
402    super::authentication_helpers::apply_creation_input_defaults(ctx, &mut create_user);
403    if phone_enabled {
404        create_user.phone_number =
405            super::phone_number::parse_signup_phone(ctx, body.phone_number.as_ref()).await?;
406    }
407    apply_default_role(ctx, &mut create_user);
408    if config.enable_username {
409        create_user.username = create_user
410            .additional_fields
411            .get("username")
412            .and_then(alibi_core::utils::json::JsValue::as_str)
413            .map(str::to_owned);
414        if create_user.username.is_none()
415            && let Some(value) = &body.username
416        {
417            create_user.username = Some(config.username.normalize(value)?);
418        }
419        if config.username.include_display_username {
420            create_user.display_username = create_user
421                .additional_fields
422                .get("displayUsername")
423                .and_then(alibi_core::utils::json::JsValue::as_str)
424                .map(str::to_owned)
425                .or_else(|| body.display_username.clone());
426        }
427    }
428    let auto_sign_in = config.auto_sign_in && !config.require_email_verification;
429    let expires_in = if body.remember_me == Some(false) {
430        chrono::Duration::days(1)
431    } else {
432        ctx.config.session.expires_in
433    };
434    let ip_address = meta.ip_address.clone();
435    let user_agent = meta.user_agent.clone();
436    let database = Arc::clone(&ctx.database);
437    let transaction_database = Arc::clone(&database);
438    let require_email_verification = config.require_email_verification;
439    let callback_url = body.callback_url.clone();
440    let remember_me = body.remember_me;
441    let duplicate_body = body.clone();
442    let duplicate_config = config.clone();
443    let signup_context = AuthContext {
444        config: Arc::clone(&ctx.config),
445        database: Arc::clone(&ctx.database),
446        email_provider: ctx.email_provider.clone(),
447        metadata: ctx.metadata.clone(),
448        extensions: ctx.extensions.clone(),
449    };
450
451    alibi_core::store::transaction(database.as_ref(), move |tx| {
452        let _database = Arc::clone(&transaction_database);
453        Box::pin(async move {
454            let user = match tx
455                .create_user_with_source_record(
456                    create_user,
457                    alibi_core::user_validation::UserValidationSource::creation("email-password"),
458                )
459                .await
460            {
461                Ok(user) => user,
462                Err(AuthError::UserCreationCancelled) => {
463                    return Err(AuthError::bad_request("Failed to create user"));
464                }
465                Err(error) if error.status_code() == 403 && !auto_sign_in => {
466                    return signup::synthetic_response(
467                        &duplicate_body,
468                        &duplicate_config,
469                        &signup_context,
470                    );
471                }
472                Err(AuthError::Database(_) | AuthError::CallbackFailure(_)) => {
473                    return Err(AuthError::UnprocessableEntity(
474                        "Failed to create user".to_owned(),
475                    ));
476                }
477                Err(error) => return Err(error),
478            };
479
480            _ = tx
481                .create_account_record(CreateAccount {
482                    additional_fields: FieldValues::default(),
483                    user_id: user.id().to_string(),
484                    account_id: user.id().to_string(),
485                    provider_id: "credential".to_owned(),
486                    access_token: None,
487                    refresh_token: None,
488                    id_token: None,
489                    access_token_expires_at: None,
490                    refresh_token_expires_at: None,
491                    scope: None,
492                    password: Some(password_hash.clone()),
493                })
494                .await?;
495
496            super::email_verification::send_signup_verification(
497                &user,
498                callback_url.as_deref(),
499                require_email_verification,
500                &signup_context,
501                tx,
502            )
503            .await?;
504
505            if auto_sign_in {
506                let session = tx
507                    .create_session_record(CreateSession {
508                        additional_fields: FieldValues::default(),
509                        token: None,
510                        active_team_id: None,
511                        user_id: user.id().to_string(),
512                        expires_at: chrono::Utc::now() + expires_in,
513                        ip_address,
514                        user_agent,
515                        impersonated_by: None,
516                        active_organization_id: None,
517                    })
518                    .await?;
519                let token = session.token().to_owned();
520                // Source sign-up emits inside its transaction. Serializer failure
521                // rolls back these rows; sign-in retains its separate commit policy.
522                let mut cookies = vec![create_session_cookie_for_remember_me(
523                    &token,
524                    remember_me,
525                    &signup_context.config,
526                )?];
527                if remember_me == Some(false) {
528                    cookies.push(create_session_like_cookie(
529                        &related_cookie_name(&signup_context.config, "dont_remember"),
530                        &sign_cookie_value("true", signup_context.config.current_secret()),
531                        None,
532                        &signup_context.config,
533                    )?);
534                }
535                alibi_core::session::cookie_cache::runtime::emit_issuance_in_transaction(
536                    &signup_context,
537                    &user,
538                    &session,
539                    tx,
540                )
541                .await?;
542                super::helpers::record_completed_session_record::<S>(&user, &session);
543
544                Ok((
545                    SignUpResponse {
546                        token: Some(token.clone()),
547                        user: password_utils::serialize_to_value(&signup_context.user_view(&user))?,
548                    },
549                    Some(cookies),
550                ))
551            } else {
552                Ok((
553                    SignUpResponse {
554                        token: None,
555                        user: password_utils::serialize_to_value(&signup_context.user_view(&user))?,
556                    },
557                    None,
558                ))
559            }
560        })
561    })
562    .await
563}
564
565async fn load_credential_password_hash(
566    user: &impl AuthUser,
567    ctx: &AuthContext<impl alibi_core::AuthSchema>,
568) -> AuthResult<String> {
569    super::helpers::get_credential_account(ctx, user.id())
570        .await?
571        .and_then(|account| account.password().map(str::to_owned))
572        .ok_or(AuthError::InvalidCredentials)
573}
574
575async fn verify_user_password(
576    user: &impl AuthUser,
577    password: &str,
578    config: &EmailPasswordConfig,
579    ctx: &AuthContext<impl alibi_core::AuthSchema>,
580) -> AuthResult<()> {
581    let stored_hash = load_credential_password_hash(user, ctx).await?;
582    password_utils::verify_password(config.password_hasher.as_ref(), password, &stored_hash).await
583}
584
585/// Shared sign-in finalization logic after user lookup and credential verification.
586async fn finalize_sign_in_with_user_core<S: alibi_core::AuthSchema>(
587    req: &AuthRequest,
588    user: alibi_core::AdapterRecord<S::User>,
589    remember_me: Option<bool>,
590    _email_verification: Option<&EmailVerificationPlugin>,
591    callback_url: Option<&str>,
592    meta: &RequestMeta,
593    ctx: &AuthContext<S>,
594) -> AuthResult<SignInCoreResult<UserView>> {
595    let mut set_cookie_headers = Vec::new();
596
597    let mut issuing_config = (*ctx.config).clone();
598    if remember_me == Some(false) {
599        issuing_config.session.expires_in = chrono::Duration::days(1);
600    }
601    let issuing_context = AuthContext {
602        config: Arc::new(issuing_config),
603        database: Arc::clone(&ctx.database),
604        email_provider: ctx.email_provider.clone(),
605        metadata: ctx.metadata.clone(),
606        extensions: ctx.extensions.clone(),
607    };
608    let issued = super::helpers::issue_selected_user_session_record(
609        &issuing_context,
610        user.clone(),
611        meta.ip_address.clone(),
612        meta.user_agent.clone(),
613    )
614    .await
615    .map_err(SessionIssueError::into_auth_error)?;
616    if two_factor::is_enabled(ctx) && user.two_factor_enabled() {
617        let trusted_device = two_factor::inspect_trusted_device(req, &user, ctx).await?;
618        if trusted_device.trusted {
619            set_cookie_headers.extend(trusted_device.set_cookie_headers);
620        } else {
621            ctx.database.delete_session(issued.session.token()).await?;
622            alibi_core::session::cookie_cache::runtime::discard_issuance(req);
623            let redirect = two_factor::begin_sign_in_challenge(&user, remember_me, ctx).await?;
624            let mut redirect_headers = trusted_device.set_cookie_headers;
625            redirect_headers.extend(redirect.set_cookie_headers);
626            return Ok(SignInCoreResult::TwoFactorRedirect {
627                response: redirect.response,
628                set_cookie_headers: redirect_headers,
629            });
630        }
631    }
632
633    let session = issued.session;
634    let token = session.token().to_owned();
635
636    let response = SignInResponse {
637        redirect: callback_url.is_some_and(|url| !url.is_empty()),
638        token: token.clone(),
639        url: callback_url.map(str::to_owned),
640        user: ctx.user_view(&issued.user),
641    };
642    Ok(SignInCoreResult::Success {
643        response,
644        token,
645        set_cookie_headers,
646    })
647}
648
649/// Core sign-in by email.
650async fn send_required_sign_in_verification(
651    user: &impl AuthUser,
652    callback_url: Option<&str>,
653    email_verification: Option<&EmailVerificationPlugin>,
654    ctx: &AuthContext<impl alibi_core::AuthSchema>,
655) -> AuthResult<()> {
656    if let Some(plugin) = email_verification {
657        plugin
658            .send_verification_on_sign_in(user, callback_url, ctx)
659            .await?;
660    } else if let Some(config) = ctx
661        .extensions
662        .get::<super::email_verification::EmailVerificationConfig>()
663        && config.send_on_sign_in
664    {
665        EmailVerificationPlugin::with_config((*config).clone())
666            .send_verification_on_sign_in(user, callback_url, ctx)
667            .await?;
668    }
669    Ok(())
670}
671
672// LCOV_EXCL_START
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use alibi_core::AuthContext;
677    use alibi_core::config::AuthConfig;
678    use std::collections::HashMap;
679    use std::sync::Arc;
680    use std::sync::atomic::{AtomicUsize, Ordering};
681
682    type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
683
684    async fn create_test_context() -> AuthContext<TestSchema> {
685        let config = AuthConfig::new("test-secret-key-at-least-32-chars-long");
686        let config = Arc::new(config);
687        let database = crate::test_helpers::create_test_database().await;
688        AuthContext::new(config, database)
689    }
690
691    fn create_signup_request(email: &str, password: &str) -> AuthRequest {
692        let body = serde_json::json!({
693            "name": "Test User",
694            "email": email,
695            "password": password,
696        });
697        AuthRequest::from_parts(
698            HttpMethod::Post,
699            "/sign-up/email".to_owned(),
700            HashMap::new(),
701            Some(body.to_string().into_bytes()),
702            HashMap::new(),
703        )
704    }
705
706    // 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.
707    #[tokio::test]
708    async fn test_auto_sign_in_false_returns_no_session() {
709        let plugin = EmailPasswordPlugin::new().auto_sign_in(false);
710        let ctx = create_test_context().await;
711
712        let req = create_signup_request("auto@example.com", "Password123!");
713        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
714        assert_eq!(response.status, 200);
715
716        // Response should NOT have a Set-Cookie header
717        let has_cookie = response
718            .headers
719            .iter()
720            .any(|(k, _)| k.eq_ignore_ascii_case("Set-Cookie"));
721        assert!(!has_cookie, "auto_sign_in=false should not set a cookie");
722
723        // Response body token should be null
724        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
725        assert!(
726            (*(body).get("token").unwrap_or(&serde_json::Value::Null)).is_null(),
727            "auto_sign_in=false should return null token"
728        );
729        // But the user should still be created
730        assert!(
731            (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
732                .get("id")
733                .unwrap_or(&serde_json::Value::Null))
734            .is_string()
735        );
736    }
737
738    // 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.
739    #[tokio::test]
740    async fn test_auto_sign_in_true_returns_session() {
741        let plugin = EmailPasswordPlugin::new(); // default auto_sign_in=true
742        let ctx = create_test_context().await;
743
744        let req = create_signup_request("autotrue@example.com", "Password123!");
745        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
746        assert_eq!(response.status, 200);
747
748        // Response SHOULD have a Set-Cookie header
749        let has_cookie = response
750            .headers
751            .iter()
752            .any(|(k, _)| k.eq_ignore_ascii_case("Set-Cookie"));
753        assert!(has_cookie, "auto_sign_in=true should set a cookie");
754
755        // Response body token should be a string
756        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
757        assert!(
758            (*(body).get("token").unwrap_or(&serde_json::Value::Null)).is_string(),
759            "auto_sign_in=true should return a session token"
760        );
761    }
762
763    // 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.
764    #[tokio::test]
765    async fn test_password_max_length_rejection() {
766        let plugin = EmailPasswordPlugin::new().password_max_length(128);
767        let ctx = create_test_context().await;
768
769        // Password of exactly 129 chars should be rejected
770        let long_password = format!("A1!{}", "a".repeat(126)); // 129 chars total
771        let req = create_signup_request("long@example.com", &long_password);
772        let err = plugin.handle_sign_up(&req, &ctx).await.unwrap_err();
773        assert_eq!(err.status_code(), 400);
774
775        // Password of exactly 128 chars should be accepted
776        let ok_password = format!("A1!{}", "a".repeat(125)); // 128 chars total
777        let req_2 = create_signup_request("ok@example.com", &ok_password);
778        let response = plugin.handle_sign_up(&req_2, &ctx).await.unwrap();
779        assert_eq!(response.status, 200);
780    }
781
782    // 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.
783    #[tokio::test]
784    async fn test_custom_password_hasher() {
785        /// A simple test hasher that prefixes the password with "hashed:"
786        struct TestHasher;
787
788        #[async_trait]
789        impl PasswordHasher for TestHasher {
790            async fn hash(&self, password: &str) -> AuthResult<String> {
791                Ok(format!("hashed:{password}"))
792            }
793            async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
794                Ok(hash == format!("hashed:{password}"))
795            }
796        }
797
798        let hasher: Arc<dyn PasswordHasher> = Arc::new(TestHasher);
799        let plugin = EmailPasswordPlugin::new().password_hasher(hasher);
800        let ctx = create_test_context().await;
801
802        // Sign up with custom hasher
803        let req = create_signup_request("hasher@example.com", "Password123!");
804        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
805        assert_eq!(response.status, 200);
806
807        // Verify the stored hash uses our custom hasher
808        let user = ctx
809            .database
810            .get_user_by_email("hasher@example.com")
811            .await
812            .unwrap()
813            .unwrap();
814        let stored_hash = ctx
815            .database
816            .get_user_accounts(&user.id())
817            .await
818            .unwrap()
819            .into_iter()
820            .find(|account| account.provider_id() == "credential")
821            .and_then(|account| account.password().map(str::to_owned))
822            .expect("credential account should store hashed password");
823        assert_eq!(stored_hash, "hashed:Password123!");
824
825        // Sign in should work with the custom hasher
826        let signin_body = serde_json::json!({
827            "email": "hasher@example.com",
828            "password": "Password123!",
829        });
830        let signin_req = AuthRequest::from_parts(
831            HttpMethod::Post,
832            "/sign-in/email".to_owned(),
833            HashMap::new(),
834            Some(signin_body.to_string().into_bytes()),
835            HashMap::new(),
836        );
837        let response_2 = plugin.handle_sign_in(&signin_req, &ctx).await.unwrap();
838        assert_eq!(response_2.status, 200);
839
840        // Sign in with wrong password should fail
841        let bad_body = serde_json::json!({
842            "email": "hasher@example.com",
843            "password": "WrongPassword!",
844        });
845        let bad_req = AuthRequest::from_parts(
846            HttpMethod::Post,
847            "/sign-in/email".to_owned(),
848            HashMap::new(),
849            Some(bad_body.to_string().into_bytes()),
850            HashMap::new(),
851        );
852        let err = plugin.handle_sign_in(&bad_req, &ctx).await.unwrap_err();
853        assert_eq!(err.to_string(), AuthError::InvalidCredentials.to_string());
854    }
855
856    // 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.
857    #[tokio::test]
858    async fn test_sign_in_username_verifies_password_once() {
859        struct CountingHasher {
860            verify_calls: Arc<AtomicUsize>,
861        }
862
863        #[async_trait]
864        impl PasswordHasher for CountingHasher {
865            async fn hash(&self, password: &str) -> AuthResult<String> {
866                Ok(format!("hashed:{password}"))
867            }
868
869            async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
870                self.verify_calls.fetch_add(1, Ordering::SeqCst);
871                Ok(hash == format!("hashed:{password}"))
872            }
873        }
874
875        let verify_calls = Arc::new(AtomicUsize::new(0));
876        let hasher: Arc<dyn PasswordHasher> = Arc::new(CountingHasher {
877            verify_calls: std::sync::Arc::clone(&verify_calls),
878        });
879        let plugin = EmailPasswordPlugin::new().password_hasher(hasher);
880        let ctx = create_test_context().await;
881
882        let signup_body = serde_json::json!({
883            "email": "username-counter@example.com",
884            "password": "Password123!",
885            "name": "Counter User",
886            "username": "Counter_User",
887        });
888        let signup_req = AuthRequest::from_parts(
889            HttpMethod::Post,
890            "/sign-up/email".to_owned(),
891            HashMap::new(),
892            Some(signup_body.to_string().into_bytes()),
893            HashMap::new(),
894        );
895        let signup_response = plugin.handle_sign_up(&signup_req, &ctx).await.unwrap();
896        assert_eq!(signup_response.status, 200);
897
898        verify_calls.store(0, Ordering::SeqCst);
899
900        let signin_body = serde_json::json!({
901            "username": "COUNTER_USER",
902            "password": "Password123!",
903        });
904        let signin_req = AuthRequest::from_parts(
905            HttpMethod::Post,
906            "/sign-in/username".to_owned(),
907            HashMap::new(),
908            Some(signin_body.to_string().into_bytes()),
909            HashMap::new(),
910        );
911        let signin_response = plugin
912            .handle_sign_in_username(&signin_req, &ctx)
913            .await
914            .unwrap();
915        assert_eq!(signin_response.status, 200);
916        assert_eq!(verify_calls.load(Ordering::SeqCst), 1);
917    }
918
919    // Rust-specific surface: route-table registration for the endpoint declared in
920    // packages/better-auth/src/plugins/username/index.ts :: isUsernameAvailable.
921    #[tokio::test]
922    async fn test_is_username_available_route_registered() {
923        let plugin = EmailPasswordPlugin::new();
924        let routes = <EmailPasswordPlugin as AuthPlugin<TestSchema>>::routes(&plugin);
925        assert!(
926            routes.iter().any(|r| r.path == "/is-username-available"),
927            "route /is-username-available should be registered"
928        );
929    }
930
931    // Upstream reference: packages/better-auth/src/plugins/username/index.ts ::
932    // isUsernameAvailable returns `{ available: true }` when no user holds the
933    // normalized username; adapted to the Rust email-password plugin.
934    #[tokio::test]
935    async fn test_is_username_available_fresh() {
936        let plugin = EmailPasswordPlugin::new();
937        let ctx = create_test_context().await;
938
939        let body = serde_json::json!({ "username": "fresh_user" });
940        let req = AuthRequest::from_parts(
941            HttpMethod::Post,
942            "/is-username-available".to_owned(),
943            HashMap::new(),
944            Some(body.to_string().into_bytes()),
945            HashMap::new(),
946        );
947        let response = plugin
948            .handle_is_username_available(&req, &ctx)
949            .await
950            .unwrap();
951        assert_eq!(response.status, 200);
952        let json: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
953        assert_eq!(
954            (*(json).get("available").unwrap_or(&serde_json::Value::Null)),
955            true
956        );
957    }
958
959    // Upstream reference: packages/better-auth/src/plugins/username/index.ts ::
960    // isUsernameAvailable returns `{ available: false }` when the adapter finds a
961    // user on the normalized username; adapted to the Rust email-password plugin.
962    #[tokio::test]
963    async fn test_is_username_available_taken() {
964        let plugin = EmailPasswordPlugin::new();
965        let ctx = create_test_context().await;
966
967        // Sign up a user with a username
968        let signup_body = serde_json::json!({
969            "name": "Taken User",
970            "email": "taken@example.com",
971            "password": "Password123!",
972            "username": "taken_user",
973        });
974        let signup_req = AuthRequest::from_parts(
975            HttpMethod::Post,
976            "/sign-up/email".to_owned(),
977            HashMap::new(),
978            Some(signup_body.to_string().into_bytes()),
979            HashMap::new(),
980        );
981        let resp = plugin.handle_sign_up(&signup_req, &ctx).await.unwrap();
982        assert_eq!(resp.status, 200);
983
984        let body = serde_json::json!({ "username": "taken_user" });
985        let req = AuthRequest::from_parts(
986            HttpMethod::Post,
987            "/is-username-available".to_owned(),
988            HashMap::new(),
989            Some(body.to_string().into_bytes()),
990            HashMap::new(),
991        );
992        let response = plugin
993            .handle_is_username_available(&req, &ctx)
994            .await
995            .unwrap();
996        assert_eq!(response.status, 200);
997        let json: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
998        assert_eq!(
999            (*(json).get("available").unwrap_or(&serde_json::Value::Null)),
1000            false
1001        );
1002    }
1003
1004    // Upstream reference: packages/better-auth/src/plugins/username/index.ts ::
1005    // isUsernameAvailable throws UNPROCESSABLE_ENTITY with code USERNAME_TOO_SHORT
1006    // below `minUsernameLength` (default 3); adapted to the Rust email-password plugin.
1007    #[tokio::test]
1008    async fn test_is_username_available_too_short() {
1009        let plugin = EmailPasswordPlugin::new();
1010        let ctx = create_test_context().await;
1011
1012        let body = serde_json::json!({ "username": "ab" });
1013        let req = AuthRequest::from_parts(
1014            HttpMethod::Post,
1015            "/is-username-available".to_owned(),
1016            HashMap::new(),
1017            Some(body.to_string().into_bytes()),
1018            HashMap::new(),
1019        );
1020        let response = plugin
1021            .handle_is_username_available(&req, &ctx)
1022            .await
1023            .unwrap();
1024        assert_eq!(response.status, 422);
1025        let json: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1026        assert_eq!(
1027            (*(json).get("code").unwrap_or(&serde_json::Value::Null)),
1028            "USERNAME_TOO_SHORT"
1029        );
1030    }
1031
1032    // Upstream reference: packages/better-auth/src/plugins/username/index.ts ::
1033    // isUsernameAvailable rejects usernames that fail `defaultUsernameValidator`
1034    // with UNPROCESSABLE_ENTITY; adapted to the Rust email-password plugin.
1035    #[tokio::test]
1036    async fn test_is_username_available_invalid_chars() {
1037        let plugin = EmailPasswordPlugin::new();
1038        let ctx = create_test_context().await;
1039
1040        let body = serde_json::json!({ "username": "bad user!" });
1041        let req = AuthRequest::from_parts(
1042            HttpMethod::Post,
1043            "/is-username-available".to_owned(),
1044            HashMap::new(),
1045            Some(body.to_string().into_bytes()),
1046            HashMap::new(),
1047        );
1048        let response = plugin
1049            .handle_is_username_available(&req, &ctx)
1050            .await
1051            .unwrap();
1052        assert_eq!(response.status, 422);
1053        let json: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1054        assert_eq!(
1055            (*(json).get("code").unwrap_or(&serde_json::Value::Null)),
1056            "INVALID_USERNAME"
1057        );
1058    }
1059}
1060// LCOV_EXCL_STOP