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 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/// A provider that verifies its own phone challenge. Its verification result
40/// replaces local verification, including expiry, replay and attempt policy.
41#[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    /// Raw numeric length. Safe positive fractions round up; NaN generates an
76    /// empty code. Nonpositive and resource-unsafe lengths fail generation.
77    pub otp_length: f64,
78    /// Lifetime in seconds. Fractions retain JavaScript millisecond rounding;
79    /// invalid dates fail before persistence.
80    pub expires_in: f64,
81    /// Raw attempt budget compared with the persisted integer counter. Zero and
82    /// negatives reject an unused proof; NaN and positive infinity never exhaust.
83    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    /// Consume a phone proof without mutating a user or issuing a session.
127    ///
128    /// # Errors
129    ///
130    /// Returns an error if the OTP is missing, expired, invalid, or cannot be consumed from storage.
131    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// LCOV_EXCL_START
180#[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    // Upstream awaits sendOTP directly for /send-otp, but uses its nonfatal
350    // background policy for unverified password signin and password reset delivery.
351    #[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    // Upstream: signup on verification proves phone ownership, leaves email
517    // unverified, and issues exactly one real session, without a password account.
518    #[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    // Upstream: attempts and expiry delete proofs, and a code for one phone cannot
593    // authorize another. The server-only consumer cannot create any user/session.
594    #[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    // Upstream: password sign-in can require phone verification before password
695    // Password admission/session expiry and reset lifecycle are exercised through
696    // installed HTTP handlers on both stores in integration/storage/plugin_flows/passwordless.rs.
697
698    // A valid server-signed trust cookie for another user cannot bypass the phone
699    // sign-in challenge or consume that user's trusted-device record.
700    #[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    // Upstream: changing a phone consumes proof before authorization/collision;
802    // the existing session token is returned without issuing a second session.
803    #[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    // Upstream phone reset burns proof on password-policy rejection and never
896    // Password admission/session expiry and reset lifecycle are exercised through
897    // installed HTTP handlers on both stores in integration/storage/plugin_flows/passwordless.rs.
898
899    // Upstream: a custom provider owns code expiry/replay. Successful provider
900    // validation deletes any local state but consumes no user/session operation.
901    #[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    // Upstream rejects non-null phone mutation in the before hook even for an
952    // unauthenticated caller. Clearing it passes to the normal update-user handler.
953    #[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    // Upstream: the atomic consume guarantees exactly one winning verification.
999    #[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    // The callback observes the persisted verified owner before session issuance.
1027    // Its failure consumes the proof and preserves the user, without a new session.
1028    #[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    // Number()-compatible persisted counters must not bypass the attempt budget.
1117    #[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// LCOV_EXCL_STOP