use std::sync::Arc; use anyhow::{Context, Result}; use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc}; use diesel::prelude::*; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; use webauthn_rs::prelude::{Credential, *}; use crate::{ config::AppConfig, error::{AppError, AppResult}, models::{NewUserPasskey, NewWebauthnChallenge, User, UserPasskey, WebauthnChallenge}, schema::{user_passkeys::dsl as passkey_dsl, webauthn_challenges::dsl as challenge_dsl}, }; const PURPOSE_REGISTRATION: &str = "registration"; const PURPOSE_AUTHENTICATION: &str = "authentication"; const DEFAULT_CHALLENGE_TTL_MINUTES: i64 = 10; #[derive(Clone)] pub struct PasskeyService { webauthn: Arc, challenge_ttl: ChronoDuration, } #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct RegistrationChallengeResponse { pub challenge_id: Uuid, #[serde(flatten)] pub challenge: CreationChallengeResponse, } #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct AuthenticationChallengeResponse { pub challenge_id: Uuid, #[serde(flatten)] pub challenge: RequestChallengeResponse, } #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct PasskeySummary { pub id: Uuid, pub nickname: Option, pub created_at: NaiveDateTime, pub last_used_at: Option, pub transports: Vec, pub revoked_at: Option, pub revoked_reason: Option, } impl PasskeyService { pub fn try_new(config: &AppConfig) -> Result> { let rp_id = match config.webauthn_rp_id.as_deref().map(str::trim) { Some(rp_id) if !rp_id.is_empty() => rp_id, _ => return Ok(None), }; let rp_origin = match config.webauthn_origin.as_ref().map(|s| s.trim()) { Some(origin) if !origin.is_empty() => origin, _ => return Ok(None), }; let origin = Url::parse(rp_origin).context("invalid webauthn_origin")?; let builder = WebauthnBuilder::new(rp_id, &origin) .context("failed to initialise WebAuthn builder")? .rp_name(&config.webauthn_rp_name) .allow_subdomains(false) .allow_any_port(false); let webauthn = builder .build() .context("failed to build WebAuthn instance")?; Ok(Some(Self { webauthn: Arc::new(webauthn), challenge_ttl: ChronoDuration::minutes(DEFAULT_CHALLENGE_TTL_MINUTES), })) } fn prune_expired(&self, conn: &mut PgConnection) { let now = Utc::now().naive_utc(); let _ = diesel::delete( challenge_dsl::webauthn_challenges.filter(challenge_dsl::expires_at.le(now)), ) .execute(conn); } pub fn start_registration( &self, conn: &mut PgConnection, user: &User, ) -> AppResult { self.prune_expired(conn); let existing: Vec = passkey_dsl::user_passkeys .filter(passkey_dsl::user_id.eq(user.id)) .filter(passkey_dsl::revoked_at.is_null()) .load(conn)?; let exclude = if existing.is_empty() { None } else { Some( existing .iter() .map(|pk| CredentialID::from(pk.credential_id.clone())) .collect(), ) }; let (challenge, state) = self .webauthn .start_passkey_registration(user.id, &user.username, &user.username, exclude) .map_err(|err| { tracing::error!(error = %err, "failed to start passkey registration"); AppError::internal("failed to start passkey registration") })?; let challenge_id = Uuid::new_v4(); let expires_at = (Utc::now() + self.challenge_ttl).naive_utc(); let challenge_bytes: Vec = challenge.public_key.challenge.clone().into(); let state_bytes = serde_json::to_vec(&state) .context("failed to encode passkey registration state") .map_err(AppError::internal)?; let record = NewWebauthnChallenge { id: challenge_id, user_id: Some(user.id), purpose: PURPOSE_REGISTRATION.to_string(), challenge: challenge_bytes, state: state_bytes, expires_at, }; diesel::insert_into(challenge_dsl::webauthn_challenges) .values(&record) .execute(conn)?; Ok(RegistrationChallengeResponse { challenge_id, challenge, }) } pub fn finish_registration( &self, conn: &mut PgConnection, user: &User, challenge_id: Uuid, credential: RegisterPublicKeyCredential, nickname: Option, ) -> AppResult { let record: WebauthnChallenge = challenge_dsl::webauthn_challenges .find(challenge_id) .first(conn) .map_err(|err| { if matches!(err, diesel::result::Error::NotFound) { AppError::bad_request("challenge not found") } else { AppError::from(err) } })?; if record.purpose != PURPOSE_REGISTRATION { return Err(AppError::bad_request("challenge is not for registration")); } if record.user_id != Some(user.id) { return Err(AppError::unauthorized()); } if record.expires_at < Utc::now().naive_utc() { diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; return Err(AppError::bad_request("challenge expired")); } let state: PasskeyRegistration = serde_json::from_slice(&record.state) .context("failed to decode registration state") .map_err(AppError::internal)?; let passkey = self .webauthn .finish_passkey_registration(&credential, &state) .map_err(|err| { tracing::warn!(error = %err, "passkey registration validation failed"); AppError::bad_request("invalid passkey attestation") })?; let credential_struct: Credential = passkey.clone().into(); let credential_id_vec: Vec = credential_struct.cred_id.clone().into(); let duplicate = passkey_dsl::user_passkeys .filter(passkey_dsl::credential_id.eq(&credential_id_vec)) .first::(conn) .optional()?; if duplicate.is_some() { diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; return Err(AppError::conflict("credential already registered")); } let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred) .context("failed to encode credential public key") .map_err(AppError::internal)?; let transports: Vec> = credential_struct .transports .clone() .unwrap_or_default() .into_iter() .map(|transport| Some(transport.as_ref().to_string())) .collect(); let aaguid = match credential_struct.attestation.metadata { AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => { Some(aaguid) } _ => None, }; let credential_json = serde_json::to_value(&passkey) .context("failed to serialise passkey") .map_err(AppError::internal)?; let new_passkey = NewUserPasskey { id: Uuid::new_v4(), user_id: user.id, credential_id: credential_id_vec, public_key: public_key_bytes, credential: credential_json, sign_count: credential_struct.counter as i64, transports, aaguid, nickname, }; diesel::insert_into(passkey_dsl::user_passkeys) .values(&new_passkey) .execute(conn)?; diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; let created: UserPasskey = passkey_dsl::user_passkeys .find(new_passkey.id) .select(UserPasskey::as_select()) .first(conn)?; Ok(created) } pub fn start_authentication( &self, conn: &mut PgConnection, user: &User, ) -> AppResult { self.prune_expired(conn); let stored: Vec = passkey_dsl::user_passkeys .filter(passkey_dsl::user_id.eq(user.id)) .filter(passkey_dsl::revoked_at.is_null()) .select(UserPasskey::as_select()) .load(conn)?; if stored.is_empty() { return Err(AppError::bad_request("no passkeys registered")); } let mut passkeys = Vec::with_capacity(stored.len()); for pk in &stored { let passkey: Passkey = serde_json::from_value(pk.credential.clone()) .context("failed to parse stored passkey") .map_err(AppError::internal)?; passkeys.push(passkey); } let (challenge, state) = self .webauthn .start_passkey_authentication(&passkeys) .map_err(|err| { tracing::error!(error = %err, "failed to start passkey authentication"); AppError::internal("failed to start passkey authentication") })?; let challenge_id = Uuid::new_v4(); let expires_at = (Utc::now() + self.challenge_ttl).naive_utc(); let challenge_bytes: Vec = challenge.public_key.challenge.clone().into(); let state_bytes = serde_json::to_vec(&state) .context("failed to encode authentication state") .map_err(AppError::internal)?; let record = NewWebauthnChallenge { id: challenge_id, user_id: Some(user.id), purpose: PURPOSE_AUTHENTICATION.to_string(), challenge: challenge_bytes, state: state_bytes, expires_at, }; diesel::insert_into(challenge_dsl::webauthn_challenges) .values(&record) .execute(conn)?; Ok(AuthenticationChallengeResponse { challenge_id, challenge, }) } pub fn list_for_user( &self, conn: &mut PgConnection, user_id: Uuid, ) -> AppResult> { let passkeys: Vec = passkey_dsl::user_passkeys .filter(passkey_dsl::user_id.eq(user_id)) .order(passkey_dsl::created_at.asc()) .select(UserPasskey::as_select()) .load(conn)?; Ok(passkeys.into_iter().map(PasskeySummary::from).collect()) } pub fn revoke_passkey( &self, conn: &mut PgConnection, user_id: Uuid, passkey_id: Uuid, reason: Option, ) -> AppResult<()> { let now = Utc::now().naive_utc(); let updated = diesel::update( passkey_dsl::user_passkeys .filter(passkey_dsl::id.eq(passkey_id)) .filter(passkey_dsl::user_id.eq(user_id)) .filter(passkey_dsl::revoked_at.is_null()), ) .set(( passkey_dsl::revoked_at.eq(Some(now)), passkey_dsl::revoked_reason.eq(reason), passkey_dsl::updated_at.eq(now), )) .execute(conn)?; if updated == 0 { return Err(AppError::not_found()); } Ok(()) } pub fn finish_authentication( &self, conn: &mut PgConnection, challenge_id: Uuid, credential: PublicKeyCredential, ) -> AppResult<(User, UserPasskey, AuthenticationResult)> { let record: WebauthnChallenge = challenge_dsl::webauthn_challenges .find(challenge_id) .first(conn) .map_err(|err| { if matches!(err, diesel::result::Error::NotFound) { AppError::bad_request("challenge not found") } else { AppError::from(err) } })?; if record.purpose != PURPOSE_AUTHENTICATION { return Err(AppError::bad_request("challenge is not for authentication")); } let user_id = record .user_id .ok_or_else(|| AppError::bad_request("challenge missing user context"))?; if record.expires_at < Utc::now().naive_utc() { diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; return Err(AppError::bad_request("challenge expired")); } let state: PasskeyAuthentication = serde_json::from_slice(&record.state) .context("failed to decode authentication state") .map_err(AppError::internal)?; let auth_result = self .webauthn .finish_passkey_authentication(&credential, &state) .map_err(|err| { tracing::warn!(error = %err, "passkey authentication failed"); AppError::unauthorized() })?; let credential_id_vec: Vec = auth_result.cred_id().clone().into(); let mut passkey: UserPasskey = passkey_dsl::user_passkeys .filter(passkey_dsl::user_id.eq(user_id)) .filter(passkey_dsl::credential_id.eq(&credential_id_vec)) .filter(passkey_dsl::revoked_at.is_null()) .select(UserPasskey::as_select()) .first(conn) .map_err(|err| { if matches!(err, diesel::result::Error::NotFound) { AppError::unauthorized() } else { AppError::from(err) } })?; let mut passkey_model: Passkey = serde_json::from_value(passkey.credential.clone()) .context("failed to parse stored passkey") .map_err(AppError::internal)?; if auth_result.needs_update() { let _ = passkey_model.update_credential(&auth_result); } let credential_struct: Credential = passkey_model.clone().into(); let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred) .context("failed to encode credential public key") .map_err(AppError::internal)?; let transports: Vec> = credential_struct .transports .clone() .unwrap_or_default() .into_iter() .map(|transport| Some(transport.as_ref().to_string())) .collect(); let credential_json = serde_json::to_value(&passkey_model) .context("failed to serialise passkey") .map_err(AppError::internal)?; let now = Utc::now().naive_utc(); diesel::update(passkey_dsl::user_passkeys.find(passkey.id)) .set(( passkey_dsl::sign_count.eq(auth_result.counter() as i64), passkey_dsl::transports.eq(&transports), passkey_dsl::credential.eq(credential_json.clone()), passkey_dsl::public_key.eq(public_key_bytes), passkey_dsl::last_used_at.eq(Some(now)), passkey_dsl::updated_at.eq(now), )) .execute(conn)?; passkey.sign_count = auth_result.counter() as i64; passkey.transports = transports; passkey.credential = credential_json; passkey.last_used_at = Some(now); passkey.updated_at = now; diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; let user = crate::schema::users::table .find(user_id) .first::(conn)?; Ok((user, passkey, auth_result)) } } impl From for PasskeySummary { fn from(passkey: UserPasskey) -> Self { let transports = passkey .transports .into_iter() .filter_map(|value| value) .collect(); Self { id: passkey.id, nickname: passkey.nickname, created_at: passkey.created_at, last_used_at: passkey.last_used_at, transports, revoked_at: passkey.revoked_at, revoked_reason: passkey.revoked_reason, } } } #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct PasskeyRegistrationFinishPayload { pub challenge_id: Uuid, pub credential: RegisterPublicKeyCredential, #[serde(default)] pub nickname: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct PasskeyLoginStartPayload { pub username: String, } #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct PasskeyLoginFinishPayload { pub challenge_id: Uuid, pub credential: PublicKeyCredential, }