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