use std::collections::HashMap; use std::sync::{Arc, Mutex}; use webauthn_rs::{ Webauthn, core::{AuthenticationState, RegistrationState, WebauthnConfig}, error::WebauthnError as WanError, proto::{ CreationChallengeResponse, Credential, PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse, UserVerificationPolicy, }, }; use crate::domain::{DomainError, DomainResult, Passkey as DbPasskey, User}; use crate::ports::{PasskeyRepository, UserRepository}; use crate::security::new_secret; use crate::sqlite::SqliteDatabase; /// Site-specific WebAuthn configuration, derived from env vars. pub struct AppWebauthnConfig { rp_id: String, rp_name: String, origin: url::Url, require_resident_key: bool, } impl AppWebauthnConfig { pub fn new(rp_id: String, rp_name: String, origin: url::Url) -> Self { Self { rp_id, rp_name, origin, // Resident (discoverable) keys let users sign in without typing an // email, because the authenticator can select the credential on its // own and return the user handle. require_resident_key: true, } } } impl WebauthnConfig for AppWebauthnConfig { fn get_relying_party_name(&self) -> &str { &self.rp_name } fn get_origin(&self) -> &url::Url { &self.origin } fn get_relying_party_id(&self) -> &str { &self.rp_id } fn get_require_resident_key(&self) -> bool { self.require_resident_key } } /// A single-use, in-memory challenge store. Registrations are keyed by user id; /// authentications are keyed by a random token so that userless (discoverable) /// ceremonies can be correlated back to the finish request. #[derive(Default)] struct ChallengeStore { registrations: HashMap, authentications: HashMap, } pub struct WebAuthnService { db: SqliteDatabase, webauthn: Webauthn, users: Arc, passkeys: Arc, challenges: Mutex, } impl WebAuthnService { pub fn new( db: SqliteDatabase, config: AppWebauthnConfig, users: Arc, passkeys: Arc, ) -> Self { let webauthn = Webauthn::new(config); Self { db, webauthn, users, passkeys, challenges: Mutex::new(ChallengeStore::default()), } } /// Start a passkey registration ceremony for an authenticated user. pub fn start_registration(&self, user: &User) -> DomainResult { // Use the user's opaque, random user handle as the WebAuthn userHandle // so that userless (discoverable) sign-in can resolve the owning user // from the assertion's userHandle without exposing the numeric id. let (challenge, state) = self .webauthn .generate_challenge_register_options( user.user_handle.clone(), user.email.clone(), user.display_name.clone(), None, Some(UserVerificationPolicy::Required), None, ) .map_err(webauthn_error)?; self.challenges .lock() .map_err(|_| DomainError::Database("challenge lock poisoned".into()))? .registrations .insert(user.id, state); Ok(challenge) } /// Finish a passkey registration ceremony and persist the credential. pub async fn finish_registration( &self, user: &User, response: RegisterPublicKeyCredential, ) -> DomainResult<()> { let state = self .challenges .lock() .map_err(|_| DomainError::Database("challenge lock poisoned".into()))? .registrations .remove(&user.id) .ok_or(DomainError::NotFound)?; let passkeys = Arc::clone(&self.passkeys); let credential_id = response.raw_id.0.clone(); let user_id = user.id; let credential = self .webauthn .register_credential(&response, &state, |_| Ok(false)) .map_err(webauthn_error)?; let serialized = serde_json::to_string(&credential.0) .map_err(|e| DomainError::Database(e.to_string()))?; let credential_id_b64 = base64_url(&credential_id); let counter = credential.0.counter as i64; self.db .run(move |txn| { let passkeys = passkeys.clone(); Box::pin(async move { passkeys .create_passkey(txn, user_id, credential_id_b64, serialized, counter) .await?; Ok(()) }) }) .await } /// Start a passkey authentication ceremony for a user identified by email. /// Returns the challenge and a token used to correlate the finish request. pub async fn start_authentication( &self, user_id: i64, ) -> DomainResult<(RequestChallengeResponse, String)> { let passkeys = Arc::clone(&self.passkeys); let db = self.db.clone(); let credentials: Vec = db .run(move |txn| { let passkeys = passkeys.clone(); Box::pin(async move { let rows = passkeys.list_for_user(txn, user_id).await?; let mut creds = Vec::new(); for row in rows { let cred: Credential = serde_json::from_str(&row.credential) .map_err(|e| DomainError::Database(e.to_string()))?; creds.push(cred); } Ok(creds) }) }) .await?; if credentials.is_empty() { return Err(DomainError::NotFound); } let (challenge, state) = self .webauthn .generate_challenge_authenticate(credentials) .map_err(webauthn_error)?; let token = hex::encode(new_secret()); self.challenges .lock() .map_err(|_| DomainError::Database("challenge lock poisoned".into()))? .authentications .insert(token.clone(), state); Ok((challenge, token)) } /// Start a userless passkey authentication ceremony. No email is required: /// the authenticator selects a discoverable credential and returns a user /// handle that we resolve to the owning user on finish. pub async fn start_userless_authentication( &self, ) -> DomainResult<(RequestChallengeResponse, String)> { let (challenge, mut state) = self .webauthn .generate_challenge_authenticate_options(vec![], None) .map_err(webauthn_error)?; // With no allowCredentials the browser will offer any discoverable // credential for this RP; the credential set is populated from the // user handle once the assertion is received. state.set_allowed_credentials(vec![]); let token = hex::encode(new_secret()); self.challenges .lock() .map_err(|_| DomainError::Database("challenge lock poisoned".into()))? .authentications .insert(token.clone(), state); Ok((challenge, token)) } /// Finish a passkey authentication ceremony, resolving the owning user from /// the credential id (and, for userless ceremonies, the user handle). pub async fn finish_authentication( &self, token: String, response: PublicKeyCredential, ) -> DomainResult { let mut state = self .challenges .lock() .map_err(|_| DomainError::Database("challenge lock poisoned".into()))? .authentications .remove(&token) .ok_or(DomainError::NotFound)?; // For userless ceremonies the assertion carries a user handle that // identifies the user; load that user's credentials so the signature // can be verified against the correct key. if let Some(user_handle) = response.get_user_handle() { let handle = user_handle.to_vec(); let users = Arc::clone(&self.users); let db = self.db.clone(); let user_id = db .run(move |txn| { let users = users.clone(); Box::pin(async move { let user = users .find_user_by_handle(txn, handle) .await? .ok_or(DomainError::NotFound)?; Ok(user.id) }) }) .await?; let passkeys = Arc::clone(&self.passkeys); let credentials: Vec = db .run(move |txn| { let passkeys = passkeys.clone(); Box::pin(async move { let rows = passkeys.list_for_user(txn, user_id).await?; let mut creds = Vec::new(); for row in rows { let cred: Credential = serde_json::from_str(&row.credential) .map_err(|e| DomainError::Database(e.to_string()))?; creds.push(cred); } Ok(creds) }) }) .await?; state.set_allowed_credentials(credentials); } let (cred_id, auth_data) = self .webauthn .authenticate_credential(&response, &state) .map_err(|e| { tracing::error!(%e, "webauthn authenticate_credential failed"); webauthn_error(e) })?; let passkeys = Arc::clone(&self.passkeys); let db = self.db.clone(); let credential_id_b64 = base64_url(cred_id); let user_id = db .run(move |txn| { let passkeys = passkeys.clone(); Box::pin(async move { let stored = passkeys .find_by_credential_id(txn, credential_id_b64) .await? .ok_or(DomainError::NotFound)?; let mut cred: Credential = serde_json::from_str(&stored.credential) .map_err(|e| DomainError::Database(e.to_string()))?; cred.counter = auth_data.counter; let serialized = serde_json::to_string(&cred) .map_err(|e| DomainError::Database(e.to_string()))?; sqlx::query("UPDATE passkeys SET credential = ?1 WHERE id = ?2") .bind(&serialized) .bind(stored.id) .execute(&mut *txn) .await .map_err(db_error)?; Ok(stored.user_id) }) }) .await?; Ok(user_id) } /// List the passkeys registered to a user. pub async fn list_passkeys(&self, user_id: i64) -> DomainResult> { let passkeys = Arc::clone(&self.passkeys); self.db .run(move |txn| { let passkeys = passkeys.clone(); Box::pin(async move { passkeys.list_for_user(txn, user_id).await }) }) .await } /// Delete a passkey owned by a user. pub async fn delete_passkey(&self, user_id: i64, passkey_id: i64) -> DomainResult<()> { let passkeys = Arc::clone(&self.passkeys); self.db .run(move |txn| { let passkeys = passkeys.clone(); Box::pin(async move { passkeys.delete_passkey(txn, user_id, passkey_id).await }) }) .await } } fn base64_url(bytes: &[u8]) -> String { use base64::Engine; base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) } fn webauthn_error(error: WanError) -> DomainError { DomainError::Database(error.to_string()) } fn db_error(error: sqlx::Error) -> DomainError { DomainError::Database(error.to_string()) }