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