Skip to main content

alibi_plugins/email_password/
mod.rs

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