1mod endpoint;
7mod handlers;
8
9mod types;
10
11use alibi_core::error::codes;
12use alibi_core::{AuthContext, AuthRequest, AuthResult, AuthSchema};
13use async_trait::async_trait;
14pub use endpoint::{
15 PhoneOtpConsumption, PhoneOtpSent, PhonePasswordResetRequested, PhonePasswordResetResult,
16 PhoneSignInResponse, PhoneVerificationInput, PhoneVerificationResult,
17};
18use std::sync::Arc;
19pub use types::{
20 PhoneNumberVerification, PhoneOtpDelivery, ResetRequest as ResetPhonePasswordRequest,
21 SignInRequest as SignInPhoneNumberRequest,
22};
23pub(crate) use types::{parse_signup_phone, reject_verified_input};
24
25#[async_trait]
26pub trait SendPhoneOtp: Send + Sync {
27 async fn send(
28 &self,
29 delivery: &PhoneOtpDelivery,
30 context: &alibi_core::CallbackContext,
31 ) -> AuthResult<()>;
32}
33
34#[async_trait]
35pub trait PhoneNumberValidator: Send + Sync {
36 async fn is_valid(&self, phone_number: &str) -> AuthResult<bool>;
37}
38
39#[async_trait]
42pub trait PhoneOtpVerifier: Send + Sync {
43 async fn verify(
44 &self,
45 delivery: &PhoneOtpDelivery,
46 context: &alibi_core::CallbackContext,
47 ) -> AuthResult<bool>;
48}
49
50pub trait PhoneSignupIdentity: Send + Sync {
51 fn temporary_email(&self, phone_number: &str) -> String;
52 fn temporary_name(&self, _phone_number: &str) -> Option<String> {
53 None
54 }
55}
56
57#[async_trait]
58pub trait PhoneVerificationHook: Send + Sync {
59 async fn verified(
60 &self,
61 result: &PhoneNumberVerification,
62 context: &alibi_core::CallbackContext,
63 ) -> AuthResult<()>;
64}
65
66#[derive(Clone)]
67pub struct PhoneNumberConfig {
68 pub send_otp: Option<Arc<dyn SendPhoneOtp>>,
69 pub send_password_reset_otp: Option<Arc<dyn SendPhoneOtp>>,
70 pub verify_otp: Option<Arc<dyn PhoneOtpVerifier>>,
71 pub phone_number_validator: Option<Arc<dyn PhoneNumberValidator>>,
72 pub sign_up_on_verification: Option<Arc<dyn PhoneSignupIdentity>>,
73 pub callback_on_verification: Option<Arc<dyn PhoneVerificationHook>>,
74 pub require_verification: bool,
75 pub otp_length: f64,
78 pub expires_in: f64,
81 pub allowed_attempts: f64,
84}
85
86impl std::fmt::Debug for PhoneNumberConfig {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.debug_struct("PhoneNumberConfig").finish_non_exhaustive()
89 }
90}
91
92impl Default for PhoneNumberConfig {
93 fn default() -> Self {
94 Self {
95 send_otp: None,
96 send_password_reset_otp: None,
97 verify_otp: None,
98 phone_number_validator: None,
99 sign_up_on_verification: None,
100 callback_on_verification: None,
101 require_verification: false,
102 otp_length: 6.0,
103 expires_in: 300.0,
104 allowed_attempts: 3.0,
105 }
106 }
107}
108
109#[derive(Clone)]
110pub struct PhoneNumberPlugin {
111 config: PhoneNumberConfig,
112}
113
114impl std::fmt::Debug for PhoneNumberPlugin {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 f.debug_struct("PhoneNumberPlugin").finish_non_exhaustive()
117 }
118}
119
120impl PhoneNumberPlugin {
121 #[must_use]
122 pub const fn new(config: PhoneNumberConfig) -> Self {
123 Self { config }
124 }
125
126 pub async fn consume_otp(
132 &self,
133 ctx: &AuthContext<impl AuthSchema>,
134 phone_number: &str,
135 code: &str,
136 ) -> AuthResult<()> {
137 self.verify_and_consume(ctx, None, phone_number, code).await
138 }
139}
140
141alibi_core::impl_auth_plugin! {
142 PhoneNumberPlugin,"phone-number";
143 routes {
144 post "/sign-in/phone-number" => sign_in,"signInPhoneNumber";
145 post "/phone-number/send-otp" => send_otp,"sendPhoneNumberOTP";
146 post "/phone-number/verify" => verify,"verifyPhoneNumber";
147 post "/phone-number/request-password-reset" => request_password_reset,"requestPasswordResetPhoneNumber";
148 post "/phone-number/reset-password" => reset_password,"resetPasswordPhoneNumber";
149 }
150 extra {
151 route_openapi_metadata!(S);
152 fn endpoint_hooks(&self) -> Vec<&dyn alibi_core::endpoint::EndpointHook<S>> { vec![self] }
153 fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> { endpoint::definitions() }
154 fn validate_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, _ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointInput> { endpoint::validate(call) }
155 async fn on_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointResponse> { self.call_endpoint(call, ctx).await }
156
157
158 fn rate_limits(&self) -> Vec<alibi_core::PluginRateLimit> {
159 vec![alibi_core::PluginRateLimit { matches: |path| path.starts_with("/phone-number"), limit: alibi_core::EndpointRateLimit { window_seconds: 60.0, max_requests: 10.0 } }]
160 }
161 async fn on_init(&self,ctx:&mut alibi_core::AuthInitContext<S>)->AuthResult<()> {
162 ctx.set_metadata("phone-number.enabled",serde_json::json!(true));
163 ctx.register_user_update_transform(|_, mut update| {
164 if matches!(update.phone_number.as_ref(), Some(None)) {
165 update.phone_number_verified = Some(false);
166 }
167 Ok(update)
168 });
169 Ok(())
170 }
171 async fn before_request(&self,req:&AuthRequest,_ctx:&AuthContext<S>)->AuthResult<Option<alibi_core::BeforeRequestAction>> {
172 if req.path()=="/update-user" && req.body_as_json::<alibi_core::utils::json::JsValue>().ok().and_then(|value|value.get("phoneNumber").cloned()).is_some_and(|value|!value.is_null()) {
173 return Err(types::phone_error(400,codes::PHONE_NUMBER_CANNOT_BE_UPDATED,"Phone number cannot be updated"));
174 }
175 Ok(None)
176 }
177 }
178}
179
180#[async_trait]
181impl<S: AuthSchema> alibi_core::endpoint::EndpointHook<S> for PhoneNumberPlugin {
182 fn matches_before(
183 &self,
184 call: &alibi_core::endpoint::EndpointCall,
185 _: &AuthContext<S>,
186 ) -> AuthResult<bool> {
187 use alibi_core::utils::json::JsValue;
188 if call.path() != Some("/update-user") {
189 return Ok(false);
190 }
191 match call.body() {
192 Some(JsValue::Object(body)) => Ok(body
193 .get("phoneNumber")
194 .is_some_and(|value| !value.is_null())),
195 Some(JsValue::Array(_) | JsValue::Date(_)) => Ok(false),
196 _ => Err(alibi_core::AuthError::internal(
199 "Phone profile matcher requires an object",
200 )),
201 }
202 }
203 async fn before(
204 &self,
205 _: &alibi_core::endpoint::EndpointCall,
206 _: &AuthContext<S>,
207 ) -> AuthResult<Option<alibi_core::endpoint::BeforeEndpointAction>> {
208 Err(types::phone_error(
209 400,
210 codes::PHONE_NUMBER_CANNOT_BE_UPDATED,
211 "Phone number cannot be updated",
212 ))
213 }
214}
215
216#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::test_helpers;
221 use alibi_core::{
222 AuthError, AuthPlugin, AuthResponse, AuthSession, AuthUser, AuthVerification,
223 CreateAccount, CreateUser, CreateVerification, HttpMethod,
224 };
225 use chrono::Duration;
226 use serde_json::{Value, json};
227 use std::sync::Mutex;
228
229 #[derive(Default)]
230 struct Outbox(Mutex<Vec<PhoneOtpDelivery>>);
231
232 #[async_trait]
233 impl SendPhoneOtp for Outbox {
234 async fn send(
235 &self,
236 delivery: &PhoneOtpDelivery,
237 _context: &alibi_core::CallbackContext,
238 ) -> AuthResult<()> {
239 self.0.lock().unwrap().push(delivery.clone());
240 Ok(())
241 }
242 }
243
244 struct RejectingSender(Arc<Outbox>);
245
246 #[async_trait]
247 impl SendPhoneOtp for RejectingSender {
248 async fn send(
249 &self,
250 delivery: &PhoneOtpDelivery,
251 _context: &alibi_core::CallbackContext,
252 ) -> AuthResult<()> {
253 self.0.0.lock().unwrap().push(delivery.clone());
254 Err(AuthError::bad_request("fixture delivery failed"))
255 }
256 }
257
258 struct SignupIdentity;
259
260 impl PhoneSignupIdentity for SignupIdentity {
261 fn temporary_email(&self, phone_number: &str) -> String {
262 format!("{phone_number}@phone.fixture.test")
263 }
264 }
265
266 struct Provider(Mutex<Option<PhoneOtpDelivery>>);
267
268 #[async_trait]
269 impl PhoneOtpVerifier for Provider {
270 async fn verify(
271 &self,
272 delivery: &PhoneOtpDelivery,
273 _context: &alibi_core::CallbackContext,
274 ) -> AuthResult<bool> {
275 let mut challenge = self.0.lock().unwrap();
276 let verified = if challenge.as_ref().is_some_and(|expected| {
277 expected.phone_number == delivery.phone_number && expected.code == delivery.code
278 }) {
279 _ = challenge.take();
280 Ok(true)
281 } else {
282 Ok(false)
283 };
284 drop(challenge);
285 verified
286 }
287 }
288
289 #[derive(Default)]
290 struct VerificationCallback {
291 captured: Mutex<Vec<PhoneNumberVerification>>,
292 reject: bool,
293 }
294
295 #[async_trait]
296 impl PhoneVerificationHook for VerificationCallback {
297 async fn verified(
298 &self,
299 result: &PhoneNumberVerification,
300 _context: &alibi_core::CallbackContext,
301 ) -> AuthResult<()> {
302 self.captured.lock().unwrap().push(result.clone());
303 if self.reject {
304 Err(AuthError::Upstream {
305 status: 400,
306 code: "PHONE_CALLBACK_REJECTED",
307 message: "Phone callback rejected the authentication",
308 })
309 } else {
310 Ok(())
311 }
312 }
313 }
314
315 fn configured() -> (PhoneNumberPlugin, Arc<Outbox>, Arc<Outbox>) {
316 let outbox = Arc::new(Outbox::default());
317 let reset = Arc::new(Outbox::default());
318 (
319 PhoneNumberPlugin::new(PhoneNumberConfig {
320 send_otp: Some(Arc::<Outbox>::clone(&outbox)),
321 send_password_reset_otp: Some(Arc::<Outbox>::clone(&reset)),
322 sign_up_on_verification: Some(Arc::new(SignupIdentity)),
323 ..Default::default()
324 }),
325 outbox,
326 reset,
327 )
328 }
329
330 async fn context()
331 -> AuthContext<alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema> {
332 let mut ctx = test_helpers::create_test_context().await;
333 ctx.set_metadata("phone-number.enabled", json!(true));
334 ctx
335 }
336
337 async fn post(
338 plugin: &PhoneNumberPlugin,
339 ctx: &AuthContext<impl AuthSchema>,
340 path: &str,
341 body: Value,
342 ) -> AuthResponse {
343 let req = test_helpers::create_auth_json_request_no_query(
344 HttpMethod::Post,
345 path,
346 None,
347 Some(body),
348 );
349 match plugin.on_request(&req, ctx).await {
350 Ok(Some(value)) => value,
351 Ok(None) => AuthResponse::text(404, "missing route"),
352 Err(error) => error.to_auth_response(),
353 }
354 }
355
356 async fn phone_user(ctx: &AuthContext<impl AuthSchema>, phone: &str, verified: bool) -> String {
357 let mut user = CreateUser::new()
358 .with_email(format!("{phone}@registered.fixture.test"))
359 .with_email_verified(true);
360 user.phone_number = Some(phone.into());
361 user.phone_number_verified = Some(verified);
362 let user = ctx.database.create_user(user).await.unwrap();
363 let hash = alibi_core::utils::password::hash_password(None, "original-password123")
364 .await
365 .unwrap();
366 _ = ctx
367 .database
368 .create_account(CreateAccount {
369 additional_fields: alibi_core::field_policy::FieldValues::default(),
370 user_id: user.id().to_string(),
371 account_id: user.id().to_string(),
372 provider_id: "credential".into(),
373 access_token: None,
374 refresh_token: None,
375 id_token: None,
376 access_token_expires_at: None,
377 refresh_token_expires_at: None,
378 scope: None,
379 password: Some(hash),
380 })
381 .await
382 .unwrap();
383 user.id().to_string()
384 }
385
386 #[tokio::test]
389 #[expect(
390 clippy::too_many_lines,
391 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
392 )]
393 async fn notification_failure_preserves_phone_authentication_gates_and_issued_proofs() {
394 let mut ctx = context().await;
395 ctx.config = Arc::new((*ctx.config).clone().awaited_notification_errors(
396 alibi_core::AwaitedNotificationErrorPolicy::LogAndContinue,
397 ));
398 let phone = "+15551110099";
399 let user_id = phone_user(&ctx, phone, false).await;
400 let outbox = Arc::new(Outbox::default());
401 let sender = Arc::new(RejectingSender(Arc::<Outbox>::clone(&outbox)));
402 let plugin = PhoneNumberPlugin::new(PhoneNumberConfig {
403 send_otp: Some(Arc::<RejectingSender>::clone(&sender)),
404 send_password_reset_otp: Some(sender),
405 require_verification: true,
406 ..Default::default()
407 });
408
409 let denied = post(
410 &plugin,
411 &ctx,
412 "/sign-in/phone-number",
413 json!({"phoneNumber":phone,"password":"original-password123"}),
414 )
415 .await;
416 assert_eq!(denied.status, 401);
417 let body: Value = serde_json::from_slice(&denied.body).unwrap();
418 assert_eq!(body.get("code"), Some(&json!("PHONE_NUMBER_NOT_VERIFIED")));
419 let initial = outbox.0.lock().unwrap().last().unwrap().clone();
420 assert_eq!(initial.phone_number, phone);
421 assert_eq!(
422 ctx.database
423 .get_latest_verification_by_identifier(phone)
424 .await
425 .unwrap()
426 .unwrap()
427 .value(),
428 initial.code
429 );
430 assert_eq!(
431 ctx.database
432 .get_user_sessions(&user_id)
433 .await
434 .unwrap()
435 .len(),
436 0
437 );
438 assert_eq!(
439 post(
440 &plugin,
441 &ctx,
442 "/phone-number/verify",
443 json!({"phoneNumber":phone,"code":initial.code,"disableSession":true})
444 )
445 .await
446 .status,
447 200
448 );
449
450 let direct = post(
451 &plugin,
452 &ctx,
453 "/phone-number/send-otp",
454 json!({"phoneNumber":phone}),
455 )
456 .await;
457 assert_eq!(direct.status, 500);
458 assert_eq!(direct.body, Vec::<u8>::new());
459 assert!(direct.headers.get("content-type").is_none());
460 let direct_code = outbox.0.lock().unwrap().last().unwrap().code.clone();
461 assert_eq!(
462 ctx.database
463 .get_latest_verification_by_identifier(phone)
464 .await
465 .unwrap()
466 .unwrap()
467 .value(),
468 format!("{direct_code}:0")
469 );
470 assert_eq!(
471 post(
472 &plugin,
473 &ctx,
474 "/phone-number/verify",
475 json!({"phoneNumber":phone,"code":direct_code,"disableSession":true})
476 )
477 .await
478 .status,
479 200
480 );
481
482 let reset = post(
483 &plugin,
484 &ctx,
485 "/phone-number/request-password-reset",
486 json!({"phoneNumber":phone}),
487 )
488 .await;
489 assert_eq!(reset.status, 200);
490 assert_eq!(
491 serde_json::from_slice::<Value>(&reset.body).unwrap(),
492 json!({"status":true})
493 );
494 let reset_code = outbox.0.lock().unwrap().last().unwrap().code.clone();
495 let key = format!("{phone}-request-password-reset");
496 assert_eq!(
497 ctx.database
498 .get_latest_verification_by_identifier(&key)
499 .await
500 .unwrap()
501 .unwrap()
502 .value(),
503 format!("{reset_code}:0")
504 );
505 let changed = post(
506 &plugin,
507 &ctx,
508 "/phone-number/reset-password",
509 json!({"phoneNumber":phone,"otp":reset_code,"newPassword":"changed-password123"}),
510 )
511 .await;
512 assert_eq!(changed.status, 200);
513 assert!(
514 ctx.database
515 .get_latest_verification_by_identifier(&key)
516 .await
517 .unwrap()
518 .is_none()
519 );
520 assert_eq!(
521 post(
522 &plugin,
523 &ctx,
524 "/sign-in/phone-number",
525 json!({"phoneNumber":phone,"password":"original-password123"})
526 )
527 .await
528 .status,
529 401
530 );
531 assert_eq!(
532 post(
533 &plugin,
534 &ctx,
535 "/sign-in/phone-number",
536 json!({"phoneNumber":phone,"password":"changed-password123"})
537 )
538 .await
539 .status,
540 200
541 );
542 assert_eq!(
543 ctx.database
544 .get_user_sessions(&user_id)
545 .await
546 .unwrap()
547 .len(),
548 1
549 );
550 assert_eq!(outbox.0.lock().unwrap().len(), 3);
551 }
552
553 #[tokio::test]
556 async fn phone_signup_and_replay_preserve_verification_identity_and_session() {
557 let ctx = context().await;
558 let (plugin, outbox, _) = configured();
559 let phone = "+15551110001";
560 assert_eq!(
561 post(
562 &plugin,
563 &ctx,
564 "/phone-number/send-otp",
565 json!({"phoneNumber":phone})
566 )
567 .await
568 .status,
569 200
570 );
571 let code = outbox.0.lock().unwrap().last().unwrap().code.clone();
572 let response = post(
573 &plugin,
574 &ctx,
575 "/phone-number/verify",
576 json!({"phoneNumber":phone,"code":code}),
577 )
578 .await;
579 assert_eq!(response.status, 200);
580 let payload: Value = serde_json::from_slice(&response.body).unwrap();
581 let token = payload.get("token").and_then(Value::as_str).unwrap();
582 let user = ctx
583 .database
584 .get_user_by_phone_number(phone)
585 .await
586 .unwrap()
587 .unwrap();
588 assert_eq!(user.phone_number_verified(), Some(true));
589 assert!(!user.email_verified());
590 assert_eq!(user.name(), Some(phone));
591 assert_eq!(
592 ctx.database
593 .get_session(token)
594 .await
595 .unwrap()
596 .unwrap()
597 .user_id(),
598 user.id()
599 );
600 assert_eq!(
601 ctx.database
602 .get_user_accounts(&user.id())
603 .await
604 .unwrap()
605 .len(),
606 0
607 );
608 assert_eq!(
609 post(
610 &plugin,
611 &ctx,
612 "/phone-number/verify",
613 json!({"phoneNumber":phone,"code":code})
614 )
615 .await
616 .status,
617 400
618 );
619 assert_eq!(
620 ctx.database
621 .get_user_sessions(&user.id())
622 .await
623 .unwrap()
624 .len(),
625 1
626 );
627 }
628
629 #[tokio::test]
632 async fn phone_consumer_enforces_scope_expiry_budget_and_single_use() {
633 let ctx = context().await;
634 let (plugin, outbox, _) = configured();
635 let phone = "+15551110002";
636 _ = post(
637 &plugin,
638 &ctx,
639 "/phone-number/send-otp",
640 json!({"phoneNumber":phone}),
641 )
642 .await;
643 let code = outbox.0.lock().unwrap().last().unwrap().code.clone();
644 assert_eq!(
645 plugin
646 .consume_otp(&ctx, "+15559999999", &code)
647 .await
648 .unwrap_err()
649 .status_code(),
650 400
651 );
652 let deadline = ctx
653 .database
654 .get_latest_verification_by_identifier(phone)
655 .await
656 .unwrap()
657 .unwrap()
658 .expires_at();
659 for _ in 0..3 {
660 assert_eq!(
661 plugin
662 .consume_otp(&ctx, phone, "incorrect")
663 .await
664 .unwrap_err()
665 .status_code(),
666 400
667 );
668 assert_eq!(
669 ctx.database
670 .get_latest_verification_by_identifier(phone)
671 .await
672 .unwrap()
673 .unwrap()
674 .expires_at(),
675 deadline
676 );
677 }
678 assert_eq!(
679 plugin
680 .consume_otp(&ctx, phone, &code)
681 .await
682 .unwrap_err()
683 .status_code(),
684 403
685 );
686 assert!(
687 ctx.database
688 .get_latest_verification_by_identifier(phone)
689 .await
690 .unwrap()
691 .is_none()
692 );
693 _ = ctx
694 .database
695 .create_verification(CreateVerification {
696 identifier: phone.into(),
697 value: "654321:0".into(),
698 expires_at: chrono::Utc::now() - Duration::seconds(1),
699 })
700 .await
701 .unwrap();
702 assert_eq!(
703 plugin
704 .consume_otp(&ctx, phone, "654321")
705 .await
706 .unwrap_err()
707 .error_payload()
708 .1
709 .as_deref(),
710 Some("OTP_EXPIRED")
711 );
712 _ = post(
713 &plugin,
714 &ctx,
715 "/phone-number/send-otp",
716 json!({"phoneNumber":phone}),
717 )
718 .await;
719 let code_2 = outbox.0.lock().unwrap().last().unwrap().code.clone();
720 plugin.consume_otp(&ctx, phone, &code_2).await.unwrap();
721 assert!(
722 ctx.database
723 .get_user_by_phone_number(phone)
724 .await
725 .unwrap()
726 .is_none()
727 );
728 assert!(plugin.consume_otp(&ctx, phone, &code_2).await.is_err());
729 }
730
731 #[tokio::test]
738 async fn phone_credentials_reject_another_users_signed_trust_before_authenticating() {
739 use alibi_core::utils::cookie_utils::{
740 related_cookie_name, sign_cookie_value, verify_cookie_value,
741 };
742 use alibi_core::{AuthInitContext, UpdateUser};
743 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
744 use hmac::{Hmac, KeyInit, Mac};
745 use sha2::Sha256;
746
747 let mut ctx = context().await;
748 let mut init = AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
749 crate::two_factor::TwoFactorPlugin::new()
750 .on_init(&mut init)
751 .await
752 .unwrap();
753 ctx.metadata.extend(init.metadata);
754 let (plugin, _, _) = configured();
755 let phone = "+15551110012";
756 let user_id = phone_user(&ctx, phone, true).await;
757 let foreign_id = phone_user(&ctx, "+15551110013", true).await;
758 _ = ctx
759 .database
760 .update_user(
761 &user_id,
762 UpdateUser {
763 two_factor_enabled: Some(true),
764 ..Default::default()
765 },
766 )
767 .await
768 .unwrap();
769 let trust_id = "trust-device-other-user";
770 _ = ctx
771 .database
772 .create_verification(CreateVerification {
773 identifier: trust_id.into(),
774 value: foreign_id.clone(),
775 expires_at: chrono::Utc::now() + Duration::days(30),
776 })
777 .await
778 .unwrap();
779 let mut mac = Hmac::<Sha256>::new_from_slice(ctx.config.secret.as_bytes()).unwrap();
780 mac.update(format!("{foreign_id}!{trust_id}").as_bytes());
781 let token = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
782 let cookie = sign_cookie_value(&format!("{token}!{trust_id}"), &ctx.config.secret);
783 let mut req = test_helpers::create_auth_json_request_no_query(
784 HttpMethod::Post,
785 "/sign-in/phone-number",
786 None,
787 Some(json!({"phoneNumber":phone,"password":"original-password123","rememberMe":false})),
788 );
789 _ = req.headers.insert(
790 "cookie".into(),
791 format!(
792 "{}={cookie}",
793 related_cookie_name(&ctx.config, "trust_device")
794 ),
795 );
796 let response = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
797 assert_eq!(response.status, 200);
798 let body: Value = serde_json::from_slice(&response.body).unwrap();
799 assert_eq!(body.get("twoFactorRedirect"), Some(&json!(true)));
800 assert!(body.get("token").is_none());
801 assert_eq!(
802 ctx.database
803 .get_user_sessions(&user_id)
804 .await
805 .unwrap()
806 .len(),
807 0
808 );
809 assert_eq!(
810 ctx.database
811 .get_latest_verification_by_identifier(trust_id)
812 .await
813 .unwrap()
814 .unwrap()
815 .value(),
816 foreign_id
817 );
818 let prefix = format!("{}=", related_cookie_name(&ctx.config, "two_factor"));
819 let challenge_cookie = response
820 .headers
821 .get_all("set-cookie")
822 .find_map(|cookie_2| cookie_2.strip_prefix(&prefix))
823 .unwrap()
824 .split(';')
825 .next()
826 .unwrap();
827 let identifier = verify_cookie_value(challenge_cookie, &ctx.config.secret).unwrap();
828 let challenge = ctx
829 .database
830 .get_latest_verification_by_identifier(&identifier)
831 .await
832 .unwrap()
833 .unwrap();
834 assert_eq!(challenge.value(), user_id);
835 assert!((challenge.expires_at() - chrono::Utc::now()).num_seconds() > 598);
836 }
837
838 #[tokio::test]
841 async fn phone_update_rejects_occupied_number_and_keeps_requester_session() {
842 let ctx = context().await;
843 let (plugin, outbox, _) = configured();
844 let user_id = phone_user(&ctx, "+15551110004", true).await;
845 let user = ctx
846 .database
847 .get_user_by_id(&user_id)
848 .await
849 .unwrap()
850 .unwrap();
851 let session = ctx
852 .session_manager()
853 .create_session(&user, None, None)
854 .await
855 .unwrap();
856 let occupied = "+15551110005";
857 _ = phone_user(&ctx, occupied, true).await;
858 _ = post(
859 &plugin,
860 &ctx,
861 "/phone-number/send-otp",
862 json!({"phoneNumber":occupied}),
863 )
864 .await;
865 let code = outbox.0.lock().unwrap().last().unwrap().code.clone();
866 let req = test_helpers::create_auth_json_request_no_query(
867 HttpMethod::Post,
868 "/phone-number/verify",
869 Some(session.token()),
870 Some(json!({"phoneNumber":occupied,"code":code,"updatePhoneNumber":true})),
871 );
872 assert_eq!(
873 plugin
874 .on_request(&req, &ctx)
875 .await
876 .unwrap_err()
877 .error_payload()
878 .1
879 .as_deref(),
880 Some("PHONE_NUMBER_EXIST")
881 );
882 assert_eq!(
883 ctx.database
884 .get_user_by_id(&user_id)
885 .await
886 .unwrap()
887 .unwrap()
888 .phone_number(),
889 Some("+15551110004")
890 );
891 let target = "+15551110006";
892 _ = post(
893 &plugin,
894 &ctx,
895 "/phone-number/send-otp",
896 json!({"phoneNumber":target}),
897 )
898 .await;
899 let code_2 = outbox.0.lock().unwrap().last().unwrap().code.clone();
900 let req_2 = test_helpers::create_auth_json_request_no_query(
901 HttpMethod::Post,
902 "/phone-number/verify",
903 Some(session.token()),
904 Some(json!({"phoneNumber":target,"code":code_2,"updatePhoneNumber":true})),
905 );
906 let response = plugin.on_request(&req_2, &ctx).await.unwrap().unwrap();
907 let payload: Value = serde_json::from_slice(&response.body).unwrap();
908 assert_eq!(
909 payload.get("token").and_then(Value::as_str),
910 Some(session.token())
911 );
912 assert!(!response.headers.contains_key("set-cookie"));
913 assert_eq!(
914 ctx.database
915 .get_user_sessions(&user_id)
916 .await
917 .unwrap()
918 .len(),
919 1
920 );
921 assert_eq!(
922 ctx.database
923 .get_user_by_id(&user_id)
924 .await
925 .unwrap()
926 .unwrap()
927 .phone_number(),
928 Some(target)
929 );
930 }
931
932 #[tokio::test]
939 async fn provider_verification_binds_phone_consumes_once_and_clears_local_rows() {
940 let ctx = context().await;
941 let (mut plugin, _, _) = configured();
942 let phone = "+15551110008";
943 plugin.config.verify_otp = Some(Arc::new(Provider(Mutex::new(Some(PhoneOtpDelivery {
944 phone_number: phone.into(),
945 code: "provider-approved".into(),
946 })))));
947 _ = ctx
948 .database
949 .create_verification(CreateVerification {
950 identifier: phone.into(),
951 value: "unrelated:100".into(),
952 expires_at: chrono::Utc::now() - Duration::days(1),
953 })
954 .await
955 .unwrap();
956 assert!(
957 plugin
958 .consume_otp(&ctx, "+15559999999", "provider-approved")
959 .await
960 .is_err()
961 );
962 plugin
963 .consume_otp(&ctx, phone, "provider-approved")
964 .await
965 .unwrap();
966 assert!(
967 ctx.database
968 .get_latest_verification_by_identifier(phone)
969 .await
970 .unwrap()
971 .is_none()
972 );
973 assert!(
974 plugin
975 .consume_otp(&ctx, phone, "provider-approved")
976 .await
977 .is_err()
978 );
979 assert!(
980 ctx.database
981 .get_user_by_phone_number(phone)
982 .await
983 .unwrap()
984 .is_none()
985 );
986 }
987
988 #[tokio::test]
991 async fn direct_phone_mutation_and_invalid_body_cannot_bypass_ownership() {
992 let ctx = context().await;
993 let (plugin, _, _) = configured();
994 let req = test_helpers::create_auth_json_request_no_query(
995 HttpMethod::Post,
996 "/update-user",
997 None,
998 Some(json!({"phoneNumber":"+15551110009"})),
999 );
1000 assert_eq!(
1001 plugin
1002 .before_request(&req, &ctx)
1003 .await
1004 .unwrap_err()
1005 .error_payload()
1006 .1
1007 .as_deref(),
1008 Some("PHONE_NUMBER_CANNOT_BE_UPDATED")
1009 );
1010 let req_2 = test_helpers::create_auth_json_request_no_query(
1011 HttpMethod::Post,
1012 "/update-user",
1013 None,
1014 Some(json!({"phoneNumber":null})),
1015 );
1016 assert!(plugin.before_request(&req_2, &ctx).await.unwrap().is_none());
1017 let response = post(
1018 &plugin,
1019 &ctx,
1020 "/phone-number/verify",
1021 json!({"phoneNumber":null,"code":4,"disableSession":null}),
1022 )
1023 .await;
1024 assert_eq!(response.status, 400);
1025 let payload: Value = serde_json::from_slice(&response.body).unwrap();
1026 assert_eq!(payload.get("code"), Some(&json!("VALIDATION_ERROR")));
1027 assert_eq!(
1028 payload.get("message"),
1029 Some(&json!(
1030 "[body.phoneNumber] Invalid input: expected string, received null; [body.code] Invalid input: expected string, received number; [body.disableSession] Invalid input: expected boolean, received null"
1031 ))
1032 );
1033 }
1034
1035 #[tokio::test]
1037 async fn concurrent_phone_consumers_cannot_reuse_a_local_proof() {
1038 let ctx = context().await;
1039 let (plugin, outbox, _) = configured();
1040 let phone = "+15551110010";
1041 _ = post(
1042 &plugin,
1043 &ctx,
1044 "/phone-number/send-otp",
1045 json!({"phoneNumber":phone}),
1046 )
1047 .await;
1048 let code = outbox.0.lock().unwrap().last().unwrap().code.clone();
1049 let (first, second) = tokio::join!(
1050 plugin.consume_otp(&ctx, phone, &code),
1051 plugin.consume_otp(&ctx, phone, &code)
1052 );
1053 assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1);
1054 assert!(
1055 ctx.database
1056 .get_user_by_phone_number(phone)
1057 .await
1058 .unwrap()
1059 .is_none()
1060 );
1061 }
1062
1063 #[tokio::test]
1066 async fn verification_callback_observes_owner_and_rejection_prevents_session_issuance() {
1067 let ctx = context().await;
1068 let (mut plugin, outbox, _) = configured();
1069 let callback = Arc::new(VerificationCallback::default());
1070 plugin.config.callback_on_verification =
1071 Some(Arc::<VerificationCallback>::clone(&callback));
1072 let phone = "+15551110011";
1073 _ = post(
1074 &plugin,
1075 &ctx,
1076 "/phone-number/send-otp",
1077 json!({"phoneNumber":phone}),
1078 )
1079 .await;
1080 let code = outbox.0.lock().unwrap().last().unwrap().code.clone();
1081 let success = post(
1082 &plugin,
1083 &ctx,
1084 "/phone-number/verify",
1085 json!({"phoneNumber":phone,"code":code}),
1086 )
1087 .await;
1088 assert_eq!(success.status, 200);
1089 let user = ctx
1090 .database
1091 .get_user_by_phone_number(phone)
1092 .await
1093 .unwrap()
1094 .unwrap();
1095 {
1096 let captured = callback.captured.lock().unwrap();
1097 assert_eq!(captured.len(), 1);
1098 let proof = captured.first().unwrap();
1099 assert_eq!(proof.phone_number, phone);
1100 assert_eq!(proof.user.id, user.id());
1101 assert_eq!(proof.user.phone_number.as_deref(), Some(phone));
1102 assert_eq!(proof.user.phone_number_verified, Some(true));
1103 drop(captured);
1104 }
1105 let rejecting = Arc::new(VerificationCallback {
1106 reject: true,
1107 ..Default::default()
1108 });
1109 plugin.config.callback_on_verification =
1110 Some(Arc::<VerificationCallback>::clone(&rejecting));
1111 let rejected_phone = "+15551110012";
1112 _ = post(
1113 &plugin,
1114 &ctx,
1115 "/phone-number/send-otp",
1116 json!({"phoneNumber":rejected_phone}),
1117 )
1118 .await;
1119 let code_2 = outbox.0.lock().unwrap().last().unwrap().code.clone();
1120 let rejected = post(
1121 &plugin,
1122 &ctx,
1123 "/phone-number/verify",
1124 json!({"phoneNumber":rejected_phone,"code":code_2}),
1125 )
1126 .await;
1127 assert_eq!(rejected.status, 400);
1128 assert_eq!(rejecting.captured.lock().unwrap().len(), 1);
1129 let user_2 = ctx
1130 .database
1131 .get_user_by_phone_number(rejected_phone)
1132 .await
1133 .unwrap()
1134 .unwrap();
1135 assert_eq!(user_2.phone_number_verified(), Some(true));
1136 assert_eq!(
1137 ctx.database
1138 .get_user_sessions(&user_2.id())
1139 .await
1140 .unwrap()
1141 .len(),
1142 0
1143 );
1144 assert!(
1145 ctx.database
1146 .get_latest_verification_by_identifier(rejected_phone)
1147 .await
1148 .unwrap()
1149 .is_none()
1150 );
1151 }
1152
1153 #[tokio::test]
1155 async fn server_consumer_rejects_exhausted_decimal_exponent_and_radix_counters() {
1156 let ctx = context().await;
1157 let (plugin, _, _) = configured();
1158 for attempts in ["3.0", "3e0", " 3 ", "0x3", "0o3", "0b11"] {
1159 let phone = format!("counter:{attempts}");
1160 _ = ctx
1161 .database
1162 .create_verification(CreateVerification {
1163 identifier: phone.clone(),
1164 value: format!("654321:{attempts}"),
1165 expires_at: chrono::Utc::now() + Duration::minutes(5),
1166 })
1167 .await
1168 .unwrap();
1169 let result = plugin
1170 .consume_otp(&ctx, &phone, "654321")
1171 .await
1172 .unwrap_err();
1173 assert_eq!(
1174 result.error_payload().1.as_deref(),
1175 Some("TOO_MANY_ATTEMPTS")
1176 );
1177 assert!(
1178 ctx.database
1179 .get_latest_verification_by_identifier(&phone)
1180 .await
1181 .unwrap()
1182 .is_none()
1183 );
1184 }
1185 }
1186}
1187