Skip to main content

alibi_plugins/phone_number/
mod.rs

1//! Phone ownership verification, password login and scoped password reset.
2//!
3//! [`PhoneNumberPlugin::consume_otp`] is a server-only verification operation.
4//! It does not create users or sessions and never registers a public route.
5
6mod 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/// A provider that verifies its own phone challenge. Its verification result
31/// replaces local verification, including expiry, replay and attempt policy.
32#[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    /// Raw numeric length. Safe positive fractions round up; NaN generates an
67    /// empty code. Nonpositive and resource-unsafe lengths fail generation.
68    pub otp_length: f64,
69    /// Lifetime in seconds. Fractions retain JavaScript millisecond rounding;
70    /// invalid dates fail before persistence.
71    pub expires_in: f64,
72    /// Raw attempt budget compared with the persisted integer counter. Zero and
73    /// negatives reject an unused proof; NaN and positive infinity never exhaust.
74    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    /// Consume a phone proof without mutating a user or issuing a session.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if the OTP is missing, expired, invalid, or cannot be consumed from storage.
122    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// LCOV_EXCL_START
167#[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    // Upstream awaits sendOTP directly for /send-otp, but uses its nonfatal
337    // background policy for unverified password signin and password reset delivery.
338    #[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    // Upstream: signup on verification proves phone ownership, leaves email
504    // unverified, and issues exactly one real session, without a password account.
505    #[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    // Upstream: attempts and expiry delete proofs, and a code for one phone cannot
580    // authorize another. The server-only consumer cannot create any user/session.
581    #[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    // Upstream: password sign-in can require phone verification before password
682    // Password admission/session expiry and reset lifecycle are exercised through
683    // installed HTTP handlers on both stores in integration/storage/plugin_flows/passwordless.rs.
684
685    // A valid server-signed trust cookie for another user cannot bypass the phone
686    // sign-in challenge or consume that user's trusted-device record.
687    #[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    // Upstream: changing a phone consumes proof before authorization/collision;
789    // the existing session token is returned without issuing a second session.
790    #[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    // Upstream phone reset burns proof on password-policy rejection and never
883    // Password admission/session expiry and reset lifecycle are exercised through
884    // installed HTTP handlers on both stores in integration/storage/plugin_flows/passwordless.rs.
885
886    // Upstream: a custom provider owns code expiry/replay. Successful provider
887    // validation deletes any local state but consumes no user/session operation.
888    #[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    // Upstream rejects non-null phone mutation in the before hook even for an
939    // unauthenticated caller. Clearing it passes to the normal update-user handler.
940    #[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    // Upstream: the atomic consume guarantees exactly one winning verification.
986    #[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    // The callback observes the persisted verified owner before session issuance.
1014    // Its failure consumes the proof and preserves the user, without a new session.
1015    #[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    // Number()-compatible persisted counters must not bypass the attempt budget.
1104    #[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// LCOV_EXCL_STOP