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}