use std::io::{self, Write};
use std::process::Command;
use std::time::{Duration, Instant};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
use crate::auth::{OnePasswordAuth, OtpRetriever, OtpSource};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum MfaType {
Totp,
SmsOtp,
EmailOtp,
Passkey,
MobileAppPush { app_name: String },
MobileCertificate { provider: String },
ExternalIdp { provider: String },
Unknown,
}
impl MfaType {
#[must_use]
pub fn is_automatable(&self) -> bool {
matches!(
self,
MfaType::Totp | MfaType::SmsOtp | MfaType::EmailOtp | MfaType::Passkey
)
}
#[must_use]
pub fn description(&self) -> String {
match self {
MfaType::Totp => "Time-based OTP (1Password)".to_string(),
MfaType::SmsOtp => "SMS verification code".to_string(),
MfaType::EmailOtp => "Email verification code".to_string(),
MfaType::Passkey => "Passkey/WebAuthn".to_string(),
MfaType::MobileAppPush { app_name } => format!("Mobile app authorization ({app_name})"),
MfaType::MobileCertificate { provider } => {
format!("Mobile certificate ({provider})")
}
MfaType::ExternalIdp { provider } => format!("External identity ({provider})"),
MfaType::Unknown => "Unknown 2FA method".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MfaResult {
pub success: bool,
pub mfa_type: MfaType,
pub code: Option<String>,
pub duration_ms: u64,
pub method: String,
}
#[derive(Debug, Clone)]
pub struct NotificationConfig {
pub pushover_user: Option<String>,
pub pushover_token: Option<String>,
pub telegram_bot_token: Option<String>,
pub telegram_chat_id: Option<String>,
pub macos_notification: bool,
pub terminal_prompt: bool,
pub timeout: Duration,
}
impl Default for NotificationConfig {
fn default() -> Self {
Self {
pushover_user: std::env::var("PUSHOVER_USER").ok(),
pushover_token: std::env::var("PUSHOVER_TOKEN").ok(),
telegram_bot_token: std::env::var("TELEGRAM_BOT_TOKEN").ok(),
telegram_chat_id: std::env::var("TELEGRAM_CHAT_ID").ok(),
macos_notification: true,
terminal_prompt: true,
timeout: Duration::from_mins(2),
}
}
}
pub struct MfaHandler {
config: NotificationConfig,
op_auth: OnePasswordAuth,
}
impl MfaHandler {
#[must_use]
pub fn new() -> Self {
Self {
config: NotificationConfig::default(),
op_auth: OnePasswordAuth::new(None),
}
}
#[must_use]
pub fn with_config(config: NotificationConfig) -> Self {
Self {
config,
op_auth: OnePasswordAuth::new(None),
}
}
pub fn handle(&self, mfa_type: &MfaType, domain: &str) -> Result<MfaResult> {
let start = Instant::now();
info!("🔐 Handling MFA challenge: {}", mfa_type.description());
let result = match mfa_type {
MfaType::Totp => Self::handle_totp(domain),
MfaType::SmsOtp => self.handle_sms_otp(domain),
MfaType::EmailOtp => self.handle_email_otp(domain),
MfaType::Passkey => self.handle_passkey(domain),
MfaType::MobileAppPush { app_name } => {
self.handle_human_in_loop(domain, &format!("Open {app_name} and approve"))
}
MfaType::MobileCertificate { provider } => self.handle_human_in_loop(
domain,
&format!("Complete {provider} authentication on your phone"),
),
MfaType::ExternalIdp { provider } => {
self.handle_human_in_loop(domain, &format!("Complete {provider} authentication"))
}
MfaType::Unknown => self.handle_human_in_loop(domain, "Complete 2FA on your device"),
};
#[allow(clippy::cast_possible_truncation)]
let duration_ms = start.elapsed().as_millis() as u64;
match result {
Ok(code) => Ok(MfaResult {
success: true,
mfa_type: mfa_type.clone(),
code,
duration_ms,
method: if mfa_type.is_automatable() {
"automated"
} else {
"human-in-loop"
}
.to_string(),
}),
Err(e) => {
warn!("MFA failed: {}", e);
Ok(MfaResult {
success: false,
mfa_type: mfa_type.clone(),
code: None,
duration_ms,
method: "failed".to_string(),
})
}
}
}
fn handle_totp(domain: &str) -> Result<Option<String>> {
if let Some(otp) = OtpRetriever::get_otp_for_domain(domain)?
&& otp.source == OtpSource::OnePasswordTotp
{
info!(" ✅ Got TOTP from 1Password");
return Ok(Some(otp.code));
}
Err(anyhow::anyhow!("No TOTP found in 1Password"))
}
fn handle_sms_otp(&self, domain: &str) -> Result<Option<String>> {
if let Some(otp) = OtpRetriever::get_otp_for_domain(domain)?
&& otp.source == OtpSource::SmsBeeper
{
info!(" ✅ Got SMS OTP via Beeper");
return Ok(Some(otp.code));
}
warn!(" ⚠️ SMS not available via Beeper, requesting manual input");
self.handle_human_in_loop(domain, "Enter SMS code")
}
fn handle_email_otp(&self, domain: &str) -> Result<Option<String>> {
if let Some(otp) = OtpRetriever::get_otp_for_domain(domain)?
&& otp.source == OtpSource::EmailGmail
{
info!(" ✅ Got Email OTP via Gmail");
return Ok(Some(otp.code));
}
warn!(" ⚠️ Email OTP not found, requesting manual input");
self.handle_human_in_loop(domain, "Enter email verification code")
}
fn handle_passkey(&self, domain: &str) -> Result<Option<String>> {
let passkeys = self.op_auth.list_passkeys()?;
for passkey in passkeys {
if let Some(ref url) = passkey.url
&& url.contains(domain)
{
info!(" ✅ Found passkey in 1Password: {}", passkey.title);
if let Some(id) = passkey.passkey_credential_id {
return Ok(Some(id));
}
}
}
debug!("Attempting passkey sign via 1Password CLI");
let output = Command::new("op")
.args(["item", "list", "--categories=Passkey", "--format=json"])
.output();
if let Ok(output) = output
&& output.status.success()
{
info!(" ⚠️ Passkey found but signing requires 1Password browser extension");
return self.handle_human_in_loop(domain, "Complete passkey authentication");
}
Err(anyhow::anyhow!("No passkey found for {domain}"))
}
fn handle_human_in_loop(&self, domain: &str, instruction: &str) -> Result<Option<String>> {
info!(" 🧑 Human-in-loop required: {}", instruction);
self.send_notifications(domain, instruction)?;
if self.config.terminal_prompt {
return self.terminal_prompt(domain, instruction);
}
std::thread::sleep(self.config.timeout);
Ok(None)
}
#[allow(clippy::unnecessary_wraps)] fn send_notifications(&self, domain: &str, instruction: &str) -> Result<()> {
let message = format!("MicroFetch: 2FA required for {domain}\n{instruction}");
if self.config.macos_notification {
let _ = Command::new("osascript")
.args([
"-e",
&format!(
r#"display notification "{instruction}" with title "MicroFetch 2FA" sound name "Ping""#
),
])
.output();
}
if let (Some(user), Some(token)) = (&self.config.pushover_user, &self.config.pushover_token)
{
let _ = Command::new("curl")
.args([
"-s",
"-F",
&format!("user={user}"),
"-F",
&format!("token={token}"),
"-F",
&format!("message={message}"),
"-F",
"priority=1",
"https://api.pushover.net/1/messages.json",
])
.output();
debug!("Sent Pushover notification");
}
if let (Some(bot_token), Some(chat_id)) = (
&self.config.telegram_bot_token,
&self.config.telegram_chat_id,
) {
let url = format!(
"https://api.telegram.org/bot{}/sendMessage?chat_id={}&text={}",
bot_token,
chat_id,
urlencoding::encode(&message)
);
let _ = Command::new("curl").args(["-s", &url]).output();
debug!("Sent Telegram notification");
}
Ok(())
}
fn terminal_prompt(&self, domain: &str, instruction: &str) -> Result<Option<String>> {
println!();
println!("┌─────────────────────────────────────────────────────────────────┐");
println!("│ MicroFetch: 2FA required for {domain:<32} │");
println!("├─────────────────────────────────────────────────────────────────┤");
println!("│ {instruction:<63} │");
println!("│ │");
println!(
"│ Timeout: {:>3}s │",
self.config.timeout.as_secs()
);
println!("├─────────────────────────────────────────────────────────────────┤");
println!("│ Enter code (or press Enter when done, 'c' to cancel): │");
println!("└─────────────────────────────────────────────────────────────────┘");
print!(" > ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim();
if input.eq_ignore_ascii_case("c") {
return Err(anyhow::anyhow!("User cancelled"));
}
if input.is_empty() {
info!(" ✅ User confirmed completion");
Ok(None)
} else {
info!(" ✅ User provided code");
Ok(Some(input.to_string()))
}
}
}
impl Default for MfaHandler {
fn default() -> Self {
Self::new()
}
}
#[must_use]
pub fn detect_mfa_type(html: &str, url: &str) -> Option<MfaType> {
let html_lower = html.to_lowercase();
let url_lower = url.to_lowercase();
if html_lower.contains("mobiilivarmenne")
|| html_lower.contains("mobiilitunnistus")
|| html_lower.contains("telia tunnistus")
|| html_lower.contains("elisa tunnistus")
|| html_lower.contains("dna tunnistus")
{
let provider = if html_lower.contains("telia") {
"Telia"
} else if html_lower.contains("elisa") {
"Elisa"
} else if html_lower.contains("dna") {
"DNA"
} else {
"Mobile Operator"
};
return Some(MfaType::MobileCertificate {
provider: provider.to_string(),
});
}
if html_lower.contains("digid") || url_lower.contains("digid.nl") {
return Some(MfaType::ExternalIdp {
provider: "DigiD".to_string(),
});
}
if html_lower.contains("bankid") || url_lower.contains("bankid.com") {
return Some(MfaType::ExternalIdp {
provider: "BankID".to_string(),
});
}
if html_lower.contains("itsme") || url_lower.contains("itsme.be") {
return Some(MfaType::ExternalIdp {
provider: "Itsme".to_string(),
});
}
if html_lower.contains("nordea codes") || html_lower.contains("tunnuslukusovellus") {
return Some(MfaType::MobileAppPush {
app_name: "Nordea Codes".to_string(),
});
}
if html_lower.contains("op-mobiili") || html_lower.contains("op avain") {
return Some(MfaType::MobileAppPush {
app_name: "OP Mobile".to_string(),
});
}
if html_lower.contains("aktia id") {
return Some(MfaType::MobileAppPush {
app_name: "Aktia ID".to_string(),
});
}
if html_lower.contains("danske id") {
return Some(MfaType::MobileAppPush {
app_name: "Danske ID".to_string(),
});
}
if html_lower.contains("webauthn")
|| html_lower.contains("passkey")
|| html_lower.contains("credential.create")
|| html_lower.contains("navigator.credentials")
{
return Some(MfaType::Passkey);
}
if html_lower.contains("authenticator")
|| html_lower.contains("6-digit")
|| html_lower.contains("one-time password")
|| html_lower.contains("totp")
{
return Some(MfaType::Totp);
}
if html_lower.contains("sms") && (html_lower.contains("code") || html_lower.contains("verify"))
{
return Some(MfaType::SmsOtp);
}
if html_lower.contains("email")
&& (html_lower.contains("code")
|| html_lower.contains("verify")
|| html_lower.contains("link"))
{
return Some(MfaType::EmailOtp);
}
None
}
mod urlencoding {
use std::fmt::Write as _;
pub fn encode(s: &str) -> String {
let mut result = String::new();
for c in s.chars() {
match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c),
' ' => result.push_str("%20"),
_ => {
for b in c.to_string().bytes() {
let _ = write!(result, "%{b:02X}");
}
}
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_mobiilivarmenne() {
let html = "<div>Käytä mobiilivarmennetta tunnistautumiseen</div>";
let mfa = detect_mfa_type(html, "https://tunnistus.fi");
assert!(matches!(mfa, Some(MfaType::MobileCertificate { .. })));
}
#[test]
fn test_detect_digid() {
let html = "<div>Login met DigiD</div>";
let mfa = detect_mfa_type(html, "https://example.nl");
assert!(matches!(mfa, Some(MfaType::ExternalIdp { provider }) if provider == "DigiD"));
}
#[test]
fn test_detect_nordea() {
let html = "<div>Vahvista Nordea Codes -sovelluksella</div>";
let mfa = detect_mfa_type(html, "https://nordea.fi");
assert!(matches!(
mfa,
Some(MfaType::MobileAppPush { app_name }) if app_name == "Nordea Codes"
));
}
#[test]
fn test_detect_totp() {
let html = "<input placeholder='Enter 6-digit code from authenticator'>";
let mfa = detect_mfa_type(html, "https://example.com");
assert!(matches!(mfa, Some(MfaType::Totp)));
}
#[test]
fn test_automatable() {
assert!(MfaType::Totp.is_automatable());
assert!(MfaType::SmsOtp.is_automatable());
assert!(MfaType::Passkey.is_automatable());
assert!(
!MfaType::MobileAppPush {
app_name: "Test".to_string()
}
.is_automatable()
);
assert!(
!MfaType::MobileCertificate {
provider: "Test".to_string()
}
.is_automatable()
);
}
}