assay-auth 0.5.1

Authentication, OIDC (client + provider), passkey, Argon2, JWT, Biscuit capability tokens, session management, and Zanzibar-style authorization for assay-engine.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
//! WebAuthn / passkey registration + authentication.
//!
//! Plan 12c task 5.2 reference. Wraps [`webauthn_rs`] 0.5 so HTTP
//! handlers can drive register / authenticate without
//! touching the library's verbose builder surface.
//!
//! In-progress state ([`PasskeyRegistration`], [`PasskeyAuthentication`])
//! is short-lived (~5 min). For phase 5 the manager just returns it; the
//! caller (phase 8 HTTP handlers) parks it however they want — typically
//! the session payload. A dedicated table is overkill for state that
//! lives less than a request round-trip.

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;

/// Operator-supplied relying-party config. `rp_id` is the host (no
/// scheme, no port — e.g. `"app.example.com"`); `rp_name` is the
/// human-readable label browsers show; `origin` is the canonical URL of
/// the page that hosts the WebAuthn JS.
///
/// All three come from `engine.toml` so a deployment can run multiple
/// engines behind one RP id without each rebuilding the wiring.
#[derive(Clone, Debug)]
pub struct PasskeyConfig {
    pub rp_id: String,
    pub rp_name: String,
    pub origin: Url,
}

/// Owns the [`Webauthn`] instance + the user store the manager needs to
/// look up existing credentials for the authenticate flow.
///
/// Cheap to clone — both fields are reference-counted.
#[derive(Clone)]
pub struct PasskeyManager {
    webauthn: Arc<Webauthn>,
    users: Arc<dyn UserStore>,
    config: PasskeyConfig,
}

impl PasskeyManager {
    /// Build the manager from operator config + the auth user store.
    /// Errors if the rp_id / origin fail [`webauthn_rs`]'s validation
    /// (e.g. mismatched host, missing TLD on a bare `localhost`-ish
    /// origin in production).
    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,
        })
    }

    /// Borrow the operator config — handy for `/well-known/...` style
    /// admin endpoints + tests.
    pub fn config(&self) -> &PasskeyConfig {
        &self.config
    }

    /// Borrow the underlying user store. Phase 8 handlers may need it
    /// directly when they upsert the resulting passkey via
    /// [`UserStore::add_passkey`].
    pub fn users(&self) -> &Arc<dyn UserStore> {
        &self.users
    }

    /// Step 1 of registration. Returns the challenge to ship to the
    /// browser plus the in-progress state to round-trip via the session.
    /// The state is short-lived; do NOT persist it long-term.
    ///
    /// `user_unique_id` is the [`Uuid`] [`webauthn_rs`] uses internally
    /// — typically a deterministic UUIDv5 derived from the user's
    /// `auth.users.id` (or any stable opaque id mapped to UUID space).
    /// `user_name` is the WebAuthn "name" (typically the email);
    /// `display_name` is the human-readable label.
    ///
    /// `auth_user_id` is the canonical opaque id stored on
    /// `auth.users.id` — used to look up existing passkeys so the
    /// browser can exclude them from the prompt. Pass `None` for fresh
    /// signups where no row exists yet.
    pub async fn start_registration(
        &self,
        user_unique_id: Uuid,
        user_name: &str,
        display_name: &str,
        auth_user_id: Option<&str>,
    ) -> Result<(CreationChallengeResponse, PasskeyRegistration)> {
        // Pre-load the user's existing passkeys so the browser can
        // exclude them from the prompt (avoids a duplicate-credential
        // attestation error). Failing this lookup is non-fatal — we just
        // skip exclusion and let webauthn-rs' own duplicate detection
        // catch it on `finish_registration`.
        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}")))
    }

    /// Step 2 of registration. Verifies the browser's
    /// [`RegisterPublicKeyCredential`] against the stored
    /// [`PasskeyRegistration`] state and returns the
    /// [`webauthn_rs::prelude::Passkey`] for the caller to persist via
    /// [`UserStore::add_passkey`].
    ///
    /// We return the library's `Passkey` rather than our
    /// [`crate::store::PasskeyCred`] so handlers can also stash the
    /// serialised form for later re-verification — converting via
    /// [`passkey_to_cred`] is a one-liner when persistence is wanted.
    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}")))
    }

    /// Step 1 of authentication. Loads the user's stored passkeys via
    /// [`UserStore::list_passkeys`] (caller passes the user_id) and
    /// asks [`webauthn_rs`] for a fresh challenge. Returns the challenge
    /// to ship to the browser plus the in-progress state to round-trip
    /// via the session.
    ///
    /// Errors with [`Error::Passkey`] when the user has no registered
    /// passkeys — callers should fall back to a different auth method
    /// instead of presenting an empty challenge.
    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}"
            )));
        }
        // We don't persist the full `Passkey` blob in `auth.passkeys`
        // (only credential_id + public_key + sign_count), so we can't
        // round-trip a `webauthn_rs::Passkey` from the table without a
        // second column carrying the serialised form. For phase 5 we
        // raise a clear error; phase 8 will introduce that column when
        // it wires the actual HTTP handler. Until then, callers that
        // hold a freshly-registered Passkey can call
        // [`PasskeyManager::start_authentication_with`] directly.
        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()
        )))
    }

    /// Variant of [`PasskeyManager::start_authentication`] that takes
    /// the already-deserialised [`webauthn_rs::prelude::Passkey`] list
    /// directly. Useful for tests + for any future caller that holds the
    /// serialised blob outside of the canonical store layout.
    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}")))
    }

    /// Step 2 of authentication. Verifies the browser's
    /// [`PublicKeyCredential`] and returns the
    /// [`AuthenticatedPasskey`] result the caller persists (sign-count
    /// bump, backup-state changes, etc.) via the user store.
    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(),
        })
    }
}

