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