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