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
//! Canonical identity records and the DTOs the flows exchange.

use serde::{Deserialize, Serialize};

/// Lifecycle state of an account. Only `Active` may authenticate.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AccountStatus {
    /// Registered but e-mail / phone not yet verified (if verification required).
    Pending,
    /// Normal, may authenticate.
    Active,
    /// Temporarily blocked by the lockout policy.
    Locked,
    /// Administratively disabled.
    Suspended,
    /// Soft-deleted; PII crypto-shredded. Never authenticates.
    Deleted,
}

impl AccountStatus {
    pub fn can_authenticate(self) -> bool {
        matches!(self, AccountStatus::Active)
    }
}

/// Which second factors an identity has enrolled.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MfaState {
    /// TOTP authenticator enrolled and confirmed.
    pub totp_enrolled: bool,
    /// Number of unused recovery codes remaining.
    pub recovery_codes_remaining: u32,
    /// Count of registered passkeys / WebAuthn credentials.
    pub passkeys: u32,
}

impl MfaState {
    /// True when *any* second factor is enrolled — login must step up.
    pub fn is_enrolled(&self) -> bool {
        self.totp_enrolled || self.passkeys > 0
    }
}

/// The canonical user record. PII fields (`email`, `phone`) are expected to be
/// encrypted at rest by the app's [`UserStore`](crate::store::UserStore) via the
/// compliance `CryptoVault`; this struct is the *decrypted* view a handler sees.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Identity {
    pub id: String,
    /// Home tenant (B2B). `None` for a global / B2C consumer.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tenant: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub email: Option<String>,
    #[serde(default)]
    pub email_verified: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub phone: Option<String>,
    #[serde(default)]
    pub phone_verified: bool,
    pub status: AccountStatus,
    #[serde(default)]
    pub roles: Vec<String>,
    /// Fine-grained `resource:action` permissions embedded in the access token.
    #[serde(default)]
    pub perms: Vec<String>,
    #[serde(default)]
    pub mfa: MfaState,
    /// Progressive-profiling / custom claims surfaced to policies and OIDC.
    #[serde(default)]
    pub attributes: serde_json::Map<String, serde_json::Value>,
}

impl Identity {
    /// Primary role string for the JWT `role` claim (first role, or `"user"`).
    pub fn primary_role(&self) -> &str {
        self.roles.first().map(|s| s.as_str()).unwrap_or("user")
    }
}

/// Registration input. Password is optional to support passwordless-first signup.
#[derive(Debug, Clone, Deserialize)]
pub struct RegisterRequest {
    pub email: String,
    #[serde(default)]
    pub password: Option<String>,
    #[serde(default)]
    pub tenant: Option<String>,
    #[serde(default)]
    pub attributes: serde_json::Map<String, serde_json::Value>,
}

/// The token pair returned to a successful authentication. Shape mirrors the
/// familiar OAuth2 token response so existing clients need no changes.
#[derive(Debug, Clone, Serialize)]
pub struct TokenResponse {
    pub access_token: String,
    pub refresh_token: String,
    pub token_type: &'static str,
    pub expires_in: u64,
}

impl TokenResponse {
    pub fn bearer(access_token: String, refresh_token: String, expires_in: u64) -> Self {
        Self {
            access_token,
            refresh_token,
            token_type: "Bearer",
            expires_in,
        }
    }
}

/// The result of a login attempt: either fully authenticated (tokens issued) or
/// a second factor is still required.
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum LoginOutcome {
    /// Authentication complete.
    Authenticated(TokenResponse),
    /// Password verified but a second factor is outstanding. The client submits
    /// the factor with `challenge_id` to `/auth/mfa/verify`.
    MfaChallenge {
        mfa_required: bool,
        challenge_id: String,
        /// Factors the user may use to satisfy the challenge, e.g. `["totp"]`.
        methods: Vec<String>,
    },
}