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    // Validate callbackURL even though sign-up does not currently use it:
274    // the request type accepts the field, so we must not let a caller
275    // think an untrusted value was accepted. Require an absolute URL so
276    // the contract matches `send_verification_email` / `/sign-in/email`
277    // when sign-up eventually wires this through.
278    if let Some(ref url) = body.callback_url
279        && !ctx.config.is_absolute_trusted_callback_url(url)
280    {
281        return Err(AuthError::bad_request(
282            "callbackURL must be an absolute http(s) URL on a trusted origin",
283        ));
284    }
285
286    password_utils::validate_password(
287        &body.password,
288        config.password_min_length,
289        config.password_max_length,
290        ctx,
291    )?;
292
293    // Check if user already exists
294    if ctx.database.get_user_by_email(&body.email).await?.is_some() {
295        return Err(AuthError::conflict("A user with this email already exists"));
296    }
297
298    // Hash password
299    let password_hash =
300        password_utils::hash_password(config.password_hasher.as_ref(), &body.password).await?;
301
302    let metadata = {
303        let mut m = serde_json::Map::new();
304        m.insert(
305            PASSWORD_HASH_KEY.to_string(),
306            serde_json::Value::String(password_hash),
307        );
308        serde_json::Value::Object(m)
309    };
310
311    let mut create_user = CreateUser::new()
312        .with_email(&body.email)
313        .with_name(&body.name);
314    if let Some(ref username) = body.username {
315        create_user = create_user.with_username(username.clone());
316    }
317    if let Some(ref display_username) = body.display_username {
318        create_user.display_username = Some(display_username.clone());
319    }
320    create_user.metadata = Some(metadata);
321
322    let user = ctx.database.create_user(create_user).await?;
323
324    if config.auto_sign_in {
325        let session = ctx
326            .session_manager()
327            .create_session(&user, None, None)
328            .await?;
329        let token = session.token().to_string();
330
331        let response = SignUpResponse {
332            token: Some(token.clone()),
333            user,
334        };
335        Ok((response, Some(token)))
336    } else {
337        let response = SignUpResponse { token: None, user };
338        Ok((response, None))
339    }
340}
341
342/// Shared sign-in logic after user lookup: verify password, check 2FA, create session.
343async fn sign_in_with_user_core<DB: DatabaseAdapter>(
344    user: DB::User,
345    password: &str,
346    config: &EmailPasswordConfig,
347    email_verification: Option<&EmailVerificationPlugin>,
348    callback_url: Option<&str>,
349    ctx: &AuthContext<DB>,
350) -> AuthResult<SignInCoreResult<DB::User>> {
351    // Defence in depth: `sign_in_core` already validated the callback,
352    // but helper functions called directly (`sign_in_username_core`
353    // passes `None`) must still honour the same contract. Require an
354    // absolute http(s) URL on a trusted origin so any future caller
355    // that plumbs a raw request value through can't regress.
356    if let Some(url) = callback_url
357        && !ctx.config.is_absolute_trusted_callback_url(url)
358    {
359        return Err(AuthError::bad_request(
360            "callbackURL must be an absolute http(s) URL on a trusted origin",
361        ));
362    }
363
364    // Verify password
365    let stored_hash = user.password_hash().ok_or(AuthError::InvalidCredentials)?;
366
367    password_utils::verify_password(config.password_hasher.as_ref(), password, stored_hash).await?;
368
369    // Check if 2FA is enabled
370    if user.two_factor_enabled() {
371        let pending_token = format!("2fa_{}", uuid::Uuid::new_v4());
372        ctx.database
373            .create_verification(CreateVerification {
374                identifier: format!("2fa_pending:{}", pending_token),
375                value: user.id().to_string(),
376                expires_at: chrono::Utc::now() + chrono::Duration::minutes(5),
377            })
378            .await?;
379        return Ok(SignInCoreResult::TwoFactorRedirect(
380            TwoFactorRedirectResponse {
381                two_factor_redirect: true,
382                token: pending_token,
383            },
384        ));
385    }
386
387    // Send verification email on sign-in if configured
388    if let Some(ev) = email_verification
389        && let Err(e) = ev
390            .send_verification_on_sign_in(&user, callback_url, ctx)
391            .await
392    {
393        tracing::warn!(
394            error = %e,
395            "Failed to send verification email on sign-in"
396        );
397    }
398
399    // Create session
400    let session = ctx
401        .session_manager()
402        .create_session(&user, None, None)
403        .await?;
404    let token = session.token().to_string();
405
406    let response = SignInResponse {
407        redirect: false,
408        token: token.clone(),
409        url: None,
410        user,
411    };
412    Ok(SignInCoreResult::Success(response, token))
413}
414
415/// Core sign-in by email.
416pub(crate) async fn sign_in_core<DB: DatabaseAdapter>(
417    body: &SignInRequest,
418    config: &EmailPasswordConfig,
419    email_verification: Option<&EmailVerificationPlugin>,
420    ctx: &AuthContext<DB>,
421) -> AuthResult<SignInCoreResult<DB::User>> {
422    // Validate callbackURL BEFORE the user lookup so that "unknown user"
423    // and "untrusted callback" return the same 400 regardless of whether
424    // the email exists — prevents the enumeration oracle that would
425    // otherwise differ 401 vs 400. The callback ends up in a
426    // verification-email `href` when the user is unverified, so require
427    // an absolute URL.
428    if let Some(ref url) = body.callback_url
429        && !ctx.config.is_absolute_trusted_callback_url(url)
430    {
431        return Err(AuthError::bad_request(
432            "callbackURL must be an absolute http(s) URL on a trusted origin",
433        ));
434    }
435
436    let user = ctx
437        .database
438        .get_user_by_email(&body.email)
439        .await?
440        .ok_or(AuthError::InvalidCredentials)?;
441
442    sign_in_with_user_core(
443        user,
444        &body.password,
445        config,
446        email_verification,
447        body.callback_url.as_deref(),
448        ctx,
449    )
450    .await
451}
452
453/// Core sign-in by username.
454pub(crate) async fn sign_in_username_core<DB: DatabaseAdapter>(
455    body: &SignInUsernameRequest,
456    config: &EmailPasswordConfig,
457    email_verification: Option<&EmailVerificationPlugin>,
458    ctx: &AuthContext<DB>,
459) -> AuthResult<SignInCoreResult<DB::User>> {
460    let user = ctx
461        .database
462        .get_user_by_username(&body.username)
463        .await?
464        .ok_or(AuthError::InvalidCredentials)?;
465
466    sign_in_with_user_core(user, &body.password, config, email_verification, None, ctx).await
467}
468
469impl Default for EmailPasswordConfig {
470    fn default() -> Self {
471        Self {
472            enable_signup: true,
473            require_email_verification: false,
474            password_min_length: 8,
475            password_max_length: 128,
476            auto_sign_in: true,
477            password_hasher: None,
478        }
479    }
480}
481
482#[async_trait]
483impl<DB: DatabaseAdapter> AuthPlugin<DB> for EmailPasswordPlugin {
484    fn name(&self) -> &'static str {
485        "email-password"
486    }
487
488    fn routes(&self) -> Vec<AuthRoute> {
489        let mut routes = vec![
490            AuthRoute::post("/sign-in/email", "sign_in_email"),
491            AuthRoute::post("/sign-in/username", "sign_in_username"),
492        ];
493
494        if self.config.enable_signup {
495            routes.push(AuthRoute::post("/sign-up/email", "sign_up_email"));
496        }
497
498        routes
499    }
500
501    async fn on_request(
502        &self,
503        req: &AuthRequest,
504        ctx: &AuthContext<DB>,
505    ) -> AuthResult<Option<AuthResponse>> {
506        match (req.method(), req.path()) {
507            (HttpMethod::Post, "/sign-up/email") if self.config.enable_signup => {
508                Ok(Some(self.handle_sign_up(req, ctx).await?))
509            }
510            (HttpMethod::Post, "/sign-in/email") => Ok(Some(self.handle_sign_in(req, ctx).await?)),
511            (HttpMethod::Post, "/sign-in/username") => {
512                Ok(Some(self.handle_sign_in_username(req, ctx).await?))
513            }
514            _ => Ok(None),
515        }
516    }
517
518    async fn on_user_created(&self, user: &DB::User, _ctx: &AuthContext<DB>) -> AuthResult<()> {
519        if self.config.require_email_verification
520            && !user.email_verified()
521            && let Some(email) = user.email()
522        {
523            println!("Email verification required for user: {}", email);
524        }
525        Ok(())
526    }
527}
528
529#[cfg(feature = "axum")]
530mod axum_impl {
531    use super::*;
532    use std::sync::Arc;
533
534    use axum::Json;
535    use axum::extract::{Extension, State};
536    use axum::http::header;
537    use axum::response::IntoResponse;
538    use better_auth_core::{AuthState, ValidatedJson};
539
540    /// Shared plugin state wrapping the full plugin (non-Clone due to
541    /// Option<Arc<EmailVerificationPlugin>>).
542    type SharedPlugin = Arc<EmailPasswordPlugin>;
543
544    async fn handle_sign_up<DB: DatabaseAdapter>(
545        State(state): State<AuthState<DB>>,
546        Extension(plugin): Extension<SharedPlugin>,
547        ValidatedJson(body): ValidatedJson<SignUpRequest>,
548    ) -> Result<axum::response::Response, AuthError> {
549        let ctx = state.to_context();
550        let (response, session_token) = sign_up_core(&body, &plugin.config, &ctx).await?;
551
552        if let Some(token) = session_token {
553            let cookie = state.session_cookie(&token);
554            Ok(([(header::SET_COOKIE, cookie)], Json(response)).into_response())
555        } else {
556            Ok(Json(response).into_response())
557        }
558    }
559
560    /// Helper to convert a `SignInCoreResult` into an axum response.
561    fn sign_in_result_to_response<DB: DatabaseAdapter>(
562        result: SignInCoreResult<DB::User>,
563        state: &AuthState<DB>,
564    ) -> axum::response::Response {
565        match result {
566            SignInCoreResult::Success(response, token) => {
567                let cookie = state.session_cookie(&token);
568                ([(header::SET_COOKIE, cookie)], Json(response)).into_response()
569            }
570            SignInCoreResult::TwoFactorRedirect(redirect) => Json(redirect).into_response(),
571        }
572    }
573
574    async fn handle_sign_in<DB: DatabaseAdapter>(
575        State(state): State<AuthState<DB>>,
576        Extension(plugin): Extension<SharedPlugin>,
577        ValidatedJson(body): ValidatedJson<SignInRequest>,
578    ) -> Result<axum::response::Response, AuthError> {
579        let ctx = state.to_context();
580        let result = sign_in_core(
581            &body,
582            &plugin.config,
583            plugin.email_verification.as_deref(),
584            &ctx,
585        )
586        .await?;
587        Ok(sign_in_result_to_response::<DB>(result, &state))
588    }
589
590    async fn handle_sign_in_username<DB: DatabaseAdapter>(
591        State(state): State<AuthState<DB>>,
592        Extension(plugin): Extension<SharedPlugin>,
593        ValidatedJson(body): ValidatedJson<SignInUsernameRequest>,
594    ) -> Result<axum::response::Response, AuthError> {
595        let ctx = state.to_context();
596        let result = sign_in_username_core(
597            &body,
598            &plugin.config,
599            plugin.email_verification.as_deref(),
600            &ctx,
601        )
602        .await?;
603        Ok(sign_in_result_to_response::<DB>(result, &state))
604    }
605
606    #[async_trait::async_trait]
607    impl<DB: DatabaseAdapter> better_auth_core::AxumPlugin<DB> for EmailPasswordPlugin {
608        fn name(&self) -> &'static str {
609            "email-password"
610        }
611
612        fn router(&self) -> axum::Router<AuthState<DB>> {
613            use axum::routing::post;
614
615            let shared: SharedPlugin = Arc::new(EmailPasswordPlugin {
616                config: self.config.clone(),
617                email_verification: self.email_verification.clone(),
618            });
619
620            axum::Router::new()
621                .route("/sign-up/email", post(handle_sign_up::<DB>))
622                .route("/sign-in/email", post(handle_sign_in::<DB>))
623                .route("/sign-in/username", post(handle_sign_in_username::<DB>))
624                .layer(Extension(shared))
625        }
626
627        async fn on_user_created(
628            &self,
629            user: &DB::User,
630            _ctx: &better_auth_core::AuthContext<DB>,
631        ) -> better_auth_core::AuthResult<()> {
632            if self.config.require_email_verification
633                && !user.email_verified()
634                && let Some(email) = user.email()
635            {
636                println!("Email verification required for user: {}", email);
637            }
638            Ok(())
639        }
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use better_auth_core::AuthContext;
647    use better_auth_core::adapters::{MemoryDatabaseAdapter, UserOps};
648    use better_auth_core::config::AuthConfig;
649    use std::collections::HashMap;
650    use std::sync::Arc;
651
652    fn create_test_context() -> AuthContext<MemoryDatabaseAdapter> {
653        let config = AuthConfig::new("test-secret-key-at-least-32-chars-long");
654        let config = Arc::new(config);
655        let database = Arc::new(MemoryDatabaseAdapter::new());
656        AuthContext::new(config, database)
657    }
658
659    fn create_signup_request(email: &str, password: &str) -> AuthRequest {
660        let body = serde_json::json!({
661            "name": "Test User",
662            "email": email,
663            "password": password,
664        });
665        AuthRequest::from_parts(
666            HttpMethod::Post,
667            "/sign-up/email".to_string(),
668            HashMap::new(),
669            Some(body.to_string().into_bytes()),
670            HashMap::new(),
671        )
672    }
673
674    #[tokio::test]
675    async fn test_auto_sign_in_false_returns_no_session() {
676        let plugin = EmailPasswordPlugin::new().auto_sign_in(false);
677        let ctx = create_test_context();
678
679        let req = create_signup_request("auto@example.com", "Password123!");
680        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
681        assert_eq!(response.status, 200);
682
683        // Response should NOT have a Set-Cookie header
684        let has_cookie = response
685            .headers
686            .iter()
687            .any(|(k, _)| k.eq_ignore_ascii_case("Set-Cookie"));
688        assert!(!has_cookie, "auto_sign_in=false should not set a cookie");
689
690        // Response body token should be null
691        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
692        assert!(
693            body["token"].is_null(),
694            "auto_sign_in=false should return null token"
695        );
696        // But the user should still be created
697        assert!(body["user"]["id"].is_string());
698    }
699
700    #[tokio::test]
701    async fn test_auto_sign_in_true_returns_session() {
702        let plugin = EmailPasswordPlugin::new(); // default auto_sign_in=true
703        let ctx = create_test_context();
704
705        let req = create_signup_request("autotrue@example.com", "Password123!");
706        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
707        assert_eq!(response.status, 200);
708
709        // Response SHOULD have a Set-Cookie header
710        let has_cookie = response
711            .headers
712            .iter()
713            .any(|(k, _)| k.eq_ignore_ascii_case("Set-Cookie"));
714        assert!(has_cookie, "auto_sign_in=true should set a cookie");
715
716        // Response body token should be a string
717        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
718        assert!(
719            body["token"].is_string(),
720            "auto_sign_in=true should return a session token"
721        );
722    }
723
724    #[tokio::test]
725    async fn test_password_max_length_rejection() {
726        let plugin = EmailPasswordPlugin::new().password_max_length(128);
727        let ctx = create_test_context();
728
729        // Password of exactly 129 chars should be rejected
730        let long_password = format!("A1!{}", "a".repeat(126)); // 129 chars total
731        let req = create_signup_request("long@example.com", &long_password);
732        let err = plugin.handle_sign_up(&req, &ctx).await.unwrap_err();
733        assert_eq!(err.status_code(), 400);
734
735        // Password of exactly 128 chars should be accepted
736        let ok_password = format!("A1!{}", "a".repeat(125)); // 128 chars total
737        let req = create_signup_request("ok@example.com", &ok_password);
738        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
739        assert_eq!(response.status, 200);
740    }
741
742    #[tokio::test]
743    async fn test_custom_password_hasher() {
744        /// A simple test hasher that prefixes the password with "hashed:"
745        struct TestHasher;
746
747        #[async_trait]
748        impl PasswordHasher for TestHasher {
749            async fn hash(&self, password: &str) -> AuthResult<String> {
750                Ok(format!("hashed:{}", password))
751            }
752            async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
753                Ok(hash == format!("hashed:{}", password))
754            }
755        }
756
757        let hasher: Arc<dyn PasswordHasher> = Arc::new(TestHasher);
758        let plugin = EmailPasswordPlugin::new().password_hasher(hasher);
759        let ctx = create_test_context();
760
761        // Sign up with custom hasher
762        let req = create_signup_request("hasher@example.com", "Password123!");
763        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
764        assert_eq!(response.status, 200);
765
766        // Verify the stored hash uses our custom hasher
767        let user = ctx
768            .database
769            .get_user_by_email("hasher@example.com")
770            .await
771            .unwrap()
772            .unwrap();
773        let stored_hash = user
774            .metadata
775            .get(PASSWORD_HASH_KEY)
776            .unwrap()
777            .as_str()
778            .unwrap();
779        assert_eq!(stored_hash, "hashed:Password123!");
780
781        // Sign in should work with the custom hasher
782        let signin_body = serde_json::json!({
783            "email": "hasher@example.com",
784            "password": "Password123!",
785        });
786        let signin_req = AuthRequest::from_parts(
787            HttpMethod::Post,
788            "/sign-in/email".to_string(),
789            HashMap::new(),
790            Some(signin_body.to_string().into_bytes()),
791            HashMap::new(),
792        );
793        let response = plugin.handle_sign_in(&signin_req, &ctx).await.unwrap();
794        assert_eq!(response.status, 200);
795
796        // Sign in with wrong password should fail
797        let bad_body = serde_json::json!({
798            "email": "hasher@example.com",
799            "password": "WrongPassword!",
800        });
801        let bad_req = AuthRequest::from_parts(
802            HttpMethod::Post,
803            "/sign-in/email".to_string(),
804            HashMap::new(),
805            Some(bad_body.to_string().into_bytes()),
806            HashMap::new(),
807        );
808        let err = plugin.handle_sign_in(&bad_req, &ctx).await.unwrap_err();
809        assert_eq!(err.to_string(), AuthError::InvalidCredentials.to_string());
810    }
811
812    fn create_signin_request_with_callback(
813        email: &str,
814        password: &str,
815        callback_url: &str,
816    ) -> AuthRequest {
817        let body = serde_json::json!({
818            "email": email,
819            "password": password,
820            "callbackURL": callback_url,
821        });
822        AuthRequest::from_parts(
823            HttpMethod::Post,
824            "/sign-in/email".to_string(),
825            HashMap::new(),
826            Some(body.to_string().into_bytes()),
827            HashMap::new(),
828        )
829    }
830
831    #[tokio::test]
832    async fn test_sign_in_rejects_untrusted_callback_url_without_email_oracle() {
833        // callback URL is rejected for both existing and non-existing users,
834        // proving the check lives before the user lookup and does not create
835        // an enumeration oracle.
836        let plugin = EmailPasswordPlugin::new();
837        let ctx = create_test_context();
838
839        let signup_req = create_signup_request("sign-in-cb@test.com", "Password123!");
840        plugin.handle_sign_up(&signup_req, &ctx).await.unwrap();
841
842        let bad_for_existing = create_signin_request_with_callback(
843            "sign-in-cb@test.com",
844            "Password123!",
845            "https://evil.example.com/cb",
846        );
847        let err_existing = plugin
848            .handle_sign_in(&bad_for_existing, &ctx)
849            .await
850            .unwrap_err();
851        assert_eq!(err_existing.status_code(), 400);
852
853        let bad_for_missing = create_signin_request_with_callback(
854            "does-not-exist@test.com",
855            "Password123!",
856            "https://evil.example.com/cb",
857        );
858        let err_missing = plugin
859            .handle_sign_in(&bad_for_missing, &ctx)
860            .await
861            .unwrap_err();
862        assert_eq!(err_missing.status_code(), 400);
863        assert_eq!(err_existing.to_string(), err_missing.to_string());
864    }
865
866    #[tokio::test]
867    async fn test_sign_up_rejects_untrusted_callback_url() {
868        let plugin = EmailPasswordPlugin::new();
869        let ctx = create_test_context();
870
871        let body = serde_json::json!({
872            "name": "Sign Up CB",
873            "email": "signup-cb@test.com",
874            "password": "Password123!",
875            "callbackURL": "https://evil.example.com/cb",
876        });
877        let req = AuthRequest::from_parts(
878            HttpMethod::Post,
879            "/sign-up/email".to_string(),
880            HashMap::new(),
881            Some(body.to_string().into_bytes()),
882            HashMap::new(),
883        );
884
885        let err = plugin.handle_sign_up(&req, &ctx).await.unwrap_err();
886        assert_eq!(err.status_code(), 400);
887    }
888}