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