use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthMethod {
Pwd,
Totp,
RecoveryCode,
Webauthn,
}
impl AuthMethod {
pub fn as_amr(self) -> &'static str {
match self {
Self::Pwd => "pwd",
Self::Totp | Self::RecoveryCode => "otp",
Self::Webauthn => "hwk",
}
}
pub fn is_second_factor(self) -> bool {
match self {
Self::Pwd => false,
Self::Totp | Self::RecoveryCode | Self::Webauthn => true,
}
}
pub fn is_phishing_resistant(self) -> bool {
matches!(self, Self::Webauthn)
}
}
pub fn acr_from_methods(methods: &[AuthMethod]) -> &'static str {
if methods.iter().any(|m| m.is_phishing_resistant()) {
"3"
} else if methods.iter().any(|m| m.is_second_factor()) {
"2"
} else {
"1"
}
}
pub fn amr_from_methods(methods: &[AuthMethod]) -> Vec<String> {
let mut out: Vec<String> = Vec::with_capacity(methods.len() + 1);
for m in methods {
let v = m.as_amr().to_string();
if !out.contains(&v) {
out.push(v);
}
}
if out.len() >= 2 && !out.contains(&"mfa".to_string()) {
out.push("mfa".to_string());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_only_is_loa_1() {
assert_eq!(acr_from_methods(&[AuthMethod::Pwd]), "1");
assert_eq!(amr_from_methods(&[AuthMethod::Pwd]), vec!["pwd"]);
}
#[test]
fn password_plus_totp_is_loa_2_with_mfa() {
let m = [AuthMethod::Pwd, AuthMethod::Totp];
assert_eq!(acr_from_methods(&m), "2");
assert_eq!(amr_from_methods(&m), vec!["pwd", "otp", "mfa"]);
}
#[test]
fn password_plus_recovery_is_loa_2_with_otp_amr() {
let m = [AuthMethod::Pwd, AuthMethod::RecoveryCode];
assert_eq!(acr_from_methods(&m), "2");
assert_eq!(amr_from_methods(&m), vec!["pwd", "otp", "mfa"]);
}
#[test]
fn password_plus_webauthn_is_loa_3() {
let m = [AuthMethod::Pwd, AuthMethod::Webauthn];
assert_eq!(acr_from_methods(&m), "3");
assert_eq!(amr_from_methods(&m), vec!["pwd", "hwk", "mfa"]);
}
#[test]
fn duplicates_are_deduped_and_mfa_isnt_added_twice() {
let m = [AuthMethod::Pwd, AuthMethod::Pwd, AuthMethod::Totp, AuthMethod::Totp];
assert_eq!(amr_from_methods(&m), vec!["pwd", "otp", "mfa"]);
}
#[test]
fn empty_methods_falls_back_to_loa_1() {
assert_eq!(acr_from_methods(&[]), "1");
assert!(amr_from_methods(&[]).is_empty());
}
#[test]
fn webauthn_alone_is_phishing_resistant_so_loa_3_but_not_mfa() {
let m = [AuthMethod::Webauthn];
assert_eq!(acr_from_methods(&m), "3");
assert_eq!(amr_from_methods(&m), vec!["hwk"]);
}
}