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