Skip to main content

modo/auth/session/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::session::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 header specifies an algorithm that differs from the verifier's algorithm.
44    AlgorithmMismatch,
45    // Server errors (500)
46    /// The HMAC signing operation failed.
47    SigningFailed,
48    /// The claims could not be serialized to JSON.
49    SerializationFailed,
50}
51
52impl JwtError {
53    /// Returns a static error code string for use with `Error::with_code()`.
54    ///
55    /// Survives the `IntoResponse` → `Clone` → error handler pipeline where
56    /// the original `source` is dropped.
57    pub fn code(&self) -> &'static str {
58        match self {
59            Self::MissingToken => "jwt:missing_token",
60            Self::InvalidHeader => "jwt:invalid_header",
61            Self::MalformedToken => "jwt:malformed_token",
62            Self::DeserializationFailed => "jwt:deserialization_failed",
63            Self::InvalidSignature => "jwt:invalid_signature",
64            Self::Expired => "jwt:expired",
65            Self::NotYetValid => "jwt:not_yet_valid",
66            Self::InvalidIssuer => "jwt:invalid_issuer",
67            Self::InvalidAudience => "jwt:invalid_audience",
68            Self::AlgorithmMismatch => "jwt:algorithm_mismatch",
69            Self::SigningFailed => "jwt:signing_failed",
70            Self::SerializationFailed => "jwt:serialization_failed",
71        }
72    }
73}
74
75impl fmt::Display for JwtError {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        match self {
78            Self::MissingToken => write!(f, "missing token"),
79            Self::InvalidHeader => write!(f, "invalid token header"),
80            Self::MalformedToken => write!(f, "malformed token"),
81            Self::DeserializationFailed => write!(f, "failed to deserialize token claims"),
82            Self::InvalidSignature => write!(f, "invalid token signature"),
83            Self::Expired => write!(f, "token has expired"),
84            Self::NotYetValid => write!(f, "token is not yet valid"),
85            Self::InvalidIssuer => write!(f, "invalid token issuer"),
86            Self::InvalidAudience => write!(f, "invalid token audience"),
87            Self::AlgorithmMismatch => write!(f, "token algorithm mismatch"),
88            Self::SigningFailed => write!(f, "failed to sign token"),
89            Self::SerializationFailed => write!(f, "failed to serialize token claims"),
90        }
91    }
92}
93
94impl std::error::Error for JwtError {}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::Error;
100
101    #[test]
102    fn all_variants_have_unique_codes() {
103        let variants = [
104            JwtError::MissingToken,
105            JwtError::InvalidHeader,
106            JwtError::MalformedToken,
107            JwtError::DeserializationFailed,
108            JwtError::InvalidSignature,
109            JwtError::Expired,
110            JwtError::NotYetValid,
111            JwtError::InvalidIssuer,
112            JwtError::InvalidAudience,
113            JwtError::AlgorithmMismatch,
114            JwtError::SigningFailed,
115            JwtError::SerializationFailed,
116        ];
117        let mut codes: Vec<&str> = variants.iter().map(|v| v.code()).collect();
118        let len_before = codes.len();
119        codes.sort();
120        codes.dedup();
121        assert_eq!(codes.len(), len_before, "duplicate error codes found");
122    }
123
124    #[test]
125    fn all_codes_start_with_jwt_prefix() {
126        let variants = [
127            JwtError::MissingToken,
128            JwtError::Expired,
129            JwtError::SigningFailed,
130        ];
131        for v in &variants {
132            assert!(
133                v.code().starts_with("jwt:"),
134                "code {} missing prefix",
135                v.code()
136            );
137        }
138    }
139
140    #[test]
141    fn display_is_human_readable() {
142        assert_eq!(JwtError::Expired.to_string(), "token has expired");
143        assert_eq!(JwtError::MissingToken.to_string(), "missing token");
144    }
145
146    #[test]
147    fn recoverable_via_source_as() {
148        let err = Error::unauthorized("unauthorized")
149            .chain(JwtError::Expired)
150            .with_code(JwtError::Expired.code());
151        let jwt_err = err.source_as::<JwtError>();
152        assert_eq!(jwt_err, Some(&JwtError::Expired));
153        assert_eq!(err.error_code(), Some("jwt:expired"));
154    }
155}