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