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
//! Error type for the identity engine, with a mapping to the framework's
//! [`HttpException`] so handlers can `?` directly and get RFC-7807 problem
//! responses with the right status codes.

use arcly_http_core::web::error::{
    BadRequest, Conflict, Forbidden, HttpException, Internal, NotFound, ServiceUnavailable,
    TooManyRequests, Unauthorized,
};

/// Everything the CIAM engine can reject a request for.
///
/// Kept deliberately coarse: the exact reason for an auth failure must never
/// leak to the caller (account enumeration), so several variants intentionally
/// collapse to the same `401` at the boundary.
#[derive(Debug, Clone)]
pub enum IdentityError {
    /// Bad email/password, unknown user, unverified account used where
    /// verification is required — anything that must present as a generic 401.
    InvalidCredentials,
    /// Account exists but is locked (too many failed attempts) or suspended.
    AccountLocked,
    /// A second factor is required and was not satisfied. Carries an opaque
    /// challenge id the client echoes back with the factor response.
    MfaRequired { challenge_id: String },
    /// The supplied MFA code / assertion was wrong.
    MfaFailed,
    /// Registration collided with an existing identity.
    AlreadyExists,
    /// A one-time token (verify link, reset, magic link, OTP) was missing,
    /// expired, already consumed, or forged.
    InvalidToken,
    /// The caller is authenticated but not allowed to perform the action.
    Forbidden,
    /// Requested identity / resource does not exist (safe to reveal).
    NotFound,
    /// Backend store / hashing / signing failure — maps to 500.
    Internal(String),
    /// A distributed dependency (lockout backend, store) was unavailable and
    /// policy is fail-closed.
    Unavailable,
    /// A chosen password failed the complexity policy or appeared in a breach
    /// corpus. Safe to reveal the reason (this is registration, not login).
    PasswordRejected(String),
    /// The subject has not granted the consents required to issue tokens.
    ConsentRequired(Vec<String>),
    /// Too many attempts against an abuse-limited action (OTP send, signup).
    RateLimited,
}

impl std::fmt::Display for IdentityError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::InvalidCredentials => write!(f, "invalid credentials"),
            Self::AccountLocked => write!(f, "account locked"),
            Self::MfaRequired { .. } => write!(f, "second factor required"),
            Self::MfaFailed => write!(f, "second factor verification failed"),
            Self::AlreadyExists => write!(f, "already exists"),
            Self::InvalidToken => write!(f, "invalid or expired token"),
            Self::Forbidden => write!(f, "forbidden"),
            Self::NotFound => write!(f, "not found"),
            Self::Internal(m) => write!(f, "internal error: {m}"),
            Self::Unavailable => write!(f, "service unavailable"),
            Self::PasswordRejected(m) => write!(f, "password rejected: {m}"),
            Self::ConsentRequired(c) => write!(f, "consent required: {}", c.join(", ")),
            Self::RateLimited => write!(f, "too many requests"),
        }
    }
}

impl std::error::Error for IdentityError {}

impl From<IdentityError> for HttpException {
    fn from(e: IdentityError) -> Self {
        match e {
            // Collapse all credential-shaped failures to a single generic 401
            // so the response can't be used to enumerate accounts.
            IdentityError::InvalidCredentials
            | IdentityError::AccountLocked
            | IdentityError::MfaFailed
            | IdentityError::InvalidToken => Unauthorized::new("invalid credentials").into(),
            // MfaRequired is a 401 too — the client learns "need second factor"
            // from the body, not a distinct status.
            IdentityError::MfaRequired { .. } => Unauthorized::new("second factor required").into(),
            IdentityError::Forbidden => Forbidden::new("forbidden").into(),
            IdentityError::NotFound => NotFound::new("not found").into(),
            IdentityError::AlreadyExists => Conflict::new("already exists").into(),
            IdentityError::Unavailable => ServiceUnavailable::new("service unavailable").into(),
            IdentityError::Internal(m) => Internal::new(m).into(),
            // Registration-time reasons are safe to surface (not account probing).
            IdentityError::PasswordRejected(m) => BadRequest::new(m).into(),
            IdentityError::ConsentRequired(c) => {
                Forbidden::new(format!("consent required: {}", c.join(", "))).into()
            }
            IdentityError::RateLimited => {
                TooManyRequests::new("too many requests — retry later").into()
            }
        }
    }
}

pub type Result<T> = std::result::Result<T, IdentityError>;