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};
17pub struct EmailPasswordPlugin {
19 config: EmailPasswordConfig,
20 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 pub password_max_length: usize,
32 pub auto_sign_in: bool,
35 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#[derive(Debug, Serialize)]
114pub(crate) struct TwoFactorRedirectResponse {
115 #[serde(rename = "twoFactorRedirect")]
116 two_factor_redirect: bool,
117 token: String,
118}
119
120pub(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 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
256pub(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 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 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 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
342async 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 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 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 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 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 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
415pub(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 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
453pub(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 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 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 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 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 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(); 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 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 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 let long_password = format!("A1!{}", "a".repeat(126)); 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 let ok_password = format!("A1!{}", "a".repeat(125)); 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 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 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 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 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 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 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}