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