Skip to main content

better_auth_api/plugins/
email_password.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::sync::Arc;
4use validator::Validate;
5
6use better_auth_core::adapters::DatabaseAdapter;
7use better_auth_core::entity::{AuthSession, AuthUser};
8use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
9use better_auth_core::{AuthError, AuthResult};
10use better_auth_core::{
11    AuthRequest, AuthResponse, CreateUser, CreateVerification, HttpMethod, PASSWORD_HASH_KEY,
12};
13
14use super::email_verification::EmailVerificationPlugin;
15use better_auth_core::utils::cookie_utils::create_session_cookie;
16use better_auth_core::utils::password::{self as password_utils, PasswordHasher};
17/// Email and password authentication plugin
18pub struct EmailPasswordPlugin {
19    config: EmailPasswordConfig,
20    /// Optional reference to the email-verification plugin so that
21    /// `send_on_sign_in` can be triggered during the sign-in flow.
22    email_verification: Option<Arc<EmailVerificationPlugin>>,
23}
24
25#[derive(Clone)]
26pub struct EmailPasswordConfig {
27    pub enable_signup: bool,
28    pub require_email_verification: bool,
29    pub password_min_length: usize,
30    /// Maximum password length (default: 128).
31    pub password_max_length: usize,
32    /// Whether to automatically sign in the user after sign-up (default: true).
33    /// When false, sign-up returns the user but doesn't create a session.
34    pub auto_sign_in: bool,
35    /// Custom password hasher. When `None`, the default Argon2 hasher is used.
36    pub password_hasher: Option<Arc<dyn PasswordHasher>>,
37}
38
39impl std::fmt::Debug for EmailPasswordConfig {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("EmailPasswordConfig")
42            .field("enable_signup", &self.enable_signup)
43            .field(
44                "require_email_verification",
45                &self.require_email_verification,
46            )
47            .field("password_min_length", &self.password_min_length)
48            .field("password_max_length", &self.password_max_length)
49            .field("auto_sign_in", &self.auto_sign_in)
50            .field(
51                "password_hasher",
52                &self.password_hasher.as_ref().map(|_| "custom"),
53            )
54            .finish()
55    }
56}
57
58#[derive(Debug, Deserialize, Validate)]
59#[allow(dead_code)]
60pub(crate) struct SignUpRequest {
61    #[validate(length(min = 1, message = "Name is required"))]
62    name: String,
63    #[validate(email(message = "Invalid email address"))]
64    email: String,
65    #[validate(length(min = 1, message = "Password is required"))]
66    password: String,
67    username: Option<String>,
68    #[serde(rename = "displayUsername")]
69    display_username: Option<String>,
70    #[serde(rename = "callbackURL")]
71    callback_url: Option<String>,
72}
73
74#[derive(Debug, Deserialize, Validate)]
75#[allow(dead_code)]
76pub(crate) struct SignInRequest {
77    #[validate(email(message = "Invalid email address"))]
78    email: String,
79    #[validate(length(min = 1, message = "Password is required"))]
80    password: String,
81    #[serde(rename = "callbackURL")]
82    callback_url: Option<String>,
83    #[serde(rename = "rememberMe")]
84    remember_me: Option<bool>,
85}
86
87#[derive(Debug, Deserialize, Validate)]
88#[allow(dead_code)]
89pub(crate) struct SignInUsernameRequest {
90    #[validate(length(min = 1, message = "Username is required"))]
91    username: String,
92    #[validate(length(min = 1, message = "Password is required"))]
93    password: String,
94    #[serde(rename = "rememberMe")]
95    remember_me: Option<bool>,
96}
97
98#[derive(Debug, Serialize)]
99pub(crate) struct SignUpResponse<U: Serialize> {
100    token: Option<String>,
101    user: U,
102}
103
104#[derive(Debug, Serialize)]
105pub(crate) struct SignInResponse<U: Serialize> {
106    redirect: bool,
107    token: String,
108    url: Option<String>,
109    user: U,
110}
111
112/// 2FA redirect response returned when the user has 2FA enabled.
113#[derive(Debug, Serialize)]
114pub(crate) struct TwoFactorRedirectResponse {
115    #[serde(rename = "twoFactorRedirect")]
116    two_factor_redirect: bool,
117    token: String,
118}
119
120/// Result of sign-in: either a successful session or a 2FA redirect.
121pub(crate) enum SignInCoreResult<U: Serialize> {
122    Success(SignInResponse<U>, String),
123    TwoFactorRedirect(TwoFactorRedirectResponse),
124}
125
126impl EmailPasswordPlugin {
127    #[allow(clippy::new_without_default)]
128    pub fn new() -> Self {
129        Self {
130            config: EmailPasswordConfig::default(),
131            email_verification: None,
132        }
133    }
134
135    pub fn with_config(config: EmailPasswordConfig) -> Self {
136        Self {
137            config,
138            email_verification: None,
139        }
140    }
141
142    /// Attach an [`EmailVerificationPlugin`] so that `send_on_sign_in` is
143    /// automatically called when a user signs in with an unverified email.
144    pub fn with_email_verification(mut self, plugin: Arc<EmailVerificationPlugin>) -> Self {
145        self.email_verification = Some(plugin);
146        self
147    }
148
149    pub fn enable_signup(mut self, enable: bool) -> Self {
150        self.config.enable_signup = enable;
151        self
152    }
153
154    pub fn require_email_verification(mut self, require: bool) -> Self {
155        self.config.require_email_verification = require;
156        self
157    }
158
159    pub fn password_min_length(mut self, length: usize) -> Self {
160        self.config.password_min_length = length;
161        self
162    }
163
164    pub fn password_max_length(mut self, length: usize) -> Self {
165        self.config.password_max_length = length;
166        self
167    }
168
169    pub fn auto_sign_in(mut self, auto: bool) -> Self {
170        self.config.auto_sign_in = auto;
171        self
172    }
173
174    pub fn password_hasher(mut self, hasher: Arc<dyn PasswordHasher>) -> Self {
175        self.config.password_hasher = Some(hasher);
176        self
177    }
178
179    async fn handle_sign_up<DB: DatabaseAdapter>(
180        &self,
181        req: &AuthRequest,
182        ctx: &AuthContext<DB>,
183    ) -> AuthResult<AuthResponse> {
184        let signup_req: SignUpRequest = match better_auth_core::validate_request_body(req) {
185            Ok(v) => v,
186            Err(resp) => return Ok(resp),
187        };
188
189        let (response, session_token) = sign_up_core(&signup_req, &self.config, ctx).await?;
190
191        if let Some(token) = session_token {
192            let cookie_header = create_session_cookie(&token, &ctx.config);
193            Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
194        } else {
195            Ok(AuthResponse::json(200, &response)?)
196        }
197    }
198
199    async fn handle_sign_in<DB: DatabaseAdapter>(
200        &self,
201        req: &AuthRequest,
202        ctx: &AuthContext<DB>,
203    ) -> AuthResult<AuthResponse> {
204        let signin_req: SignInRequest = match better_auth_core::validate_request_body(req) {
205            Ok(v) => v,
206            Err(resp) => return Ok(resp),
207        };
208
209        match sign_in_core(
210            &signin_req,
211            &self.config,
212            self.email_verification.as_deref(),
213            ctx,
214        )
215        .await?
216        {
217            SignInCoreResult::Success(response, token) => {
218                let cookie_header = create_session_cookie(&token, &ctx.config);
219                Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
220            }
221            SignInCoreResult::TwoFactorRedirect(redirect) => {
222                Ok(AuthResponse::json(200, &redirect)?)
223            }
224        }
225    }
226
227    async fn handle_sign_in_username<DB: DatabaseAdapter>(
228        &self,
229        req: &AuthRequest,
230        ctx: &AuthContext<DB>,
231    ) -> AuthResult<AuthResponse> {
232        let signin_req: SignInUsernameRequest = match better_auth_core::validate_request_body(req) {
233            Ok(v) => v,
234            Err(resp) => return Ok(resp),
235        };
236
237        match sign_in_username_core(
238            &signin_req,
239            &self.config,
240            self.email_verification.as_deref(),
241            ctx,
242        )
243        .await?
244        {
245            SignInCoreResult::Success(response, token) => {
246                let cookie_header = create_session_cookie(&token, &ctx.config);
247                Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
248            }
249            SignInCoreResult::TwoFactorRedirect(redirect) => {
250                Ok(AuthResponse::json(200, &redirect)?)
251            }
252        }
253    }
254}
255
256// ---------------------------------------------------------------------------
257// Core functions — framework-agnostic business logic
258// ---------------------------------------------------------------------------
259
260/// Core sign-up logic.
261///
262/// Returns `(response, Option<session_token>)`. The session token is present
263/// only when `auto_sign_in` is true.
264pub(crate) async fn sign_up_core<DB: DatabaseAdapter>(
265    body: &SignUpRequest,
266    config: &EmailPasswordConfig,
267    ctx: &AuthContext<DB>,
268) -> AuthResult<(SignUpResponse<DB::User>, Option<String>)> {
269    if !config.enable_signup {
270        return Err(AuthError::forbidden("User registration is not enabled"));
271    }
272
273    password_utils::validate_password(
274        &body.password,
275        config.password_min_length,
276        config.password_max_length,
277        ctx,
278    )?;
279
280    // Check if user already exists
281    if ctx.database.get_user_by_email(&body.email).await?.is_some() {
282        return Err(AuthError::conflict("A user with this email already exists"));
283    }
284
285    // Hash password
286    let password_hash =
287        password_utils::hash_password(config.password_hasher.as_ref(), &body.password).await?;
288
289    let metadata = {
290        let mut m = serde_json::Map::new();
291        m.insert(
292            PASSWORD_HASH_KEY.to_string(),
293            serde_json::Value::String(password_hash),
294        );
295        serde_json::Value::Object(m)
296    };
297
298    let mut create_user = CreateUser::new()
299        .with_email(&body.email)
300        .with_name(&body.name);
301    if let Some(ref username) = body.username {
302        create_user = create_user.with_username(username.clone());
303    }
304    if let Some(ref display_username) = body.display_username {
305        create_user.display_username = Some(display_username.clone());
306    }
307    create_user.metadata = Some(metadata);
308
309    let user = ctx.database.create_user(create_user).await?;
310
311    if config.auto_sign_in {
312        let session = ctx
313            .session_manager()
314            .create_session(&user, None, None)
315            .await?;
316        let token = session.token().to_string();
317
318        let response = SignUpResponse {
319            token: Some(token.clone()),
320            user,
321        };
322        Ok((response, Some(token)))
323    } else {
324        let response = SignUpResponse { token: None, user };
325        Ok((response, None))
326    }
327}
328
329/// Shared sign-in logic after user lookup: verify password, check 2FA, create session.
330async fn sign_in_with_user_core<DB: DatabaseAdapter>(
331    user: DB::User,
332    password: &str,
333    config: &EmailPasswordConfig,
334    email_verification: Option<&EmailVerificationPlugin>,
335    callback_url: Option<&str>,
336    ctx: &AuthContext<DB>,
337) -> AuthResult<SignInCoreResult<DB::User>> {
338    // Verify password
339    let stored_hash = user.password_hash().ok_or(AuthError::InvalidCredentials)?;
340
341    password_utils::verify_password(config.password_hasher.as_ref(), password, stored_hash).await?;
342
343    // Check if 2FA is enabled
344    if user.two_factor_enabled() {
345        let pending_token = format!("2fa_{}", uuid::Uuid::new_v4());
346        ctx.database
347            .create_verification(CreateVerification {
348                identifier: format!("2fa_pending:{}", pending_token),
349                value: user.id().to_string(),
350                expires_at: chrono::Utc::now() + chrono::Duration::minutes(5),
351            })
352            .await?;
353        return Ok(SignInCoreResult::TwoFactorRedirect(
354            TwoFactorRedirectResponse {
355                two_factor_redirect: true,
356                token: pending_token,
357            },
358        ));
359    }
360
361    // Send verification email on sign-in if configured
362    if let Some(ev) = email_verification
363        && let Err(e) = ev
364            .send_verification_on_sign_in(&user, callback_url, ctx)
365            .await
366    {
367        tracing::warn!(
368            error = %e,
369            "Failed to send verification email on sign-in"
370        );
371    }
372
373    // Create session
374    let session = ctx
375        .session_manager()
376        .create_session(&user, None, None)
377        .await?;
378    let token = session.token().to_string();
379
380    let response = SignInResponse {
381        redirect: false,
382        token: token.clone(),
383        url: None,
384        user,
385    };
386    Ok(SignInCoreResult::Success(response, token))
387}
388
389/// Core sign-in by email.
390pub(crate) async fn sign_in_core<DB: DatabaseAdapter>(
391    body: &SignInRequest,
392    config: &EmailPasswordConfig,
393    email_verification: Option<&EmailVerificationPlugin>,
394    ctx: &AuthContext<DB>,
395) -> AuthResult<SignInCoreResult<DB::User>> {
396    let user = ctx
397        .database
398        .get_user_by_email(&body.email)
399        .await?
400        .ok_or(AuthError::InvalidCredentials)?;
401
402    sign_in_with_user_core(
403        user,
404        &body.password,
405        config,
406        email_verification,
407        body.callback_url.as_deref(),
408        ctx,
409    )
410    .await
411}
412
413/// Core sign-in by username.
414pub(crate) async fn sign_in_username_core<DB: DatabaseAdapter>(
415    body: &SignInUsernameRequest,
416    config: &EmailPasswordConfig,
417    email_verification: Option<&EmailVerificationPlugin>,
418    ctx: &AuthContext<DB>,
419) -> AuthResult<SignInCoreResult<DB::User>> {
420    let user = ctx
421        .database
422        .get_user_by_username(&body.username)
423        .await?
424        .ok_or(AuthError::InvalidCredentials)?;
425
426    sign_in_with_user_core(user, &body.password, config, email_verification, None, ctx).await
427}
428
429impl Default for EmailPasswordConfig {
430    fn default() -> Self {
431        Self {
432            enable_signup: true,
433            require_email_verification: false,
434            password_min_length: 8,
435            password_max_length: 128,
436            auto_sign_in: true,
437            password_hasher: None,
438        }
439    }
440}
441
442#[async_trait]
443impl<DB: DatabaseAdapter> AuthPlugin<DB> for EmailPasswordPlugin {
444    fn name(&self) -> &'static str {
445        "email-password"
446    }
447
448    fn routes(&self) -> Vec<AuthRoute> {
449        let mut routes = vec![
450            AuthRoute::post("/sign-in/email", "sign_in_email"),
451            AuthRoute::post("/sign-in/username", "sign_in_username"),
452        ];
453
454        if self.config.enable_signup {
455            routes.push(AuthRoute::post("/sign-up/email", "sign_up_email"));
456        }
457
458        routes
459    }
460
461    async fn on_request(
462        &self,
463        req: &AuthRequest,
464        ctx: &AuthContext<DB>,
465    ) -> AuthResult<Option<AuthResponse>> {
466        match (req.method(), req.path()) {
467            (HttpMethod::Post, "/sign-up/email") if self.config.enable_signup => {
468                Ok(Some(self.handle_sign_up(req, ctx).await?))
469            }
470            (HttpMethod::Post, "/sign-in/email") => Ok(Some(self.handle_sign_in(req, ctx).await?)),
471            (HttpMethod::Post, "/sign-in/username") => {
472                Ok(Some(self.handle_sign_in_username(req, ctx).await?))
473            }
474            _ => Ok(None),
475        }
476    }
477
478    async fn on_user_created(&self, user: &DB::User, _ctx: &AuthContext<DB>) -> AuthResult<()> {
479        if self.config.require_email_verification
480            && !user.email_verified()
481            && let Some(email) = user.email()
482        {
483            println!("Email verification required for user: {}", email);
484        }
485        Ok(())
486    }
487}
488
489#[cfg(feature = "axum")]
490mod axum_impl {
491    use super::*;
492    use std::sync::Arc;
493
494    use axum::Json;
495    use axum::extract::{Extension, State};
496    use axum::http::header;
497    use axum::response::IntoResponse;
498    use better_auth_core::{AuthState, ValidatedJson};
499
500    /// Shared plugin state wrapping the full plugin (non-Clone due to
501    /// Option<Arc<EmailVerificationPlugin>>).
502    type SharedPlugin = Arc<EmailPasswordPlugin>;
503
504    async fn handle_sign_up<DB: DatabaseAdapter>(
505        State(state): State<AuthState<DB>>,
506        Extension(plugin): Extension<SharedPlugin>,
507        ValidatedJson(body): ValidatedJson<SignUpRequest>,
508    ) -> Result<axum::response::Response, AuthError> {
509        let ctx = state.to_context();
510        let (response, session_token) = sign_up_core(&body, &plugin.config, &ctx).await?;
511
512        if let Some(token) = session_token {
513            let cookie = state.session_cookie(&token);
514            Ok(([(header::SET_COOKIE, cookie)], Json(response)).into_response())
515        } else {
516            Ok(Json(response).into_response())
517        }
518    }
519
520    /// Helper to convert a `SignInCoreResult` into an axum response.
521    fn sign_in_result_to_response<DB: DatabaseAdapter>(
522        result: SignInCoreResult<DB::User>,
523        state: &AuthState<DB>,
524    ) -> axum::response::Response {
525        match result {
526            SignInCoreResult::Success(response, token) => {
527                let cookie = state.session_cookie(&token);
528                ([(header::SET_COOKIE, cookie)], Json(response)).into_response()
529            }
530            SignInCoreResult::TwoFactorRedirect(redirect) => Json(redirect).into_response(),
531        }
532    }
533
534    async fn handle_sign_in<DB: DatabaseAdapter>(
535        State(state): State<AuthState<DB>>,
536        Extension(plugin): Extension<SharedPlugin>,
537        ValidatedJson(body): ValidatedJson<SignInRequest>,
538    ) -> Result<axum::response::Response, AuthError> {
539        let ctx = state.to_context();
540        let result = sign_in_core(
541            &body,
542            &plugin.config,
543            plugin.email_verification.as_deref(),
544            &ctx,
545        )
546        .await?;
547        Ok(sign_in_result_to_response::<DB>(result, &state))
548    }
549
550    async fn handle_sign_in_username<DB: DatabaseAdapter>(
551        State(state): State<AuthState<DB>>,
552        Extension(plugin): Extension<SharedPlugin>,
553        ValidatedJson(body): ValidatedJson<SignInUsernameRequest>,
554    ) -> Result<axum::response::Response, AuthError> {
555        let ctx = state.to_context();
556        let result = sign_in_username_core(
557            &body,
558            &plugin.config,
559            plugin.email_verification.as_deref(),
560            &ctx,
561        )
562        .await?;
563        Ok(sign_in_result_to_response::<DB>(result, &state))
564    }
565
566    #[async_trait::async_trait]
567    impl<DB: DatabaseAdapter> better_auth_core::AxumPlugin<DB> for EmailPasswordPlugin {
568        fn name(&self) -> &'static str {
569            "email-password"
570        }
571
572        fn router(&self) -> axum::Router<AuthState<DB>> {
573            use axum::routing::post;
574
575            let shared: SharedPlugin = Arc::new(EmailPasswordPlugin {
576                config: self.config.clone(),
577                email_verification: self.email_verification.clone(),
578            });
579
580            axum::Router::new()
581                .route("/sign-up/email", post(handle_sign_up::<DB>))
582                .route("/sign-in/email", post(handle_sign_in::<DB>))
583                .route("/sign-in/username", post(handle_sign_in_username::<DB>))
584                .layer(Extension(shared))
585        }
586
587        async fn on_user_created(
588            &self,
589            user: &DB::User,
590            _ctx: &better_auth_core::AuthContext<DB>,
591        ) -> better_auth_core::AuthResult<()> {
592            if self.config.require_email_verification
593                && !user.email_verified()
594                && let Some(email) = user.email()
595            {
596                println!("Email verification required for user: {}", email);
597            }
598            Ok(())
599        }
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use better_auth_core::AuthContext;
607    use better_auth_core::adapters::{MemoryDatabaseAdapter, UserOps};
608    use better_auth_core::config::AuthConfig;
609    use std::collections::HashMap;
610    use std::sync::Arc;
611
612    fn create_test_context() -> AuthContext<MemoryDatabaseAdapter> {
613        let config = AuthConfig::new("test-secret-key-at-least-32-chars-long");
614        let config = Arc::new(config);
615        let database = Arc::new(MemoryDatabaseAdapter::new());
616        AuthContext::new(config, database)
617    }
618
619    fn create_signup_request(email: &str, password: &str) -> AuthRequest {
620        let body = serde_json::json!({
621            "name": "Test User",
622            "email": email,
623            "password": password,
624        });
625        AuthRequest::from_parts(
626            HttpMethod::Post,
627            "/sign-up/email".to_string(),
628            HashMap::new(),
629            Some(body.to_string().into_bytes()),
630            HashMap::new(),
631        )
632    }
633
634    #[tokio::test]
635    async fn test_auto_sign_in_false_returns_no_session() {
636        let plugin = EmailPasswordPlugin::new().auto_sign_in(false);
637        let ctx = create_test_context();
638
639        let req = create_signup_request("auto@example.com", "Password123!");
640        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
641        assert_eq!(response.status, 200);
642
643        // Response should NOT have a Set-Cookie header
644        let has_cookie = response
645            .headers
646            .iter()
647            .any(|(k, _)| k.eq_ignore_ascii_case("Set-Cookie"));
648        assert!(!has_cookie, "auto_sign_in=false should not set a cookie");
649
650        // Response body token should be null
651        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
652        assert!(
653            body["token"].is_null(),
654            "auto_sign_in=false should return null token"
655        );
656        // But the user should still be created
657        assert!(body["user"]["id"].is_string());
658    }
659
660    #[tokio::test]
661    async fn test_auto_sign_in_true_returns_session() {
662        let plugin = EmailPasswordPlugin::new(); // default auto_sign_in=true
663        let ctx = create_test_context();
664
665        let req = create_signup_request("autotrue@example.com", "Password123!");
666        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
667        assert_eq!(response.status, 200);
668
669        // Response SHOULD have a Set-Cookie header
670        let has_cookie = response
671            .headers
672            .iter()
673            .any(|(k, _)| k.eq_ignore_ascii_case("Set-Cookie"));
674        assert!(has_cookie, "auto_sign_in=true should set a cookie");
675
676        // Response body token should be a string
677        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
678        assert!(
679            body["token"].is_string(),
680            "auto_sign_in=true should return a session token"
681        );
682    }
683
684    #[tokio::test]
685    async fn test_password_max_length_rejection() {
686        let plugin = EmailPasswordPlugin::new().password_max_length(128);
687        let ctx = create_test_context();
688
689        // Password of exactly 129 chars should be rejected
690        let long_password = format!("A1!{}", "a".repeat(126)); // 129 chars total
691        let req = create_signup_request("long@example.com", &long_password);
692        let err = plugin.handle_sign_up(&req, &ctx).await.unwrap_err();
693        assert_eq!(err.status_code(), 400);
694
695        // Password of exactly 128 chars should be accepted
696        let ok_password = format!("A1!{}", "a".repeat(125)); // 128 chars total
697        let req = create_signup_request("ok@example.com", &ok_password);
698        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
699        assert_eq!(response.status, 200);
700    }
701
702    #[tokio::test]
703    async fn test_custom_password_hasher() {
704        /// A simple test hasher that prefixes the password with "hashed:"
705        struct TestHasher;
706
707        #[async_trait]
708        impl PasswordHasher for TestHasher {
709            async fn hash(&self, password: &str) -> AuthResult<String> {
710                Ok(format!("hashed:{}", password))
711            }
712            async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
713                Ok(hash == format!("hashed:{}", password))
714            }
715        }
716
717        let hasher: Arc<dyn PasswordHasher> = Arc::new(TestHasher);
718        let plugin = EmailPasswordPlugin::new().password_hasher(hasher);
719        let ctx = create_test_context();
720
721        // Sign up with custom hasher
722        let req = create_signup_request("hasher@example.com", "Password123!");
723        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
724        assert_eq!(response.status, 200);
725
726        // Verify the stored hash uses our custom hasher
727        let user = ctx
728            .database
729            .get_user_by_email("hasher@example.com")
730            .await
731            .unwrap()
732            .unwrap();
733        let stored_hash = user
734            .metadata
735            .get(PASSWORD_HASH_KEY)
736            .unwrap()
737            .as_str()
738            .unwrap();
739        assert_eq!(stored_hash, "hashed:Password123!");
740
741        // Sign in should work with the custom hasher
742        let signin_body = serde_json::json!({
743            "email": "hasher@example.com",
744            "password": "Password123!",
745        });
746        let signin_req = AuthRequest::from_parts(
747            HttpMethod::Post,
748            "/sign-in/email".to_string(),
749            HashMap::new(),
750            Some(signin_body.to_string().into_bytes()),
751            HashMap::new(),
752        );
753        let response = plugin.handle_sign_in(&signin_req, &ctx).await.unwrap();
754        assert_eq!(response.status, 200);
755
756        // Sign in with wrong password should fail
757        let bad_body = serde_json::json!({
758            "email": "hasher@example.com",
759            "password": "WrongPassword!",
760        });
761        let bad_req = AuthRequest::from_parts(
762            HttpMethod::Post,
763            "/sign-in/email".to_string(),
764            HashMap::new(),
765            Some(bad_body.to_string().into_bytes()),
766            HashMap::new(),
767        );
768        let err = plugin.handle_sign_in(&bad_req, &ctx).await.unwrap_err();
769        assert_eq!(err.to_string(), AuthError::InvalidCredentials.to_string());
770    }
771}