Skip to main content

modo/auth/jwt/
error.rs

1use std::fmt;
2
3/// Typed JWT error enum. Stored as `modo::Error` source via `chain()`.
4///
5/// Use `error.source_as::<JwtError>()` before the response pipeline
6/// or `error.error_code()` after `IntoResponse` to identify the failure.
7///
8/// # Error identity pattern
9///
10/// ```rust,ignore
11/// use modo::auth::jwt::JwtError;
12///
13/// let err = modo::Error::unauthorized("unauthorized")
14///     .chain(JwtError::Expired)
15///     .with_code(JwtError::Expired.code());
16///
17/// // Before IntoResponse:
18/// assert_eq!(err.source_as::<JwtError>(), Some(&JwtError::Expired));
19/// // After IntoResponse (in error handler):
20/// assert_eq!(err.error_code(), Some("jwt:expired"));
21/// ```
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum JwtError {
24    // Request errors (401)
25    /// No token was found by any configured `TokenSource`.
26    MissingToken,
27    /// The token header could not be decoded or parsed.
28    InvalidHeader,
29    /// The token does not have the expected three-part structure.
30    MalformedToken,
31    /// The token payload could not be deserialized into the target claims type.
32    DeserializationFailed,
33    /// The token signature does not match the signing key.
34    InvalidSignature,
35    /// The token has expired (`exp` is in the past, beyond leeway).
36    Expired,
37    /// The token is not yet valid (`nbf` is in the future, beyond leeway).
38    NotYetValid,
39    /// The `iss` claim does not match the required issuer.
40    InvalidIssuer,
41    /// The `aud` claim does not match the required audience.
42    InvalidAudience,
43    /// The token's `jti` was found in the revocation store.
44    Revoked,
45    /// The revocation store returned an error (fail-closed).
46    RevocationCheckFailed,
47    /// The token header specifies an algorithm that differs from the verifier's algorithm.
48    AlgorithmMismatch,
49    // Server errors (500)
50    /// The HMAC signing operation failed.
51    SigningFailed,
52    /// The claims could not be serialized to JSON.
53    SerializationFailed,
54}
55
56impl JwtError {
57    /// Returns a static error code string for use with `Error::with_code()`.
58    ///
59    /// Survives the `IntoResponse` → `Clone` → error handler pipeline where
60    /// the original `source` is dropped.
61    pub fn code(&self) -> &'static str {
62        match self {
63            Self::MissingToken => "jwt:missing_token",
64            Self::InvalidHeader => "jwt:invalid_header",
65            Self::MalformedToken => "jwt:malformed_token",
66            Self::DeserializationFailed => "jwt:deserialization_failed",
67            Self::InvalidSignature => "jwt:invalid_signature",
68            Self::Expired => "jwt:expired",
69            Self::NotYetValid => "jwt:not_yet_valid",
70            Self::InvalidIssuer => "jwt:invalid_issuer",
71            Self::InvalidAudience => "jwt:invalid_audience",
72            Self::Revoked => "jwt:revoked",
73            Self::RevocationCheckFailed => "jwt:revocation_check_failed",
74            Self::AlgorithmMismatch => "jwt:algorithm_mismatch",
75            Self::SigningFailed => "jwt:signing_failed",
76            Self::SerializationFailed => "jwt:serialization_failed",
77        }
78    }
79}
80
81impl fmt::Display for JwtError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            Self::MissingToken => write!(f, "missing token"),
85            Self::InvalidHeader => write!(f, "invalid token header"),
86            Self::MalformedToken => write!(f, "malformed token"),
87            Self::DeserializationFailed => write!(f, "failed to deserialize token claims"),
88            Self::InvalidSignature => write!(f, "invalid token signature"),
89            Self::Expired => write!(f, "token has expired"),
90            Self::NotYetValid => write!(f, "token is not yet valid"),
91            Self::InvalidIssuer => write!(f, "invalid token issuer"),
92            Self::InvalidAudience => write!(f, "invalid token audience"),
93            Self::Revoked => write!(f, "token has been revoked"),
94            Self::RevocationCheckFailed => write!(f, "token revocation check failed"),
95            Self::AlgorithmMismatch => write!(f, "token algorithm mismatch"),
96            Self::SigningFailed => write!(f, "failed to sign token"),
97            Self::SerializationFailed => write!(f, "failed to serialize token claims"),
98        }
99    }
100}
101
102impl std::error::Error for JwtError {}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::Error;
108
109    #[test]
110    fn all_variants_have_unique_codes() {
111        let variants = [
112            JwtError::MissingToken,
113            JwtError::InvalidHeader,
114            JwtError::MalformedToken,
115            JwtError::DeserializationFailed,
116            JwtError::InvalidSignature,
117            JwtError::Expired,
118            JwtError::NotYetValid,
119            JwtError::InvalidIssuer,
120            JwtError::InvalidAudience,
121            JwtError::Revoked,
122            JwtError::RevocationCheckFailed,
123            JwtError::AlgorithmMismatch,
124            JwtError::SigningFailed,
125            JwtError::SerializationFailed,
126        ];
127        let mut codes: Vec<&str> = variants.iter().map(|v| v.code()).collect();
128        let len_before = codes.len();
129        codes.sort();
130        codes.dedup();
131        assert_eq!(codes.len(), len_before, "duplicate error codes found");
132    }
133
134    #[test]
135    fn all_codes_start_with_jwt_prefix() {
136        let variants = [
137            JwtError::MissingToken,
138            JwtError::Expired,
139            JwtError::SigningFailed,
140        ];
141        for v in &variants {
142            assert!(
143                v.code().starts_with("jwt:"),
144                "code {} missing prefix",
145                v.code()
146            );
147        }
148    }
149
150    #[test]
151    fn display_is_human_readable() {
152        assert_eq!(JwtError::Expired.to_string(), "token has expired");
153        assert_eq!(JwtError::MissingToken.to_string(), "missing token");
154    }
155
156    #[test]
157    fn recoverable_via_source_as() {
158        let err = Error::unauthorized("unauthorized")
159            .chain(JwtError::Expired)
160            .with_code(JwtError::Expired.code());
161        let jwt_err = err.source_as::<JwtError>();
162        assert_eq!(jwt_err, Some(&JwtError::Expired));
163        assert_eq!(err.error_code(), Some("jwt:expired"));
164    }
165}