Skip to main content

arcly_http_identity/
error.rs

1//! Error type for the identity engine, with a mapping to the framework's
2//! [`HttpException`] so handlers can `?` directly and get RFC-7807 problem
3//! responses with the right status codes.
4
5use arcly_http_core::web::error::{
6    BadRequest, Conflict, Forbidden, HttpException, Internal, NotFound, ServiceUnavailable,
7    TooManyRequests, Unauthorized,
8};
9
10/// Everything the CIAM engine can reject a request for.
11///
12/// Kept deliberately coarse: the exact reason for an auth failure must never
13/// leak to the caller (account enumeration), so several variants intentionally
14/// collapse to the same `401` at the boundary.
15#[derive(Debug, Clone)]
16pub enum IdentityError {
17    /// Bad email/password, unknown user, unverified account used where
18    /// verification is required — anything that must present as a generic 401.
19    InvalidCredentials,
20    /// Account exists but is locked (too many failed attempts) or suspended.
21    AccountLocked,
22    /// A second factor is required and was not satisfied. Carries an opaque
23    /// challenge id the client echoes back with the factor response.
24    MfaRequired { challenge_id: String },
25    /// The supplied MFA code / assertion was wrong.
26    MfaFailed,
27    /// Registration collided with an existing identity.
28    AlreadyExists,
29    /// A one-time token (verify link, reset, magic link, OTP) was missing,
30    /// expired, already consumed, or forged.
31    InvalidToken,
32    /// The caller is authenticated but not allowed to perform the action.
33    Forbidden,
34    /// Requested identity / resource does not exist (safe to reveal).
35    NotFound,
36    /// Backend store / hashing / signing failure — maps to 500.
37    Internal(String),
38    /// A distributed dependency (lockout backend, store) was unavailable and
39    /// policy is fail-closed.
40    Unavailable,
41    /// A chosen password failed the complexity policy or appeared in a breach
42    /// corpus. Safe to reveal the reason (this is registration, not login).
43    PasswordRejected(String),
44    /// The subject has not granted the consents required to issue tokens.
45    ConsentRequired(Vec<String>),
46    /// Too many attempts against an abuse-limited action (OTP send, signup).
47    RateLimited,
48}
49
50impl std::fmt::Display for IdentityError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            Self::InvalidCredentials => write!(f, "invalid credentials"),
54            Self::AccountLocked => write!(f, "account locked"),
55            Self::MfaRequired { .. } => write!(f, "second factor required"),
56            Self::MfaFailed => write!(f, "second factor verification failed"),
57            Self::AlreadyExists => write!(f, "already exists"),
58            Self::InvalidToken => write!(f, "invalid or expired token"),
59            Self::Forbidden => write!(f, "forbidden"),
60            Self::NotFound => write!(f, "not found"),
61            Self::Internal(m) => write!(f, "internal error: {m}"),
62            Self::Unavailable => write!(f, "service unavailable"),
63            Self::PasswordRejected(m) => write!(f, "password rejected: {m}"),
64            Self::ConsentRequired(c) => write!(f, "consent required: {}", c.join(", ")),
65            Self::RateLimited => write!(f, "too many requests"),
66        }
67    }
68}
69
70impl std::error::Error for IdentityError {}
71
72impl From<IdentityError> for HttpException {
73    fn from(e: IdentityError) -> Self {
74        match e {
75            // Collapse all credential-shaped failures to a single generic 401
76            // so the response can't be used to enumerate accounts.
77            IdentityError::InvalidCredentials
78            | IdentityError::AccountLocked
79            | IdentityError::MfaFailed
80            | IdentityError::InvalidToken => Unauthorized::new("invalid credentials").into(),
81            // MfaRequired is a 401 too — the client learns "need second factor"
82            // from the body, not a distinct status.
83            IdentityError::MfaRequired { .. } => Unauthorized::new("second factor required").into(),
84            IdentityError::Forbidden => Forbidden::new("forbidden").into(),
85            IdentityError::NotFound => NotFound::new("not found").into(),
86            IdentityError::AlreadyExists => Conflict::new("already exists").into(),
87            IdentityError::Unavailable => ServiceUnavailable::new("service unavailable").into(),
88            IdentityError::Internal(m) => Internal::new(m).into(),
89            // Registration-time reasons are safe to surface (not account probing).
90            IdentityError::PasswordRejected(m) => BadRequest::new(m).into(),
91            IdentityError::ConsentRequired(c) => {
92                Forbidden::new(format!("consent required: {}", c.join(", "))).into()
93            }
94            IdentityError::RateLimited => {
95                TooManyRequests::new("too many requests — retry later").into()
96            }
97        }
98    }
99}
100
101pub type Result<T> = std::result::Result<T, IdentityError>;