Skip to main content

alibi_plugins/phone_number/
endpoint.rs

1//! Phone challenge delivery and consumption through installed logical endpoint dispatch.
2use super::{
3    PhoneNumberPlugin,
4    types::{ResetRequest, SendRequest, SignInRequest, VerifyRequest},
5};
6use crate::authentication_helpers::JsonField;
7use crate::endpoint::{definition, validate_fields};
8use alibi_core::endpoint::{
9    EndpointCall, EndpointDefinition, EndpointInput, EndpointResponse, ServerEndpoint,
10};
11use alibi_core::utils::json::JsValue;
12use alibi_core::{AuthContext, AuthResult, AuthSchema, HttpMethod};
13use serde::{Deserialize, Serialize};
14
15/// Successful proof consumption does not create an identity or session.
16#[derive(Debug, Deserialize, Serialize)]
17pub struct PhoneOtpConsumption {
18    pub status: bool,
19}
20
21#[derive(Deserialize)]
22#[serde(rename_all = "camelCase")]
23struct ConsumeInput {
24    phone_number: String,
25    code: String,
26}
27
28#[derive(Debug, Deserialize, Serialize)]
29pub struct PhoneOtpSent {
30    pub message: String,
31}
32
33/// A reset challenge was issued without publishing browser authority.
34#[derive(Debug, Deserialize, Serialize)]
35pub struct PhonePasswordResetRequested {
36    pub status: bool,
37}
38
39/// The reset proof was consumed and the replacement credential was committed.
40#[derive(Debug, Deserialize, Serialize)]
41pub struct PhonePasswordResetResult {
42    pub status: bool,
43}
44
45/// A credential session or the installed second-factor challenge.
46#[derive(Debug, Deserialize)]
47#[serde(untagged)]
48pub enum PhoneSignInResponse {
49    Session {
50        token: String,
51        user: Box<alibi_core::UserView>,
52    },
53    TwoFactor {
54        #[serde(rename = "twoFactorRedirect")]
55        two_factor_redirect: bool,
56        #[serde(rename = "twoFactorMethods")]
57        two_factor_methods: Vec<String>,
58    },
59}
60
61/// Proof and optional authority policy for phone verification. Creation fields are retained.
62#[derive(Debug, Deserialize, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct PhoneVerificationInput {
65    pub phone_number: String,
66    pub code: String,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub disable_session: Option<bool>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub update_phone_number: Option<bool>,
71    #[serde(flatten)]
72    pub additional_fields: serde_json::Map<String, serde_json::Value>,
73}
74
75/// Verified ownership, with either existing, newly issued, or disabled session authority.
76#[derive(Debug, Deserialize, Serialize)]
77pub struct PhoneVerificationResult {
78    pub status: bool,
79    pub token: Option<String>,
80    pub user: Box<alibi_core::UserView>,
81}
82
83pub(super) fn definitions() -> Vec<EndpointDefinition> {
84    vec![
85        definition(
86            "verifyPhoneNumber",
87            "verifyPhoneNumber",
88            Some("/phone-number/verify"),
89            HttpMethod::Post,
90        ),
91        definition(
92            "signInPhoneNumber",
93            "signInPhoneNumber",
94            Some("/sign-in/phone-number"),
95            HttpMethod::Post,
96        ),
97        definition(
98            "resetPasswordPhoneNumber",
99            "resetPasswordPhoneNumber",
100            Some("/phone-number/reset-password"),
101            HttpMethod::Post,
102        ),
103        definition(
104            "requestPasswordResetPhoneNumber",
105            "requestPasswordResetPhoneNumber",
106            Some("/phone-number/request-password-reset"),
107            HttpMethod::Post,
108        ),
109        definition(
110            "sendPhoneNumberOTP",
111            "sendPhoneNumberOTP",
112            Some("/phone-number/send-otp"),
113            HttpMethod::Post,
114        ),
115        definition(
116            "consumePhoneNumberOTP",
117            "consumePhoneNumberOTP",
118            None,
119            HttpMethod::Post,
120        ),
121    ]
122}
123
124const SEND_FIELDS: &[JsonField] = &[JsonField::string("phoneNumber", true)];
125const CONSUME_FIELDS: &[JsonField] = &[
126    JsonField::string("phoneNumber", true),
127    JsonField::string("code", true),
128];
129
130pub(super) fn validate(call: &EndpointCall) -> AuthResult<EndpointInput> {
131    if call.operation_id() == "verifyPhoneNumber" {
132        let _ = validate_fields(
133            call.body(),
134            "body",
135            <VerifyRequest as crate::authentication_helpers::RequestBody>::FIELDS,
136        )?;
137        return Ok(EndpointInput {
138            body: call.body().cloned(),
139            query: call.query().cloned(),
140        });
141    }
142    Ok(EndpointInput {
143        body: Some(validate_fields(
144            call.body(),
145            "body",
146            match call.operation_id() {
147                "consumePhoneNumberOTP" => CONSUME_FIELDS,
148                "signInPhoneNumber" => {
149                    <SignInRequest as crate::authentication_helpers::RequestBody>::FIELDS
150                }
151                "resetPasswordPhoneNumber" => {
152                    <ResetRequest as crate::authentication_helpers::RequestBody>::FIELDS
153                }
154                _ => SEND_FIELDS,
155            },
156        )?),
157        query: call.query().cloned(),
158    })
159}
160
161impl PhoneNumberPlugin {
162    /// Verify a phone proof and apply the requested identity/session policy.
163    ///
164    /// # Errors
165    /// Returns an error if verification input cannot be serialized.
166    pub fn verify_endpoint(
167        input: &PhoneVerificationInput,
168    ) -> AuthResult<ServerEndpoint<PhoneVerificationResult>> {
169        ServerEndpoint::new("phone-number", "verifyPhoneNumber").with_body(input)
170    }
171
172    /// Admit phone credentials and publish an owned session or second-factor challenge.
173    ///
174    /// # Errors
175    /// Returns an error if the credential input cannot be serialized.
176    pub fn sign_in_endpoint(
177        input: &SignInRequest,
178    ) -> AuthResult<ServerEndpoint<PhoneSignInResponse>> {
179        ServerEndpoint::new("phone-number", "signInPhoneNumber").with_body(input)
180    }
181
182    /// Consume a reset challenge and commit a replacement password.
183    ///
184    /// # Errors
185    /// Returns an error if the supplied request cannot be serialized.
186    pub fn reset_password_endpoint(
187        input: &ResetRequest,
188    ) -> AuthResult<ServerEndpoint<PhonePasswordResetResult>> {
189        ServerEndpoint::new("phone-number", "resetPasswordPhoneNumber").with_body(input)
190    }
191
192    /// Issue a password reset challenge without requiring a browser session.
193    #[must_use]
194    pub fn request_password_reset_endpoint(
195        phone_number: impl Into<String>,
196    ) -> ServerEndpoint<PhonePasswordResetRequested> {
197        ServerEndpoint::new("phone-number", "requestPasswordResetPhoneNumber").with_body_value(
198            JsValue::Object(
199                [("phoneNumber".into(), JsValue::String(phone_number.into()))]
200                    .into_iter()
201                    .collect(),
202            ),
203        )
204    }
205
206    /// Send a phone challenge without requiring a browser request or session.
207    #[must_use]
208    pub fn send_otp_endpoint(phone_number: impl Into<String>) -> ServerEndpoint<PhoneOtpSent> {
209        ServerEndpoint::new("phone-number", "sendPhoneNumberOTP").with_body_value(JsValue::Object(
210            [("phoneNumber".into(), JsValue::String(phone_number.into()))]
211                .into_iter()
212                .collect(),
213        ))
214    }
215
216    /// Consume an issued proof through installed endpoint hooks, without an HTTP request.
217    #[must_use]
218    pub fn consume_otp_endpoint(
219        phone_number: impl Into<String>,
220        code: impl Into<String>,
221    ) -> ServerEndpoint<PhoneOtpConsumption> {
222        ServerEndpoint::new("phone-number", "consumePhoneNumberOTP").with_body_value(
223            JsValue::Object(
224                [
225                    ("phoneNumber".into(), JsValue::String(phone_number.into())),
226                    ("code".into(), JsValue::String(code.into())),
227                ]
228                .into_iter()
229                .collect(),
230            ),
231        )
232    }
233
234    pub(super) async fn call_endpoint(
235        &self,
236        call: &EndpointCall,
237        ctx: &AuthContext<impl AuthSchema>,
238    ) -> AuthResult<EndpointResponse> {
239        if call.operation_id() != "consumePhoneNumberOTP" {
240            let output = match call.operation_id() {
241                "verifyPhoneNumber" => self.verify_core(call, ctx, call.body_as()?).await?,
242                "signInPhoneNumber" => self.sign_in_core(call, ctx, call.body_as()?).await?,
243                "sendPhoneNumberOTP" => {
244                    self.send_otp_core(ctx, call.request(), call.body_as::<SendRequest>()?)
245                        .await?
246                }
247                "resetPasswordPhoneNumber" => {
248                    self.reset_password_core(ctx, call.body_as()?).await?
249                }
250                _ => {
251                    self.request_password_reset_core(
252                        ctx,
253                        call.request(),
254                        call.body_as::<SendRequest>()?,
255                    )
256                    .await?
257                }
258            };
259            let mut response = if output.status >= 400 {
260                EndpointResponse::error(crate::endpoint::error_response(output.clone()))
261                    .without_error_body()
262            } else {
263                EndpointResponse::value(alibi_core::utils::json::from_slice(&output.body)?)
264            };
265            response.set_status(Some(output.status));
266            response.merge_headers(output.headers);
267            return Ok(response);
268        }
269        let body: ConsumeInput = call.body_as()?;
270        self.verify_and_consume(ctx, call.request(), &body.phone_number, &body.code)
271            .await?;
272        EndpointResponse::json(&PhoneOtpConsumption { status: true })
273    }
274}