shopify_sdk/auth/oauth/begin_auth.rs
1//! OAuth authorization URL generation.
2//!
3//! This module provides the [`begin_auth`] function for generating Shopify
4//! OAuth authorization URLs and the [`BeginAuthResult`] struct containing
5//! the URL and state parameter.
6//!
7//! # Overview
8//!
9//! The `begin_auth` function is the first step in the OAuth authorization code
10//! flow. It generates:
11//! 1. A cryptographically secure state parameter for CSRF protection
12//! 2. An authorization URL to redirect the user to Shopify
13//!
14//! # Example
15//!
16//! ```rust,ignore
17//! use shopify_sdk::{ShopifyConfig, ApiKey, ApiSecretKey, ShopDomain, HostUrl};
18//! use shopify_sdk::auth::oauth::begin_auth;
19//!
20//! let config = ShopifyConfig::builder()
21//! .api_key(ApiKey::new("your-api-key").unwrap())
22//! .api_secret_key(ApiSecretKey::new("your-secret").unwrap())
23//! .host(HostUrl::new("https://your-app.com").unwrap())
24//! .scopes("read_products,write_orders".parse().unwrap())
25//! .build()
26//! .unwrap();
27//!
28//! let shop = ShopDomain::new("example-shop").unwrap();
29//! let result = begin_auth(&config, &shop, "/auth/callback", true, None).unwrap();
30//!
31//! // Store result.state in the user's session
32//! // Redirect user to result.auth_url
33//! ```
34
35use crate::auth::oauth::error::OAuthError;
36use crate::auth::oauth::state::StateParam;
37use crate::auth::AuthScopes;
38use crate::config::{ShopDomain, ShopifyConfig};
39
40/// Result of initiating OAuth authorization.
41///
42/// This struct contains the authorization URL to redirect users to and the
43/// state parameter that should be persisted (typically in a session or cookie)
44/// for verification when the callback is received.
45///
46/// # Important
47///
48/// The `state` value **must** be stored by your application and passed to
49/// [`validate_auth_callback`](crate::auth::oauth::validate_auth_callback)
50/// when handling the callback. This is essential for CSRF protection.
51///
52/// # Example
53///
54/// ```rust,ignore
55/// let result = begin_auth(&config, &shop, "/callback", true, None)?;
56///
57/// // Store in session (implementation depends on your web framework)
58/// session.set("oauth_state", result.state.as_ref());
59///
60/// // Redirect to Shopify
61/// return Redirect::to(&result.auth_url);
62/// ```
63#[derive(Clone, Debug)]
64pub struct BeginAuthResult {
65 /// The full authorization URL to redirect the user to.
66 ///
67 /// This URL points to Shopify's OAuth authorization endpoint with all
68 /// required query parameters.
69 pub auth_url: String,
70
71 /// The state parameter generated for this authorization request.
72 ///
73 /// Store this value and compare it against the `state` parameter
74 /// in the OAuth callback to prevent CSRF attacks.
75 pub state: StateParam,
76}
77
78/// Initiates the OAuth authorization code flow.
79///
80/// This function generates an authorization URL that the user should be
81/// redirected to, along with a cryptographically secure state parameter
82/// for CSRF protection.
83///
84/// # Arguments
85///
86/// * `config` - Shopify SDK configuration (must have `host` configured)
87/// * `shop` - The shop domain to authorize against
88/// * `redirect_path` - Path on your app to receive the callback (e.g., "/auth/callback")
89/// * `is_online` - `true` for online (user-specific) tokens, `false` for offline (app) tokens
90/// * `scope_override` - Optional scope override (uses `config.scopes()` if `None`)
91///
92/// # Returns
93///
94/// A [`BeginAuthResult`] containing the authorization URL and state parameter,
95/// or an [`OAuthError`] if the configuration is invalid.
96///
97/// # Errors
98///
99/// Returns [`OAuthError::MissingHostConfig`] if `config.host()` is `None`.
100///
101/// # Online vs Offline Tokens
102///
103/// - **Online tokens** (`is_online = true`): User-specific, expire, and are
104/// tied to a particular Shopify admin user. Use for user-facing operations.
105/// - **Offline tokens** (`is_online = false`): App-level, don't expire, and
106/// work regardless of user. Use for background tasks and webhooks.
107///
108/// # Example
109///
110/// ```rust
111/// use shopify_sdk::{ShopifyConfig, ApiKey, ApiSecretKey, ShopDomain, HostUrl, AuthScopes};
112/// use shopify_sdk::auth::oauth::begin_auth;
113///
114/// let config = ShopifyConfig::builder()
115/// .api_key(ApiKey::new("api-key").unwrap())
116/// .api_secret_key(ApiSecretKey::new("secret").unwrap())
117/// .host(HostUrl::new("https://myapp.example.com").unwrap())
118/// .scopes("read_products".parse().unwrap())
119/// .build()
120/// .unwrap();
121///
122/// let shop = ShopDomain::new("test-shop").unwrap();
123///
124/// // Request online token with default scopes
125/// let result = begin_auth(&config, &shop, "/auth/callback", true, None).unwrap();
126/// assert!(result.auth_url.contains("test-shop.myshopify.com"));
127/// assert!(result.auth_url.contains("oauth/authorize"));
128///
129/// // Request offline token with custom scopes
130/// let custom_scopes: AuthScopes = "write_orders".parse().unwrap();
131/// let result = begin_auth(&config, &shop, "/auth/callback", false, Some(&custom_scopes)).unwrap();
132/// assert!(result.auth_url.contains("write_orders"));
133/// ```
134pub fn begin_auth(
135 config: &ShopifyConfig,
136 shop: &ShopDomain,
137 redirect_path: &str,
138 is_online: bool,
139 scope_override: Option<&AuthScopes>,
140) -> Result<BeginAuthResult, OAuthError> {
141 // Validate that host is configured
142 let host = config.host().ok_or(OAuthError::MissingHostConfig)?;
143
144 // Generate cryptographically secure state
145 let state = StateParam::new();
146
147 // Determine scopes to use
148 let scopes = scope_override.unwrap_or_else(|| config.scopes());
149
150 // Build redirect URI
151 let redirect_uri = format!("{}{}", host.as_ref(), redirect_path);
152
153 // Build authorization URL
154 let mut params = vec![
155 ("client_id", config.api_key().as_ref().to_string()),
156 ("scope", scopes.to_string()),
157 ("redirect_uri", redirect_uri),
158 ("state", state.to_string()),
159 ];
160
161 // Add grant_options[] for online tokens
162 if is_online {
163 params.push(("grant_options[]", "per-user".to_string()));
164 }
165
166 // Build query string with proper URL encoding for both keys and values
167 let query_string = params
168 .iter()
169 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
170 .collect::<Vec<_>>()
171 .join("&");
172
173 let auth_url = format!(
174 "https://{}/admin/oauth/authorize?{}",
175 shop.as_ref(),
176 query_string
177 );
178
179 Ok(BeginAuthResult { auth_url, state })
180}
181
182// Verify BeginAuthResult is Send + Sync at compile time
183const _: fn() = || {
184 const fn assert_send_sync<T: Send + Sync>() {}
185 assert_send_sync::<BeginAuthResult>();
186};
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use crate::config::{ApiKey, ApiSecretKey, HostUrl};
192
193 fn create_test_config() -> ShopifyConfig {
194 ShopifyConfig::builder()
195 .api_key(ApiKey::new("test-api-key").unwrap())
196 .api_secret_key(ApiSecretKey::new("test-secret").unwrap())
197 .host(HostUrl::new("https://myapp.example.com").unwrap())
198 .scopes("read_products,write_orders".parse().unwrap())
199 .build()
200 .unwrap()
201 }
202
203 fn create_test_shop() -> ShopDomain {
204 ShopDomain::new("test-shop").unwrap()
205 }
206
207 #[test]
208 fn test_begin_auth_generates_correct_url_structure() {
209 let config = create_test_config();
210 let shop = create_test_shop();
211
212 let result = begin_auth(&config, &shop, "/auth/callback", true, None).unwrap();
213
214 // Check URL structure
215 assert!(result
216 .auth_url
217 .starts_with("https://test-shop.myshopify.com/admin/oauth/authorize?"));
218 }
219
220 #[test]
221 fn test_begin_auth_includes_all_required_params() {
222 let config = create_test_config();
223 let shop = create_test_shop();
224
225 let result = begin_auth(&config, &shop, "/auth/callback", true, None).unwrap();
226
227 // Check required parameters are present
228 assert!(result.auth_url.contains("client_id="));
229 assert!(result.auth_url.contains("scope="));
230 assert!(result.auth_url.contains("redirect_uri="));
231 assert!(result.auth_url.contains("state="));
232 }
233
234 #[test]
235 fn test_begin_auth_sets_grant_options_for_online() {
236 let config = create_test_config();
237 let shop = create_test_shop();
238
239 let result = begin_auth(&config, &shop, "/auth/callback", true, None).unwrap();
240
241 // Online should have grant_options[]=per-user (URL encoded key)
242 // grant_options[] encodes to grant_options%5B%5D
243 assert!(result.auth_url.contains("grant_options%5B%5D=per-user"));
244 }
245
246 #[test]
247 fn test_begin_auth_no_grant_options_for_offline() {
248 let config = create_test_config();
249 let shop = create_test_shop();
250
251 let result = begin_auth(&config, &shop, "/auth/callback", false, None).unwrap();
252
253 // Offline should NOT have grant_options
254 assert!(!result.auth_url.contains("grant_options"));
255 }
256
257 #[test]
258 fn test_begin_auth_uses_scope_override() {
259 let config = create_test_config();
260 let shop = create_test_shop();
261 let custom_scopes: AuthScopes = "read_customers".parse().unwrap();
262
263 let result = begin_auth(&config, &shop, "/callback", true, Some(&custom_scopes)).unwrap();
264
265 // Should use custom scopes, not config scopes
266 assert!(result.auth_url.contains("read_customers"));
267 // Should not contain the config scopes
268 assert!(!result.auth_url.contains("write_orders"));
269 }
270
271 #[test]
272 fn test_begin_auth_returns_state() {
273 let config = create_test_config();
274 let shop = create_test_shop();
275
276 let result = begin_auth(&config, &shop, "/callback", true, None).unwrap();
277
278 // State should be a 15-char alphanumeric nonce
279 let nonce = result.state.nonce();
280 assert_eq!(nonce.len(), 15);
281 assert!(nonce.chars().all(|c| c.is_ascii_alphanumeric()));
282 }
283
284 #[test]
285 fn test_begin_auth_state_in_url_matches_returned_state() {
286 let config = create_test_config();
287 let shop = create_test_shop();
288
289 let result = begin_auth(&config, &shop, "/callback", true, None).unwrap();
290
291 // URL should contain the state value
292 assert!(result.auth_url.contains(&format!(
293 "state={}",
294 urlencoding::encode(result.state.as_ref())
295 )));
296 }
297
298 #[test]
299 fn test_begin_auth_redirect_uri_format() {
300 let config = create_test_config();
301 let shop = create_test_shop();
302
303 let result = begin_auth(&config, &shop, "/auth/callback", true, None).unwrap();
304
305 // Redirect URI should be host + path, URL encoded
306 let expected = urlencoding::encode("https://myapp.example.com/auth/callback");
307 assert!(result
308 .auth_url
309 .contains(&format!("redirect_uri={expected}")));
310 }
311
312 #[test]
313 fn test_begin_auth_fails_without_host() {
314 let config = ShopifyConfig::builder()
315 .api_key(ApiKey::new("key").unwrap())
316 .api_secret_key(ApiSecretKey::new("secret").unwrap())
317 // No host configured
318 .build()
319 .unwrap();
320
321 let shop = create_test_shop();
322
323 let result = begin_auth(&config, &shop, "/callback", true, None);
324
325 assert!(matches!(result, Err(OAuthError::MissingHostConfig)));
326 }
327
328 #[test]
329 fn test_begin_auth_result_is_send_sync() {
330 fn assert_send_sync<T: Send + Sync>() {}
331 assert_send_sync::<BeginAuthResult>();
332 }
333
334 #[test]
335 fn test_begin_auth_with_different_shops() {
336 let config = create_test_config();
337
338 let shop1 = ShopDomain::new("shop-one").unwrap();
339 let shop2 = ShopDomain::new("shop-two").unwrap();
340
341 let result1 = begin_auth(&config, &shop1, "/callback", true, None).unwrap();
342 let result2 = begin_auth(&config, &shop2, "/callback", true, None).unwrap();
343
344 assert!(result1.auth_url.contains("shop-one.myshopify.com"));
345 assert!(result2.auth_url.contains("shop-two.myshopify.com"));
346 }
347
348 #[test]
349 fn test_begin_auth_unique_states() {
350 let config = create_test_config();
351 let shop = create_test_shop();
352
353 let result1 = begin_auth(&config, &shop, "/callback", true, None).unwrap();
354 let result2 = begin_auth(&config, &shop, "/callback", true, None).unwrap();
355
356 // Each call should generate a unique state
357 assert_ne!(result1.state.as_ref(), result2.state.as_ref());
358 }
359}