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, 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}