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
//! Adaptive / risk-based authentication. A [`RiskEngine`] scores a login
//! attempt from environmental signals; the login flow uses the verdict to
//! allow, force step-up MFA, or deny.
//!
//! The engine is a trait so apps can plug in device fingerprint stores, geo/IP
//! reputation feeds, or an external fraud service. A default heuristic engine is
//! provided for the common "new IP / new device ⇒ step up" case.

use async_trait::async_trait;

/// Signals available for a risk decision, gathered at the boundary. Extend by
/// reading `RequestContext::client_ip()` and headers in your controller.
#[derive(Debug, Clone, Default)]
pub struct RiskSignal {
    pub user_id: String,
    pub ip: Option<String>,
    /// A stable device/browser fingerprint, if the app computes one.
    pub device_id: Option<String>,
    /// Whether this IP has been seen for this user before.
    pub known_ip: bool,
    /// Whether this device has been seen for this user before.
    pub known_device: bool,
    /// Coarse geo (country code) if resolved from IP.
    pub country: Option<String>,
    /// The user's usual country, if known.
    pub usual_country: Option<String>,
    /// The originating request's W3C trace id (`ctx.trace_id_hex()`), carried so
    /// a federated-login audit record correlates to the request span.
    pub trace_id: Option<String>,
}

/// What to do with an attempt given its risk.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskVerdict {
    /// Proceed normally.
    Allow,
    /// Force a second factor even if the account would otherwise skip it.
    StepUp,
    /// Block outright.
    Deny,
}

/// Scores login attempts. Implementations must be cheap and non-blocking on the
/// hot path (do any I/O against a cache, not a synchronous DB call).
#[async_trait]
pub trait RiskEngine: Send + Sync + 'static {
    async fn assess(&self, signal: &RiskSignal) -> RiskVerdict;
}

/// A conservative default: unknown device *or* a country change forces step-up;
/// everything familiar is allowed. Never denies outright (that's a policy an app
/// layers on top with its own reputation data).
pub struct HeuristicRiskEngine;

#[async_trait]
impl RiskEngine for HeuristicRiskEngine {
    async fn assess(&self, s: &RiskSignal) -> RiskVerdict {
        let country_changed = matches!(
            (&s.country, &s.usual_country),
            (Some(c), Some(u)) if c != u
        );
        if !s.known_device || country_changed || !s.known_ip {
            RiskVerdict::StepUp
        } else {
            RiskVerdict::Allow
        }
    }
}