use std::sync::Arc;
use url::Url;
use uuid::Uuid;
use webauthn_rs::Webauthn;
use webauthn_rs::prelude::{
CreationChallengeResponse, Passkey, PasskeyAuthentication, PasskeyRegistration,
PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse, WebauthnBuilder,
};
use crate::error::{Error, Result};
use crate::store::UserStore;
#[derive(Clone, Debug)]
pub struct PasskeyConfig {
pub rp_id: String,
pub rp_name: String,
pub origin: Url,
}
#[derive(Clone)]
pub struct PasskeyManager {
webauthn: Arc<Webauthn>,
users: Arc<dyn UserStore>,
config: PasskeyConfig,
}
impl PasskeyManager {
pub fn new(config: PasskeyConfig, users: Arc<dyn UserStore>) -> Result<Self> {
let webauthn = WebauthnBuilder::new(&config.rp_id, &config.origin)
.map_err(|e| Error::Passkey(format!("WebauthnBuilder::new: {e}")))?
.rp_name(&config.rp_name)
.build()
.map_err(|e| Error::Passkey(format!("WebauthnBuilder::build: {e}")))?;
Ok(Self {
webauthn: Arc::new(webauthn),
users,
config,
})
}
pub fn config(&self) -> &PasskeyConfig {
&self.config
}
pub fn users(&self) -> &Arc<dyn UserStore> {
&self.users
}
pub async fn start_registration(
&self,
user_unique_id: Uuid,
user_name: &str,
display_name: &str,
auth_user_id: Option<&str>,
) -> Result<(CreationChallengeResponse, PasskeyRegistration)> {
let exclude = if let Some(uid) = auth_user_id {
self.users
.list_passkeys(uid)
.await
.map(|creds| {
creds
.into_iter()
.map(|c| c.credential_id.into())
.collect::<Vec<_>>()
})
.unwrap_or_default()
} else {
Vec::new()
};
let exclude = if exclude.is_empty() {
None
} else {
Some(exclude)
};
self.webauthn
.start_passkey_registration(user_unique_id, user_name, display_name, exclude)
.map_err(|e| Error::Passkey(format!("start_passkey_registration: {e}")))
}
pub fn finish_registration(
&self,
state: &PasskeyRegistration,
response: &RegisterPublicKeyCredential,
) -> Result<Passkey> {
self.webauthn
.finish_passkey_registration(response, state)
.map_err(|e| Error::Passkey(format!("finish_passkey_registration: {e}")))
}
pub async fn start_authentication(
&self,
user_id: &str,
) -> Result<(RequestChallengeResponse, PasskeyAuthentication)> {
let stored = self
.users
.list_passkeys(user_id)
.await
.map_err(|e| Error::Backend(anyhow::anyhow!("list_passkeys({user_id}): {e}")))?;
if stored.is_empty() {
return Err(Error::Passkey(format!(
"no passkeys registered for user {user_id}"
)));
}
Err(Error::Passkey(format!(
"passkey reauthentication needs the serialised Passkey blob (count={}); \
use PasskeyManager::start_authentication_with after wiring `auth.passkeys.passkey_json`",
stored.len()
)))
}
pub fn start_authentication_with(
&self,
creds: &[Passkey],
) -> Result<(RequestChallengeResponse, PasskeyAuthentication)> {
if creds.is_empty() {
return Err(Error::Passkey(
"passkey list is empty; cannot start authentication".to_string(),
));
}
self.webauthn
.start_passkey_authentication(creds)
.map_err(|e| Error::Passkey(format!("start_passkey_authentication: {e}")))
}
pub fn finish_authentication(
&self,
state: &PasskeyAuthentication,
response: &PublicKeyCredential,
) -> Result<AuthenticatedPasskey> {
let result = self
.webauthn
.finish_passkey_authentication(response, state)
.map_err(|e| Error::Passkey(format!("finish_passkey_authentication: {e}")))?;
Ok(AuthenticatedPasskey {
credential_id: result.cred_id().as_ref().to_vec(),
sign_count: result.counter(),
user_verified: result.user_verified(),
needs_update: result.needs_update(),
})
}
}
#[derive(Clone, Debug)]
pub struct AuthenticatedPasskey {
pub credential_id: Vec<u8>,
pub sign_count: u32,
pub user_verified: bool,
pub needs_update: bool,
}
pub fn passkey_to_cred(passkey: &Passkey, created_at: f64) -> crate::store::PasskeyCred {
crate::store::PasskeyCred {
credential_id: passkey.cred_id().as_ref().to_vec(),
public_key: serde_json::to_vec(passkey.get_public_key()).unwrap_or_default(),
sign_count: 0,
transports: Vec::new(),
created_at,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::types::{PasskeyCred, Session, User};
use crate::store::{SessionStore, UserStore};
use std::collections::HashMap;
use std::sync::Mutex;
struct MemUserStore(Mutex<HashMap<String, Vec<PasskeyCred>>>);
#[async_trait::async_trait]
impl UserStore for MemUserStore {
async fn create_user(&self, _user: &User) -> anyhow::Result<()> {
Ok(())
}
async fn get_user_by_id(&self, _id: &str) -> anyhow::Result<Option<User>> {
Ok(None)
}
async fn get_user_by_email(&self, _email: &str) -> anyhow::Result<Option<User>> {
Ok(None)
}
async fn update_user(&self, _user: &User) -> anyhow::Result<()> {
Ok(())
}
async fn set_password_hash(&self, _user_id: &str, _hash: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_password_hash(&self, _user_id: &str) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn list_passkeys(&self, user_id: &str) -> anyhow::Result<Vec<PasskeyCred>> {
Ok(self
.0
.lock()
.unwrap()
.get(user_id)
.cloned()
.unwrap_or_default())
}
async fn add_passkey(&self, user_id: &str, cred: &PasskeyCred) -> anyhow::Result<()> {
self.0
.lock()
.unwrap()
.entry(user_id.to_string())
.or_default()
.push(cred.clone());
Ok(())
}
async fn remove_passkey(&self, _credential_id: &[u8]) -> anyhow::Result<bool> {
Ok(true)
}
async fn link_upstream(
&self,
_user_id: &str,
_provider: &str,
_subject: &str,
) -> anyhow::Result<()> {
Ok(())
}
async fn get_user_by_upstream(
&self,
_provider: &str,
_subject: &str,
) -> anyhow::Result<Option<User>> {
Ok(None)
}
async fn list_users(
&self,
_limit: i64,
_offset: i64,
_search: Option<&str>,
) -> anyhow::Result<Vec<User>> {
Ok(vec![])
}
async fn count_users(&self, _search: Option<&str>) -> anyhow::Result<i64> {
Ok(0)
}
async fn delete_user(&self, _id: &str) -> anyhow::Result<bool> {
Ok(false)
}
async fn list_upstream_for_user(
&self,
_user_id: &str,
) -> anyhow::Result<Vec<(String, String)>> {
Ok(vec![])
}
}
#[allow(dead_code)]
struct MemSessionStore(Mutex<HashMap<String, Session>>);
#[async_trait::async_trait]
impl SessionStore for MemSessionStore {
async fn create(&self, s: &Session) -> anyhow::Result<()> {
self.0.lock().unwrap().insert(s.id.clone(), s.clone());
Ok(())
}
async fn get(&self, id: &str) -> anyhow::Result<Option<Session>> {
Ok(self.0.lock().unwrap().get(id).cloned())
}
async fn delete(&self, id: &str) -> anyhow::Result<bool> {
Ok(self.0.lock().unwrap().remove(id).is_some())
}
async fn list_for_user(&self, _u: &str) -> anyhow::Result<Vec<Session>> {
Ok(vec![])
}
async fn delete_for_user(&self, _u: &str) -> anyhow::Result<u64> {
Ok(0)
}
async fn purge_expired(&self, _n: f64) -> anyhow::Result<u64> {
Ok(0)
}
async fn list_all(
&self,
_limit: i64,
_offset: i64,
_user_filter: Option<&str>,
) -> anyhow::Result<Vec<Session>> {
Ok(vec![])
}
async fn count_all(&self, _user_filter: Option<&str>) -> anyhow::Result<i64> {
Ok(0)
}
}
fn manager() -> PasskeyManager {
let cfg = PasskeyConfig {
rp_id: "localhost".to_string(),
rp_name: "Assay Test".to_string(),
origin: Url::parse("http://localhost:3000").unwrap(),
};
let users: Arc<dyn UserStore> = Arc::new(MemUserStore(Mutex::new(HashMap::new())));
PasskeyManager::new(cfg, users).unwrap()
}
#[test]
fn manager_construction_succeeds_for_localhost() {
let m = manager();
assert_eq!(m.config().rp_id, "localhost");
assert_eq!(m.config().rp_name, "Assay Test");
}
#[tokio::test]
async fn start_registration_emits_a_challenge_and_state() {
let m = manager();
let user_id = Uuid::new_v4();
let (challenge, _state) = m
.start_registration(user_id, "alice@example.com", "Alice", None)
.await
.expect("start_registration");
assert_eq!(challenge.public_key.user.name, "alice@example.com");
assert_eq!(challenge.public_key.user.display_name, "Alice");
}
#[tokio::test]
async fn start_authentication_errors_when_no_passkeys() {
let m = manager();
let result = m.start_authentication("user_with_no_keys").await;
assert!(matches!(result, Err(Error::Passkey(_))));
}
#[test]
fn start_authentication_with_empty_list_errors() {
let m = manager();
let result = m.start_authentication_with(&[]);
assert!(matches!(result, Err(Error::Passkey(_))));
}
}