pub mod email_link;
pub mod sms_relay;
pub mod totp;
use crate::error::MfaError;
use async_trait::async_trait;
use std::sync::{Arc, LazyLock};
use std::time::Duration;
pub use email_link::*;
pub use sms_relay::*;
pub use totp::{totp_code_at, TotpSecret};
pub(crate) const MFA_CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
static POLLING_CLIENT: LazyLock<Result<reqwest::Client, String>> = LazyLock::new(|| {
reqwest::Client::builder()
.timeout(MFA_CLIENT_TIMEOUT)
.build()
.map_err(|e| format!("loginflow MFA HTTP client failed to build (TLS/resolver init): {e}"))
});
pub(crate) fn polling_client() -> Result<&'static reqwest::Client, MfaError> {
POLLING_CLIENT
.as_ref()
.map_err(|e| MfaError::Email(e.clone()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum MfaPrompt {
Totp {
field_name: Option<String>,
},
}
#[derive(Clone, PartialEq, Eq)]
pub struct MfaResponse {
pub code: String,
}
impl std::fmt::Debug for MfaResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MfaResponse")
.field("code", &"[redacted]")
.finish()
}
}
#[async_trait]
pub trait MfaSource: Send + Sync {
async fn fetch(&self, prompt: &MfaPrompt) -> Result<MfaResponse, MfaError>;
}
#[derive(Debug, Clone)]
pub struct TotpMfaSource {
secret: TotpSecret,
}
impl TotpMfaSource {
#[must_use]
pub fn new(secret_base32: impl Into<String>) -> Self {
Self {
secret: TotpSecret::new(secret_base32),
}
}
}
#[async_trait]
impl MfaSource for TotpMfaSource {
async fn fetch(&self, prompt: &MfaPrompt) -> Result<MfaResponse, MfaError> {
let _ = prompt;
let code = self.secret.current_code()?;
if code.is_empty() {
return Err(MfaError::Empty);
}
Ok(MfaResponse { code })
}
}
#[allow(dead_code)]
pub type SharedMfaSource = Arc<dyn MfaSource>;
#[cfg(test)]
mod client_tests {
use super::{polling_client, MFA_CLIENT_TIMEOUT};
use reqwest::Url;
use std::time::Duration;
#[test]
fn mfa_client_timeout_is_ten_seconds() {
assert_eq!(MFA_CLIENT_TIMEOUT, Duration::from_secs(10));
}
#[test]
fn polling_client_succeeds_and_is_shared_by_both_sources() {
let first = polling_client().expect("shared client builds");
let second = polling_client().expect("shared client builds");
assert!(
std::ptr::eq(first, second),
"client must be shared, not rebuilt"
);
let url = Url::parse("https://mfa.example/api").unwrap();
let _sms = super::sms_relay::SmsRelayMfaSource::new(url.clone(), "+15550001111");
let _email = super::email_link::EmailLinkMfaSource::new(url, "user@example.com");
}
}