use axum::{extract::State, http::HeaderMap, response::IntoResponse, Json};
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::callback::{AuthCallback, AuthCallbackPayload};
use crate::errors::AppError;
use crate::handlers::auth::call_authenticated_callback_with_timeout;
use crate::models::{AuthMethod, AuthResponse};
use crate::repositories::{
normalize_email, AuditEventType, CredentialEntity, CredentialType, SessionEntity,
};
use crate::services::{
webauthn_service::{VerifyAuthenticationRequest, VerifyRegistrationRequest},
EmailService,
};
use crate::utils::{
auth::authenticate, build_json_response_with_cookies, extract_client_ip,
get_default_org_context, hash_refresh_token, user_entity_to_auth_user,
};
use crate::AppState;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RegisterOptionsResponse {
pub challenge_id: Uuid,
pub options: serde_json::Value,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthOptionsResponse {
pub challenge_id: Uuid,
pub options: serde_json::Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartAuthRequest {
pub email: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifyRegisterRequest {
pub challenge_id: Uuid,
pub credential: serde_json::Value,
pub label: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifyAuthRequest {
pub challenge_id: Uuid,
pub credential: serde_json::Value,
}
pub async fn register_options<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
) -> Result<Json<RegisterOptionsResponse>, AppError> {
let auth_user = authenticate(&state, &headers).await?;
let user = state
.user_repo
.find_by_id(auth_user.user_id)
.await?
.ok_or(AppError::InvalidToken)?;
let existing = state
.storage
.webauthn_repository()
.find_by_user(auth_user.user_id)
.await?;
let result = state
.webauthn_service
.start_registration(
auth_user.user_id,
user.email.as_deref(),
user.name.as_deref(),
&existing,
&state.storage.webauthn_repo,
)
.await?;
let options_json =
serde_json::to_value(&result.options).map_err(|e| AppError::Internal(e.into()))?;
Ok(Json(RegisterOptionsResponse {
challenge_id: result.challenge_id,
options: options_json,
}))
}
pub async fn register_verify<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Json(request): Json<VerifyRegisterRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let auth_user = authenticate(&state, &headers).await?;
let credential: webauthn_rs::prelude::RegisterPublicKeyCredential =
serde_json::from_value(request.credential)
.map_err(|e| AppError::Validation(format!("Invalid credential format: {}", e)))?;
let webauthn_cred = state
.webauthn_service
.finish_registration(
VerifyRegistrationRequest {
challenge_id: request.challenge_id,
credential,
label: request.label.clone(),
},
&state.storage.webauthn_repo,
)
.await?;
let unified_cred = CredentialEntity::new(
auth_user.user_id,
CredentialType::WebauthnPasskey,
request.label,
);
if let Err(e) = state
.storage
.credential_repository()
.create(unified_cred)
.await
{
tracing::warn!(
user_id = %auth_user.user_id,
error = %e,
"Failed to create unified credential entry for WebAuthn passkey"
);
}
Ok(Json(serde_json::json!({
"success": true,
"credentialId": webauthn_cred.id,
"label": webauthn_cred.label
})))
}
pub async fn auth_options<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
Json(request): Json<StartAuthRequest>,
) -> Result<Json<AuthOptionsResponse>, AppError> {
let enabled = state
.settings_service
.get_bool("auth_webauthn_enabled")
.await
.ok()
.flatten()
.unwrap_or(state.config.webauthn.enabled);
if !enabled {
return Err(AppError::NotFound("WebAuthn auth disabled".into()));
}
let result = if let Some(ref email) = request.email {
let normalized = normalize_email(email);
let user = state
.user_repo
.find_by_email(&normalized)
.await?
.ok_or_else(|| AppError::InvalidCredentials)?;
let creds = state
.storage
.webauthn_repository()
.find_by_user(user.id)
.await?;
if creds.is_empty() {
return Err(AppError::InvalidCredentials);
}
state
.webauthn_service
.start_authentication(Some(user.id), &creds, &state.storage.webauthn_repo)
.await?
} else {
state
.webauthn_service
.start_discoverable_authentication(&state.storage.webauthn_repo)
.await?
};
let options_json =
serde_json::to_value(&result.options).map_err(|e| AppError::Internal(e.into()))?;
Ok(Json(AuthOptionsResponse {
challenge_id: result.challenge_id,
options: options_json,
}))
}
pub async fn auth_verify<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Json(request): Json<VerifyAuthRequest>,
) -> Result<impl IntoResponse, AppError> {
let enabled = state
.settings_service
.get_bool("auth_webauthn_enabled")
.await
.ok()
.flatten()
.unwrap_or(state.config.webauthn.enabled);
if !enabled {
return Err(AppError::NotFound("WebAuthn auth disabled".into()));
}
let credential: webauthn_rs::prelude::PublicKeyCredential =
serde_json::from_value(request.credential.clone())
.map_err(|e| AppError::Validation(format!("Invalid credential format: {}", e)))?;
let challenge = state
.storage
.webauthn_repository()
.find_challenge(request.challenge_id)
.await?
.ok_or_else(|| AppError::Validation("Challenge expired or not found".into()))?;
let verified_user_id = if challenge.challenge_type == "discoverable" {
let (user_id, _cred) = state
.webauthn_service
.finish_discoverable_authentication(
VerifyAuthenticationRequest {
challenge_id: request.challenge_id,
credential,
},
&state.storage.webauthn_repo,
)
.await?;
user_id
} else {
let user_id = challenge
.user_id
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("Missing user_id in challenge")))?;
let credentials = state
.storage
.webauthn_repository()
.find_by_user(user_id)
.await?;
let (verified_user_id, _cred) = state
.webauthn_service
.finish_authentication(
VerifyAuthenticationRequest {
challenge_id: request.challenge_id,
credential,
},
&credentials,
&state.storage.webauthn_repo,
)
.await?;
verified_user_id
};
let user = state
.user_repo
.find_by_id(verified_user_id)
.await?
.ok_or(AppError::Internal(anyhow::anyhow!(
"User not found after WebAuthn auth"
)))?;
let memberships = state.membership_repo.find_by_user(verified_user_id).await?;
let token_context = get_default_org_context(&memberships, user.is_system_admin, user.email_verified);
let session_id = Uuid::new_v4();
let token_pair = state.jwt_service.generate_token_pair_with_context(
verified_user_id,
session_id,
&token_context,
)?;
let refresh_expiry =
Utc::now() + Duration::seconds(state.jwt_service.refresh_expiry_secs() as i64);
let ip_address = extract_client_ip(&headers, state.config.server.trust_proxy);
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let mut session = SessionEntity::new_with_id(
session_id,
verified_user_id,
hash_refresh_token(&token_pair.refresh_token, &state.config.jwt.secret),
refresh_expiry,
ip_address.clone(),
user_agent.clone(),
);
session.last_strong_auth_at = Some(Utc::now());
state.session_repo.create(session).await?;
let auth_user = user_entity_to_auth_user(&user);
let payload = AuthCallbackPayload {
user: auth_user.clone(),
method: AuthMethod::WebAuthn,
is_new_user: false,
session_id: session_id.to_string(),
ip_address,
user_agent,
};
let callback_data = call_authenticated_callback_with_timeout(&state.callback, &payload).await;
let _ = state
.audit_service
.log_user_event(AuditEventType::UserLogin, verified_user_id, Some(&headers))
.await;
let response_tokens = if state.config.cookie.enabled {
None
} else {
Some(token_pair.clone())
};
let response = AuthResponse {
user: auth_user,
tokens: response_tokens,
is_new_user: false,
callback_data,
api_key: None,
email_queued: None,
};
Ok(build_json_response_with_cookies(
&state.config.cookie,
&token_pair,
state.jwt_service.refresh_expiry_secs(),
response,
))
}