Skip to main content

alibi_api/plugins/email_password/
mod.rs

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