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
//! Passwordless authentication built on [`OneTimeTokenService`] + [`Notifier`]:
//! magic-links (email) and one-time codes (email or SMS).

use std::sync::Arc;

use arcly_http_core::resilience::distributed_rate_limit::{RateDecision, RateLimitBackend};

use crate::error::{IdentityError, Result};
use crate::identity::IdentityService;
use crate::model::TokenResponse;
use crate::notify::{Channel, Notification, NotificationKind, Notifier};
use crate::ott::{OneTimeTokenService, OttPurpose};
use crate::store::UserStore;

/// Per-destination send throttle (H1) — caps how often a magic link / OTP can be
/// requested for one address, defeating email/SMS bombing and the cost abuse it
/// causes. Fail-closed: if the backend is down, sends are refused.
struct SendLimit {
    backend: Arc<dyn RateLimitBackend>,
    max: u32,
    window_secs: u32,
}

impl SendLimit {
    async fn check(&self, destination: &str) -> Result<()> {
        let key = format!("pwless_send::{}", destination.to_ascii_lowercase());
        match self.backend.hit(&key, self.max, self.window_secs).await {
            RateDecision::Allow { .. } => Ok(()),
            RateDecision::Deny { .. } => Err(IdentityError::RateLimited),
            RateDecision::Unavailable => Err(IdentityError::Unavailable),
        }
    }
}

/// Orchestrates the passwordless flows. `link_base` is the front-end URL the
/// magic-link token is appended to (e.g. `https://app.example.com/auth/magic`).
pub struct PasswordlessService {
    users: Arc<dyn UserStore>,
    ott: Arc<OneTimeTokenService>,
    notifier: Arc<dyn Notifier>,
    identity: IdentityService,
    link_base: String,
    magic_ttl_secs: u64,
    otp_ttl_secs: u64,
    otp_digits: u32,
    limit: Option<SendLimit>,
}

impl PasswordlessService {
    pub fn new(
        users: Arc<dyn UserStore>,
        ott: Arc<OneTimeTokenService>,
        notifier: Arc<dyn Notifier>,
        identity: IdentityService,
        link_base: impl Into<String>,
    ) -> Self {
        Self {
            users,
            ott,
            notifier,
            identity,
            link_base: link_base.into(),
            magic_ttl_secs: 900,
            otp_ttl_secs: 300,
            otp_digits: 6,
            limit: None,
        }
    }

    /// Enable per-destination send throttling: at most `max` sends per
    /// `window_secs` for any one address (fail-closed). Reuses the framework's
    /// distributed rate-limit backend.
    pub fn with_send_limit(
        mut self,
        backend: Arc<dyn RateLimitBackend>,
        max: u32,
        window_secs: u32,
    ) -> Self {
        self.limit = Some(SendLimit {
            backend,
            max,
            window_secs,
        });
        self
    }

    /// Start a magic-link login: issue a token and email it as a link. Always
    /// returns `Ok(())` even when the email is unknown, so the response cannot
    /// enumerate accounts.
    pub async fn start_magic_link(&self, tenant: Option<&str>, email: &str) -> Result<()> {
        // Throttle *before* the user lookup so an unknown address is limited too
        // (blocks enumeration-by-timing and bombing of arbitrary inboxes).
        if let Some(limit) = &self.limit {
            limit.check(email).await?;
        }
        if let Some(user) = self.users.find_by_email(tenant, email).await? {
            let token = self
                .ott
                .issue(&user.id, OttPurpose::MagicLink, self.magic_ttl_secs)
                .await?;
            let link = format!("{}?token={}", self.link_base, token);
            self.notifier
                .send(Notification {
                    to: email.to_owned(),
                    channel: Channel::Email,
                    kind: NotificationKind::MagicLink,
                    payload: link,
                })
                .await?;
        }
        Ok(())
    }

    /// Redeem a magic-link token → issue tokens.
    pub async fn complete_magic_link(&self, token: &str) -> Result<TokenResponse> {
        let user_id = self.ott.consume(token, OttPurpose::MagicLink).await?;
        let user = self
            .users
            .find_by_id(&user_id)
            .await?
            .ok_or(IdentityError::NotFound)?;
        self.identity.mint(&user).await
    }

    /// Start OTP login: send a numeric code over `channel`. Enumeration-safe.
    pub async fn start_otp(
        &self,
        tenant: Option<&str>,
        destination: &str,
        channel: Channel,
    ) -> Result<()> {
        if let Some(limit) = &self.limit {
            limit.check(destination).await?;
        }
        // For SMS the destination is a phone; for email it's the address. We
        // look up by email here; a phone-based lookup is a store concern.
        if let Some(user) = self.users.find_by_email(tenant, destination).await? {
            let code = self
                .ott
                .issue_numeric(
                    &user.id,
                    OttPurpose::LoginOtp,
                    self.otp_digits,
                    self.otp_ttl_secs,
                )
                .await?;
            self.notifier
                .send(Notification {
                    to: destination.to_owned(),
                    channel,
                    kind: NotificationKind::LoginOtp,
                    payload: code,
                })
                .await?;
        }
        Ok(())
    }

    /// Complete OTP login → issue tokens.
    pub async fn complete_otp(&self, code: &str) -> Result<TokenResponse> {
        let user_id = self.ott.consume(code, OttPurpose::LoginOtp).await?;
        let user = self
            .users
            .find_by_id(&user_id)
            .await?
            .ok_or(IdentityError::NotFound)?;
        self.identity.mint(&user).await
    }
}