axum_gate/gate/oauth2/
errors.rs

1//! OAuth2 errors for `gate::oauth2`.
2//!
3//! These error types model failures that can occur while configuring or executing the
4//! OAuth2 Authorization Code + PKCE flow within `OAuth2Gate`.
5//!
6//! Design goals:
7//! - Provide a small, expressive enum for all OAuth2-related failures
8//! - Keep messages safe for end users; avoid leaking sensitive details
9//! - Include deterministic support codes per variant for service/support workflows
10//! - Avoid hard dependencies on specific HTTP clients; store messages instead of foreign errors
11//!
12//! Integration:
13//! - The enum implements `crate::errors::UserFriendlyError` for consistent messaging
14//! - A local `Result<T>` alias is provided for gate-internal use
15//! - The crate-level integration (e.g., adding a new top-level variant in `crate::errors::Error`)
16//!   can be done separately if/when you want to surface OAuth2 errors consistently across the crate.
17
18use crate::errors::{ErrorSeverity, UserFriendlyError};
19use std::fmt;
20use thiserror::Error;
21
22/// OAuth2 cookie kinds used in validation errors.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum OAuth2CookieKind {
25    /// CSRF state cookie used during the authorization redirect round-trip.
26    State,
27    /// PKCE verifier cookie used to complete the token exchange.
28    Pkce,
29    /// First‑party auth cookie (e.g., JWT) set after successful callback.
30    Auth,
31}
32
33impl fmt::Display for OAuth2CookieKind {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            OAuth2CookieKind::State => f.write_str("state"),
37            OAuth2CookieKind::Pkce => f.write_str("pkce"),
38            OAuth2CookieKind::Auth => f.write_str("auth"),
39        }
40    }
41}
42
43/// OAuth2-specific error type for `OAuth2Gate`.
44///
45/// This enum intentionally uses string messages for external/foreign errors to keep this module
46/// decoupled from particular HTTP or OAuth client implementations. Prefer mapping concrete errors
47/// into one of these variants at the boundary layers.
48#[derive(Debug, Error)]
49pub enum OAuth2Error {
50    // Configuration and setup
51    /// A required configuration field is missing on the `OAuth2Gate` builder.
52    #[error("OAuth2 misconfiguration: missing {field}")]
53    ConfigMissing {
54        /// Name of the missing field (e.g., "auth_url", "token_url", "client_id", "redirect_url").
55        field: &'static str,
56    },
57
58    /// A provided URL failed validation or parsing.
59    #[error("Invalid OAuth2 URL for {field}: {reason}")]
60    InvalidUrl {
61        /// Which URL field failed (e.g., "auth_url", "token_url", "redirect_url").
62        field: &'static str,
63        /// Reason or parser message (redacted for end users).
64        reason: String,
65    },
66
67    /// A cookie template used by the OAuth2 flow failed validation.
68    #[error("Invalid {which} cookie template: {reason}")]
69    CookieTemplateInvalid {
70        /// Which cookie template failed validation (state, pkce, or auth).
71        which: OAuth2CookieKind,
72        /// Reason or validator message (redacted for end users).
73        reason: String,
74    },
75
76    // Redirect and callback flow
77    /// Required state cookie is missing at callback time.
78    #[error("Missing OAuth2 state cookie")]
79    MissingStateCookie,
80
81    /// Required PKCE cookie is missing at callback time.
82    #[error("Missing OAuth2 PKCE cookie")]
83    MissingPkceCookie,
84
85    /// Provider returned an error to the callback endpoint.
86    #[error("OAuth2 provider returned error: {error}")]
87    ProviderReturnedError {
88        /// Provider error identifier.
89        error: String,
90        /// Optional provider-supplied description.
91        description: Option<String>,
92    },
93
94    /// The state parameter from the provider did not match the stored cookie.
95    #[error("OAuth2 state mismatch")]
96    StateMismatch,
97
98    /// The provider callback did not include an authorization code.
99    #[error("OAuth2 callback missing authorization code")]
100    MissingAuthorizationCode,
101
102    /// Token exchange with the provider failed.
103    #[error("OAuth2 token exchange failed: {message}")]
104    TokenExchange {
105        /// Failure message (e.g., request or response parsing reason).
106        message: String,
107    },
108
109    // Session issuance after successful token exchange
110    /// Mapping the provider token response to a domain `Account` failed.
111    #[error("OAuth2 account mapping failed: {message}")]
112    AccountMapping {
113        /// Failure message (e.g., userinfo retrieval/mapping reason).
114        message: String,
115    },
116
117    /// Persisting or loading the account prior to JWT issuance failed.
118    #[error("OAuth2 account persistence failed: {message}")]
119    AccountPersistence {
120        /// Failure message (e.g., repository/backend reason).
121        message: String,
122    },
123
124    /// Encoding the first‑party JWT failed.
125    #[error("OAuth2 JWT encoding failed: {message}")]
126    JwtEncoding {
127        /// Failure message (e.g., codec/serialization reason).
128        message: String,
129    },
130
131    /// The JWT produced by the encoder was not valid UTF‑8.
132    #[error("OAuth2 JWT is not valid UTF‑8")]
133    JwtNotUtf8,
134}
135
136impl OAuth2Error {
137    // Convenience constructors
138
139    /// Helper to construct a `ConfigMissing` error.
140    #[must_use]
141    pub fn missing(field: &'static str) -> Self {
142        Self::ConfigMissing { field }
143    }
144
145    /// Helper to construct an `InvalidUrl` error.
146    #[must_use]
147    pub fn invalid_url(field: &'static str, reason: impl Into<String>) -> Self {
148        Self::InvalidUrl {
149            field,
150            reason: reason.into(),
151        }
152    }
153
154    /// Helper to construct a `CookieTemplateInvalid` error.
155    #[must_use]
156    pub fn cookie_invalid(which: OAuth2CookieKind, reason: impl Into<String>) -> Self {
157        Self::CookieTemplateInvalid {
158            which,
159            reason: reason.into(),
160        }
161    }
162
163    /// Helper to construct a `ProviderReturnedError` error.
164    #[must_use]
165    pub fn provider_error(error: impl Into<String>, description: Option<String>) -> Self {
166        Self::ProviderReturnedError {
167            error: error.into(),
168            description,
169        }
170    }
171
172    /// Helper to construct a `TokenExchange` error.
173    #[must_use]
174    pub fn token_exchange(message: impl Into<String>) -> Self {
175        Self::TokenExchange {
176            message: message.into(),
177        }
178    }
179
180    /// Helper to construct an `AccountMapping` error.
181    #[must_use]
182    pub fn account_mapping(message: impl Into<String>) -> Self {
183        Self::AccountMapping {
184            message: message.into(),
185        }
186    }
187
188    /// Helper to construct an `AccountPersistence` error.
189    #[must_use]
190    pub fn account_persistence(message: impl Into<String>) -> Self {
191        Self::AccountPersistence {
192            message: message.into(),
193        }
194    }
195
196    /// Helper to construct a `JwtEncoding` error.
197    #[must_use]
198    pub fn jwt_encoding(message: impl Into<String>) -> Self {
199        Self::JwtEncoding {
200            message: message.into(),
201        }
202    }
203}
204
205/// Local `Result` alias for OAuth2 flows.
206pub type Result<T> = std::result::Result<T, OAuth2Error>;
207
208impl UserFriendlyError for OAuth2Error {
209    fn user_message(&self) -> String {
210        match self {
211            // Configuration/validation (users see a generic message)
212            OAuth2Error::ConfigMissing { .. }
213            | OAuth2Error::InvalidUrl { .. }
214            | OAuth2Error::CookieTemplateInvalid { .. } => {
215                "We’re experiencing a technical issue with sign-in. Please try again later."
216                    .to_string()
217            }
218
219            // Redirect/callback issues (safe messages)
220            OAuth2Error::MissingStateCookie
221            | OAuth2Error::MissingPkceCookie
222            | OAuth2Error::ProviderReturnedError { .. }
223            | OAuth2Error::StateMismatch
224            | OAuth2Error::MissingAuthorizationCode
225            | OAuth2Error::TokenExchange { .. } => {
226                "We couldn’t complete the sign-in with your provider. Please try again.".to_string()
227            }
228
229            // Session issuance issues (safe messages)
230            OAuth2Error::AccountMapping { .. }
231            | OAuth2Error::AccountPersistence { .. }
232            | OAuth2Error::JwtEncoding { .. }
233            | OAuth2Error::JwtNotUtf8 => {
234                "We signed you in, but couldn’t complete the session setup. Please try again."
235                    .to_string()
236            }
237        }
238    }
239
240    fn developer_message(&self) -> String {
241        match self {
242            OAuth2Error::ConfigMissing { field } => {
243                format!("OAuth2Gate configuration missing required field: {field}")
244            }
245            OAuth2Error::InvalidUrl { field, reason } => {
246                format!("Invalid OAuth2 URL for {field}: {reason}")
247            }
248            OAuth2Error::CookieTemplateInvalid { which, reason } => {
249                format!("Invalid {which} cookie template: {reason}")
250            }
251            OAuth2Error::MissingStateCookie => "Missing OAuth2 state cookie at callback".into(),
252            OAuth2Error::MissingPkceCookie => "Missing OAuth2 PKCE cookie at callback".into(),
253            OAuth2Error::ProviderReturnedError { error, description } => format!(
254                "OAuth2 provider returned error: {error} {:?}",
255                description.as_deref()
256            ),
257            OAuth2Error::StateMismatch => "OAuth2 state parameter mismatch".into(),
258            OAuth2Error::MissingAuthorizationCode => {
259                "OAuth2 callback missing authorization code".into()
260            }
261            OAuth2Error::TokenExchange { message } => {
262                format!("OAuth2 token exchange failed: {message}")
263            }
264            OAuth2Error::AccountMapping { message } => {
265                format!("OAuth2 account mapping failed: {message}")
266            }
267            OAuth2Error::AccountPersistence { message } => {
268                format!("OAuth2 account persistence failed: {message}")
269            }
270            OAuth2Error::JwtEncoding { message } => {
271                format!("OAuth2 JWT encoding failed: {message}")
272            }
273            OAuth2Error::JwtNotUtf8 => "OAuth2 JWT is not valid UTF‑8".into(),
274        }
275    }
276
277    fn support_code(&self) -> String {
278        // Deterministic, human-parseable support codes by variant
279        match self {
280            OAuth2Error::ConfigMissing { .. } => "OAUTH2-CONFIG-MISSING-001".into(),
281            OAuth2Error::InvalidUrl { .. } => "OAUTH2-URL-INVALID-002".into(),
282            OAuth2Error::CookieTemplateInvalid { .. } => "OAUTH2-COOKIE-INVALID-003".into(),
283            OAuth2Error::MissingStateCookie => "OAUTH2-STATE-MISSING-004".into(),
284            OAuth2Error::MissingPkceCookie => "OAUTH2-PKCE-MISSING-005".into(),
285            OAuth2Error::ProviderReturnedError { .. } => "OAUTH2-PROVIDER-ERROR-006".into(),
286            OAuth2Error::StateMismatch => "OAUTH2-STATE-MISMATCH-007".into(),
287            OAuth2Error::MissingAuthorizationCode => "OAUTH2-CODE-MISSING-008".into(),
288            OAuth2Error::TokenExchange { .. } => "OAUTH2-TOKEN-EXCHANGE-009".into(),
289            OAuth2Error::AccountMapping { .. } => "OAUTH2-ACCOUNT-MAP-010".into(),
290            OAuth2Error::AccountPersistence { .. } => "OAUTH2-ACCOUNT-PERSIST-011".into(),
291            OAuth2Error::JwtEncoding { .. } => "OAUTH2-JWT-ENCODE-012".into(),
292            OAuth2Error::JwtNotUtf8 => "OAUTH2-JWT-NONUTF8-013".into(),
293        }
294    }
295
296    fn severity(&self) -> ErrorSeverity {
297        match self {
298            // Misconfiguration and invalid templates are deployment-time issues
299            OAuth2Error::ConfigMissing { .. }
300            | OAuth2Error::InvalidUrl { .. }
301            | OAuth2Error::CookieTemplateInvalid { .. } => ErrorSeverity::Error,
302
303            // Callback-level issues vary; treat as warnings unless systemic
304            OAuth2Error::MissingStateCookie
305            | OAuth2Error::MissingPkceCookie
306            | OAuth2Error::ProviderReturnedError { .. }
307            | OAuth2Error::StateMismatch
308            | OAuth2Error::MissingAuthorizationCode => ErrorSeverity::Warning,
309
310            // Network/exchange or backend failures
311            OAuth2Error::TokenExchange { .. }
312            | OAuth2Error::AccountMapping { .. }
313            | OAuth2Error::AccountPersistence { .. }
314            | OAuth2Error::JwtEncoding { .. }
315            | OAuth2Error::JwtNotUtf8 => ErrorSeverity::Error,
316        }
317    }
318
319    fn suggested_actions(&self) -> Vec<String> {
320        match self {
321            OAuth2Error::ConfigMissing { field } => vec![format!(
322                "Set OAuth2Gate builder field: {field} (auth_url, token_url, client_id, redirect_url)"
323            )],
324            OAuth2Error::InvalidUrl { field, .. } => {
325                vec![format!("Verify URL format and scheme for {field}")]
326            }
327            OAuth2Error::CookieTemplateInvalid { which, .. } => vec![format!(
328                "Review {} cookie template (SameSite/Secure/Max-Age). SameSite=None requires Secure=true",
329                which
330            )],
331            OAuth2Error::MissingStateCookie | OAuth2Error::MissingPkceCookie => vec![
332                "Ensure cookies are set for the same domain and path during /login → /callback"
333                    .into(),
334                "Check SameSite and Secure attributes for OAuth redirect round-trip".into(),
335            ],
336            OAuth2Error::ProviderReturnedError { .. } => vec![
337                "Verify client id/secret and callback URL in provider settings".into(),
338                "Check provider status and retry later".into(),
339            ],
340            OAuth2Error::StateMismatch => vec![
341                "Ensure the same domain/protocol is used during the OAuth redirect round-trip"
342                    .into(),
343                "Avoid navigating away or opening multiple OAuth tabs simultaneously".into(),
344            ],
345            OAuth2Error::MissingAuthorizationCode => {
346                vec!["Retry sign-in; ensure the provider granted access".into()]
347            }
348            OAuth2Error::TokenExchange { .. } => vec![
349                "Verify token endpoint URL and client credentials".into(),
350                "Check network egress, DNS, and request timeouts".into(),
351            ],
352            OAuth2Error::AccountMapping { .. } => {
353                vec!["Review userinfo call and mapping logic; handle missing fields".into()]
354            }
355            OAuth2Error::AccountPersistence { .. } => {
356                vec!["Check repository connectivity and unique constraints".into()]
357            }
358            OAuth2Error::JwtEncoding { .. } => {
359                vec!["Verify JWT codec configuration and payload serialization".into()]
360            }
361            OAuth2Error::JwtNotUtf8 => {
362                vec!["Ensure JWT codec returns UTF‑8 compatible bytes for transport".into()]
363            }
364        }
365    }
366
367    fn is_retryable(&self) -> bool {
368        matches!(
369            self,
370            OAuth2Error::ProviderReturnedError { .. }
371                | OAuth2Error::TokenExchange { .. }
372                | OAuth2Error::AccountPersistence { .. }
373        )
374    }
375}