Skip to main content

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