/// Successful authentication result — carries the credential id the
/// caller looks up in `auth.passkeys`, plus the new sign-count the
/// caller persists (cheap UPDATE keyed on credential_id).
#[derive(Clone, Debug)]
pub struct AuthenticatedPasskey {
    /// Raw bytes of the verified credential id. Matches the
    /// `auth.passkeys.credential_id` primary key.
    pub credential_id: Vec<u8>,
    /// New sign-count from the authenticator. Spec requires the server
    /// to assert this is greater than the stored value — when it isn't,
    /// the caller MAY treat the credential as cloned and revoke it.
    pub sign_count: u32,
    /// Whether the user verified themselves on this authentication
    /// (PIN, biometric, …). Useful for step-up flows.
    pub user_verified: bool,
    /// `webauthn-rs` thinks the stored Passkey blob is out-of-date with
    /// respect to the new `AuthenticationResult` (counter or backup
    /// state changed). Re-persist via the user store when true.
    pub needs_update: bool,
}

/// Project a [`webauthn_rs::prelude::Passkey`] into a
/// [`crate::store::PasskeyCred`] for persistence in `auth.passkeys`.
/// Phase 5 lacks a place for the full serialised `Passkey` JSON blob
/// (the table doesn't have a payload column yet), so the projection is
/// lossy — re-authentication needs a future column to round-trip the
/// blob. Tests and admin tooling that just want to enumerate stored
/// credentials are fine with the projection.
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 bytes aren't directly exposed by webauthn-rs'
        // public surface; we fall back to a JSON serialisation of the
        // COSE key for storage. Phase 8 may swap this for the raw COSE
        // bytes once the schema lands.
        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;

    /// Trivial in-memory user store for unit tests — no persistence,
    /// just enough to satisfy the trait so `PasskeyManager::new` works.
    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");
        // The challenge struct exposes `public_key` — sanity-check the
        // user shape made it through.
        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(_))));
    }
}