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