cirrus_auth/error.rs
1//! Error types for `cirrus-auth`.
2//!
3//! Salesforce OAuth token endpoints return errors in the standard
4//! `{error, error_description}` shape (RFC 6749 §5.2), which
5//! [`AuthError::OAuth`] models directly. Everything else — missing
6//! builder fields, transport failures, malformed responses — is
7//! categorized into the remaining variants.
8//!
9//! The companion `cirrus` crate carries a `From<AuthError> for
10//! CirrusError` impl so REST call sites that invoke an
11//! [`crate::AuthSession`] can use `?` and surface the auth failure as
12//! part of the unified client error.
13//!
14//! `AuthError` is intentionally `non_exhaustive` so we can grow it
15//! without a SemVer break.
16
17use thiserror::Error;
18
19/// Specialized `Result` type for `cirrus-auth` operations.
20pub type AuthResult<T> = Result<T, AuthError>;
21
22/// Errors produced while acquiring or refreshing a Salesforce OAuth
23/// session.
24///
25/// The `error_description` carried by [`AuthError::OAuth`] is
26/// server-supplied free text that can include partial token material, so
27/// it is redacted from both the `Display` and `Debug` representations of
28/// this type — only the machine-readable `error` code is shown. Callers
29/// that need the description can read it from the variant field directly.
30#[derive(Error)]
31#[non_exhaustive]
32pub enum AuthError {
33 /// A required builder field was not set.
34 #[error("missing required builder field: {0}")]
35 MissingField(&'static str),
36
37 /// OAuth token endpoint returned an error response (`error` /
38 /// `error_description` shape from RFC 6749 §5.2).
39 ///
40 /// Only the machine-readable `error` code is surfaced via `Display`;
41 /// `error_description` is redacted (see the type-level note) but
42 /// remains available by matching on the field.
43 #[error("OAuth error: {error}")]
44 OAuth {
45 error: String,
46 error_description: Option<String>,
47 },
48
49 /// Catch-all for auth failures not modelled by a dedicated variant
50 /// (system clock skew, JWT signing failure, CSPRNG failure,
51 /// malformed token responses, etc.). Carries the underlying
52 /// message.
53 #[error("authentication failed: {0}")]
54 Other(String),
55
56 /// Network or transport-level HTTP failure while contacting an
57 /// OAuth endpoint.
58 #[error("HTTP request failed: {0}")]
59 Http(#[from] reqwest::Error),
60
61 /// JSON serialization or deserialization failure.
62 #[error("serialization error: {0}")]
63 Serialization(#[from] serde_json::Error),
64
65 /// URL parsing failure (instance URL, redirect URI, login URL,
66 /// etc.).
67 #[error("invalid URL: {0}")]
68 Url(#[from] url::ParseError),
69}
70
71// Hand-written so `OAuth.error_description` is redacted in `{:?}` output —
72// a derived `Debug` would print the raw description verbatim, defeating the
73// redaction applied at every other layer. The `error` code and all other
74// variants are non-sensitive and printed as usual.
75impl std::fmt::Debug for AuthError {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 match self {
78 Self::MissingField(field) => f.debug_tuple("MissingField").field(field).finish(),
79 Self::OAuth {
80 error,
81 error_description,
82 } => f
83 .debug_struct("OAuth")
84 .field("error", error)
85 .field(
86 "error_description",
87 &error_description.as_ref().map(|_| "[redacted]"),
88 )
89 .finish(),
90 Self::Other(msg) => f.debug_tuple("Other").field(msg).finish(),
91 Self::Http(e) => f.debug_tuple("Http").field(e).finish(),
92 Self::Serialization(e) => f.debug_tuple("Serialization").field(e).finish(),
93 Self::Url(e) => f.debug_tuple("Url").field(e).finish(),
94 }
95 }
96}
97
98#[cfg(test)]
99#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn oauth_error_redacts_description_in_display_and_debug() {
105 // A description carrying token-like material must not surface in
106 // either formatted representation, but must remain readable via
107 // the field.
108 let err = AuthError::OAuth {
109 error: "invalid_grant".to_string(),
110 error_description: Some("00Dxx!AQ.SECRET_TOKEN_FRAGMENT".to_string()),
111 };
112
113 let display = err.to_string();
114 assert!(display.contains("invalid_grant"));
115 assert!(
116 !display.contains("SECRET_TOKEN_FRAGMENT"),
117 "Display leaked error_description: {display}"
118 );
119
120 let debug = format!("{err:?}");
121 assert!(debug.contains("invalid_grant"));
122 assert!(debug.contains("[redacted]"));
123 assert!(
124 !debug.contains("SECRET_TOKEN_FRAGMENT"),
125 "Debug leaked error_description: {debug}"
126 );
127
128 // The description is still programmatically accessible.
129 match err {
130 AuthError::OAuth {
131 error_description, ..
132 } => assert!(error_description.is_some()),
133 other => panic!("expected OAuth, got {other:?}"),
134 }
135 }
136
137 #[test]
138 fn oauth_error_debug_shows_none_description_without_redaction_marker() {
139 let err = AuthError::OAuth {
140 error: "invalid_client".to_string(),
141 error_description: None,
142 };
143 let debug = format!("{err:?}");
144 assert!(debug.contains("invalid_client"));
145 assert!(debug.contains("None"));
146 assert!(!debug.contains("[redacted]"));
147 }
148}