Skip to main content

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}