Skip to main content

shopify_sdk/auth/oauth/
auth_query.rs

1//! OAuth callback query parameter representation.
2//!
3//! This module provides the [`AuthQuery`] struct for representing the query
4//! parameters received in an OAuth callback from Shopify.
5//!
6//! # Overview
7//!
8//! When a user authorizes your app, Shopify redirects them back to your
9//! redirect URI with several query parameters including:
10//! - `code`: The authorization code to exchange for an access token
11//! - `shop`: The shop domain that authorized the app
12//! - `state`: The state parameter for CSRF verification
13//! - `timestamp`: When the authorization was granted
14//! - `host`: Base64-encoded host for embedded apps
15//! - `hmac`: HMAC signature for request verification
16//!
17//! # Example
18//!
19//! ```rust
20//! use shopify_sdk::auth::oauth::AuthQuery;
21//!
22//! // Parse from incoming callback (typically via a web framework)
23//! let query = AuthQuery::new(
24//!     "authorization-code".to_string(),
25//!     "example-shop.myshopify.com".to_string(),
26//!     "1234567890".to_string(),
27//!     "state-param".to_string(),
28//!     "host-value".to_string(),
29//!     "computed-hmac".to_string(),
30//! );
31//!
32//! // The signable string is used for HMAC verification
33//! let signable = query.to_signable_string();
34//! ```
35
36use serde::{Deserialize, Serialize};
37
38/// OAuth callback query parameters from Shopify.
39///
40/// This struct represents all the query parameters that Shopify sends to your
41/// redirect URI after a user authorizes your app. Use this with
42/// [`validate_auth_callback`](crate::auth::oauth::validate_auth_callback) to
43/// verify the callback and exchange the code for an access token.
44///
45/// # Fields
46///
47/// All fields are strings as received from the query string:
48/// - `code`: Authorization code to exchange for tokens
49/// - `shop`: Shop domain (e.g., "example.myshopify.com")
50/// - `timestamp`: Unix timestamp of the authorization
51/// - `state`: State parameter for CSRF verification
52/// - `host`: Base64-encoded host for embedded apps
53/// - `hmac`: HMAC signature for request validation
54///
55/// # Serialization
56///
57/// `AuthQuery` derives `Serialize` and `Deserialize` to facilitate parsing
58/// from query strings using web frameworks like Axum or Actix-web.
59///
60/// # Example
61///
62/// ```rust
63/// use shopify_sdk::auth::oauth::AuthQuery;
64///
65/// let query = AuthQuery::new(
66///     "auth-code".to_string(),
67///     "my-shop.myshopify.com".to_string(),
68///     "1699999999".to_string(),
69///     "csrf-state".to_string(),
70///     "aG9zdC12YWx1ZQ==".to_string(),
71///     "abc123def456".to_string(),
72/// );
73///
74/// assert_eq!(query.code, "auth-code");
75/// assert_eq!(query.shop, "my-shop.myshopify.com");
76/// ```
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub struct AuthQuery {
79    /// The authorization code from Shopify.
80    ///
81    /// This code is exchanged for an access token via a POST request to
82    /// `https://{shop}/admin/oauth/access_token`.
83    pub code: String,
84
85    /// The shop domain that authorized the app.
86    ///
87    /// This is the full domain (e.g., "example.myshopify.com").
88    pub shop: String,
89
90    /// Unix timestamp of when the authorization was granted.
91    pub timestamp: String,
92
93    /// The state parameter for CSRF protection.
94    ///
95    /// This should match the state generated by `begin_auth()`.
96    pub state: String,
97
98    /// Base64-encoded host for embedded apps.
99    ///
100    /// Used by Shopify's App Bridge for embedded app authentication.
101    pub host: String,
102
103    /// HMAC signature for verifying the request authenticity.
104    ///
105    /// This is computed by Shopify using your API secret key.
106    pub hmac: String,
107}
108
109impl AuthQuery {
110    /// Creates a new `AuthQuery` with all fields.
111    ///
112    /// # Arguments
113    ///
114    /// * `code` - Authorization code from Shopify
115    /// * `shop` - Shop domain
116    /// * `timestamp` - Unix timestamp string
117    /// * `state` - CSRF state parameter
118    /// * `host` - Base64-encoded host
119    /// * `hmac` - HMAC signature
120    ///
121    /// # Example
122    ///
123    /// ```rust
124    /// use shopify_sdk::auth::oauth::AuthQuery;
125    ///
126    /// let query = AuthQuery::new(
127    ///     "code123".to_string(),
128    ///     "shop.myshopify.com".to_string(),
129    ///     "1700000000".to_string(),
130    ///     "state456".to_string(),
131    ///     "host789".to_string(),
132    ///     "hmac012".to_string(),
133    /// );
134    /// ```
135    #[must_use]
136    pub const fn new(
137        code: String,
138        shop: String,
139        timestamp: String,
140        state: String,
141        host: String,
142        hmac: String,
143    ) -> Self {
144        Self {
145            code,
146            shop,
147            timestamp,
148            state,
149            host,
150            hmac,
151        }
152    }
153
154    /// Converts the query parameters to a signable string for HMAC verification.
155    ///
156    /// This produces a string suitable for HMAC computation by:
157    /// 1. Excluding the `hmac` parameter
158    /// 2. Sorting remaining parameters alphabetically by key
159    /// 3. URI-encoding each value
160    /// 4. Joining as `key=value&key=value` format
161    ///
162    /// # Returns
163    ///
164    /// A string ready for HMAC-SHA256 computation.
165    ///
166    /// # Example
167    ///
168    /// ```rust
169    /// use shopify_sdk::auth::oauth::AuthQuery;
170    ///
171    /// let query = AuthQuery::new(
172    ///     "code123".to_string(),
173    ///     "shop.myshopify.com".to_string(),
174    ///     "1700000000".to_string(),
175    ///     "state456".to_string(),
176    ///     "host789".to_string(),
177    ///     "hmac-ignored".to_string(),
178    /// );
179    ///
180    /// let signable = query.to_signable_string();
181    /// // Parameters are sorted alphabetically: code, host, shop, state, timestamp
182    /// assert!(signable.starts_with("code="));
183    /// assert!(signable.contains("&shop="));
184    /// assert!(!signable.contains("hmac")); // hmac is excluded
185    /// ```
186    #[must_use]
187    pub fn to_signable_string(&self) -> String {
188        // Collect all parameters except hmac
189        let mut params: Vec<(&str, &str)> = vec![
190            ("code", &self.code),
191            ("host", &self.host),
192            ("shop", &self.shop),
193            ("state", &self.state),
194            ("timestamp", &self.timestamp),
195        ];
196
197        // Sort alphabetically by key (already sorted, but being explicit)
198        params.sort_by_key(|(key, _)| *key);
199
200        // Build the signable string with URI encoding
201        params
202            .iter()
203            .map(|(key, value)| format!("{}={}", key, urlencoding::encode(value)))
204            .collect::<Vec<_>>()
205            .join("&")
206    }
207}
208
209// Verify AuthQuery is Send + Sync at compile time
210const _: fn() = || {
211    const fn assert_send_sync<T: Send + Sync>() {}
212    assert_send_sync::<AuthQuery>();
213};
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_auth_query_creation_with_all_fields() {
221        let query = AuthQuery::new(
222            "abc123".to_string(),
223            "my-shop.myshopify.com".to_string(),
224            "1699999999".to_string(),
225            "state-param".to_string(),
226            "host-base64".to_string(),
227            "hmac-value".to_string(),
228        );
229
230        assert_eq!(query.code, "abc123");
231        assert_eq!(query.shop, "my-shop.myshopify.com");
232        assert_eq!(query.timestamp, "1699999999");
233        assert_eq!(query.state, "state-param");
234        assert_eq!(query.host, "host-base64");
235        assert_eq!(query.hmac, "hmac-value");
236    }
237
238    #[test]
239    fn test_to_signable_string_sorts_alphabetically() {
240        let query = AuthQuery::new(
241            "z-code".to_string(),
242            "a-shop.myshopify.com".to_string(),
243            "1234567890".to_string(),
244            "m-state".to_string(),
245            "b-host".to_string(),
246            "ignored-hmac".to_string(),
247        );
248
249        let signable = query.to_signable_string();
250
251        // Should be sorted: code, host, shop, state, timestamp
252        let parts: Vec<&str> = signable.split('&').collect();
253        assert_eq!(parts.len(), 5);
254        assert!(parts[0].starts_with("code="));
255        assert!(parts[1].starts_with("host="));
256        assert!(parts[2].starts_with("shop="));
257        assert!(parts[3].starts_with("state="));
258        assert!(parts[4].starts_with("timestamp="));
259    }
260
261    #[test]
262    fn test_to_signable_string_uri_encodes_values() {
263        let query = AuthQuery::new(
264            "code with spaces".to_string(),
265            "shop.myshopify.com".to_string(),
266            "1234567890".to_string(),
267            "state=special&chars".to_string(),
268            "host+plus".to_string(),
269            "hmac".to_string(),
270        );
271
272        let signable = query.to_signable_string();
273
274        // Spaces should be encoded as %20
275        assert!(signable.contains("code%20with%20spaces"));
276        // Special characters should be encoded
277        assert!(signable.contains("state%3Dspecial%26chars"));
278        // Plus should be encoded as %2B
279        assert!(signable.contains("host%2Bplus"));
280    }
281
282    #[test]
283    fn test_to_signable_string_excludes_hmac() {
284        let query = AuthQuery::new(
285            "code".to_string(),
286            "shop.myshopify.com".to_string(),
287            "12345".to_string(),
288            "state".to_string(),
289            "host".to_string(),
290            "this-should-not-appear".to_string(),
291        );
292
293        let signable = query.to_signable_string();
294
295        assert!(!signable.contains("hmac"));
296        assert!(!signable.contains("this-should-not-appear"));
297    }
298
299    #[test]
300    fn test_auth_query_fields_match_expected_structure() {
301        // Verify the struct has all expected public fields
302        let query = AuthQuery {
303            code: "c".to_string(),
304            shop: "s".to_string(),
305            timestamp: "t".to_string(),
306            state: "st".to_string(),
307            host: "h".to_string(),
308            hmac: "hm".to_string(),
309        };
310
311        assert_eq!(query.code, "c");
312        assert_eq!(query.shop, "s");
313        assert_eq!(query.timestamp, "t");
314        assert_eq!(query.state, "st");
315        assert_eq!(query.host, "h");
316        assert_eq!(query.hmac, "hm");
317    }
318
319    #[test]
320    fn test_auth_query_serialization() {
321        let query = AuthQuery::new(
322            "code".to_string(),
323            "shop.myshopify.com".to_string(),
324            "12345".to_string(),
325            "state".to_string(),
326            "host".to_string(),
327            "hmac".to_string(),
328        );
329
330        let json = serde_json::to_string(&query).unwrap();
331        assert!(json.contains("\"code\":\"code\""));
332        assert!(json.contains("\"shop\":\"shop.myshopify.com\""));
333    }
334
335    #[test]
336    fn test_auth_query_deserialization() {
337        let json = r#"{
338            "code": "auth-code",
339            "shop": "test.myshopify.com",
340            "timestamp": "1700000000",
341            "state": "test-state",
342            "host": "test-host",
343            "hmac": "test-hmac"
344        }"#;
345
346        let query: AuthQuery = serde_json::from_str(json).unwrap();
347        assert_eq!(query.code, "auth-code");
348        assert_eq!(query.shop, "test.myshopify.com");
349        assert_eq!(query.hmac, "test-hmac");
350    }
351
352    #[test]
353    fn test_auth_query_clone() {
354        let query = AuthQuery::new(
355            "code".to_string(),
356            "shop".to_string(),
357            "time".to_string(),
358            "state".to_string(),
359            "host".to_string(),
360            "hmac".to_string(),
361        );
362
363        let cloned = query.clone();
364        assert_eq!(query, cloned);
365    }
366
367    #[test]
368    fn test_auth_query_is_send_sync() {
369        fn assert_send_sync<T: Send + Sync>() {}
370        assert_send_sync::<AuthQuery>();
371    }
372
373    #[test]
374    fn test_signable_string_format() {
375        let query = AuthQuery::new(
376            "0907a61c0c8d55e99db179b68161bc00".to_string(),
377            "some-shop.myshopify.com".to_string(),
378            "1337178173".to_string(),
379            "123".to_string(),
380            "dGVzdC5teXNob3BpZnkuY29tL2FkbWlu".to_string(),
381            "expected-hmac".to_string(),
382        );
383
384        let signable = query.to_signable_string();
385
386        // Verify format: key=value&key=value
387        assert!(signable.contains("="));
388        assert!(signable.contains("&"));
389
390        // Verify all expected keys are present
391        assert!(signable.contains("code="));
392        assert!(signable.contains("host="));
393        assert!(signable.contains("shop="));
394        assert!(signable.contains("state="));
395        assert!(signable.contains("timestamp="));
396    }
397}