1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
//! 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>;