Skip to main content

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}