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