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