Skip to main content

auths_oidc_port/
error.rs

1use auths_crypto::error::AuthsErrorInfo;
2use thiserror::Error;
3
4/// Error type for OIDC operations with cryptographic and validation failures.
5///
6/// # Usage
7///
8/// ```ignore
9/// use auths_oidc_port::OidcError;
10///
11/// let err = OidcError::JwtDecode("invalid token".to_string());
12/// assert_eq!(err.error_code(), "AUTHS-E8001");
13/// ```
14#[derive(Debug, Error)]
15#[non_exhaustive]
16pub enum OidcError {
17    /// JWT token is malformed or has invalid encoding.
18    #[error("JWT decode failed: {0}")]
19    JwtDecode(String),
20
21    /// JWKS signature verification failed for the JWT.
22    #[error("signature verification failed")]
23    SignatureVerificationFailed,
24
25    /// OIDC claim validation failed (e.g., exp, iss, aud, sub).
26    #[error("claim validation failed - {claim}: {reason}")]
27    ClaimsValidationFailed { claim: String, reason: String },
28
29    /// Key ID from JWT header not found in JWKS.
30    #[error("unknown key ID: {0}")]
31    UnknownKeyId(String),
32
33    /// JWKS resolution/fetch failed due to network or other issues.
34    #[error("JWKS resolution failed: {0}")]
35    JwksResolutionFailed(String),
36
37    /// JWT algorithm doesn't match expected algorithm.
38    #[error("algorithm mismatch: expected {expected}, got {got}")]
39    AlgorithmMismatch { expected: String, got: String },
40
41    /// Token has expired beyond the configured clock skew tolerance.
42    #[error("token expired (exp: {token_exp}, now: {current_time}, leeway: {leeway}s)")]
43    ClockSkewExceeded {
44        token_exp: i64,
45        current_time: i64,
46        leeway: i64,
47    },
48
49    /// Token JTI (JWT ID) has already been used (replay detected).
50    #[error("token replay detected (jti: {0})")]
51    TokenReplayDetected(String),
52}
53
54impl AuthsErrorInfo for OidcError {
55    fn error_code(&self) -> &'static str {
56        match self {
57            Self::JwtDecode(_) => "AUTHS-E8001",
58            Self::SignatureVerificationFailed => "AUTHS-E8002",
59            Self::ClaimsValidationFailed { .. } => "AUTHS-E8003",
60            Self::UnknownKeyId(_) => "AUTHS-E8004",
61            Self::JwksResolutionFailed(_) => "AUTHS-E8005",
62            Self::AlgorithmMismatch { .. } => "AUTHS-E8006",
63            Self::ClockSkewExceeded { .. } => "AUTHS-E8007",
64            Self::TokenReplayDetected(_) => "AUTHS-E8008",
65        }
66    }
67
68    fn suggestion(&self) -> Option<&'static str> {
69        match self {
70            Self::JwtDecode(_) => {
71                Some("Verify the token format and ensure it is a valid JWT")
72            }
73            Self::SignatureVerificationFailed => {
74                Some("Check that the JWKS endpoint is up-to-date and the token is from a trusted issuer")
75            }
76            Self::ClaimsValidationFailed { claim, .. } => {
77                if claim == "exp" {
78                    Some("The token has expired; acquire a new token from the OIDC provider")
79                } else if claim == "iss" {
80                    Some("Verify that the token issuer matches the configured trusted issuer")
81                } else if claim == "aud" {
82                    Some("Ensure the token audience matches the configured expected audience")
83                } else {
84                    Some("Check that the OIDC provider configuration matches the token claims")
85                }
86            }
87            Self::UnknownKeyId(_) => {
88                Some("The JWKS cache may be stale; refresh the JWKS from the issuer endpoint")
89            }
90            Self::JwksResolutionFailed(_) => {
91                Some("Check network connectivity to the JWKS endpoint and ensure the issuer URL is correct")
92            }
93            Self::AlgorithmMismatch { .. } => {
94                Some("Verify that the expected algorithm matches the algorithm used by the OIDC provider")
95            }
96            Self::ClockSkewExceeded { .. } => {
97                Some("Synchronize the system clock or increase the configured clock skew tolerance")
98            }
99            Self::TokenReplayDetected(_) => {
100                Some("A token with this ID has already been used; acquire a new token from the OIDC provider")
101            }
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn test_jwt_decode_error_code() {
112        let err = OidcError::JwtDecode("invalid format".to_string());
113        assert_eq!(err.error_code(), "AUTHS-E8001");
114        assert!(err.suggestion().is_some());
115    }
116
117    #[test]
118    fn test_signature_verification_error_code() {
119        let err = OidcError::SignatureVerificationFailed;
120        assert_eq!(err.error_code(), "AUTHS-E8002");
121        assert!(err.suggestion().is_some());
122    }
123
124    #[test]
125    fn test_claims_validation_error_code() {
126        let err = OidcError::ClaimsValidationFailed {
127            claim: "exp".to_string(),
128            reason: "token expired".to_string(),
129        };
130        assert_eq!(err.error_code(), "AUTHS-E8003");
131        assert!(err.suggestion().is_some());
132    }
133
134    #[test]
135    fn test_unknown_key_id_error_code() {
136        let err = OidcError::UnknownKeyId("key-123".to_string());
137        assert_eq!(err.error_code(), "AUTHS-E8004");
138        assert!(err.suggestion().is_some());
139    }
140
141    #[test]
142    fn test_jwks_resolution_error_code() {
143        let err = OidcError::JwksResolutionFailed("connection timeout".to_string());
144        assert_eq!(err.error_code(), "AUTHS-E8005");
145        assert!(err.suggestion().is_some());
146    }
147
148    #[test]
149    fn test_algorithm_mismatch_error_code() {
150        let err = OidcError::AlgorithmMismatch {
151            expected: "RS256".to_string(),
152            got: "HS256".to_string(),
153        };
154        assert_eq!(err.error_code(), "AUTHS-E8006");
155        assert!(err.suggestion().is_some());
156    }
157
158    #[test]
159    fn test_clock_skew_exceeded_error_code() {
160        let err = OidcError::ClockSkewExceeded {
161            token_exp: 1000,
162            current_time: 2000,
163            leeway: 60,
164        };
165        assert_eq!(err.error_code(), "AUTHS-E8007");
166        assert!(err.suggestion().is_some());
167    }
168
169    #[test]
170    fn test_token_replay_detected_error_code() {
171        let err = OidcError::TokenReplayDetected("jti-123".to_string());
172        assert_eq!(err.error_code(), "AUTHS-E8008");
173        assert!(err.suggestion().is_some());
174    }
175
176    #[test]
177    fn test_all_error_codes_are_unique() {
178        let errors = [
179            OidcError::JwtDecode("".to_string()),
180            OidcError::SignatureVerificationFailed,
181            OidcError::ClaimsValidationFailed {
182                claim: "exp".to_string(),
183                reason: "".to_string(),
184            },
185            OidcError::UnknownKeyId("".to_string()),
186            OidcError::JwksResolutionFailed("".to_string()),
187            OidcError::AlgorithmMismatch {
188                expected: "".to_string(),
189                got: "".to_string(),
190            },
191            OidcError::ClockSkewExceeded {
192                token_exp: 0,
193                current_time: 0,
194                leeway: 0,
195            },
196            OidcError::TokenReplayDetected("".to_string()),
197        ];
198
199        let mut codes: Vec<_> = errors.iter().map(|e| e.error_code()).collect();
200        codes.sort();
201        codes.dedup();
202
203        assert_eq!(codes.len(), errors.len(), "All error codes must be unique");
204    }
205}