use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::{Duration, Utc};
use std::sync::Arc;
use uuid::Uuid;
use webauthn_rs::prelude::{DiscoverableAuthentication, Passkey, PasskeyAuthentication};
use crate::errors::AppError;
use crate::repositories::{WebAuthnChallenge, WebAuthnCredential, WebAuthnRepository};
use super::{AuthenticationOptionsResponse, VerifyAuthenticationRequest, WebAuthnService};
impl WebAuthnService {
pub async fn start_authentication(
&self,
user_id: Option<Uuid>,
credentials: &[WebAuthnCredential],
repo: &Arc<dyn WebAuthnRepository>,
) -> Result<AuthenticationOptionsResponse, AppError> {
let webauthn = self.get_webauthn().await?;
let policy = self.user_verification_policy();
let passkeys: Vec<Passkey> = credentials
.iter()
.filter_map(|c| serde_json::from_str(&c.public_key).ok())
.collect();
if passkeys.is_empty() {
return Err(AppError::NotFound("No passkeys registered for user".into()));
}
let (mut rcr, auth_state) =
webauthn
.start_passkey_authentication(&passkeys)
.map_err(|e| {
AppError::Internal(anyhow::anyhow!(
"WebAuthn authentication start failed: {:?}",
e
))
})?;
self.apply_authentication_options(&mut rcr, policy);
let state_json = self.serialize_authentication_state(&auth_state, policy)?;
let challenge_id = Uuid::new_v4();
let challenge = WebAuthnChallenge {
challenge_id,
user_id,
state: state_json,
challenge_type: "authenticate".to_string(),
created_at: Utc::now(),
expires_at: Utc::now() + Duration::seconds(self.config.challenge_ttl_seconds as i64),
};
repo.store_challenge(challenge).await?;
Ok(AuthenticationOptionsResponse {
challenge_id,
options: rcr,
})
}
pub async fn start_discoverable_authentication(
&self,
repo: &Arc<dyn WebAuthnRepository>,
) -> Result<AuthenticationOptionsResponse, AppError> {
let webauthn = self.get_webauthn().await?;
let policy = self.user_verification_policy();
let (mut rcr, auth_state) = webauthn.start_discoverable_authentication().map_err(|e| {
AppError::Internal(anyhow::anyhow!(
"WebAuthn discoverable auth start failed: {:?}",
e
))
})?;
self.apply_authentication_options(&mut rcr, policy);
let state_json = self.serialize_authentication_state(&auth_state, policy)?;
let challenge_id = Uuid::new_v4();
let challenge = WebAuthnChallenge {
challenge_id,
user_id: None, state: state_json,
challenge_type: "discoverable".to_string(),
created_at: Utc::now(),
expires_at: Utc::now() + Duration::seconds(self.config.challenge_ttl_seconds as i64),
};
repo.store_challenge(challenge).await?;
Ok(AuthenticationOptionsResponse {
challenge_id,
options: rcr,
})
}
pub async fn finish_discoverable_authentication(
&self,
request: VerifyAuthenticationRequest,
repo: &Arc<dyn WebAuthnRepository>,
) -> Result<(Uuid, WebAuthnCredential), AppError> {
let webauthn = self.get_webauthn().await?;
let challenge = repo
.consume_challenge(request.challenge_id)
.await?
.ok_or_else(|| AppError::Validation("Challenge expired or not found".into()))?;
if challenge.challenge_type != "discoverable" {
return Err(AppError::Validation(
"Invalid challenge type for discoverable auth".into(),
));
}
let auth_state: DiscoverableAuthentication =
serde_json::from_str(&challenge.state).map_err(|e| AppError::Internal(e.into()))?;
let cred_id_bytes: &[u8] = request.credential.get_credential_id();
let cred_id = URL_SAFE_NO_PAD.encode(cred_id_bytes);
tracing::info!(
cred_id_lookup = %cred_id,
cred_id_from_id_field = %request.credential.id,
raw_id_len = cred_id_bytes.len(),
"Discoverable auth: looking up credential"
);
let stored_credential = repo.find_by_credential_id(&cred_id).await?;
if stored_credential.is_none() {
tracing::warn!(
cred_id_lookup = %cred_id,
cred_id_from_id_field = %request.credential.id,
"Discoverable auth: credential NOT found in DB"
);
return Err(AppError::InvalidCredentials);
}
let stored_credential = stored_credential.unwrap();
let passkey: Passkey = serde_json::from_str(&stored_credential.public_key)
.map_err(|e| AppError::Internal(e.into()))?;
let auth_result = webauthn
.finish_discoverable_authentication(&request.credential, auth_state, &[passkey.into()])
.map_err(|_| AppError::InvalidCredentials)?;
let new_counter = auth_result.counter();
let old_counter = stored_credential.sign_count;
if new_counter <= old_counter && old_counter > 0 {
tracing::warn!(
credential_id = %stored_credential.credential_id,
user_id = %stored_credential.user_id,
old_counter = old_counter,
new_counter = new_counter,
"M-02: WebAuthn sign count did not increase - possible cloned authenticator"
);
if self.config.reject_cloned_credentials {
return Err(AppError::Validation(
"Authentication rejected: possible cloned authenticator detected".into(),
));
}
}
repo.record_successful_auth(stored_credential.id, new_counter)
.await?;
Ok((stored_credential.user_id, stored_credential))
}
pub async fn finish_authentication(
&self,
request: VerifyAuthenticationRequest,
credentials: &[WebAuthnCredential],
repo: &Arc<dyn WebAuthnRepository>,
) -> Result<(Uuid, WebAuthnCredential), AppError> {
let webauthn = self.get_webauthn().await?;
let challenge = repo
.consume_challenge(request.challenge_id)
.await?
.ok_or_else(|| AppError::Validation("Challenge expired or not found".into()))?;
if challenge.challenge_type != "authenticate" {
return Err(AppError::Validation("Invalid challenge type".into()));
}
let auth_state: PasskeyAuthentication =
serde_json::from_str(&challenge.state).map_err(|e| AppError::Internal(e.into()))?;
let auth_result = webauthn
.finish_passkey_authentication(&request.credential, &auth_state)
.map_err(|_| AppError::InvalidCredentials)?;
let used_cred_id = URL_SAFE_NO_PAD.encode(auth_result.cred_id());
let credential = credentials
.iter()
.find(|c| c.credential_id == used_cred_id)
.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
"Credential not found after successful auth"
))
})?;
let new_counter = auth_result.counter();
let old_counter = credential.sign_count;
if new_counter <= old_counter && old_counter > 0 {
tracing::warn!(
credential_id = %credential.credential_id,
user_id = %credential.user_id,
old_counter = old_counter,
new_counter = new_counter,
"M-02: WebAuthn sign count did not increase - possible cloned authenticator"
);
if self.config.reject_cloned_credentials {
return Err(AppError::Validation(
"Authentication rejected: possible cloned authenticator detected".into(),
));
}
}
repo.record_successful_auth(credential.id, new_counter)
.await?;
Ok((credential.user_id, credential.clone()))
}
}