assay_auth/passkey.rs
1//! WebAuthn / passkey registration + authentication.
2//!
3//! Plan 12c task 5.2 reference. Wraps [`webauthn_rs`] 0.5 so HTTP
4//! handlers can drive register / authenticate without
5//! touching the library's verbose builder surface.
6//!
7//! In-progress state ([`PasskeyRegistration`], [`PasskeyAuthentication`])
8//! is short-lived (~5 min). For phase 5 the manager just returns it; the
9//! caller (phase 8 HTTP handlers) parks it however they want — typically
10//! the session payload. A dedicated table is overkill for state that
11//! lives less than a request round-trip.
12
13use std::sync::Arc;
14
15use url::Url;
16use uuid::Uuid;
17use webauthn_rs::Webauthn;
18use webauthn_rs::prelude::{
19 CreationChallengeResponse, Passkey, PasskeyAuthentication, PasskeyRegistration,
20 PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse,
21 WebauthnBuilder,
22};
23
24use crate::error::{Error, Result};
25use crate::store::UserStore;
26
27/// Operator-supplied relying-party config. `rp_id` is the host (no
28/// scheme, no port — e.g. `"app.example.com"`); `rp_name` is the
29/// human-readable label browsers show; `origin` is the canonical URL of
30/// the page that hosts the WebAuthn JS.
31///
32/// All three come from `engine.toml` so a deployment can run multiple
33/// engines behind one RP id without each rebuilding the wiring.
34#[derive(Clone, Debug)]
35pub struct PasskeyConfig {
36 pub rp_id: String,
37 pub rp_name: String,
38 pub origin: Url,
39}
40
41/// Owns the [`Webauthn`] instance + the user store the manager needs to
42/// look up existing credentials for the authenticate flow.
43///
44/// Cheap to clone — both fields are reference-counted.
45#[derive(Clone)]
46pub struct PasskeyManager {
47 webauthn: Arc<Webauthn>,
48 users: Arc<dyn UserStore>,
49 config: PasskeyConfig,
50}
51
52impl PasskeyManager {
53 /// Build the manager from operator config + the auth user store.
54 /// Errors if the rp_id / origin fail [`webauthn_rs`]'s validation
55 /// (e.g. mismatched host, missing TLD on a bare `localhost`-ish
56 /// origin in production).
57 pub fn new(config: PasskeyConfig, users: Arc<dyn UserStore>) -> Result<Self> {
58 let webauthn = WebauthnBuilder::new(&config.rp_id, &config.origin)
59 .map_err(|e| Error::Passkey(format!("WebauthnBuilder::new: {e}")))?
60 .rp_name(&config.rp_name)
61 .build()
62 .map_err(|e| Error::Passkey(format!("WebauthnBuilder::build: {e}")))?;
63 Ok(Self {
64 webauthn: Arc::new(webauthn),
65 users,
66 config,
67 })
68 }
69
70 /// Borrow the operator config — handy for `/well-known/...` style
71 /// admin endpoints + tests.
72 pub fn config(&self) -> &PasskeyConfig {
73 &self.config
74 }
75
76 /// Borrow the underlying user store. Phase 8 handlers may need it
77 /// directly when they upsert the resulting passkey via
78 /// [`UserStore::add_passkey`].
79 pub fn users(&self) -> &Arc<dyn UserStore> {
80 &self.users
81 }
82
83 /// Step 1 of registration. Returns the challenge to ship to the
84 /// browser plus the in-progress state to round-trip via the session.
85 /// The state is short-lived; do NOT persist it long-term.
86 ///
87 /// `user_unique_id` is the [`Uuid`] [`webauthn_rs`] uses internally
88 /// — typically a deterministic UUIDv5 derived from the user's
89 /// `auth.users.id` (or any stable opaque id mapped to UUID space).
90 /// `user_name` is the WebAuthn "name" (typically the email);
91 /// `display_name` is the human-readable label.
92 ///
93 /// `auth_user_id` is the canonical opaque id stored on
94 /// `auth.users.id` — used to look up existing passkeys so the
95 /// browser can exclude them from the prompt. Pass `None` for fresh
96 /// signups where no row exists yet.
97 pub async fn start_registration(
98 &self,
99 user_unique_id: Uuid,
100 user_name: &str,
101 display_name: &str,
102 auth_user_id: Option<&str>,
103 ) -> Result<(CreationChallengeResponse, PasskeyRegistration)> {
104 // Pre-load the user's existing passkeys so the browser can
105 // exclude them from the prompt (avoids a duplicate-credential
106 // attestation error). Failing this lookup is non-fatal — we just
107 // skip exclusion and let webauthn-rs' own duplicate detection
108 // catch it on `finish_registration`.
109 let exclude = if let Some(uid) = auth_user_id {
110 self.users
111 .list_passkeys(uid)
112 .await
113 .map(|creds| {
114 creds
115 .into_iter()
116 .map(|c| c.credential_id.into())
117 .collect::<Vec<_>>()
118 })
119 .unwrap_or_default()
120 } else {
121 Vec::new()
122 };
123 let exclude = if exclude.is_empty() {
124 None
125 } else {
126 Some(exclude)
127 };
128 self.webauthn
129 .start_passkey_registration(user_unique_id, user_name, display_name, exclude)
130 .map_err(|e| Error::Passkey(format!("start_passkey_registration: {e}")))
131 }
132
133 /// Step 2 of registration. Verifies the browser's
134 /// [`RegisterPublicKeyCredential`] against the stored
135 /// [`PasskeyRegistration`] state and returns the
136 /// [`webauthn_rs::prelude::Passkey`] for the caller to persist via
137 /// [`UserStore::add_passkey`].
138 ///
139 /// We return the library's `Passkey` rather than our
140 /// [`crate::store::PasskeyCred`] so handlers can also stash the
141 /// serialised form for later re-verification — converting via
142 /// [`passkey_to_cred`] is a one-liner when persistence is wanted.
143 pub fn finish_registration(
144 &self,
145 state: &PasskeyRegistration,
146 response: &RegisterPublicKeyCredential,
147 ) -> Result<Passkey> {
148 self.webauthn
149 .finish_passkey_registration(response, state)
150 .map_err(|e| Error::Passkey(format!("finish_passkey_registration: {e}")))
151 }
152
153 /// Step 1 of authentication. Loads the user's stored passkeys via
154 /// [`UserStore::list_passkeys`] (caller passes the user_id) and
155 /// asks [`webauthn_rs`] for a fresh challenge. Returns the challenge
156 /// to ship to the browser plus the in-progress state to round-trip
157 /// via the session.
158 ///
159 /// Errors with [`Error::Passkey`] when the user has no registered
160 /// passkeys — callers should fall back to a different auth method
161 /// instead of presenting an empty challenge.
162 pub async fn start_authentication(
163 &self,
164 user_id: &str,
165 ) -> Result<(RequestChallengeResponse, PasskeyAuthentication)> {
166 let stored = self
167 .users
168 .list_passkeys(user_id)
169 .await
170 .map_err(|e| Error::Backend(anyhow::anyhow!("list_passkeys({user_id}): {e}")))?;
171 if stored.is_empty() {
172 return Err(Error::Passkey(format!(
173 "no passkeys registered for user {user_id}"
174 )));
175 }
176 // We don't persist the full `Passkey` blob in `auth.passkeys`
177 // (only credential_id + public_key + sign_count), so we can't
178 // round-trip a `webauthn_rs::Passkey` from the table without a
179 // second column carrying the serialised form. For phase 5 we
180 // raise a clear error; phase 8 will introduce that column when
181 // it wires the actual HTTP handler. Until then, callers that
182 // hold a freshly-registered Passkey can call
183 // [`PasskeyManager::start_authentication_with`] directly.
184 Err(Error::Passkey(format!(
185 "passkey reauthentication needs the serialised Passkey blob (count={}); \
186 use PasskeyManager::start_authentication_with after wiring `auth.passkeys.passkey_json`",
187 stored.len()
188 )))
189 }
190
191 /// Variant of [`PasskeyManager::start_authentication`] that takes
192 /// the already-deserialised [`webauthn_rs::prelude::Passkey`] list
193 /// directly. Useful for tests + for any future caller that holds the
194 /// serialised blob outside of the canonical store layout.
195 pub fn start_authentication_with(
196 &self,
197 creds: &[Passkey],
198 ) -> Result<(RequestChallengeResponse, PasskeyAuthentication)> {
199 if creds.is_empty() {
200 return Err(Error::Passkey(
201 "passkey list is empty; cannot start authentication".to_string(),
202 ));
203 }
204 self.webauthn
205 .start_passkey_authentication(creds)
206 .map_err(|e| Error::Passkey(format!("start_passkey_authentication: {e}")))
207 }
208
209 /// Step 2 of authentication. Verifies the browser's
210 /// [`PublicKeyCredential`] and returns the
211 /// [`AuthenticatedPasskey`] result the caller persists (sign-count
212 /// bump, backup-state changes, etc.) via the user store.
213 pub fn finish_authentication(
214 &self,
215 state: &PasskeyAuthentication,
216 response: &PublicKeyCredential,
217 ) -> Result<AuthenticatedPasskey> {
218 let result = self
219 .webauthn
220 .finish_passkey_authentication(response, state)
221 .map_err(|e| Error::Passkey(format!("finish_passkey_authentication: {e}")))?;
222 Ok(AuthenticatedPasskey {
223 credential_id: result.cred_id().as_ref().to_vec(),
224 sign_count: result.counter(),
225 user_verified: result.user_verified(),
226 needs_update: result.needs_update(),
227 })
228 }
229}
230
231/// Successful authentication result — carries the credential id the
232/// caller looks up in `auth.passkeys`, plus the new sign-count the
233/// caller persists (cheap UPDATE keyed on credential_id).
234#[derive(Clone, Debug)]
235pub struct AuthenticatedPasskey {
236 /// Raw bytes of the verified credential id. Matches the
237 /// `auth.passkeys.credential_id` primary key.
238 pub credential_id: Vec<u8>,
239 /// New sign-count from the authenticator. Spec requires the server
240 /// to assert this is greater than the stored value — when it isn't,
241 /// the caller MAY treat the credential as cloned and revoke it.
242 pub sign_count: u32,
243 /// Whether the user verified themselves on this authentication
244 /// (PIN, biometric, …). Useful for step-up flows.
245 pub user_verified: bool,
246 /// `webauthn-rs` thinks the stored Passkey blob is out-of-date with
247 /// respect to the new `AuthenticationResult` (counter or backup
248 /// state changed). Re-persist via the user store when true.
249 pub needs_update: bool,
250}
251
252/// Project a [`webauthn_rs::prelude::Passkey`] into a
253/// [`crate::store::PasskeyCred`] for persistence in `auth.passkeys`.
254/// Phase 5 lacks a place for the full serialised `Passkey` JSON blob
255/// (the table doesn't have a payload column yet), so the projection is
256/// lossy — re-authentication needs a future column to round-trip the
257/// blob. Tests and admin tooling that just want to enumerate stored
258/// credentials are fine with the projection.
259pub fn passkey_to_cred(passkey: &Passkey, created_at: f64) -> crate::store::PasskeyCred {
260 crate::store::PasskeyCred {
261 credential_id: passkey.cred_id().as_ref().to_vec(),
262 // Public key bytes aren't directly exposed by webauthn-rs'
263 // public surface; we fall back to a JSON serialisation of the
264 // COSE key for storage. Phase 8 may swap this for the raw COSE
265 // bytes once the schema lands.
266 public_key: serde_json::to_vec(passkey.get_public_key()).unwrap_or_default(),
267 sign_count: 0,
268 transports: Vec::new(),
269 created_at,
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::store::types::{PasskeyCred, Session, User};
277 use crate::store::{SessionStore, UserStore};
278 use std::collections::HashMap;
279 use std::sync::Mutex;
280
281 /// Trivial in-memory user store for unit tests — no persistence,
282 /// just enough to satisfy the trait so `PasskeyManager::new` works.
283 struct MemUserStore(Mutex<HashMap<String, Vec<PasskeyCred>>>);
284
285 #[async_trait::async_trait]
286 impl UserStore for MemUserStore {
287 async fn create_user(&self, _user: &User) -> anyhow::Result<()> {
288 Ok(())
289 }
290 async fn get_user_by_id(&self, _id: &str) -> anyhow::Result<Option<User>> {
291 Ok(None)
292 }
293 async fn get_user_by_email(&self, _email: &str) -> anyhow::Result<Option<User>> {
294 Ok(None)
295 }
296 async fn update_user(&self, _user: &User) -> anyhow::Result<()> {
297 Ok(())
298 }
299 async fn set_password_hash(&self, _user_id: &str, _hash: &str) -> anyhow::Result<()> {
300 Ok(())
301 }
302 async fn get_password_hash(&self, _user_id: &str) -> anyhow::Result<Option<String>> {
303 Ok(None)
304 }
305 async fn list_passkeys(&self, user_id: &str) -> anyhow::Result<Vec<PasskeyCred>> {
306 Ok(self.0.lock().unwrap().get(user_id).cloned().unwrap_or_default())
307 }
308 async fn add_passkey(
309 &self,
310 user_id: &str,
311 cred: &PasskeyCred,
312 ) -> anyhow::Result<()> {
313 self.0
314 .lock()
315 .unwrap()
316 .entry(user_id.to_string())
317 .or_default()
318 .push(cred.clone());
319 Ok(())
320 }
321 async fn remove_passkey(&self, _credential_id: &[u8]) -> anyhow::Result<bool> {
322 Ok(true)
323 }
324 async fn link_upstream(
325 &self,
326 _user_id: &str,
327 _provider: &str,
328 _subject: &str,
329 ) -> anyhow::Result<()> {
330 Ok(())
331 }
332 async fn get_user_by_upstream(
333 &self,
334 _provider: &str,
335 _subject: &str,
336 ) -> anyhow::Result<Option<User>> {
337 Ok(None)
338 }
339 async fn list_users(
340 &self,
341 _limit: i64,
342 _offset: i64,
343 _search: Option<&str>,
344 ) -> anyhow::Result<Vec<User>> {
345 Ok(vec![])
346 }
347 async fn count_users(&self, _search: Option<&str>) -> anyhow::Result<i64> {
348 Ok(0)
349 }
350 async fn delete_user(&self, _id: &str) -> anyhow::Result<bool> {
351 Ok(false)
352 }
353 async fn list_upstream_for_user(
354 &self,
355 _user_id: &str,
356 ) -> anyhow::Result<Vec<(String, String)>> {
357 Ok(vec![])
358 }
359 }
360
361 #[allow(dead_code)]
362 struct MemSessionStore(Mutex<HashMap<String, Session>>);
363 #[async_trait::async_trait]
364 impl SessionStore for MemSessionStore {
365 async fn create(&self, s: &Session) -> anyhow::Result<()> {
366 self.0.lock().unwrap().insert(s.id.clone(), s.clone());
367 Ok(())
368 }
369 async fn get(&self, id: &str) -> anyhow::Result<Option<Session>> {
370 Ok(self.0.lock().unwrap().get(id).cloned())
371 }
372 async fn delete(&self, id: &str) -> anyhow::Result<bool> {
373 Ok(self.0.lock().unwrap().remove(id).is_some())
374 }
375 async fn list_for_user(&self, _u: &str) -> anyhow::Result<Vec<Session>> {
376 Ok(vec![])
377 }
378 async fn delete_for_user(&self, _u: &str) -> anyhow::Result<u64> {
379 Ok(0)
380 }
381 async fn purge_expired(&self, _n: f64) -> anyhow::Result<u64> {
382 Ok(0)
383 }
384 async fn list_all(
385 &self,
386 _limit: i64,
387 _offset: i64,
388 _user_filter: Option<&str>,
389 ) -> anyhow::Result<Vec<Session>> {
390 Ok(vec![])
391 }
392 async fn count_all(&self, _user_filter: Option<&str>) -> anyhow::Result<i64> {
393 Ok(0)
394 }
395 }
396
397 fn manager() -> PasskeyManager {
398 let cfg = PasskeyConfig {
399 rp_id: "localhost".to_string(),
400 rp_name: "Assay Test".to_string(),
401 origin: Url::parse("http://localhost:3000").unwrap(),
402 };
403 let users: Arc<dyn UserStore> =
404 Arc::new(MemUserStore(Mutex::new(HashMap::new())));
405 PasskeyManager::new(cfg, users).unwrap()
406 }
407
408 #[test]
409 fn manager_construction_succeeds_for_localhost() {
410 let m = manager();
411 assert_eq!(m.config().rp_id, "localhost");
412 assert_eq!(m.config().rp_name, "Assay Test");
413 }
414
415 #[tokio::test]
416 async fn start_registration_emits_a_challenge_and_state() {
417 let m = manager();
418 let user_id = Uuid::new_v4();
419 let (challenge, _state) = m
420 .start_registration(user_id, "alice@example.com", "Alice", None)
421 .await
422 .expect("start_registration");
423 // The challenge struct exposes `public_key` — sanity-check the
424 // user shape made it through.
425 assert_eq!(challenge.public_key.user.name, "alice@example.com");
426 assert_eq!(challenge.public_key.user.display_name, "Alice");
427 }
428
429 #[tokio::test]
430 async fn start_authentication_errors_when_no_passkeys() {
431 let m = manager();
432 let result = m.start_authentication("user_with_no_keys").await;
433 assert!(matches!(result, Err(Error::Passkey(_))));
434 }
435
436 #[test]
437 fn start_authentication_with_empty_list_errors() {
438 let m = manager();
439 let result = m.start_authentication_with(&[]);
440 assert!(matches!(result, Err(Error::Passkey(_))));
441 }
442}