use crate::auth::oauth::error::OAuthError;
use crate::auth::oauth::state::StateParam;
use crate::auth::AuthScopes;
use crate::config::{ShopDomain, ShopifyConfig};
#[derive(Clone, Debug)]
pub struct BeginAuthResult {
pub auth_url: String,
pub state: StateParam,
}
pub fn begin_auth(
config: &ShopifyConfig,
shop: &ShopDomain,
redirect_path: &str,
is_online: bool,
scope_override: Option<&AuthScopes>,
) -> Result<BeginAuthResult, OAuthError> {
let host = config.host().ok_or(OAuthError::MissingHostConfig)?;
let state = StateParam::new();
let scopes = scope_override.unwrap_or_else(|| config.scopes());
let redirect_uri = format!("{}{}", host.as_ref(), redirect_path);
let mut params = vec![
("client_id", config.api_key().as_ref().to_string()),
("scope", scopes.to_string()),
("redirect_uri", redirect_uri),
("state", state.to_string()),
];
if is_online {
params.push(("grant_options[]", "per-user".to_string()));
}
let query_string = params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
let auth_url = format!(
"https://{}/admin/oauth/authorize?{}",
shop.as_ref(),
query_string
);
Ok(BeginAuthResult { auth_url, state })
}
const _: fn() = || {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<BeginAuthResult>();
};
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ApiKey, ApiSecretKey, HostUrl};
fn create_test_config() -> ShopifyConfig {
ShopifyConfig::builder()
.api_key(ApiKey::new("test-api-key").unwrap())
.api_secret_key(ApiSecretKey::new("test-secret").unwrap())
.host(HostUrl::new("https://myapp.example.com").unwrap())
.scopes("read_products,write_orders".parse().unwrap())
.build()
.unwrap()
}
fn create_test_shop() -> ShopDomain {
ShopDomain::new("test-shop").unwrap()
}
#[test]
fn test_begin_auth_generates_correct_url_structure() {
let config = create_test_config();
let shop = create_test_shop();
let result = begin_auth(&config, &shop, "/auth/callback", true, None).unwrap();
assert!(result
.auth_url
.starts_with("https://test-shop.myshopify.com/admin/oauth/authorize?"));
}
#[test]
fn test_begin_auth_includes_all_required_params() {
let config = create_test_config();
let shop = create_test_shop();
let result = begin_auth(&config, &shop, "/auth/callback", true, None).unwrap();
assert!(result.auth_url.contains("client_id="));
assert!(result.auth_url.contains("scope="));
assert!(result.auth_url.contains("redirect_uri="));
assert!(result.auth_url.contains("state="));
}
#[test]
fn test_begin_auth_sets_grant_options_for_online() {
let config = create_test_config();
let shop = create_test_shop();
let result = begin_auth(&config, &shop, "/auth/callback", true, None).unwrap();
assert!(result.auth_url.contains("grant_options%5B%5D=per-user"));
}
#[test]
fn test_begin_auth_no_grant_options_for_offline() {
let config = create_test_config();
let shop = create_test_shop();
let result = begin_auth(&config, &shop, "/auth/callback", false, None).unwrap();
assert!(!result.auth_url.contains("grant_options"));
}
#[test]
fn test_begin_auth_uses_scope_override() {
let config = create_test_config();
let shop = create_test_shop();
let custom_scopes: AuthScopes = "read_customers".parse().unwrap();
let result = begin_auth(&config, &shop, "/callback", true, Some(&custom_scopes)).unwrap();
assert!(result.auth_url.contains("read_customers"));
assert!(!result.auth_url.contains("write_orders"));
}
#[test]
fn test_begin_auth_returns_state() {
let config = create_test_config();
let shop = create_test_shop();
let result = begin_auth(&config, &shop, "/callback", true, None).unwrap();
let nonce = result.state.nonce();
assert_eq!(nonce.len(), 15);
assert!(nonce.chars().all(|c| c.is_ascii_alphanumeric()));
}
#[test]
fn test_begin_auth_state_in_url_matches_returned_state() {
let config = create_test_config();
let shop = create_test_shop();
let result = begin_auth(&config, &shop, "/callback", true, None).unwrap();
assert!(result.auth_url.contains(&format!(
"state={}",
urlencoding::encode(result.state.as_ref())
)));
}
#[test]
fn test_begin_auth_redirect_uri_format() {
let config = create_test_config();
let shop = create_test_shop();
let result = begin_auth(&config, &shop, "/auth/callback", true, None).unwrap();
let expected = urlencoding::encode("https://myapp.example.com/auth/callback");
assert!(result
.auth_url
.contains(&format!("redirect_uri={expected}")));
}
#[test]
fn test_begin_auth_fails_without_host() {
let config = ShopifyConfig::builder()
.api_key(ApiKey::new("key").unwrap())
.api_secret_key(ApiSecretKey::new("secret").unwrap())
.build()
.unwrap();
let shop = create_test_shop();
let result = begin_auth(&config, &shop, "/callback", true, None);
assert!(matches!(result, Err(OAuthError::MissingHostConfig)));
}
#[test]
fn test_begin_auth_result_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<BeginAuthResult>();
}
#[test]
fn test_begin_auth_with_different_shops() {
let config = create_test_config();
let shop1 = ShopDomain::new("shop-one").unwrap();
let shop2 = ShopDomain::new("shop-two").unwrap();
let result1 = begin_auth(&config, &shop1, "/callback", true, None).unwrap();
let result2 = begin_auth(&config, &shop2, "/callback", true, None).unwrap();
assert!(result1.auth_url.contains("shop-one.myshopify.com"));
assert!(result2.auth_url.contains("shop-two.myshopify.com"));
}
#[test]
fn test_begin_auth_unique_states() {
let config = create_test_config();
let shop = create_test_shop();
let result1 = begin_auth(&config, &shop, "/callback", true, None).unwrap();
let result2 = begin_auth(&config, &shop, "/callback", true, None).unwrap();
assert_ne!(result1.state.as_ref(), result2.state.as_ref());
}
}