Skip to main content

alibi_plugins/two_factor/
endpoint.rs

1use super::{
2    BackupOperationError, EnableTwoFactorInput, TwoFactorEnabled, TwoFactorMethod, TwoFactorPlugin,
3    enable_core,
4};
5use crate::authentication_helpers::JsonField;
6use crate::endpoint::{definition, validate_fields};
7use alibi_core::endpoint::{
8    EndpointCall, EndpointDefinition, EndpointInput, EndpointResponse, ServerEndpoint,
9};
10use alibi_core::utils::json::JsValue;
11use alibi_core::{AuthContext, AuthResult, AuthSchema, HttpMethod};
12use serde::Deserialize;
13
14#[derive(Debug, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct BackupCodesOutput {
17    pub status: bool,
18    pub backup_codes: serde_json::Value,
19}
20
21#[derive(Debug, Deserialize)]
22pub struct TotpOutput {
23    pub code: String,
24}
25
26/// The caller's factor was disabled and its current session replaced.
27#[derive(Debug, Deserialize)]
28pub struct TwoFactorDisabled {
29    pub status: bool,
30}
31
32/// Session token and public identity returned after authenticator verification.
33#[derive(Debug, Deserialize)]
34pub struct TotpVerified {
35    pub token: String,
36    pub user: serde_json::Value,
37}
38
39/// A code was persisted and handed to the configured delivery policy.
40#[derive(Debug, Deserialize)]
41pub struct TwoFactorOtpSent {
42    pub status: bool,
43}
44
45/// Public recovery result, with a token only when session publication was requested.
46#[derive(Debug, Deserialize)]
47pub struct BackupCodeVerified {
48    pub token: Option<String>,
49    pub user: serde_json::Value,
50}
51
52pub(super) fn definitions() -> Vec<EndpointDefinition> {
53    vec![
54        definition(
55            "generateBackupCodes",
56            "generateBackupCodes",
57            Some("/two-factor/generate-backup-codes"),
58            HttpMethod::Post,
59        ),
60        definition(
61            "verifyBackupCode",
62            "verifyBackupCode",
63            Some("/two-factor/verify-backup-code"),
64            HttpMethod::Post,
65        ),
66        definition(
67            "verifyTwoFactorOTP",
68            "verifyTwoFactorOTP",
69            Some("/two-factor/verify-otp"),
70            HttpMethod::Post,
71        ),
72        definition(
73            "sendTwoFactorOTP",
74            "sendTwoFactorOTP",
75            Some("/two-factor/send-otp"),
76            HttpMethod::Post,
77        ),
78        definition(
79            "verifyTOTP",
80            "verifyTOTP",
81            Some("/two-factor/verify-totp"),
82            HttpMethod::Post,
83        ),
84        definition(
85            "getTOTPURI",
86            "getTOTPURI",
87            Some("/two-factor/get-totp-uri"),
88            HttpMethod::Post,
89        ),
90        definition(
91            "disableTwoFactor",
92            "disableTwoFactor",
93            Some("/two-factor/disable"),
94            HttpMethod::Post,
95        ),
96        definition(
97            "enableTwoFactor",
98            "enableTwoFactor",
99            Some("/two-factor/enable"),
100            HttpMethod::Post,
101        ),
102        definition("viewBackupCodes", "viewBackupCodes", None, HttpMethod::Post),
103        definition("generateTOTP", "generateTOTP", None, HttpMethod::Post),
104    ]
105}
106
107pub(super) fn validate(
108    call: &EndpointCall,
109    config: &super::TwoFactorConfig,
110) -> AuthResult<EndpointInput> {
111    let allow_passwordless = config.allow_passwordless;
112    if call.operation_id() == "verifyBackupCode" {
113        return Ok(EndpointInput {
114            body: Some(validate_fields(
115                call.body(),
116                "body",
117                &[
118                    JsonField::string("code", true),
119                    JsonField {
120                        name: "disableSession",
121                        kind: crate::authentication_helpers::JsonFieldKind::Boolean,
122                        required: false,
123                    },
124                    JsonField {
125                        name: "trustDevice",
126                        kind: crate::authentication_helpers::JsonFieldKind::Boolean,
127                        required: false,
128                    },
129                ],
130            )?),
131            query: call.query().cloned(),
132        });
133    }
134    if call.operation_id() == "sendTwoFactorOTP" {
135        return Ok(EndpointInput {
136            body: Some(validate_fields(
137                call.body(),
138                "body",
139                &[JsonField {
140                    name: "trustDevice",
141                    kind: crate::authentication_helpers::JsonFieldKind::Boolean,
142                    required: false,
143                }],
144            )?),
145            query: call.query().cloned(),
146        });
147    }
148    if matches!(call.operation_id(), "verifyTOTP" | "verifyTwoFactorOTP") {
149        return Ok(EndpointInput {
150            body: Some(validate_fields(
151                call.body(),
152                "body",
153                &[
154                    JsonField::string("code", true),
155                    JsonField {
156                        name: "trustDevice",
157                        kind: crate::authentication_helpers::JsonFieldKind::Boolean,
158                        required: false,
159                    },
160                ],
161            )?),
162            query: call.query().cloned(),
163        });
164    }
165    if call.operation_id() == "generateBackupCodes" {
166        return Ok(EndpointInput {
167            body: Some(validate_fields(
168                call.body(),
169                "body",
170                &[JsonField::string(
171                    "password",
172                    !config
173                        .backup_allow_passwordless
174                        .unwrap_or(allow_passwordless),
175                )],
176            )?),
177            query: call.query().cloned(),
178        });
179    }
180    if call.operation_id() == "getTOTPURI" {
181        return Ok(EndpointInput {
182            body: Some(validate_fields(
183                call.body(),
184                "body",
185                &[JsonField::string(
186                    "password",
187                    !config.totp_allow_passwordless.unwrap_or(allow_passwordless),
188                )],
189            )?),
190            query: call.query().cloned(),
191        });
192    }
193    if call.operation_id() == "disableTwoFactor" {
194        return Ok(EndpointInput {
195            body: Some(validate_fields(
196                call.body(),
197                "body",
198                &[JsonField::string("password", !allow_passwordless)],
199            )?),
200            query: call.query().cloned(),
201        });
202    }
203    if call.operation_id() == "enableTwoFactor" {
204        let mut body = validate_fields(
205            call.body(),
206            "body",
207            &[
208                JsonField::string("password", !allow_passwordless),
209                JsonField {
210                    name: "method",
211                    kind: crate::authentication_helpers::JsonFieldKind::OneOf(&["otp", "totp"]),
212                    required: false,
213                },
214                JsonField::string("issuer", false),
215            ],
216        )?;
217        if let JsValue::Object(object) = &mut body {
218            _ = object
219                .entry("method".into())
220                .or_insert_with(|| JsValue::String("totp".into()));
221        }
222        return Ok(EndpointInput {
223            body: Some(body),
224            query: call.query().cloned(),
225        });
226    }
227    let field = if call.operation_id() == "viewBackupCodes" {
228        "userId"
229    } else {
230        "secret"
231    };
232    let mut body = call.body().cloned();
233    if field == "userId"
234        && let Some(JsValue::Object(body)) = &mut body
235    {
236        let user_id = body
237            .get(field)
238            .map_or_else(|| Ok("undefined".into()), JsValue::coerce_string)
239            .map_err(crate::endpoint::validation)?;
240        _ = body.insert(field.into(), JsValue::String(user_id));
241    }
242    Ok(EndpointInput {
243        body: Some(validate_fields(
244            body.as_ref(),
245            "body",
246            &[JsonField::string(field, true)],
247        )?),
248        query: call.query().cloned(),
249    })
250}
251
252fn verification_response(output: alibi_core::AuthResponse) -> AuthResult<EndpointResponse> {
253    let body = alibi_core::utils::json::from_slice::<JsValue>(&output.body).ok();
254    let mut response = if output.status >= 400 {
255        let response = EndpointResponse::error(crate::endpoint::error_response(output.clone()));
256        if let Some(body) = body {
257            response.with_error_body(body)
258        } else {
259            response.without_error_body()
260        }
261    } else {
262        EndpointResponse::json(&body)?
263    };
264    response.merge_headers(output.headers);
265    Ok(response)
266}
267
268impl TwoFactorPlugin {
269    /// Replace the authenticated owner's recovery-code set.
270    #[must_use]
271    pub fn generate_backup_codes_endpoint(
272        input: super::GenerateBackupCodesInput,
273    ) -> ServerEndpoint<BackupCodesOutput> {
274        let mut body = indexmap::IndexMap::new();
275        if let Some(password) = input.password {
276            _ = body.insert("password".into(), JsValue::String(password));
277        }
278        ServerEndpoint::new("two-factor", "generateBackupCodes")
279            .with_body_value(JsValue::Object(body))
280    }
281
282    async fn generate_backup_endpoint_call(
283        &self,
284        call: &EndpointCall,
285        ctx: &AuthContext<impl AuthSchema>,
286    ) -> AuthResult<EndpointResponse> {
287        let (user, _) = Box::pin(crate::helpers::ordinary_session(call, ctx)).await?;
288        match Box::pin(super::generate_backup_codes_core(
289            &call.body_as()?,
290            &user,
291            &self.config,
292            ctx,
293        ))
294        .await
295        {
296            Ok(output) => EndpointResponse::json(&output),
297            Err(BackupOperationError::InvalidGeneration) => Ok(EndpointResponse::error(
298                alibi_core::AuthError::internal("Backup code generation failed"),
299            )
300            .without_error_body()),
301            Err(BackupOperationError::Auth(error)) => Err(error),
302        }
303    }
304
305    /// Consume a recovery code for the current owner or pending sign-in challenge.
306    #[must_use]
307    pub fn verify_backup_code_endpoint(
308        input: super::VerifyBackupCodeInput,
309    ) -> ServerEndpoint<BackupCodeVerified> {
310        let mut body = [("code".into(), JsValue::String(input.code))]
311            .into_iter()
312            .collect::<indexmap::IndexMap<_, _>>();
313        if let Some(disable_session) = input.disable_session {
314            _ = body.insert("disableSession".into(), JsValue::Bool(disable_session));
315        }
316        if let Some(trust_device) = input.trust_device {
317            _ = body.insert("trustDevice".into(), JsValue::Bool(trust_device));
318        }
319        ServerEndpoint::new("two-factor", "verifyBackupCode").with_body_value(JsValue::Object(body))
320    }
321
322    async fn verify_backup_endpoint_call(
323        &self,
324        call: &EndpointCall,
325        ctx: &AuthContext<impl AuthSchema>,
326    ) -> AuthResult<EndpointResponse> {
327        verification_response(self.verify_backup_code(&call.body_as()?, call, ctx).await?)
328    }
329
330    /// Verify a delivered code for the current session or pending sign-in challenge.
331    #[must_use]
332    pub fn verify_two_factor_otp_endpoint(
333        input: super::VerifyOtpInput,
334    ) -> ServerEndpoint<TotpVerified> {
335        let mut body = [("code".into(), JsValue::String(input.code))]
336            .into_iter()
337            .collect::<indexmap::IndexMap<_, _>>();
338        if let Some(trust_device) = input.trust_device {
339            _ = body.insert("trustDevice".into(), JsValue::Bool(trust_device));
340        }
341        ServerEndpoint::new("two-factor", "verifyTwoFactorOTP")
342            .with_body_value(JsValue::Object(body))
343    }
344
345    async fn verify_otp_endpoint_call(
346        &self,
347        call: &EndpointCall,
348        ctx: &AuthContext<impl AuthSchema>,
349    ) -> AuthResult<EndpointResponse> {
350        verification_response(self.verify_otp(&call.body_as()?, call, ctx).await?)
351    }
352
353    /// Persist and deliver a code for the session owner or pending sign-in challenge.
354    #[must_use]
355    pub fn send_two_factor_otp_endpoint() -> ServerEndpoint<TwoFactorOtpSent> {
356        ServerEndpoint::new("two-factor", "sendTwoFactorOTP")
357            .with_body_value(JsValue::Object(indexmap::IndexMap::new()))
358    }
359
360    async fn send_otp_endpoint_call(
361        &self,
362        call: &EndpointCall,
363        ctx: &AuthContext<impl AuthSchema>,
364    ) -> AuthResult<EndpointResponse> {
365        let output = self.send_otp(call, ctx).await?;
366        let mut response = if output.status >= 400 {
367            EndpointResponse::error(crate::endpoint::error_response(output.clone()))
368                .without_error_body()
369        } else {
370            EndpointResponse::json(&alibi_core::utils::json::from_slice::<JsValue>(
371                &output.body,
372            )?)?
373        };
374        response.merge_headers(output.headers);
375        Ok(response)
376    }
377
378    /// Verify an authenticator code for enrollment or a pending sign-in challenge.
379    #[must_use]
380    pub fn verify_totp_endpoint(input: super::VerifyTotpInput) -> ServerEndpoint<TotpVerified> {
381        let mut body = [("code".into(), JsValue::String(input.code))]
382            .into_iter()
383            .collect::<indexmap::IndexMap<_, _>>();
384        if let Some(trust_device) = input.trust_device {
385            _ = body.insert("trustDevice".into(), JsValue::Bool(trust_device));
386        }
387        ServerEndpoint::new("two-factor", "verifyTOTP").with_body_value(JsValue::Object(body))
388    }
389
390    async fn verify_totp_endpoint_call(
391        &self,
392        call: &EndpointCall,
393        ctx: &AuthContext<impl AuthSchema>,
394    ) -> AuthResult<EndpointResponse> {
395        let output = self.verify_totp(&call.body_as()?, call, ctx).await?;
396        verification_response(output)
397    }
398
399    /// Recover the signed-in owner's authenticator URI using configured password policy.
400    #[must_use]
401    pub fn get_totp_uri_endpoint(
402        input: super::GetTotpUriInput,
403    ) -> ServerEndpoint<super::TotpUriOutput> {
404        let body = input
405            .password
406            .map(|password| ("password".into(), JsValue::String(password)))
407            .into_iter()
408            .collect();
409        ServerEndpoint::new("two-factor", "getTOTPURI").with_body_value(JsValue::Object(body))
410    }
411
412    async fn uri_endpoint(
413        &self,
414        call: &EndpointCall,
415        ctx: &AuthContext<impl AuthSchema>,
416    ) -> AuthResult<EndpointResponse> {
417        let (user, _) = crate::helpers::ordinary_session(call, ctx).await?;
418        EndpointResponse::json(
419            &super::get_totp_uri_core(&call.body_as()?, &user, &self.config, ctx).await?,
420        )
421    }
422
423    /// Disable the persistent session owner's factor and rotate that session.
424    #[must_use]
425    pub fn disable_two_factor_endpoint(
426        input: super::DisableTwoFactorInput,
427    ) -> ServerEndpoint<TwoFactorDisabled> {
428        let body = input
429            .password
430            .map(|password| ("password".into(), JsValue::String(password)))
431            .into_iter()
432            .collect();
433        ServerEndpoint::new("two-factor", "disableTwoFactor").with_body_value(JsValue::Object(body))
434    }
435
436    async fn disable_endpoint(
437        &self,
438        call: &EndpointCall,
439        ctx: &AuthContext<impl AuthSchema>,
440    ) -> AuthResult<EndpointResponse> {
441        let output = self.disable_factor(&call.body_as()?, call, ctx).await?;
442        let mut response = EndpointResponse::json(
443            &alibi_core::utils::json::from_slice::<JsValue>(&output.body)?,
444        )?;
445        response.merge_headers(output.headers);
446        Ok(response)
447    }
448
449    /// Enroll the signed-in owner using the configured factor and backup-code policies.
450    #[must_use]
451    pub fn enable_two_factor_endpoint(
452        input: EnableTwoFactorInput,
453    ) -> ServerEndpoint<TwoFactorEnabled> {
454        let mut body = [(
455            "method".into(),
456            JsValue::String(
457                match input.method {
458                    TwoFactorMethod::Otp => "otp",
459                    TwoFactorMethod::Totp => "totp",
460                }
461                .into(),
462            ),
463        )]
464        .into_iter()
465        .collect::<indexmap::IndexMap<_, _>>();
466        if let Some(password) = input.password {
467            _ = body.insert("password".into(), JsValue::String(password));
468        }
469        if let Some(issuer) = input.issuer {
470            _ = body.insert("issuer".into(), JsValue::String(issuer));
471        }
472        ServerEndpoint::new("two-factor", "enableTwoFactor").with_body_value(JsValue::Object(body))
473    }
474
475    async fn enable_endpoint(
476        &self,
477        call: &EndpointCall,
478        ctx: &AuthContext<impl AuthSchema>,
479    ) -> AuthResult<EndpointResponse> {
480        let (user, session) = crate::helpers::ordinary_session(call, ctx).await?;
481        match enable_core(&call.body_as()?, &user, &session, &self.config, ctx).await {
482            Ok((value, cookies)) => {
483                let mut response = EndpointResponse::json(&value)?;
484                for cookie in cookies {
485                    response = response.with_header("set-cookie", cookie);
486                }
487                Ok(response)
488            }
489            Err(
490                BackupOperationError::Auth(alibi_core::AuthError::SessionCreationCancelled)
491                | BackupOperationError::InvalidGeneration,
492            ) => Ok(EndpointResponse::error(alibi_core::AuthError::Api {
493                status: 500,
494                code: None,
495                message: String::new(),
496            })
497            .without_error_body()),
498            Err(BackupOperationError::Auth(error)) => Err(error),
499        }
500    }
501
502    /// Read trusted server-only backup codes through installed endpoint hooks.
503    #[must_use]
504    pub fn view_backup_codes_endpoint(
505        user_id: impl Into<String>,
506    ) -> ServerEndpoint<BackupCodesOutput> {
507        ServerEndpoint::new("two-factor", "viewBackupCodes")
508            .with_body_value(single_input("userId", user_id.into()))
509    }
510
511    /// Generate a TOTP through installed endpoint hooks and current configured policy.
512    #[must_use]
513    pub fn generate_totp_endpoint(secret: impl Into<String>) -> ServerEndpoint<TotpOutput> {
514        ServerEndpoint::new("two-factor", "generateTOTP")
515            .with_body_value(single_input("secret", secret.into()))
516    }
517
518    pub(super) async fn call_endpoint(
519        &self,
520        call: &EndpointCall,
521        ctx: &AuthContext<impl AuthSchema>,
522    ) -> AuthResult<EndpointResponse> {
523        if call.operation_id() == "generateBackupCodes" {
524            Box::pin(self.generate_backup_endpoint_call(call, ctx)).await
525        } else if call.operation_id() == "verifyBackupCode" {
526            Box::pin(self.verify_backup_endpoint_call(call, ctx)).await
527        } else if call.operation_id() == "verifyTwoFactorOTP" {
528            Box::pin(self.verify_otp_endpoint_call(call, ctx)).await
529        } else if call.operation_id() == "sendTwoFactorOTP" {
530            Box::pin(self.send_otp_endpoint_call(call, ctx)).await
531        } else if call.operation_id() == "verifyTOTP" {
532            Box::pin(self.verify_totp_endpoint_call(call, ctx)).await
533        } else if call.operation_id() == "getTOTPURI" {
534            Box::pin(self.uri_endpoint(call, ctx)).await
535        } else if call.operation_id() == "disableTwoFactor" {
536            Box::pin(self.disable_endpoint(call, ctx)).await
537        } else if call.operation_id() == "enableTwoFactor" {
538            Box::pin(self.enable_endpoint(call, ctx)).await
539        } else if call.operation_id() == "viewBackupCodes" {
540            #[derive(Deserialize)]
541            #[serde(rename_all = "camelCase")]
542            struct Body {
543                user_id: String,
544            }
545            let body: Body = call.body_as()?;
546            EndpointResponse::json(
547                &serde_json::json!({"status":true,"backupCodes":Box::pin(self.view_backup_codes(&body.user_id,ctx)).await?}),
548            )
549        } else {
550            #[derive(Deserialize)]
551            struct Body {
552                secret: String,
553            }
554            let body: Body = call.body_as()?;
555            EndpointResponse::json(&serde_json::json!({"code":self.generate_totp(&body.secret)?}))
556        }
557    }
558}
559
560fn single_input(name: &str, value: String) -> JsValue {
561    JsValue::Object(
562        [(name.into(), JsValue::String(value))]
563            .into_iter()
564            .collect(),
565    )
566}