arcly-http-identity 0.9.0

CIAM building-block engine for arcly-http: identity store traits, password hashing, MFA (TOTP), passwordless & account lifecycle, risk signals, and an OIDC provider — storage-agnostic, zero-lock, all I/O behind traits.
Documentation
//! WebAuthn / passkeys — trait-based, so no heavy WebAuthn SDK is linked into
//! the framework. The app supplies a [`WebAuthnBackend`] (e.g. built on
//! `webauthn-rs`) that performs the actual attestation/assertion crypto; this
//! module wires it into the MFA flow and stores credential handles.
//!
//! This keeps arcly-http-identity honest to its "all I/O behind a trait, link no
//! external SDK" contract while still giving passkeys a first-class seam.

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::error::Result;
use crate::mfa::MfaVerifier;

/// A stored passkey credential handle (opaque to the framework; its bytes are
/// the WebAuthn credential id + public key the backend needs to verify).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PasskeyCredential {
    pub credential_id: String,
    /// Backend-defined opaque state (COSE public key, sign counter, transports).
    pub state: serde_json::Value,
    pub created_at: u64,
}

/// The crypto seam. Implement over `webauthn-rs` (or a platform SDK). The
/// `challenge`/`response` payloads are opaque JSON passed straight through from
/// the client — the backend owns their schema.
#[async_trait]
pub trait WebAuthnBackend: Send + Sync + 'static {
    /// Begin registration: produce the `PublicKeyCredentialCreationOptions`
    /// JSON the browser's `navigator.credentials.create` consumes.
    async fn start_registration(&self, user_id: &str) -> Result<serde_json::Value>;
    /// Finish registration: verify the attestation, returning the credential to
    /// persist.
    async fn finish_registration(
        &self,
        user_id: &str,
        response: serde_json::Value,
    ) -> Result<PasskeyCredential>;
    /// Begin an assertion: produce `PublicKeyCredentialRequestOptions` JSON.
    async fn start_assertion(&self, user_id: &str) -> Result<serde_json::Value>;
    /// Verify an assertion response against the user's stored credentials.
    async fn finish_assertion(&self, user_id: &str, response: serde_json::Value) -> Result<bool>;
}

/// Persistence for a user's passkey credentials.
#[async_trait]
pub trait PasskeyStore: Send + Sync + 'static {
    async fn add(&self, user_id: &str, credential: PasskeyCredential) -> Result<()>;
    async fn list(&self, user_id: &str) -> Result<Vec<PasskeyCredential>>;
    async fn remove(&self, user_id: &str, credential_id: &str) -> Result<()>;
}

/// Passkey factor. Bridges the app's [`WebAuthnBackend`] into [`MfaVerifier`] so
/// a passkey can satisfy an MFA challenge (`method = "passkey"`). For assertion,
/// the `code` passed to [`MfaVerifier::verify`] is the JSON assertion response
/// (as a string).
pub struct PasskeyService {
    backend: std::sync::Arc<dyn WebAuthnBackend>,
    store: std::sync::Arc<dyn PasskeyStore>,
}

impl PasskeyService {
    pub fn new(
        backend: std::sync::Arc<dyn WebAuthnBackend>,
        store: std::sync::Arc<dyn PasskeyStore>,
    ) -> Self {
        Self { backend, store }
    }

    pub async fn start_registration(&self, user_id: &str) -> Result<serde_json::Value> {
        self.backend.start_registration(user_id).await
    }

    pub async fn finish_registration(
        &self,
        user_id: &str,
        response: serde_json::Value,
    ) -> Result<()> {
        let cred = self.backend.finish_registration(user_id, response).await?;
        self.store.add(user_id, cred).await
    }

    pub async fn start_assertion(&self, user_id: &str) -> Result<serde_json::Value> {
        self.backend.start_assertion(user_id).await
    }

    pub async fn list(&self, user_id: &str) -> Result<Vec<PasskeyCredential>> {
        self.store.list(user_id).await
    }

    pub async fn remove(&self, user_id: &str, credential_id: &str) -> Result<()> {
        self.store.remove(user_id, credential_id).await
    }
}

#[async_trait]
impl MfaVerifier for PasskeyService {
    async fn verify(&self, user_id: &str, method: &str, code: &str) -> Result<bool> {
        if method != "passkey" {
            return Ok(false);
        }
        let response: serde_json::Value = match serde_json::from_str(code) {
            Ok(v) => v,
            Err(_) => return Ok(false),
        };
        self.backend.finish_assertion(user_id, response).await
    }
}