use crate::config::{JwtConfig, LdapConfig, SecurityConfig, UserConfig};
use crate::error::{FusekiError, FusekiResult};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info};
pub mod api_key_service; pub mod certificate;
pub mod cluster_auth; pub mod graph_acl; pub mod graph_auth; pub mod http_auth; pub mod jwt; pub mod jwt_validation; pub mod ldap;
pub mod ldap_ha; pub mod mfa_storage; pub mod oauth;
pub mod oauth_providers; pub mod password;
pub mod permissions;
pub mod policy_engine; pub mod policy_templates; pub mod query_filter; pub mod rbac; pub mod rdf_rebac; pub mod rebac; pub mod rebac_migration; pub mod refresh_token; #[cfg(feature = "saml")]
pub mod saml;
#[cfg(feature = "saml")]
pub mod saml_helpers;
#[cfg(feature = "saml")]
pub mod saml_parser;
#[cfg(feature = "saml")]
pub mod saml_provider;
#[cfg(all(test, feature = "saml"))]
mod saml_tests;
#[cfg(feature = "saml")]
pub mod saml_types;
pub mod session;
pub mod token_management; pub mod types;
pub use certificate::CertificateAuthService as CertificateAuthenticator;
pub use cluster_auth::{
ClusterAuthConfig, ClusterAuthError, ClusterAuthManager, ClusterNodeToken, NodeIdentity,
};
pub use policy_templates::{GraphScope, PolicyTemplate, PolicyTemplateRegistry};
pub use session::SessionManager;
pub use types::*;
#[derive(Clone)]
pub struct AuthService {
config: Arc<SecurityConfig>,
users: Arc<RwLock<HashMap<String, UserConfig>>>,
session_manager: Arc<SessionManager>,
certificate_auth: Arc<CertificateAuthenticator>,
oauth2_service: Option<oauth::OAuth2Service>,
ldap_service: Option<ldap::LdapService>,
#[cfg(feature = "saml")]
saml_provider: Option<Arc<saml::SamlProvider>>,
mfa_challenges: Arc<RwLock<HashMap<String, MfaChallenge>>>,
mfa_storage: Arc<mfa_storage::MfaStorage>,
webauthn_challenges: Arc<RwLock<HashMap<String, String>>>,
}
impl AuthService {
pub async fn new(config: SecurityConfig) -> FusekiResult<Self> {
let users = config.users.clone();
let config_arc = Arc::new(config);
let session_manager = Arc::new(SessionManager::new(config_arc.session.timeout_secs as i64));
let certificate_auth = Arc::new(CertificateAuthenticator::new(config_arc.clone()));
let oauth2_service = config_arc
.oauth
.as_ref()
.map(|oauth_config| oauth::OAuth2Service::new(oauth_config.clone()));
let ldap_service = if let Some(ldap_config) = config_arc.ldap.as_ref() {
Some(ldap::LdapService::new(ldap_config.clone()).await?)
} else {
None
};
#[cfg(feature = "saml")]
let saml_provider = if let Some(saml_config) = config_arc.saml.as_ref() {
if saml_config.enabled {
use url::Url;
let saml_internal_config = saml::SamlConfig {
sp: saml::ServiceProviderConfig {
entity_id: saml_config.sp_entity_id.clone(),
acs_url: Url::parse(&saml_config.acs_url).map_err(|e| {
FusekiError::configuration(format!("Invalid SAML ACS URL: {}", e))
})?,
sls_url: saml_config
.slo_url
.as_ref()
.and_then(|url| Url::parse(url).ok()),
certificate: None, private_key: None, },
idp: saml::IdentityProviderConfig {
entity_id: saml_config.idp.entity_id.clone(),
sso_url: Url::parse(&saml_config.idp.sso_url).map_err(|e| {
FusekiError::configuration(format!("Invalid SAML SSO URL: {}", e))
})?,
slo_url: saml_config
.idp
.slo_url
.as_ref()
.and_then(|url| Url::parse(url).ok()),
certificate: String::new(), metadata_url: saml_config
.idp
.metadata_url
.as_ref()
.and_then(|url| Url::parse(url).ok()),
},
attribute_mapping: saml::AttributeMapping {
username: saml_config.attribute_mappings.username_attribute.clone(),
email: saml_config.attribute_mappings.email_attribute.clone(),
display_name: saml_config.attribute_mappings.name_attribute.clone(),
groups: saml_config.attribute_mappings.groups_attribute.clone(),
custom: std::collections::HashMap::new(),
},
session: saml::SessionConfig {
timeout: std::time::Duration::from_secs(saml_config.session_timeout_secs),
allow_idp_initiated: false,
force_authn: false,
track_session_index: true,
},
};
Some(Arc::new(saml::SamlProvider::new(saml_internal_config)))
} else {
None
}
} else {
None
};
Ok(Self {
config: config_arc,
users: Arc::new(RwLock::new(users)),
session_manager,
certificate_auth,
oauth2_service,
ldap_service,
#[cfg(feature = "saml")]
saml_provider,
mfa_challenges: Arc::new(RwLock::new(HashMap::new())),
mfa_storage: Arc::new(mfa_storage::MfaStorage::new(None)),
webauthn_challenges: Arc::new(RwLock::new(HashMap::new())),
})
}
pub async fn authenticate_user(
&self,
username: &str,
password: &str,
) -> FusekiResult<AuthResult> {
let users = self.users.read().await;
if let Some(user_config) = users.get(username) {
if !user_config.enabled {
info!("Login attempt for disabled user: {}", username);
return Ok(AuthResult::Forbidden);
}
if password::PasswordUtils::verify_password(password, &user_config.password_hash)? {
debug!("Successful local authentication for user: {}", username);
let permissions =
permissions::PermissionChecker::compute_user_permissions(user_config);
let user = User {
username: username.to_string(),
roles: user_config.roles.clone(),
email: user_config.email.clone(),
full_name: user_config.full_name.clone(),
last_login: user_config.last_login,
permissions,
};
return Ok(AuthResult::Authenticated(user));
}
}
if let Some(ldap_service) = &self.ldap_service {
debug!("Trying LDAP authentication for user: {}", username);
return ldap_service
.authenticate_ldap_user(username, password)
.await;
}
Ok(AuthResult::Unauthenticated)
}
pub async fn authenticate_certificate(&self, cert_data: &[u8]) -> FusekiResult<AuthResult> {
self.certificate_auth
.authenticate_certificate(cert_data)
.await
}
pub async fn create_session(&self, user: User) -> FusekiResult<String> {
self.session_manager.create_session(user).await
}
pub async fn validate_session(&self, session_id: &str) -> FusekiResult<Option<User>> {
match self.session_manager.validate_session(session_id).await? {
AuthResult::Authenticated(user) => Ok(Some(user)),
_ => Ok(None),
}
}
pub async fn logout(&self, session_id: &str) -> FusekiResult<bool> {
self.session_manager
.invalidate_session(session_id)
.await
.map(|_| true)
}
pub fn create_jwt_token(&self, user: &User) -> FusekiResult<String> {
self.session_manager.create_jwt_token(user)
}
pub fn validate_jwt_token(&self, token: &str) -> FusekiResult<TokenValidation> {
self.session_manager.validate_jwt_token(token)
}
pub fn get_oauth2_auth_url(&self, state: &str) -> FusekiResult<String> {
self.oauth2_service
.as_ref()
.ok_or_else(|| FusekiError::configuration("OAuth2 not configured"))?
.get_auth_url(state)
}
pub async fn complete_oauth2_authentication(
&self,
code: &str,
state: &str,
redirect_uri: &str,
) -> FusekiResult<AuthResult> {
let oauth2_service = self
.oauth2_service
.as_ref()
.ok_or_else(|| FusekiError::configuration("OAuth2 not configured"))?;
let token = oauth2_service
.exchange_code_for_token(code, state, redirect_uri)
.await?;
let user_info = oauth2_service.get_user_info(&token.access_token).await?;
let username = user_info.sub.clone();
let email = user_info.email.clone();
let full_name = user_info.name.clone();
let roles = vec!["user".to_string()];
let mut permissions = std::collections::HashSet::new();
for role in &roles {
if let Some(role_permissions) =
permissions::PermissionChecker::get_role_permissions(role)
{
permissions.extend(role_permissions);
}
}
let permissions: Vec<_> = permissions.into_iter().collect();
let user = User {
username: username.clone(),
roles,
email,
full_name,
last_login: Some(chrono::Utc::now()),
permissions,
};
debug!("Successful OAuth2 authentication for user: {}", username);
Ok(AuthResult::Authenticated(user))
}
pub fn is_oauth2_enabled(&self) -> bool {
self.oauth2_service.is_some()
}
pub async fn generate_oauth2_auth_url(
&self,
redirect_uri: &str,
scopes: &[String],
use_pkce: bool,
) -> FusekiResult<(String, String)> {
self.oauth2_service
.as_ref()
.ok_or_else(|| FusekiError::configuration("OAuth2 not configured"))?
.generate_authorization_url(redirect_uri, scopes, use_pkce)
.await
}
pub async fn validate_access_token(&self, access_token: &str) -> FusekiResult<bool> {
self.oauth2_service
.as_ref()
.ok_or_else(|| FusekiError::configuration("OAuth2 not configured"))?
.validate_access_token(access_token)
.await
}
pub fn get_oauth_config(&self) -> Option<&crate::config::OAuthConfig> {
self.config.oauth.as_ref()
}
pub async fn get_oauth2_user_info(
&self,
access_token: &str,
) -> FusekiResult<oauth::OIDCUserInfo> {
self.oauth2_service
.as_ref()
.ok_or_else(|| FusekiError::configuration("OAuth2 not configured"))?
.get_user_info(access_token)
.await
}
pub async fn refresh_oauth2_token(
&self,
refresh_token: &str,
) -> FusekiResult<oauth::OAuth2Token> {
self.oauth2_service
.as_ref()
.ok_or_else(|| FusekiError::configuration("OAuth2 not configured"))?
.refresh_token(refresh_token)
.await
}
#[cfg(feature = "saml")]
pub async fn generate_saml_auth_request(
&self,
relay_state: Option<String>,
) -> FusekiResult<String> {
if let Some(saml_provider) = &self.saml_provider {
let url = saml_provider.generate_login_url(relay_state).await?;
Ok(url.to_string())
} else {
Err(FusekiError::configuration("SAML not configured"))
}
}
#[cfg(feature = "saml")]
pub fn is_saml_enabled(&self) -> bool {
self.saml_provider.is_some()
}
#[cfg(feature = "saml")]
pub async fn complete_saml_authentication(
&self,
saml_response: &str,
relay_state: Option<&str>,
) -> FusekiResult<AuthResult> {
let saml_provider = self
.saml_provider
.as_ref()
.ok_or_else(|| FusekiError::configuration("SAML not configured"))?;
let user = saml_provider
.process_response(saml_response, relay_state)
.await?;
debug!("Successful SAML authentication for user: {}", user.username);
Ok(AuthResult::Authenticated(user))
}
#[cfg(feature = "saml")]
pub async fn logout_by_session_index(&self, session_index: &str) -> FusekiResult<bool> {
let saml_provider = self
.saml_provider
.as_ref()
.ok_or_else(|| FusekiError::configuration("SAML not configured"))?;
if let Some(session_id) = saml_provider.get_session_by_index(session_index).await? {
self.session_manager.invalidate_session(&session_id).await?;
debug!("Logged out session with SAML index: {}", session_index);
Ok(true)
} else {
debug!("No session found for SAML index: {}", session_index);
Ok(false)
}
}
#[cfg(feature = "saml")]
pub fn get_saml_sp_config(&self) -> FusekiResult<saml::ServiceProviderConfig> {
self.saml_provider
.as_ref()
.map(|provider| provider.config.sp.clone())
.ok_or_else(|| FusekiError::configuration("SAML not configured"))
}
#[cfg(feature = "saml")]
pub fn get_saml_attribute_mapping(&self) -> FusekiResult<saml::AttributeMapping> {
self.saml_provider
.as_ref()
.map(|provider| provider.config.attribute_mapping.clone())
.ok_or_else(|| FusekiError::configuration("SAML not configured"))
}
#[cfg(feature = "saml")]
pub async fn generate_saml_logout_request(
&self,
session_index: &str,
name_id: &str,
) -> FusekiResult<String> {
let saml_provider = self
.saml_provider
.as_ref()
.ok_or_else(|| FusekiError::configuration("SAML not configured"))?;
saml_provider
.generate_logout_request(session_index, name_id)
.await
}
#[cfg(feature = "saml")]
pub fn get_saml_metadata(&self) -> FusekiResult<String> {
let saml_provider = self
.saml_provider
.as_ref()
.ok_or_else(|| FusekiError::configuration("SAML not configured"))?;
Ok(saml_provider.get_metadata())
}
pub fn config(&self) -> &SecurityConfig {
&self.config
}
pub fn session_manager(&self) -> &SessionManager {
&self.session_manager
}
pub async fn get_user(&self, username: &str) -> Option<UserConfig> {
let users = self.users.read().await;
users.get(username).cloned()
}
pub fn hash_password(&self, password: &str) -> FusekiResult<String> {
#[cfg(feature = "auth")]
{
use bcrypt::{hash, DEFAULT_COST};
hash(password, DEFAULT_COST)
.map_err(|e| FusekiError::authentication(format!("Failed to hash password: {e}")))
}
#[cfg(not(feature = "auth"))]
{
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
password.hash(&mut hasher);
Ok(format!("hash_{:x}", hasher.finish()))
}
}
pub fn verify_password(&self, password: &str, hash: &str) -> FusekiResult<bool> {
#[cfg(feature = "auth")]
{
use bcrypt::verify;
verify(password, hash)
.map_err(|e| FusekiError::authentication(format!("Failed to verify password: {e}")))
}
#[cfg(not(feature = "auth"))]
{
let computed_hash = self.hash_password(password)?;
Ok(computed_hash == hash)
}
}
pub async fn upsert_user(&self, username: String, config: UserConfig) -> FusekiResult<()> {
let mut users = self.users.write().await;
users.insert(username, config);
Ok(())
}
pub async fn remove_user(&self, username: &str) -> FusekiResult<bool> {
let mut users = self.users.write().await;
Ok(users.remove(username).is_some())
}
pub fn is_ldap_enabled(&self) -> bool {
self.ldap_service.is_some()
}
pub async fn authenticate_ldap(
&self,
username: &str,
password: &str,
) -> FusekiResult<AuthResult> {
if let Some(ref ldap_service) = self.ldap_service {
ldap_service
.authenticate_ldap_user(username, password)
.await
} else {
Err(FusekiError::configuration("LDAP not configured"))
}
}
pub fn jwt_config(&self) -> Option<&JwtConfig> {
self.config.jwt.as_ref()
}
pub async fn generate_jwt_token(&self, user: &User) -> FusekiResult<String> {
self.create_jwt_token(user)
}
pub async fn test_ldap_connection(&self) -> FusekiResult<bool> {
if let Some(ref ldap_service) = self.ldap_service {
ldap_service.test_connection().await
} else {
Err(FusekiError::configuration("LDAP not configured"))
}
}
pub fn ldap_config(&self) -> Option<&LdapConfig> {
self.config.ldap.as_ref()
}
pub async fn get_ldap_user_groups(&self, username: &str) -> FusekiResult<Vec<String>> {
if let Some(ref ldap_service) = self.ldap_service {
let groups = ldap_service.get_user_groups(username).await?;
Ok(groups.into_iter().map(|group| group.cn).collect())
} else {
Err(FusekiError::configuration("LDAP not configured"))
}
}
pub async fn store_mfa_challenge(
&self,
challenge_id: &str,
challenge: MfaChallenge,
) -> FusekiResult<()> {
let mut challenges = self.mfa_challenges.write().await;
challenges.insert(challenge_id.to_string(), challenge);
debug!("Stored MFA challenge: {}", challenge_id);
Ok(())
}
pub async fn get_mfa_challenge(
&self,
challenge_id: &str,
) -> FusekiResult<Option<MfaChallenge>> {
let challenges = self.mfa_challenges.read().await;
Ok(challenges.get(challenge_id).cloned())
}
pub async fn remove_mfa_challenge(&self, challenge_id: &str) -> FusekiResult<bool> {
let mut challenges = self.mfa_challenges.write().await;
let removed = challenges.remove(challenge_id).is_some();
if removed {
debug!("Removed MFA challenge: {}", challenge_id);
}
Ok(removed)
}
pub async fn store_mfa_email(&self, username: &str, email: &str) -> FusekiResult<()> {
info!("Storing MFA email for user: {}", username);
self.mfa_storage.store_email(username, email).await
}
pub async fn get_user_sms_phone(&self, username: &str) -> FusekiResult<Option<String>> {
self.mfa_storage.get_sms_phone(username).await
}
pub async fn get_user_mfa_email(&self, username: &str) -> FusekiResult<Option<String>> {
self.mfa_storage.get_email(username).await
}
pub async fn store_webauthn_challenge(
&self,
username: &str,
challenge: &str,
) -> FusekiResult<()> {
let mut challenges = self.webauthn_challenges.write().await;
challenges.insert(username.to_string(), challenge.to_string());
debug!("Stored WebAuthn challenge for user: {}", username);
Ok(())
}
pub async fn store_sms_phone(&self, username: &str, phone: &str) -> FusekiResult<()> {
info!("Storing SMS phone for user: {}", username);
self.mfa_storage.store_sms_phone(username, phone).await
}
pub async fn update_mfa_challenge(
&self,
challenge_id: &str,
challenge: MfaChallenge,
) -> FusekiResult<()> {
let mut challenges = self.mfa_challenges.write().await;
challenges.insert(challenge_id.to_string(), challenge);
debug!("Updated MFA challenge: {}", challenge_id);
Ok(())
}
pub async fn get_user_mfa_status(&self, username: &str) -> FusekiResult<MfaStatus> {
use chrono::Utc;
match self.mfa_storage.get_user_data(username).await? {
None => Ok(MfaStatus {
enabled: false,
enrolled_methods: vec![],
backup_codes_remaining: 0,
last_used: None,
expires_at: None,
message: "MFA not configured".to_string(),
}),
Some(data) => {
let enrolled_methods: Vec<MfaMethodInfo> = data
.enrolled_methods
.iter()
.filter_map(|m| {
let method_type = match m.as_str() {
"totp" => MfaType::Totp,
"sms" => MfaType::Sms,
"email" => MfaType::Email,
"webauthn" => MfaType::Hardware,
"backup" => MfaType::Backup,
_ => return None,
};
let identifier = match m.as_str() {
"sms" => data.sms_phone.clone().unwrap_or_default(),
"email" => data.email.clone().unwrap_or_default(),
_ => m.clone(),
};
Some(MfaMethodInfo {
method_type,
identifier,
enrolled_at: data.created_at,
last_used: Some(data.updated_at),
enabled: true,
})
})
.collect();
let backup_codes_remaining = data.backup_codes.len().min(255) as u8;
let enabled = !enrolled_methods.is_empty();
let message = if enabled {
format!("{} MFA method(s) enrolled", enrolled_methods.len())
} else {
"MFA disabled".to_string()
};
Ok(MfaStatus {
enabled,
enrolled_methods,
backup_codes_remaining,
last_used: Some(data.updated_at),
expires_at: None,
message,
})
}
}
}
pub async fn disable_mfa_method(&self, username: &str, method: MfaMethod) -> FusekiResult<()> {
let method_str = match method {
MfaMethod::Totp => "totp",
MfaMethod::Sms => "sms",
MfaMethod::Email => "email",
MfaMethod::Hardware => "webauthn",
MfaMethod::Backup => "backup",
};
info!(
"Disabling MFA method '{}' for user: {}",
method_str, username
);
self.mfa_storage.disable_method(username, method_str).await
}
pub async fn store_backup_codes(&self, username: &str, codes: Vec<String>) -> FusekiResult<()> {
info!(
"Storing {} backup codes for user: {}",
codes.len(),
username
);
self.mfa_storage.store_backup_codes(username, codes).await
}
pub async fn store_totp_secret(&self, username: &str, secret: &str) -> FusekiResult<()> {
info!("Storing TOTP secret for user: {}", username);
self.mfa_storage.store_totp_secret(username, secret).await
}
pub async fn cleanup_ldap_cache(&self) {
if let Some(ref ldap_service) = self.ldap_service {
ldap_service.cleanup_expired_cache().await;
}
}
}
#[derive(Debug, Clone)]
pub struct AuthUser(pub User);
impl AuthUser {
pub fn into_inner(self) -> User {
self.0
}
}
impl From<AuthUser> for User {
fn from(auth_user: AuthUser) -> Self {
auth_user.0
}
}
impl From<User> for AuthUser {
fn from(user: User) -> Self {
AuthUser(user)
}
}
use axum::{
extract::{FromRequestParts, OptionalFromRequestParts},
http::{request::Parts, StatusCode},
};
use axum_extra::headers::{authorization::Bearer, Authorization, HeaderMapExt};
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
if let Some(auth_header) = parts.headers.typed_get::<Authorization<Bearer>>() {
let _token = auth_header.token();
return Err(StatusCode::UNAUTHORIZED);
}
Err(StatusCode::UNAUTHORIZED)
}
}
impl<S> OptionalFromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Option<Self>, Self::Rejection> {
Ok(
<Self as FromRequestParts<S>>::from_request_parts(parts, state)
.await
.ok(),
)
}
}
pub struct RequirePermission(pub Permission);
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Authentication required")]
AuthenticationRequired,
#[error("Invalid credentials")]
InvalidCredentials,
#[error("Permission denied")]
PermissionDenied,
#[error("Token expired")]
TokenExpired,
#[error("Invalid token")]
InvalidToken,
#[error("MFA required")]
MfaRequired,
}
impl axum::response::IntoResponse for AuthError {
fn into_response(self) -> axum::response::Response {
let status = match self {
AuthError::AuthenticationRequired => StatusCode::UNAUTHORIZED,
AuthError::InvalidCredentials => StatusCode::UNAUTHORIZED,
AuthError::PermissionDenied => StatusCode::FORBIDDEN,
AuthError::TokenExpired => StatusCode::UNAUTHORIZED,
AuthError::InvalidToken => StatusCode::UNAUTHORIZED,
AuthError::MfaRequired => StatusCode::UNAUTHORIZED,
};
(status, self.to_string()).into_response()
}
}
#[allow(dead_code)]
fn decode_basic_auth(encoded: &str) -> Result<(String, String), Box<dyn std::error::Error + Send>> {
use base64::{engine::general_purpose::STANDARD, Engine};
let decoded = STANDARD
.decode(encoded)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send>)?;
let credential =
String::from_utf8(decoded).map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send>)?;
if let Some((username, password)) = credential.split_once(':') {
Ok((username.to_string(), password.to_string()))
} else {
Err(Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid basic auth format",
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SecurityConfig;
async fn make_auth_service() -> AuthService {
AuthService::new(SecurityConfig::default())
.await
.expect("AuthService::new should succeed with default config")
}
#[test]
fn test_decode_basic_auth() {
let encoded = "dGVzdDpwYXNzd29yZA=="; let result = decode_basic_auth(encoded).unwrap();
assert_eq!(result.0, "test");
assert_eq!(result.1, "password");
}
#[tokio::test]
async fn test_store_and_get_mfa_email() {
let svc = make_auth_service().await;
let user = "alice";
let email = "alice@example.com";
svc.store_mfa_email(user, email)
.await
.expect("store_mfa_email");
let retrieved = svc
.get_user_mfa_email(user)
.await
.expect("get_user_mfa_email");
assert_eq!(retrieved, Some(email.to_string()));
}
#[tokio::test]
async fn test_get_user_mfa_email_returns_none_when_absent() {
let svc = make_auth_service().await;
let result = svc
.get_user_mfa_email("nobody")
.await
.expect("get_user_mfa_email");
assert!(result.is_none());
}
#[tokio::test]
async fn test_store_and_get_sms_phone() {
let svc = make_auth_service().await;
let user = "bob";
let phone = "+15550001234";
svc.store_sms_phone(user, phone)
.await
.expect("store_sms_phone");
let retrieved = svc
.get_user_sms_phone(user)
.await
.expect("get_user_sms_phone");
assert_eq!(retrieved, Some(phone.to_string()));
}
#[tokio::test]
async fn test_get_user_sms_phone_returns_none_when_absent() {
let svc = make_auth_service().await;
let result = svc
.get_user_sms_phone("nobody")
.await
.expect("get_user_sms_phone");
assert!(result.is_none());
}
#[tokio::test]
async fn test_store_webauthn_challenge() {
let svc = make_auth_service().await;
let user = "carol";
let challenge = "base64urlencodedchallenge==";
svc.store_webauthn_challenge(user, challenge)
.await
.expect("store_webauthn_challenge");
let challenges = svc.webauthn_challenges.read().await;
assert_eq!(challenges.get(user).map(String::as_str), Some(challenge));
}
#[tokio::test]
async fn test_store_totp_secret() {
let svc = make_auth_service().await;
let user = "dave";
let secret = "JBSWY3DPEHPK3PXP";
svc.store_totp_secret(user, secret)
.await
.expect("store_totp_secret");
let status = svc
.get_user_mfa_status(user)
.await
.expect("get_user_mfa_status");
assert!(status.enabled);
assert!(status
.enrolled_methods
.iter()
.any(|m| m.method_type == MfaType::Totp));
}
#[tokio::test]
async fn test_store_backup_codes() {
let svc = make_auth_service().await;
let user = "eve";
let codes = vec![
"CODE1".to_string(),
"CODE2".to_string(),
"CODE3".to_string(),
];
svc.store_backup_codes(user, codes.clone())
.await
.expect("store_backup_codes");
let status = svc
.get_user_mfa_status(user)
.await
.expect("get_user_mfa_status");
assert_eq!(status.backup_codes_remaining, 3);
}
#[tokio::test]
async fn test_disable_mfa_method_totp() {
let svc = make_auth_service().await;
let user = "frank";
svc.store_totp_secret(user, "SECRETKEY")
.await
.expect("store_totp_secret");
let before = svc.get_user_mfa_status(user).await.expect("status before");
assert!(before
.enrolled_methods
.iter()
.any(|m| m.method_type == MfaType::Totp));
svc.disable_mfa_method(user, MfaMethod::Totp)
.await
.expect("disable_mfa_method");
let after = svc.get_user_mfa_status(user).await.expect("status after");
assert!(!after
.enrolled_methods
.iter()
.any(|m| m.method_type == MfaType::Totp));
}
#[tokio::test]
async fn test_disable_mfa_method_sms() {
let svc = make_auth_service().await;
let user = "grace";
svc.store_sms_phone(user, "+15559990000")
.await
.expect("store_sms_phone");
svc.disable_mfa_method(user, MfaMethod::Sms)
.await
.expect("disable_mfa_method sms");
let phone = svc
.get_user_sms_phone(user)
.await
.expect("get phone after disable");
assert!(phone.is_none());
}
#[tokio::test]
async fn test_get_user_mfa_status_no_profile() {
let svc = make_auth_service().await;
let status = svc.get_user_mfa_status("nobody").await.expect("mfa_status");
assert!(!status.enabled);
assert!(status.enrolled_methods.is_empty());
assert_eq!(status.backup_codes_remaining, 0);
}
#[tokio::test]
async fn test_get_user_mfa_status_full_profile() {
let svc = make_auth_service().await;
let user = "heidi";
svc.store_totp_secret(user, "TOTPSECRET")
.await
.expect("store totp");
svc.store_sms_phone(user, "+15551112222")
.await
.expect("store sms");
svc.store_mfa_email(user, "heidi@example.com")
.await
.expect("store email");
svc.store_backup_codes(user, vec!["B1".to_string(), "B2".to_string()])
.await
.expect("store backup codes");
let status = svc.get_user_mfa_status(user).await.expect("mfa status");
assert!(status.enabled);
assert!(status
.enrolled_methods
.iter()
.any(|m| m.method_type == MfaType::Totp));
assert!(status
.enrolled_methods
.iter()
.any(|m| m.method_type == MfaType::Sms));
assert_eq!(status.backup_codes_remaining, 2);
}
}