use foundation_netio::http::NativeHttpClient;
use foundation_netio::shared::http::{SendSafeBody, SimpleHeader};
use serde::{Deserialize, Serialize};
use crate::ConfidentialText;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MfaType {
Totp,
WebAuthn,
}
impl MfaType {
fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"webauthn" | "fido2" => MfaType::WebAuthn,
_ => MfaType::Totp,
}
}
}
#[derive(Debug, Serialize)]
pub struct LoginRequest<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<&'a str>,
pub password: &'a str,
}
#[derive(Debug, Deserialize)]
pub struct LoginResponse {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub challenge_id: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub mfa_type_str: Option<String>,
#[serde(default)]
pub mfa_required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub attempts_remaining: Option<u32>,
#[serde(default)]
pub locked: bool,
}
#[derive(Debug)]
pub enum LoginResult {
Authenticated {
session_id: String,
mfa_required: bool,
},
MfaRequired {
challenge_id: String,
mfa_type: MfaType,
},
InvalidCredentials {
attempts_remaining: Option<u32>,
locked: bool,
},
}
#[derive(Clone)]
pub struct PasswordAuthClient {
base_url: String,
}
impl PasswordAuthClient {
#[must_use]
pub fn new(base_url: String) -> Self {
Self { base_url }
}
pub async fn login_email(
&self,
email: &str,
password: &ConfidentialText,
) -> Result<LoginResult, PasswordAuthError> {
self.do_login(LoginRequest {
email: Some(email),
username: None,
password: &password.get(),
})
.await
}
pub async fn login_username(
&self,
username: &str,
password: &ConfidentialText,
) -> Result<LoginResult, PasswordAuthError> {
self.do_login(LoginRequest {
email: None,
username: Some(username),
password: &password.get(),
})
.await
}
pub async fn submit_mfa(
&self,
challenge_id: &str,
code: &str,
) -> Result<LoginResult, PasswordAuthError> {
let url = format!("{}/auth/v1/mfa", self.base_url);
let body = serde_json::json!({
"challenge_id": challenge_id,
"code": code,
});
let body_str = serde_json::to_string(&body)
.map_err(|e| PasswordAuthError::ParseError(e.to_string()))?;
let resp = Self::post_json(&url, &body_str).await?;
parse_login_response(resp).await
}
async fn do_login(
&self,
request: LoginRequest<'_>,
) -> Result<LoginResult, PasswordAuthError> {
let url = format!("{}/auth/v1/login", self.base_url);
let body = serde_json::to_string(&request)
.map_err(|e| PasswordAuthError::ParseError(e.to_string()))?;
let resp = Self::post_json(&url, &body).await?;
parse_login_response(resp).await
}
async fn post_json(
url: &str,
body: &str,
) -> Result<serde_json::Value, PasswordAuthError> {
let client = NativeHttpClient::from_system();
let resp = client
.post(url)
.map_err(|e| PasswordAuthError::ConnectionFailed(e.to_string()))?
.header(SimpleHeader::CONTENT_TYPE, "application/json")
.body_text(body.to_string())
.build_client()
.map_err(|e| PasswordAuthError::ConnectionFailed(e.to_string()))?
.send_async()
.await
.map_err(|e| PasswordAuthError::ConnectionFailed(e.to_string()))?;
let status: usize = resp.get_status().into();
if status == 423 {
return Err(PasswordAuthError::AccountLocked);
}
if status == 401 {
let body = extract_body(resp.get_body_ref());
return serde_json::from_str(&body)
.map_err(|e| PasswordAuthError::ParseError(e.to_string()));
}
if !resp.is_success() {
let body = extract_body(resp.get_body_ref());
return Err(PasswordAuthError::ServerError {
status: status as u16,
message: body,
});
}
let body = extract_body(resp.get_body_ref());
serde_json::from_str(&body)
.map_err(|e| PasswordAuthError::ParseError(e.to_string()))
}
}
async fn parse_login_response(
value: serde_json::Value,
) -> Result<LoginResult, PasswordAuthError> {
let status = value
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
match status.as_str() {
"authenticated" => Ok(LoginResult::Authenticated {
session_id: value
.get("session_id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
mfa_required: value.get("mfa_required").and_then(|v| v.as_bool()).unwrap_or(false),
}),
"mfa_required" => {
let challenge_id = value
.get("challenge_id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let mfa_type = value
.get("type")
.and_then(|v| v.as_str())
.map(MfaType::from_str)
.unwrap_or(MfaType::Totp);
Ok(LoginResult::MfaRequired {
challenge_id,
mfa_type,
})
}
"invalid_credentials" | "" => Ok(LoginResult::InvalidCredentials {
attempts_remaining: value
.get("attempts_remaining")
.and_then(|v| v.as_u64())
.map(|n| n as u32),
locked: value.get("locked").and_then(|v| v.as_bool()).unwrap_or(false),
}),
other => Err(PasswordAuthError::ParseError(format!(
"unknown login status: {other}"
))),
}
}
fn extract_body(body: &SendSafeBody) -> String {
match body {
SendSafeBody::Text(t) => t.clone(),
SendSafeBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
_ => String::new(),
}
}
#[derive(Debug)]
pub enum PasswordAuthError {
ConnectionFailed(String),
ServerError { status: u16, message: String },
ParseError(String),
AccountLocked,
}
impl core::fmt::Display for PasswordAuthError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
PasswordAuthError::ConnectionFailed(s) => {
write!(f, "Password auth connection failed: {s}")
}
PasswordAuthError::ServerError { status, message } => {
write!(f, "Password auth server error ({status}): {message}")
}
PasswordAuthError::ParseError(s) => {
write!(f, "Password auth parse error: {s}")
}
PasswordAuthError::AccountLocked => write!(f, "Account locked"),
}
}
}
impl std::error::Error for PasswordAuthError {}