Skip to main content

shopify_sdk/auth/oauth/
error.rs

1//! OAuth-specific error types for the Shopify API SDK.
2//!
3//! This module contains error types for OAuth operations including HMAC validation,
4//! state verification, token exchange failures, client credentials failures,
5//! token refresh failures, and JWT validation for embedded apps.
6//!
7//! # Error Types
8//!
9//! - [`OAuthError::InvalidHmac`]: HMAC signature validation failed
10//! - [`OAuthError::StateMismatch`]: OAuth state parameter doesn't match expected
11//! - [`OAuthError::TokenExchangeFailed`]: Token exchange request failed
12//! - [`OAuthError::ClientCredentialsFailed`]: Client credentials exchange request failed
13//! - [`OAuthError::TokenRefreshFailed`]: Token refresh or migration request failed
14//! - [`OAuthError::InvalidCallback`]: Callback parameters are malformed
15//! - [`OAuthError::MissingHostConfig`]: Host URL not configured for redirect URI
16//! - [`OAuthError::InvalidJwt`]: JWT validation failed (for token exchange)
17//! - [`OAuthError::NotEmbeddedApp`]: Token exchange requires embedded app configuration
18//! - [`OAuthError::NotPrivateApp`]: Client credentials requires non-embedded app configuration
19//! - [`OAuthError::HttpError`]: Wrapped HTTP client error
20//!
21//! # Example
22//!
23//! ```rust
24//! use shopify_sdk::auth::oauth::OAuthError;
25//!
26//! let error = OAuthError::InvalidHmac;
27//! assert_eq!(error.to_string(), "HMAC signature validation failed");
28//!
29//! let error = OAuthError::StateMismatch {
30//!     expected: "abc123".to_string(),
31//!     received: "xyz789".to_string(),
32//! };
33//! assert!(error.to_string().contains("abc123"));
34//!
35//! let error = OAuthError::InvalidJwt {
36//!     reason: "Token expired".to_string(),
37//! };
38//! assert!(error.to_string().contains("Token expired"));
39//!
40//! let error = OAuthError::ClientCredentialsFailed {
41//!     status: 401,
42//!     message: "Invalid credentials".to_string(),
43//! };
44//! assert!(error.to_string().contains("401"));
45//!
46//! let error = OAuthError::TokenRefreshFailed {
47//!     status: 400,
48//!     message: "Invalid refresh token".to_string(),
49//! };
50//! assert!(error.to_string().contains("400"));
51//! ```
52
53use crate::clients::HttpError;
54use thiserror::Error;
55
56/// Errors that can occur during OAuth operations.
57///
58/// This enum covers all failure modes in OAuth flows, including the authorization
59/// code flow, token exchange, client credentials, token refresh, and JWT validation
60/// for embedded apps.
61///
62/// # Thread Safety
63///
64/// `OAuthError` is `Send + Sync`, making it safe to use across async boundaries.
65///
66/// # Example
67///
68/// ```rust
69/// use shopify_sdk::auth::oauth::OAuthError;
70///
71/// fn handle_oauth_error(err: OAuthError) {
72///     match err {
73///         OAuthError::InvalidHmac => {
74///             eprintln!("Security: HMAC validation failed");
75///         }
76///         OAuthError::StateMismatch { expected, received } => {
77///             eprintln!("CSRF: State mismatch - expected {}, got {}", expected, received);
78///         }
79///         OAuthError::TokenExchangeFailed { status, message } => {
80///             eprintln!("Token exchange failed ({}): {}", status, message);
81///         }
82///         OAuthError::ClientCredentialsFailed { status, message } => {
83///             eprintln!("Client credentials failed ({}): {}", status, message);
84///         }
85///         OAuthError::TokenRefreshFailed { status, message } => {
86///             eprintln!("Token refresh failed ({}): {}", status, message);
87///         }
88///         OAuthError::InvalidCallback { reason } => {
89///             eprintln!("Invalid callback: {}", reason);
90///         }
91///         OAuthError::MissingHostConfig => {
92///             eprintln!("Configuration error: Host URL not configured");
93///         }
94///         OAuthError::InvalidJwt { reason } => {
95///             eprintln!("JWT validation failed: {}", reason);
96///         }
97///         OAuthError::NotEmbeddedApp => {
98///             eprintln!("Token exchange only works for embedded apps");
99///         }
100///         OAuthError::NotPrivateApp => {
101///             eprintln!("Client credentials only works for private apps");
102///         }
103///         OAuthError::HttpError(e) => {
104///             eprintln!("HTTP error: {}", e);
105///         }
106///     }
107/// }
108/// ```
109#[derive(Debug, Error)]
110pub enum OAuthError {
111    /// HMAC signature validation failed.
112    ///
113    /// This indicates the callback request's HMAC signature does not match
114    /// the expected value computed with the API secret key. This could indicate
115    /// a tampered request or misconfigured secret key.
116    #[error("HMAC signature validation failed")]
117    InvalidHmac,
118
119    /// OAuth state parameter mismatch.
120    ///
121    /// The state parameter in the callback does not match the expected state
122    /// that was generated during `begin_auth()`. This is a security measure
123    /// against CSRF attacks.
124    #[error("State parameter mismatch: expected '{expected}', received '{received}'")]
125    StateMismatch {
126        /// The expected state value that was generated.
127        expected: String,
128        /// The state value received in the callback.
129        received: String,
130    },
131
132    /// Token exchange request failed.
133    ///
134    /// The POST request to exchange the authorization code for an access token
135    /// returned a non-success HTTP status.
136    #[error("Token exchange failed with status {status}: {message}")]
137    TokenExchangeFailed {
138        /// The HTTP status code returned.
139        status: u16,
140        /// The error message from the response.
141        message: String,
142    },
143
144    /// Client credentials exchange request failed.
145    ///
146    /// The POST request to obtain an access token using client credentials
147    /// returned a non-success HTTP status. This error is specific to the
148    /// Client Credentials Grant flow used by private/organization apps.
149    ///
150    /// # Example
151    ///
152    /// ```rust
153    /// use shopify_sdk::auth::oauth::OAuthError;
154    ///
155    /// let error = OAuthError::ClientCredentialsFailed {
156    ///     status: 401,
157    ///     message: "Invalid client credentials".to_string(),
158    /// };
159    /// assert!(error.to_string().contains("Client credentials"));
160    /// assert!(error.to_string().contains("401"));
161    /// ```
162    #[error("Client credentials exchange failed with status {status}: {message}")]
163    ClientCredentialsFailed {
164        /// The HTTP status code returned (0 for network errors).
165        status: u16,
166        /// The error message from the response or network error description.
167        message: String,
168    },
169
170    /// Token refresh or migration request failed.
171    ///
172    /// The POST request to refresh an access token or migrate to expiring tokens
173    /// returned a non-success HTTP status. This error is used for both the
174    /// `refresh_access_token` and `migrate_to_expiring_token` functions.
175    ///
176    /// # Example
177    ///
178    /// ```rust
179    /// use shopify_sdk::auth::oauth::OAuthError;
180    ///
181    /// let error = OAuthError::TokenRefreshFailed {
182    ///     status: 400,
183    ///     message: "Invalid refresh token".to_string(),
184    /// };
185    /// assert!(error.to_string().contains("Token refresh"));
186    /// assert!(error.to_string().contains("400"));
187    /// ```
188    #[error("Token refresh failed with status {status}: {message}")]
189    TokenRefreshFailed {
190        /// The HTTP status code returned (0 for network errors).
191        status: u16,
192        /// The error message from the response or network error description.
193        message: String,
194    },
195
196    /// Callback parameters are invalid or malformed.
197    ///
198    /// One or more parameters in the OAuth callback are missing, empty,
199    /// or have invalid formats.
200    #[error("Invalid callback: {reason}")]
201    InvalidCallback {
202        /// Description of what's invalid about the callback.
203        reason: String,
204    },
205
206    /// Host URL is not configured in `ShopifyConfig`.
207    ///
208    /// The `begin_auth()` function requires a host URL to construct the
209    /// redirect URI. Configure this via `ShopifyConfigBuilder::host()`.
210    #[error("Host URL must be configured in ShopifyConfig for OAuth")]
211    MissingHostConfig,
212
213    /// JWT validation failed.
214    ///
215    /// This error occurs during token exchange when the session token (JWT)
216    /// provided by App Bridge cannot be validated. Common causes include:
217    ///
218    /// - Token is expired or not yet valid
219    /// - Token was signed with a different secret key
220    /// - Token's audience (`aud`) claim doesn't match the app's API key
221    /// - Token structure is malformed
222    /// - Shopify rejected the token during token exchange
223    ///
224    /// # Example
225    ///
226    /// ```rust
227    /// use shopify_sdk::auth::oauth::OAuthError;
228    ///
229    /// let error = OAuthError::InvalidJwt {
230    ///     reason: "Session token had invalid API key".to_string(),
231    /// };
232    /// assert!(error.to_string().contains("Invalid JWT"));
233    /// ```
234    #[error("Invalid JWT: {reason}")]
235    InvalidJwt {
236        /// Description of why the JWT validation failed.
237        reason: String,
238    },
239
240    /// Token exchange requires an embedded app configuration.
241    ///
242    /// Token exchange OAuth flow is only available for embedded apps that
243    /// receive session tokens from Shopify App Bridge. Ensure that
244    /// `ShopifyConfigBuilder::is_embedded(true)` is set.
245    ///
246    /// # Example
247    ///
248    /// ```rust
249    /// use shopify_sdk::auth::oauth::OAuthError;
250    ///
251    /// let error = OAuthError::NotEmbeddedApp;
252    /// assert!(error.to_string().contains("embedded app"));
253    /// ```
254    #[error("Token exchange requires an embedded app configuration")]
255    NotEmbeddedApp,
256
257    /// Client credentials requires a non-embedded app configuration.
258    ///
259    /// Client Credentials Grant OAuth flow is only available for private or
260    /// organization apps that are NOT embedded in the Shopify admin. Ensure
261    /// that `ShopifyConfigBuilder::is_embedded(false)` is set (or not set,
262    /// as `false` is the default).
263    ///
264    /// This error is the inverse of [`NotEmbeddedApp`](OAuthError::NotEmbeddedApp),
265    /// which is used for token exchange flows that require embedded apps.
266    ///
267    /// # Example
268    ///
269    /// ```rust
270    /// use shopify_sdk::auth::oauth::OAuthError;
271    ///
272    /// let error = OAuthError::NotPrivateApp;
273    /// assert!(error.to_string().contains("non-embedded"));
274    /// ```
275    #[error("Client credentials requires a non-embedded app configuration")]
276    NotPrivateApp,
277
278    /// Wrapped HTTP client error.
279    ///
280    /// An error occurred during HTTP communication, such as a network failure
281    /// or request validation error.
282    #[error(transparent)]
283    HttpError(#[from] HttpError),
284}
285
286// Verify OAuthError is Send + Sync at compile time
287const _: fn() = || {
288    const fn assert_send_sync<T: Send + Sync>() {}
289    assert_send_sync::<OAuthError>();
290};
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::clients::{HttpResponseError, InvalidHttpRequestError};
296
297    #[test]
298    fn test_invalid_hmac_formats_correctly() {
299        let error = OAuthError::InvalidHmac;
300        assert_eq!(error.to_string(), "HMAC signature validation failed");
301    }
302
303    #[test]
304    fn test_state_mismatch_includes_expected_and_received() {
305        let error = OAuthError::StateMismatch {
306            expected: "abc123".to_string(),
307            received: "xyz789".to_string(),
308        };
309        let message = error.to_string();
310        assert!(message.contains("abc123"));
311        assert!(message.contains("xyz789"));
312        assert!(message.contains("expected"));
313        assert!(message.contains("received"));
314    }
315
316    #[test]
317    fn test_token_exchange_failed_includes_status_and_message() {
318        let error = OAuthError::TokenExchangeFailed {
319            status: 401,
320            message: "Invalid client credentials".to_string(),
321        };
322        let message = error.to_string();
323        assert!(message.contains("401"));
324        assert!(message.contains("Invalid client credentials"));
325    }
326
327    #[test]
328    fn test_from_http_error_conversion() {
329        let http_error = HttpError::Response(HttpResponseError {
330            code: 500,
331            message: "Internal server error".to_string(),
332            error_reference: None,
333        });
334        let oauth_error: OAuthError = http_error.into();
335        match oauth_error {
336            OAuthError::HttpError(_) => {}
337            _ => panic!("Expected HttpError variant"),
338        }
339    }
340
341    #[test]
342    fn test_oauth_error_implements_std_error() {
343        let error: &dyn std::error::Error = &OAuthError::InvalidHmac;
344        let _ = error;
345
346        let error: &dyn std::error::Error = &OAuthError::StateMismatch {
347            expected: "a".to_string(),
348            received: "b".to_string(),
349        };
350        let _ = error;
351
352        let error: &dyn std::error::Error = &OAuthError::TokenExchangeFailed {
353            status: 400,
354            message: "test".to_string(),
355        };
356        let _ = error;
357
358        let error: &dyn std::error::Error = &OAuthError::InvalidCallback {
359            reason: "test".to_string(),
360        };
361        let _ = error;
362
363        let error: &dyn std::error::Error = &OAuthError::MissingHostConfig;
364        let _ = error;
365
366        let error: &dyn std::error::Error = &OAuthError::InvalidJwt {
367            reason: "test".to_string(),
368        };
369        let _ = error;
370
371        let error: &dyn std::error::Error = &OAuthError::NotEmbeddedApp;
372        let _ = error;
373
374        let error: &dyn std::error::Error = &OAuthError::ClientCredentialsFailed {
375            status: 401,
376            message: "test".to_string(),
377        };
378        let _ = error;
379
380        let error: &dyn std::error::Error = &OAuthError::NotPrivateApp;
381        let _ = error;
382
383        let error: &dyn std::error::Error = &OAuthError::TokenRefreshFailed {
384            status: 400,
385            message: "test".to_string(),
386        };
387        let _ = error;
388    }
389
390    #[test]
391    fn test_invalid_callback_includes_reason() {
392        let error = OAuthError::InvalidCallback {
393            reason: "Shop domain is invalid".to_string(),
394        };
395        assert!(error.to_string().contains("Shop domain is invalid"));
396    }
397
398    #[test]
399    fn test_missing_host_config_message() {
400        let error = OAuthError::MissingHostConfig;
401        assert!(error.to_string().contains("Host URL"));
402        assert!(error.to_string().contains("configured"));
403    }
404
405    #[test]
406    fn test_http_error_from_invalid_request() {
407        let invalid = InvalidHttpRequestError::MissingBodyType;
408        let http_error = HttpError::InvalidRequest(invalid);
409        let oauth_error: OAuthError = http_error.into();
410
411        match oauth_error {
412            OAuthError::HttpError(HttpError::InvalidRequest(_)) => {}
413            _ => panic!("Expected HttpError::InvalidRequest variant"),
414        }
415    }
416
417    #[test]
418    fn test_oauth_error_is_send_sync() {
419        fn assert_send_sync<T: Send + Sync>() {}
420        assert_send_sync::<OAuthError>();
421    }
422
423    // === New tests for InvalidJwt and NotEmbeddedApp variants ===
424
425    #[test]
426    fn test_invalid_jwt_formats_error_message_with_reason() {
427        let error = OAuthError::InvalidJwt {
428            reason: "Token expired".to_string(),
429        };
430        let message = error.to_string();
431        assert!(message.contains("Invalid JWT"));
432        assert!(message.contains("Token expired"));
433    }
434
435    #[test]
436    fn test_not_embedded_app_has_correct_error_message() {
437        let error = OAuthError::NotEmbeddedApp;
438        let message = error.to_string();
439        assert!(message.contains("embedded app"));
440        assert!(message.contains("Token exchange"));
441    }
442
443    #[test]
444    fn test_new_variants_implement_std_error() {
445        // InvalidJwt implements std::error::Error
446        let invalid_jwt_error: &dyn std::error::Error = &OAuthError::InvalidJwt {
447            reason: "test reason".to_string(),
448        };
449        assert!(invalid_jwt_error.to_string().contains("Invalid JWT"));
450
451        // NotEmbeddedApp implements std::error::Error
452        let not_embedded_error: &dyn std::error::Error = &OAuthError::NotEmbeddedApp;
453        assert!(not_embedded_error.to_string().contains("embedded app"));
454    }
455
456    #[test]
457    fn test_new_variants_are_send_sync() {
458        fn assert_send_sync<T: Send + Sync>() {}
459
460        // These compile-time assertions verify Send + Sync
461        assert_send_sync::<OAuthError>();
462
463        // Also verify the specific variants at runtime
464        let invalid_jwt = OAuthError::InvalidJwt {
465            reason: "test".to_string(),
466        };
467        let not_embedded = OAuthError::NotEmbeddedApp;
468
469        // Can be sent across threads
470        std::thread::spawn(move || {
471            let _ = invalid_jwt;
472        })
473        .join()
474        .unwrap();
475
476        std::thread::spawn(move || {
477            let _ = not_embedded;
478        })
479        .join()
480        .unwrap();
481    }
482
483    // === Tests for ClientCredentialsFailed and NotPrivateApp variants ===
484
485    #[test]
486    fn test_client_credentials_failed_formats_error_message_with_status_and_message() {
487        let error = OAuthError::ClientCredentialsFailed {
488            status: 401,
489            message: "Invalid client credentials".to_string(),
490        };
491        let message = error.to_string();
492        assert!(message.contains("Client credentials"));
493        assert!(message.contains("401"));
494        assert!(message.contains("Invalid client credentials"));
495    }
496
497    #[test]
498    fn test_not_private_app_has_correct_error_message() {
499        let error = OAuthError::NotPrivateApp;
500        let message = error.to_string();
501        assert!(message.contains("non-embedded"));
502        assert!(message.contains("Client credentials"));
503    }
504
505    #[test]
506    fn test_client_credentials_variants_implement_std_error() {
507        // ClientCredentialsFailed implements std::error::Error
508        let client_creds_error: &dyn std::error::Error = &OAuthError::ClientCredentialsFailed {
509            status: 500,
510            message: "Server error".to_string(),
511        };
512        assert!(client_creds_error
513            .to_string()
514            .contains("Client credentials"));
515
516        // NotPrivateApp implements std::error::Error
517        let not_private_error: &dyn std::error::Error = &OAuthError::NotPrivateApp;
518        assert!(not_private_error.to_string().contains("non-embedded"));
519    }
520
521    #[test]
522    fn test_client_credentials_variants_are_send_sync() {
523        fn assert_send_sync<T: Send + Sync>() {}
524
525        // These compile-time assertions verify Send + Sync
526        assert_send_sync::<OAuthError>();
527
528        // Also verify the specific variants at runtime
529        let client_creds_failed = OAuthError::ClientCredentialsFailed {
530            status: 401,
531            message: "test".to_string(),
532        };
533        let not_private = OAuthError::NotPrivateApp;
534
535        // Can be sent across threads
536        std::thread::spawn(move || {
537            let _ = client_creds_failed;
538        })
539        .join()
540        .unwrap();
541
542        std::thread::spawn(move || {
543            let _ = not_private;
544        })
545        .join()
546        .unwrap();
547    }
548
549    // === Tests for TokenRefreshFailed variant ===
550
551    #[test]
552    fn test_token_refresh_failed_formats_error_message_with_status_and_message() {
553        let error = OAuthError::TokenRefreshFailed {
554            status: 400,
555            message: "Invalid refresh token".to_string(),
556        };
557        let message = error.to_string();
558        assert!(message.contains("Token refresh"));
559        assert!(message.contains("400"));
560        assert!(message.contains("Invalid refresh token"));
561    }
562
563    #[test]
564    fn test_token_refresh_failed_with_network_error_status_zero() {
565        let error = OAuthError::TokenRefreshFailed {
566            status: 0,
567            message: "Network error: connection refused".to_string(),
568        };
569        let message = error.to_string();
570        assert!(message.contains("Token refresh"));
571        assert!(message.contains("0"));
572        assert!(message.contains("Network error"));
573    }
574
575    #[test]
576    fn test_token_refresh_failed_implements_std_error() {
577        let error: &dyn std::error::Error = &OAuthError::TokenRefreshFailed {
578            status: 401,
579            message: "Unauthorized".to_string(),
580        };
581        assert!(error.to_string().contains("Token refresh"));
582    }
583
584    #[test]
585    fn test_token_refresh_failed_is_send_sync() {
586        fn assert_send_sync<T: Send + Sync>() {}
587        assert_send_sync::<OAuthError>();
588
589        let token_refresh_failed = OAuthError::TokenRefreshFailed {
590            status: 400,
591            message: "test".to_string(),
592        };
593
594        std::thread::spawn(move || {
595            let _ = token_refresh_failed;
596        })
597        .join()
598        .unwrap();
599    }
600}