Skip to main content

uptrakit_web_api_types/
mfa.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4use crate::validation::{Validate, ValidationError};
5
6/// Identifies the MFA method used in a challenge verification request.
7///
8/// Deserialized from HTTP bodies — uses infallible custom `Deserialize` with
9/// `Other(String)` so unknown methods never cause a 400 parse error.
10/// Loses `Copy` due to `String`.
11#[non_exhaustive]
12#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
13#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
14#[serde(rename_all = "snake_case")]
15pub enum MfaMethod {
16    Totp,
17    Email,
18    RecoveryCode,
19    /// Unknown method from a future client; verified as false.
20    #[cfg_attr(feature = "openapi", schema(value_type = String))]
21    Other(String),
22}
23
24impl MfaMethod {
25    pub fn as_str(&self) -> &str {
26        match self {
27            Self::Totp => "totp",
28            Self::Email => "email",
29            Self::RecoveryCode => "recovery_code",
30            Self::Other(s) => s.as_str(),
31        }
32    }
33}
34
35impl fmt::Display for MfaMethod {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.write_str(self.as_str())
38    }
39}
40
41impl From<String> for MfaMethod {
42    fn from(s: String) -> Self {
43        match s.as_str() {
44            "totp" => Self::Totp,
45            "email" => Self::Email,
46            "recovery_code" => Self::RecoveryCode,
47            _ => Self::Other(s),
48        }
49    }
50}
51
52impl<'de> Deserialize<'de> for MfaMethod {
53    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
54        let s = String::deserialize(d)?;
55        Ok(Self::from(s))
56    }
57}
58
59/// Returned by `POST /api/v1/auth/login` when the user has 2FA enrolled.
60#[non_exhaustive]
61#[derive(Debug, Serialize, Deserialize)]
62#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
63pub struct MfaChallengeResponse {
64    pub mfa_token: String,
65    pub mfa_methods: Vec<MfaMethod>,
66}
67
68impl MfaChallengeResponse {
69    pub fn new(mfa_token: String, mfa_methods: Vec<MfaMethod>) -> Self {
70        Self {
71            mfa_token,
72            mfa_methods,
73        }
74    }
75}
76
77/// Body for `POST /api/v1/auth/mfa/verify`.
78#[derive(Debug, Serialize, Deserialize)]
79#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
80pub struct MfaVerifyRequest {
81    pub mfa_token: String,
82    pub code: String,
83    pub method: MfaMethod,
84}
85
86impl Validate for MfaVerifyRequest {
87    fn validate(&self) -> Result<(), ValidationError> {
88        if self.mfa_token.is_empty() {
89            return Err(ValidationError {
90                field: "mfa_token",
91                message: "mfa_token must not be empty".to_string(),
92            });
93        }
94        if self.code.is_empty() {
95            return Err(ValidationError {
96                field: "code",
97                message: "code must not be empty".to_string(),
98            });
99        }
100        Ok(())
101    }
102}
103
104/// Body for `POST /api/v1/auth/mfa/email`.
105#[derive(Debug, Serialize, Deserialize)]
106#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
107pub struct MfaEmailRequest {
108    pub mfa_token: String,
109}
110
111impl Validate for MfaEmailRequest {
112    fn validate(&self) -> Result<(), ValidationError> {
113        if self.mfa_token.is_empty() {
114            return Err(ValidationError {
115                field: "mfa_token",
116                message: "mfa_token must not be empty".to_string(),
117            });
118        }
119        Ok(())
120    }
121}
122
123/// Returned by `GET /api/v1/auth/me/2fa`.
124#[non_exhaustive]
125#[derive(Debug, Serialize, Deserialize)]
126#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
127pub struct MfaStatusResponse {
128    pub totp_enrolled: bool,
129    pub recovery_codes_count: u32,
130    pub methods_available: Vec<MfaMethod>,
131}
132
133impl MfaStatusResponse {
134    /// Construct a new [`MfaStatusResponse`].
135    #[must_use]
136    pub fn new(
137        totp_enrolled: bool,
138        recovery_codes_count: u32,
139        methods_available: Vec<MfaMethod>,
140    ) -> Self {
141        Self {
142            totp_enrolled,
143            recovery_codes_count,
144            methods_available,
145        }
146    }
147}
148
149/// Returned by `POST /api/v1/auth/me/2fa/totp/enroll`.
150#[non_exhaustive]
151#[derive(Debug, Serialize, Deserialize)]
152#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
153pub struct TotpEnrollResponse {
154    /// `otpauth://totp/` URI for QR generation in the browser.
155    pub otpauth_uri: String,
156    /// Human-readable base32 secret (for manual entry). Treated as a secret —
157    /// never logged.
158    pub secret: uptrakit_shared_types::SecretString,
159}
160
161impl TotpEnrollResponse {
162    /// Construct a new [`TotpEnrollResponse`].
163    #[must_use]
164    pub fn new(otpauth_uri: String, secret: uptrakit_shared_types::SecretString) -> Self {
165        Self {
166            otpauth_uri,
167            secret,
168        }
169    }
170}
171
172/// Body for `POST /api/v1/auth/me/2fa/totp/confirm`.
173#[derive(Debug, Serialize, Deserialize)]
174#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
175pub struct TotpConfirmRequest {
176    pub code: String,
177}
178
179impl Validate for TotpConfirmRequest {
180    fn validate(&self) -> Result<(), ValidationError> {
181        if self.code.len() != 6 || !self.code.chars().all(|c| c.is_ascii_digit()) {
182            return Err(ValidationError {
183                field: "code",
184                message: "code must be exactly 6 digits".to_string(),
185            });
186        }
187        Ok(())
188    }
189}
190
191/// Returned by `POST /api/v1/auth/me/2fa/totp/confirm`.
192#[non_exhaustive]
193#[derive(Debug, Serialize, Deserialize)]
194#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
195pub struct TotpConfirmResponse {
196    /// Plaintext recovery codes shown once.
197    pub recovery_codes: Vec<String>,
198    /// New full-session tokens (replaces the restricted session, if any).
199    pub session: Option<crate::auth::AuthResponse>,
200}
201
202impl TotpConfirmResponse {
203    /// Construct a new [`TotpConfirmResponse`].
204    #[must_use]
205    pub fn new(recovery_codes: Vec<String>, session: Option<crate::auth::AuthResponse>) -> Self {
206        Self {
207            recovery_codes,
208            session,
209        }
210    }
211}
212
213/// Body for `POST /api/v1/auth/me/2fa/totp/disable`.
214#[derive(Debug, Serialize, Deserialize)]
215#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
216pub struct DisableTotpRequest {
217    pub password: Option<uptrakit_shared_types::SecretString>,
218    pub totp_code: Option<String>,
219}
220
221impl Validate for DisableTotpRequest {
222    fn validate(&self) -> Result<(), ValidationError> {
223        match (&self.password, &self.totp_code) {
224            (Some(_), None) | (None, Some(_)) => Ok(()),
225            _ => Err(ValidationError {
226                field: "password",
227                message: "exactly one of password or totp_code must be provided".to_string(),
228            }),
229        }
230    }
231}
232
233/// Body for `POST /api/v1/auth/me/2fa/recovery-codes/regenerate`.
234#[derive(Debug, Serialize, Deserialize)]
235#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
236pub struct RegenerateRecoveryCodesRequest {
237    pub password: Option<uptrakit_shared_types::SecretString>,
238    pub totp_code: Option<String>,
239}
240
241impl Validate for RegenerateRecoveryCodesRequest {
242    fn validate(&self) -> Result<(), ValidationError> {
243        match (&self.password, &self.totp_code) {
244            (Some(_), None) | (None, Some(_)) => Ok(()),
245            _ => Err(ValidationError {
246                field: "password",
247                message: "exactly one of password or totp_code must be provided".to_string(),
248            }),
249        }
250    }
251}
252
253/// Returned by `POST /api/v1/auth/me/2fa/recovery-codes/regenerate`.
254#[non_exhaustive]
255#[derive(Debug, Serialize, Deserialize)]
256#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
257pub struct RegenerateRecoveryCodesResponse {
258    pub recovery_codes: Vec<String>,
259}
260
261impl RegenerateRecoveryCodesResponse {
262    /// Construct a new [`RegenerateRecoveryCodesResponse`].
263    #[must_use]
264    pub fn new(recovery_codes: Vec<String>) -> Self {
265        Self { recovery_codes }
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    #![expect(
272        clippy::assertions_on_result_states,
273        reason = "test assertions — is_ok/is_err provides readable failure messages"
274    )]
275    use super::*;
276
277    // ── MfaMethod enum ───────────────────────────────────────────────────────
278
279    #[test]
280    fn mfa_method_totp_as_str() {
281        assert_eq!(MfaMethod::Totp.as_str(), "totp");
282    }
283
284    #[test]
285    fn mfa_method_email_as_str() {
286        assert_eq!(MfaMethod::Email.as_str(), "email");
287    }
288
289    #[test]
290    fn mfa_method_recovery_code_as_str() {
291        assert_eq!(MfaMethod::RecoveryCode.as_str(), "recovery_code");
292    }
293
294    #[test]
295    fn mfa_method_other_as_str() {
296        let other = MfaMethod::Other("future_method".to_string());
297        assert_eq!(other.as_str(), "future_method");
298    }
299
300    #[test]
301    fn mfa_method_display_matches_as_str() {
302        assert_eq!(format!("{}", MfaMethod::Totp), "totp");
303        assert_eq!(format!("{}", MfaMethod::Email), "email");
304        assert_eq!(format!("{}", MfaMethod::RecoveryCode), "recovery_code");
305    }
306
307    #[test]
308    fn mfa_method_from_string_totp() {
309        assert_eq!(MfaMethod::from("totp".to_string()), MfaMethod::Totp);
310    }
311
312    #[test]
313    fn mfa_method_from_string_email() {
314        assert_eq!(MfaMethod::from("email".to_string()), MfaMethod::Email);
315    }
316
317    #[test]
318    fn mfa_method_from_string_recovery_code() {
319        assert_eq!(
320            MfaMethod::from("recovery_code".to_string()),
321            MfaMethod::RecoveryCode
322        );
323    }
324
325    #[test]
326    fn mfa_method_from_string_unknown() {
327        let unknown = MfaMethod::from("future_method".to_string());
328        assert!(matches!(
329            unknown,
330            MfaMethod::Other(ref s) if s == "future_method"
331        ));
332    }
333
334    #[test]
335    fn mfa_method_serde_round_trip_totp() {
336        let method = MfaMethod::Totp;
337        let json = serde_json::to_string(&method).unwrap();
338        let deserialized: MfaMethod = serde_json::from_str(&json).unwrap();
339        assert_eq!(deserialized, method);
340    }
341
342    #[test]
343    fn mfa_method_serde_round_trip_recovery_code() {
344        let method = MfaMethod::RecoveryCode;
345        let json = serde_json::to_string(&method).unwrap();
346        let deserialized: MfaMethod = serde_json::from_str(&json).unwrap();
347        assert_eq!(deserialized, method);
348    }
349
350    #[test]
351    fn mfa_method_deserialize_unknown() {
352        let json = r#""future_method""#;
353        let method: MfaMethod = serde_json::from_str(json).unwrap();
354        assert!(matches!(
355            method,
356            MfaMethod::Other(ref s) if s == "future_method"
357        ));
358    }
359
360    // ── MfaVerifyRequest ─────────────────────────────────────────────────────
361
362    fn valid_mfa_verify() -> MfaVerifyRequest {
363        MfaVerifyRequest {
364            mfa_token: "token_123".to_string(),
365            code: "123456".to_string(),
366            method: MfaMethod::Totp,
367        }
368    }
369
370    #[test]
371    fn mfa_verify_request_valid() {
372        assert!(valid_mfa_verify().validate().is_ok());
373    }
374
375    #[test]
376    fn mfa_verify_request_empty_token() {
377        let mut req = valid_mfa_verify();
378        req.mfa_token = String::new();
379        let err = req.validate().unwrap_err();
380        assert_eq!(err.field, "mfa_token");
381    }
382
383    #[test]
384    fn mfa_verify_request_empty_code() {
385        let mut req = valid_mfa_verify();
386        req.code = String::new();
387        let err = req.validate().unwrap_err();
388        assert_eq!(err.field, "code");
389    }
390
391    // ── MfaEmailRequest ──────────────────────────────────────────────────────
392
393    fn valid_mfa_email() -> MfaEmailRequest {
394        MfaEmailRequest {
395            mfa_token: "token_123".to_string(),
396        }
397    }
398
399    #[test]
400    fn mfa_email_request_valid() {
401        assert!(valid_mfa_email().validate().is_ok());
402    }
403
404    #[test]
405    fn mfa_email_request_empty_token() {
406        let mut req = valid_mfa_email();
407        req.mfa_token = String::new();
408        let err = req.validate().unwrap_err();
409        assert_eq!(err.field, "mfa_token");
410    }
411
412    // ── TotpConfirmRequest ───────────────────────────────────────────────────
413
414    #[test]
415    fn totp_confirm_request_valid() {
416        let req = TotpConfirmRequest {
417            code: "123456".to_string(),
418        };
419        assert!(req.validate().is_ok());
420    }
421
422    #[test]
423    fn totp_confirm_request_not_digits() {
424        let req = TotpConfirmRequest {
425            code: "12345a".to_string(),
426        };
427        let err = req.validate().unwrap_err();
428        assert_eq!(err.field, "code");
429    }
430
431    #[test]
432    fn totp_confirm_request_too_short() {
433        let req = TotpConfirmRequest {
434            code: "12345".to_string(),
435        };
436        let err = req.validate().unwrap_err();
437        assert_eq!(err.field, "code");
438    }
439
440    #[test]
441    fn totp_confirm_request_too_long() {
442        let req = TotpConfirmRequest {
443            code: "1234567".to_string(),
444        };
445        let err = req.validate().unwrap_err();
446        assert_eq!(err.field, "code");
447    }
448
449    // ── DisableTotpRequest ───────────────────────────────────────────────────
450
451    #[test]
452    fn disable_totp_request_with_password() {
453        let req = DisableTotpRequest {
454            password: Some(uptrakit_shared_types::SecretString::new("pass123")),
455            totp_code: None,
456        };
457        assert!(req.validate().is_ok());
458    }
459
460    #[test]
461    fn disable_totp_request_with_totp_code() {
462        let req = DisableTotpRequest {
463            password: None,
464            totp_code: Some("123456".to_string()),
465        };
466        assert!(req.validate().is_ok());
467    }
468
469    #[test]
470    fn disable_totp_request_with_both() {
471        let req = DisableTotpRequest {
472            password: Some(uptrakit_shared_types::SecretString::new("pass123")),
473            totp_code: Some("123456".to_string()),
474        };
475        let err = req.validate().unwrap_err();
476        assert_eq!(err.field, "password");
477    }
478
479    #[test]
480    fn disable_totp_request_with_neither() {
481        let req = DisableTotpRequest {
482            password: None,
483            totp_code: None,
484        };
485        let err = req.validate().unwrap_err();
486        assert_eq!(err.field, "password");
487    }
488
489    // ── RegenerateRecoveryCodesRequest ───────────────────────────────────────
490
491    #[test]
492    fn regenerate_recovery_codes_request_with_password() {
493        let req = RegenerateRecoveryCodesRequest {
494            password: Some(uptrakit_shared_types::SecretString::new("pass123")),
495            totp_code: None,
496        };
497        assert!(req.validate().is_ok());
498    }
499
500    #[test]
501    fn regenerate_recovery_codes_request_with_totp_code() {
502        let req = RegenerateRecoveryCodesRequest {
503            password: None,
504            totp_code: Some("123456".to_string()),
505        };
506        assert!(req.validate().is_ok());
507    }
508
509    #[test]
510    fn regenerate_recovery_codes_request_with_both() {
511        let req = RegenerateRecoveryCodesRequest {
512            password: Some(uptrakit_shared_types::SecretString::new("pass123")),
513            totp_code: Some("123456".to_string()),
514        };
515        let err = req.validate().unwrap_err();
516        assert_eq!(err.field, "password");
517    }
518
519    #[test]
520    fn regenerate_recovery_codes_request_with_neither() {
521        let req = RegenerateRecoveryCodesRequest {
522            password: None,
523            totp_code: None,
524        };
525        let err = req.validate().unwrap_err();
526        assert_eq!(err.field, "password");
527    }
528
529    // ── Struct serialization round-trips ─────────────────────────────────────
530
531    #[test]
532    fn mfa_challenge_response_round_trip() {
533        let resp = MfaChallengeResponse {
534            mfa_token: "token_abc".to_string(),
535            mfa_methods: vec![MfaMethod::Totp, MfaMethod::Email],
536        };
537        let json = serde_json::to_string(&resp).unwrap();
538        let deserialized: MfaChallengeResponse = serde_json::from_str(&json).unwrap();
539        assert_eq!(deserialized.mfa_token, "token_abc");
540        assert_eq!(deserialized.mfa_methods.len(), 2);
541        assert_eq!(deserialized.mfa_methods[0], MfaMethod::Totp);
542        assert_eq!(deserialized.mfa_methods[1], MfaMethod::Email);
543    }
544
545    #[test]
546    fn mfa_status_response_round_trip() {
547        let resp = MfaStatusResponse {
548            totp_enrolled: true,
549            recovery_codes_count: 5,
550            methods_available: vec![MfaMethod::Totp, MfaMethod::Email, MfaMethod::RecoveryCode],
551        };
552        let json = serde_json::to_string(&resp).unwrap();
553        let deserialized: MfaStatusResponse = serde_json::from_str(&json).unwrap();
554        assert!(deserialized.totp_enrolled);
555        assert_eq!(deserialized.recovery_codes_count, 5);
556        assert_eq!(deserialized.methods_available.len(), 3);
557    }
558
559    #[test]
560    fn totp_enroll_response_round_trip() {
561        let resp = TotpEnrollResponse {
562            otpauth_uri: "otpauth://totp/test".to_string(),
563            secret: uptrakit_shared_types::SecretString::new("JBSWY3DPEBLW64TMMQ======"),
564        };
565        let json = serde_json::to_string(&resp).unwrap();
566        let deserialized: TotpEnrollResponse = serde_json::from_str(&json).unwrap();
567        assert_eq!(deserialized.otpauth_uri, "otpauth://totp/test");
568        assert_eq!(
569            deserialized.secret.expose_secret(),
570            "JBSWY3DPEBLW64TMMQ======"
571        );
572    }
573
574    #[test]
575    fn totp_confirm_response_round_trip() {
576        let resp = TotpConfirmResponse {
577            recovery_codes: vec!["code1".to_string(), "code2".to_string()],
578            session: None,
579        };
580        let json = serde_json::to_string(&resp).unwrap();
581        let deserialized: TotpConfirmResponse = serde_json::from_str(&json).unwrap();
582        assert_eq!(deserialized.recovery_codes.len(), 2);
583        assert!(deserialized.session.is_none());
584    }
585
586    #[test]
587    fn regenerate_recovery_codes_response_round_trip() {
588        let resp = RegenerateRecoveryCodesResponse {
589            recovery_codes: vec![
590                "code1".to_string(),
591                "code2".to_string(),
592                "code3".to_string(),
593            ],
594        };
595        let json = serde_json::to_string(&resp).unwrap();
596        let deserialized: RegenerateRecoveryCodesResponse = serde_json::from_str(&json).unwrap();
597        assert_eq!(deserialized.recovery_codes.len(), 3);
598    }
599}