Skip to main content

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}