Skip to main content

arcly_http_identity/mfa/
passkey.rs

1//! WebAuthn / passkeys — trait-based, so no heavy WebAuthn SDK is linked into
2//! the framework. The app supplies a [`WebAuthnBackend`] (e.g. built on
3//! `webauthn-rs`) that performs the actual attestation/assertion crypto; this
4//! module wires it into the MFA flow and stores credential handles.
5//!
6//! This keeps arcly-http-identity honest to its "all I/O behind a trait, link no
7//! external SDK" contract while still giving passkeys a first-class seam.
8
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11
12use crate::error::Result;
13use crate::mfa::MfaVerifier;
14
15/// A stored passkey credential handle (opaque to the framework; its bytes are
16/// the WebAuthn credential id + public key the backend needs to verify).
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct PasskeyCredential {
19    pub credential_id: String,
20    /// Backend-defined opaque state (COSE public key, sign counter, transports).
21    pub state: serde_json::Value,
22    pub created_at: u64,
23}
24
25/// The crypto seam. Implement over `webauthn-rs` (or a platform SDK). The
26/// `challenge`/`response` payloads are opaque JSON passed straight through from
27/// the client — the backend owns their schema.
28#[async_trait]
29pub trait WebAuthnBackend: Send + Sync + 'static {
30    /// Begin registration: produce the `PublicKeyCredentialCreationOptions`
31    /// JSON the browser's `navigator.credentials.create` consumes.
32    async fn start_registration(&self, user_id: &str) -> Result<serde_json::Value>;
33    /// Finish registration: verify the attestation, returning the credential to
34    /// persist.
35    async fn finish_registration(
36        &self,
37        user_id: &str,
38        response: serde_json::Value,
39    ) -> Result<PasskeyCredential>;
40    /// Begin an assertion: produce `PublicKeyCredentialRequestOptions` JSON.
41    async fn start_assertion(&self, user_id: &str) -> Result<serde_json::Value>;
42    /// Verify an assertion response against the user's stored credentials.
43    async fn finish_assertion(&self, user_id: &str, response: serde_json::Value) -> Result<bool>;
44}
45
46/// Persistence for a user's passkey credentials.
47#[async_trait]
48pub trait PasskeyStore: Send + Sync + 'static {
49    async fn add(&self, user_id: &str, credential: PasskeyCredential) -> Result<()>;
50    async fn list(&self, user_id: &str) -> Result<Vec<PasskeyCredential>>;
51    async fn remove(&self, user_id: &str, credential_id: &str) -> Result<()>;
52}
53
54/// Passkey factor. Bridges the app's [`WebAuthnBackend`] into [`MfaVerifier`] so
55/// a passkey can satisfy an MFA challenge (`method = "passkey"`). For assertion,
56/// the `code` passed to [`MfaVerifier::verify`] is the JSON assertion response
57/// (as a string).
58pub struct PasskeyService {
59    backend: std::sync::Arc<dyn WebAuthnBackend>,
60    store: std::sync::Arc<dyn PasskeyStore>,
61}
62
63impl PasskeyService {
64    pub fn new(
65        backend: std::sync::Arc<dyn WebAuthnBackend>,
66        store: std::sync::Arc<dyn PasskeyStore>,
67    ) -> Self {
68        Self { backend, store }
69    }
70
71    pub async fn start_registration(&self, user_id: &str) -> Result<serde_json::Value> {
72        self.backend.start_registration(user_id).await
73    }
74
75    pub async fn finish_registration(
76        &self,
77        user_id: &str,
78        response: serde_json::Value,
79    ) -> Result<()> {
80        let cred = self.backend.finish_registration(user_id, response).await?;
81        self.store.add(user_id, cred).await
82    }
83
84    pub async fn start_assertion(&self, user_id: &str) -> Result<serde_json::Value> {
85        self.backend.start_assertion(user_id).await
86    }
87
88    pub async fn list(&self, user_id: &str) -> Result<Vec<PasskeyCredential>> {
89        self.store.list(user_id).await
90    }
91
92    pub async fn remove(&self, user_id: &str, credential_id: &str) -> Result<()> {
93        self.store.remove(user_id, credential_id).await
94    }
95}
96
97#[async_trait]
98impl MfaVerifier for PasskeyService {
99    async fn verify(&self, user_id: &str, method: &str, code: &str) -> Result<bool> {
100        if method != "passkey" {
101            return Ok(false);
102        }
103        let response: serde_json::Value = match serde_json::from_str(code) {
104            Ok(v) => v,
105            Err(_) => return Ok(false),
106        };
107        self.backend.finish_assertion(user_id, response).await
108    }
109}