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}