Skip to main content

alibi_plugins/email_otp/
endpoint.rs

1use super::{EmailOtpPlugin, EmailOtpType};
2use crate::authentication_helpers::{JsonField, JsonFieldKind};
3use crate::endpoint::{definition, validate_fields};
4use alibi_core::endpoint::{
5    EndpointCall, EndpointDefinition, EndpointInput, EndpointResponse, ServerEndpoint,
6};
7use alibi_core::utils::json::JsValue;
8use alibi_core::{AuthContext, AuthResult, AuthSchema, HttpMethod};
9use serde::Deserialize;
10
11#[derive(Debug, Deserialize)]
12pub struct EmailOtpRead {
13    pub otp: Option<String>,
14}
15
16/// The sending operation acknowledges both delivery and concealed unknown mailboxes.
17#[derive(Debug, Deserialize)]
18pub struct EmailOtpSent {
19    pub success: bool,
20}
21
22/// A successful check retains the proof and its current attempt count.
23#[derive(Debug, Deserialize)]
24pub struct EmailOtpCheck {
25    pub success: bool,
26}
27
28/// A verified mailbox with optional newly issued browser authority.
29#[derive(Debug, Deserialize)]
30pub struct EmailOtpVerification {
31    pub status: bool,
32    pub token: Option<String>,
33    pub user: Box<alibi_core::UserView>,
34}
35
36/// Sign-in credentials with schema-defined fields retained for new identities.
37#[derive(Debug, Deserialize, serde::Serialize)]
38pub struct EmailOtpSignInInput {
39    pub email: String,
40    pub otp: String,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub name: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub image: Option<String>,
45    #[serde(flatten)]
46    pub additional_fields: serde_json::Map<String, serde_json::Value>,
47}
48
49/// Authority published after consuming a sign-in proof.
50#[derive(Debug, Deserialize)]
51pub struct EmailOtpSignInResult {
52    pub token: String,
53    pub user: Box<alibi_core::UserView>,
54}
55
56/// Reset request aliases acknowledge delivered or concealed mailbox requests.
57#[derive(Debug, Deserialize)]
58pub struct EmailOtpResetRequested {
59    pub success: bool,
60}
61
62/// Scoped reset proof and replacement credential for the public server operation.
63#[derive(Debug, Deserialize, serde::Serialize)]
64pub struct EmailOtpPasswordResetInput {
65    pub email: String,
66    pub otp: String,
67    pub password: String,
68}
69
70/// A replacement credential was committed without issuing browser authority.
71#[derive(Debug, Deserialize)]
72pub struct EmailOtpPasswordReset {
73    pub success: bool,
74}
75
76/// New mailbox and optional proof of the current mailbox.
77#[derive(Debug, Deserialize, serde::Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct EmailOtpChangeRequestInput {
80    pub new_email: String,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub otp: Option<String>,
83}
84
85/// The change challenge was delivered or an existing target mailbox was concealed.
86#[derive(Debug, Deserialize)]
87pub struct EmailOtpChangeRequested {
88    pub success: bool,
89}
90
91/// New mailbox and its scoped change proof.
92#[derive(Debug, Deserialize, serde::Serialize)]
93#[serde(rename_all = "camelCase")]
94pub struct EmailOtpChangeInput {
95    pub new_email: String,
96    pub otp: String,
97}
98
99/// The caller's verified mailbox was changed while retaining session authority.
100#[derive(Debug, Deserialize)]
101pub struct EmailOtpChanged {
102    pub success: bool,
103}
104
105const FIELDS: &[JsonField] = &[
106    JsonField::string("email", true),
107    JsonField {
108        name: "type",
109        kind: JsonFieldKind::OneOf(&[
110            "email-verification",
111            "sign-in",
112            "forget-password",
113            "change-email",
114        ]),
115        required: true,
116    },
117];
118
119pub(super) fn definitions() -> Vec<EndpointDefinition> {
120    vec![
121        definition(
122            "changeEmailEmailOTP",
123            "changeEmailWithEmailOTP",
124            Some("/email-otp/change-email"),
125            HttpMethod::Post,
126        ),
127        definition(
128            "requestEmailChangeEmailOTP",
129            "requestEmailChangeWithEmailOTP",
130            Some("/email-otp/request-email-change"),
131            HttpMethod::Post,
132        ),
133        definition(
134            "resetPasswordEmailOTP",
135            "resetPasswordWithEmailOTP",
136            Some("/email-otp/reset-password"),
137            HttpMethod::Post,
138        ),
139        definition(
140            "requestPasswordResetEmailOTP",
141            "requestPasswordResetWithEmailOTP",
142            Some("/email-otp/request-password-reset"),
143            HttpMethod::Post,
144        ),
145        definition(
146            "forgetPasswordEmailOTP",
147            "forgetPasswordWithEmailOTP",
148            Some("/forget-password/email-otp"),
149            HttpMethod::Post,
150        ),
151        definition(
152            "signInEmailOTP",
153            "signInWithEmailOTP",
154            Some("/sign-in/email-otp"),
155            HttpMethod::Post,
156        ),
157        definition(
158            "verifyEmailOTP",
159            "verifyEmailOTP",
160            Some("/email-otp/verify-email"),
161            HttpMethod::Post,
162        ),
163        definition(
164            "checkVerificationOTP",
165            "verifyEmailWithOTP",
166            Some("/email-otp/check-verification-otp"),
167            HttpMethod::Post,
168        ),
169        definition(
170            "sendVerificationOTP",
171            "sendEmailVerificationOTP",
172            Some("/email-otp/send-verification-otp"),
173            HttpMethod::Post,
174        ),
175        definition(
176            "createVerificationOTP",
177            "createEmailVerificationOTP",
178            None,
179            HttpMethod::Post,
180        ),
181        definition(
182            "getVerificationOTP",
183            "getEmailVerificationOTP",
184            None,
185            HttpMethod::Get,
186        ),
187    ]
188}
189
190pub(super) fn validate(call: &EndpointCall) -> AuthResult<EndpointInput> {
191    if call.operation_id() == "changeEmailWithEmailOTP" {
192        return Ok(EndpointInput {
193            body: Some(validate_fields(
194                call.body(),
195                "body",
196                <super::types::ConfirmChangeRequest as crate::authentication_helpers::RequestBody>::FIELDS,
197            )?),
198            query: call.query().cloned(),
199        });
200    }
201    if call.operation_id() == "requestEmailChangeWithEmailOTP" {
202        return Ok(EndpointInput {
203            body:Some(validate_fields(call.body(),"body",<super::types::ChangeEmailRequest as crate::authentication_helpers::RequestBody>::FIELDS)?),
204            query:call.query().cloned(),
205        });
206    }
207    if call.operation_id() == "resetPasswordWithEmailOTP" {
208        return Ok(EndpointInput {
209            body:Some(validate_fields(call.body(),"body",<super::types::PasswordRequest as crate::authentication_helpers::RequestBody>::FIELDS)?),
210            query:call.query().cloned(),
211        });
212    }
213    if matches!(
214        call.operation_id(),
215        "requestPasswordResetWithEmailOTP" | "forgetPasswordWithEmailOTP"
216    ) {
217        return Ok(EndpointInput {
218            body: Some(validate_fields(
219                call.body(),
220                "body",
221                <super::types::EmailRequest as crate::authentication_helpers::RequestBody>::FIELDS,
222            )?),
223            query: call.query().cloned(),
224        });
225    }
226    if call.operation_id() == "signInWithEmailOTP" {
227        let _ = validate_fields(
228            call.body(),
229            "body",
230            <super::types::SignInRequest as crate::authentication_helpers::RequestBody>::FIELDS,
231        )?;
232        return Ok(EndpointInput {
233            body: call.body().cloned(),
234            query: call.query().cloned(),
235        });
236    }
237    if call.operation_id() == "verifyEmailOTP" {
238        return Ok(EndpointInput {
239            body: Some(validate_fields(
240                call.body(),
241                "body",
242                <super::types::VerifyRequest as crate::authentication_helpers::RequestBody>::FIELDS,
243            )?),
244            query: call.query().cloned(),
245        });
246    }
247    if call.operation_id() == "verifyEmailWithOTP" {
248        return Ok(EndpointInput {
249            body: Some(validate_fields(
250                call.body(),
251                "body",
252                <super::types::CheckRequest as crate::authentication_helpers::RequestBody>::FIELDS,
253            )?),
254            query: call.query().cloned(),
255        });
256    }
257    if call.operation_id() == "getEmailVerificationOTP" {
258        Ok(EndpointInput {
259            body: call.body().cloned(),
260            query: Some(validate_fields(call.query(), "query", FIELDS)?),
261        })
262    } else {
263        Ok(EndpointInput {
264            body: Some(validate_fields(call.body(), "body", FIELDS)?),
265            query: call.query().cloned(),
266        })
267    }
268}
269
270impl EmailOtpPlugin {
271    /// Consume a new-mailbox challenge using the caller's authoritative session.
272    ///
273    /// # Errors
274    /// Returns an error if the supplied input cannot be serialized.
275    pub fn change_email_email_otp_endpoint(
276        input: &EmailOtpChangeInput,
277    ) -> AuthResult<ServerEndpoint<EmailOtpChanged>> {
278        ServerEndpoint::new("email-otp", "changeEmailEmailOTP").with_body(input)
279    }
280
281    /// Request a new-mailbox challenge using an authoritative caller session.
282    ///
283    /// # Errors
284    /// Returns an error if the supplied input cannot be serialized.
285    pub fn request_email_change_email_otp_endpoint(
286        input: &EmailOtpChangeRequestInput,
287    ) -> AuthResult<ServerEndpoint<EmailOtpChangeRequested>> {
288        ServerEndpoint::new("email-otp", "requestEmailChangeEmailOTP").with_body(input)
289    }
290
291    /// Consume a reset proof and replace or create the owner's credential.
292    ///
293    /// # Errors
294    /// Returns an error if reset input cannot be serialized.
295    pub fn reset_password_email_otp_endpoint(
296        input: &EmailOtpPasswordResetInput,
297    ) -> AuthResult<ServerEndpoint<EmailOtpPasswordReset>> {
298        ServerEndpoint::new("email-otp", "resetPasswordEmailOTP").with_body(input)
299    }
300
301    /// Request a scoped reset proof without exposing whether the mailbox exists.
302    #[must_use]
303    pub fn request_password_reset_email_otp_endpoint(
304        email: impl Into<String>,
305    ) -> ServerEndpoint<EmailOtpResetRequested> {
306        reset_request("requestPasswordResetEmailOTP", email.into())
307    }
308    /// Call the published deprecated reset-request alias.
309    #[must_use]
310    pub fn forget_password_email_otp_endpoint(
311        email: impl Into<String>,
312    ) -> ServerEndpoint<EmailOtpResetRequested> {
313        reset_request("forgetPasswordEmailOTP", email.into())
314    }
315
316    /// Consume a sign-in proof and apply returning-user or signup policy.
317    ///
318    /// # Errors
319    /// Returns an error if the supplied sign-in input cannot be serialized.
320    pub fn sign_in_email_otp_endpoint(
321        input: &EmailOtpSignInInput,
322    ) -> AuthResult<ServerEndpoint<EmailOtpSignInResult>> {
323        ServerEndpoint::new("email-otp", "signInEmailOTP").with_body(input)
324    }
325
326    /// Consume a mailbox proof and apply verification/session policy.
327    #[must_use]
328    pub fn verify_email_otp_endpoint(
329        email: impl Into<String>,
330        otp: impl Into<String>,
331    ) -> ServerEndpoint<EmailOtpVerification> {
332        ServerEndpoint::new("email-otp", "verifyEmailOTP").with_body_value(JsValue::Object(
333            [
334                ("email".into(), JsValue::String(email.into())),
335                ("otp".into(), JsValue::String(otp.into())),
336            ]
337            .into_iter()
338            .collect(),
339        ))
340    }
341
342    /// Check a scoped proof without consuming a successful validation.
343    #[must_use]
344    pub fn check_verification_otp_endpoint(
345        email: impl Into<String>,
346        otp_type: EmailOtpType,
347        otp: impl Into<String>,
348    ) -> ServerEndpoint<EmailOtpCheck> {
349        let mut body = input(email.into(), otp_type);
350        if let JsValue::Object(fields) = &mut body {
351            let _ = fields.insert("otp".into(), JsValue::String(otp.into()));
352        }
353        ServerEndpoint::new("email-otp", "checkVerificationOTP").with_body_value(body)
354    }
355
356    /// Send a scoped OTP through the installed delivery and storage policies.
357    #[must_use]
358    pub fn send_verification_otp_endpoint(
359        email: impl Into<String>,
360        otp_type: EmailOtpType,
361    ) -> ServerEndpoint<EmailOtpSent> {
362        ServerEndpoint::new("email-otp", "sendVerificationOTP")
363            .with_body_value(input(email.into(), otp_type))
364    }
365
366    /// Create a code through the installed endpoint hooks and post-hook validation.
367    #[must_use]
368    pub fn create_verification_otp_endpoint(
369        email: impl Into<String>,
370        otp_type: EmailOtpType,
371    ) -> ServerEndpoint<String> {
372        ServerEndpoint::new("email-otp", "createVerificationOTP")
373            .with_body_value(input(email.into(), otp_type))
374    }
375
376    /// Retrieve a code through the installed endpoint hooks.
377    #[must_use]
378    pub fn get_verification_otp_endpoint(
379        email: impl Into<String>,
380        otp_type: EmailOtpType,
381    ) -> ServerEndpoint<EmailOtpRead> {
382        ServerEndpoint::new("email-otp", "getVerificationOTP")
383            .with_query_value(input(email.into(), otp_type))
384    }
385
386    pub(super) async fn call_endpoint(
387        &self,
388        call: &EndpointCall,
389        ctx: &AuthContext<impl AuthSchema>,
390    ) -> AuthResult<EndpointResponse> {
391        if call.operation_id() == "verifyEmailWithOTP" {
392            let output = self.check_verification_core(ctx, call.body_as()?).await?;
393            return EndpointResponse::json(&alibi_core::utils::json::from_slice::<JsValue>(
394                &output.body,
395            )?);
396        }
397        if matches!(
398            call.operation_id(),
399            "sendEmailVerificationOTP"
400                | "verifyEmailOTP"
401                | "signInWithEmailOTP"
402                | "requestPasswordResetWithEmailOTP"
403                | "forgetPasswordWithEmailOTP"
404                | "resetPasswordWithEmailOTP"
405                | "requestEmailChangeWithEmailOTP"
406                | "changeEmailWithEmailOTP"
407        ) {
408            if call.operation_id() == "sendEmailVerificationOTP"
409                && let Some(request) = call.request()
410            {
411                alibi_core::middleware::CsrfMiddleware::new(
412                    alibi_core::middleware::CsrfConfig::new(),
413                    ctx.config.clone(),
414                )
415                .check_login_origin(request)?;
416            }
417            let output = match call.operation_id() {
418                "sendEmailVerificationOTP" => {
419                    self.send_verification_core(call.request(), ctx, call.body_as()?)
420                        .await?
421                }
422                "signInWithEmailOTP" => {
423                    let raw = call.body().cloned().unwrap_or(JsValue::Null);
424                    let mut parsed = raw.clone();
425                    if let JsValue::Object(fields) = &mut parsed {
426                        let _ = fields.shift_remove("username");
427                        let _ = fields.shift_remove("displayUsername");
428                    }
429                    let body = alibi_core::utils::json::from_value(parsed)?;
430                    self.sign_in_core(call, ctx, body, raw).await?
431                }
432                "requestPasswordResetWithEmailOTP" | "forgetPasswordWithEmailOTP" => {
433                    self.request_password_reset_core(call.request(), ctx, call.body_as()?)
434                        .await?
435                }
436                "resetPasswordWithEmailOTP" => {
437                    self.reset_password_core(ctx, call.body_as()?).await?
438                }
439                "changeEmailWithEmailOTP" => {
440                    self.change_email_core(call, ctx, call.body_as()?).await?
441                }
442                "requestEmailChangeWithEmailOTP" => {
443                    self.request_email_change_core(call, ctx, call.body_as()?)
444                        .await?
445                }
446                _ => self.verify_email_core(call, ctx, call.body_as()?).await?,
447            };
448            let mut response = if output.status >= 400 {
449                EndpointResponse::error(crate::endpoint::error_response(output.clone()))
450                    .without_error_body()
451            } else {
452                EndpointResponse::value(alibi_core::utils::json::from_slice(&output.body)?)
453            };
454            response.set_status(Some(output.status));
455            response.merge_headers(output.headers);
456            return Ok(response);
457        }
458        let (email, otp_type) = if call.operation_id() == "createEmailVerificationOTP" {
459            let body: super::types::SendRequest = call.body_as()?;
460            (body.email, body.otp_type)
461        } else {
462            let query: super::types::SendRequest = call.query_as()?;
463            (query.email, query.otp_type)
464        };
465        if call.operation_id() == "createEmailVerificationOTP" {
466            EndpointResponse::json(&self.create_verification_otp(ctx, &email, otp_type).await?)
467        } else {
468            EndpointResponse::json(
469                &serde_json::json!({"otp": self.get_verification_otp(ctx, &email, otp_type).await?}),
470            )
471        }
472    }
473}
474
475fn input(email: String, otp_type: EmailOtpType) -> JsValue {
476    JsValue::Object(
477        [
478            ("email".into(), JsValue::String(email)),
479            ("type".into(), JsValue::String(otp_type.as_str().into())),
480        ]
481        .into_iter()
482        .collect(),
483    )
484}
485
486fn reset_request(name: &'static str, email: String) -> ServerEndpoint<EmailOtpResetRequested> {
487    ServerEndpoint::new("email-otp", name).with_body_value(JsValue::Object(
488        [("email".into(), JsValue::String(email))]
489            .into_iter()
490            .collect(),
491    ))
492}