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