1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4use crate::validation::{Validate, ValidationError};
5
6#[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 #[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#[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#[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#[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#[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 #[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#[non_exhaustive]
151#[derive(Debug, Serialize, Deserialize)]
152#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
153pub struct TotpEnrollResponse {
154 pub otpauth_uri: String,
156 pub secret: uptrakit_shared_types::SecretString,
159}
160
161impl TotpEnrollResponse {
162 #[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#[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#[non_exhaustive]
193#[derive(Debug, Serialize, Deserialize)]
194#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
195pub struct TotpConfirmResponse {
196 pub recovery_codes: Vec<String>,
198 pub session: Option<crate::auth::AuthResponse>,
200}
201
202impl TotpConfirmResponse {
203 #[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#[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#[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#[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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}