use super::token_exchange::RequestedTokenType;
use crate::auth::oauth::OAuthError;
use crate::auth::session::AccessTokenResponse;
use crate::auth::Session;
use crate::config::{ShopDomain, ShopifyConfig};
use serde::Serialize;
const TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
const REFRESH_TOKEN_GRANT_TYPE: &str = "refresh_token";
#[derive(Debug, Serialize)]
struct TokenRefreshRequest<'a> {
client_id: &'a str,
client_secret: &'a str,
grant_type: &'a str,
refresh_token: &'a str,
}
#[derive(Debug, Serialize)]
struct MigrateTokenRequest<'a> {
client_id: &'a str,
client_secret: &'a str,
grant_type: &'a str,
subject_token: &'a str,
subject_token_type: &'a str,
requested_token_type: &'a str,
expiring: &'a str,
}
pub async fn refresh_access_token(
config: &ShopifyConfig,
shop: &ShopDomain,
refresh_token: &str,
) -> Result<Session, OAuthError> {
let token_url = format!("https://{}/admin/oauth/access_token", shop.as_ref());
let request_body = TokenRefreshRequest {
client_id: config.api_key().as_ref(),
client_secret: config.api_secret_key().as_ref(),
grant_type: REFRESH_TOKEN_GRANT_TYPE,
refresh_token,
};
let client = reqwest::Client::new();
let response = client
.post(&token_url)
.json(&request_body)
.send()
.await
.map_err(|e| OAuthError::TokenRefreshFailed {
status: 0,
message: format!("Network error: {e}"),
})?;
let status = response.status().as_u16();
if !response.status().is_success() {
let error_body = response.text().await.unwrap_or_default();
return Err(OAuthError::TokenRefreshFailed {
status,
message: error_body,
});
}
let token_response: AccessTokenResponse =
response
.json()
.await
.map_err(|e| OAuthError::TokenRefreshFailed {
status,
message: format!("Failed to parse token response: {e}"),
})?;
let session = Session::from_access_token_response(shop.clone(), &token_response);
Ok(session)
}
pub async fn migrate_to_expiring_token(
config: &ShopifyConfig,
shop: &ShopDomain,
access_token: &str,
) -> Result<Session, OAuthError> {
let token_url = format!("https://{}/admin/oauth/access_token", shop.as_ref());
let offline_token_urn = RequestedTokenType::OfflineAccessToken.as_urn();
let request_body = MigrateTokenRequest {
client_id: config.api_key().as_ref(),
client_secret: config.api_secret_key().as_ref(),
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
subject_token: access_token,
subject_token_type: offline_token_urn,
requested_token_type: offline_token_urn,
expiring: "1",
};
let client = reqwest::Client::new();
let response = client
.post(&token_url)
.json(&request_body)
.send()
.await
.map_err(|e| OAuthError::TokenRefreshFailed {
status: 0,
message: format!("Network error: {e}"),
})?;
let status = response.status().as_u16();
if !response.status().is_success() {
let error_body = response.text().await.unwrap_or_default();
return Err(OAuthError::TokenRefreshFailed {
status,
message: error_body,
});
}
let token_response: AccessTokenResponse =
response
.json()
.await
.map_err(|e| OAuthError::TokenRefreshFailed {
status,
message: format!("Failed to parse token response: {e}"),
})?;
let session = Session::from_access_token_response(shop.clone(), &token_response);
Ok(session)
}
const _: fn() = || {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<TokenRefreshRequest<'_>>();
assert_send_sync::<MigrateTokenRequest<'_>>();
};
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ApiKey, ApiSecretKey};
fn create_config() -> ShopifyConfig {
ShopifyConfig::builder()
.api_key(ApiKey::new("test-api-key").unwrap())
.api_secret_key(ApiSecretKey::new("test-secret").unwrap())
.build()
.unwrap()
}
#[test]
fn test_token_refresh_request_serializes_with_correct_grant_type() {
let request = TokenRefreshRequest {
client_id: "test-client-id",
client_secret: "test-client-secret",
grant_type: REFRESH_TOKEN_GRANT_TYPE,
refresh_token: "test-refresh-token",
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"grant_type\":\"refresh_token\""));
assert!(json.contains("\"client_id\":\"test-client-id\""));
assert!(json.contains("\"client_secret\":\"test-client-secret\""));
assert!(json.contains("\"refresh_token\":\"test-refresh-token\""));
}
#[test]
fn test_refresh_token_grant_type_constant_is_correct() {
assert_eq!(REFRESH_TOKEN_GRANT_TYPE, "refresh_token");
}
#[test]
fn test_migrate_token_request_serializes_with_correct_fields() {
let request = MigrateTokenRequest {
client_id: "test-client-id",
client_secret: "test-client-secret",
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
subject_token: "old-access-token",
subject_token_type: RequestedTokenType::OfflineAccessToken.as_urn(),
requested_token_type: RequestedTokenType::OfflineAccessToken.as_urn(),
expiring: "1",
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"grant_type\":\"urn:ietf:params:oauth:grant-type:token-exchange\""));
assert!(json.contains("\"expiring\":\"1\""));
assert!(json.contains("\"subject_token\":\"old-access-token\""));
assert!(json.contains(
"\"subject_token_type\":\"urn:shopify:params:oauth:token-type:offline-access-token\""
));
assert!(json.contains(
"\"requested_token_type\":\"urn:shopify:params:oauth:token-type:offline-access-token\""
));
}
#[test]
fn test_token_exchange_grant_type_constant_is_correct() {
assert_eq!(
TOKEN_EXCHANGE_GRANT_TYPE,
"urn:ietf:params:oauth:grant-type:token-exchange"
);
}
#[tokio::test]
async fn test_refresh_access_token_returns_token_refresh_failed_error_on_failure() {
let config = create_config();
let shop = ShopDomain::new("test-shop").unwrap();
let result = refresh_access_token(&config, &shop, "test-refresh-token").await;
match result {
Err(OAuthError::TokenRefreshFailed { status, message }) => {
assert!(status == 0 || status >= 400);
assert!(!message.is_empty());
}
_ => panic!("Expected TokenRefreshFailed error"),
}
}
#[tokio::test]
async fn test_refresh_access_token_constructs_correct_url() {
let config = create_config();
let shop = ShopDomain::new("my-test-shop").unwrap();
let result = refresh_access_token(&config, &shop, "test-refresh-token").await;
assert!(matches!(result, Err(OAuthError::TokenRefreshFailed { .. })));
}
#[tokio::test]
async fn test_migrate_to_expiring_token_returns_token_refresh_failed_error_on_failure() {
let config = create_config();
let shop = ShopDomain::new("test-shop").unwrap();
let result = migrate_to_expiring_token(&config, &shop, "old-access-token").await;
match result {
Err(OAuthError::TokenRefreshFailed { status, message }) => {
assert!(status == 0 || status >= 400);
assert!(!message.is_empty());
}
_ => panic!("Expected TokenRefreshFailed error"),
}
}
#[tokio::test]
async fn test_migrate_to_expiring_token_constructs_correct_url() {
let config = create_config();
let shop = ShopDomain::new("my-test-shop").unwrap();
let result = migrate_to_expiring_token(&config, &shop, "old-token").await;
assert!(matches!(result, Err(OAuthError::TokenRefreshFailed { .. })));
}
#[test]
fn test_token_refresh_request_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<TokenRefreshRequest<'_>>();
}
#[test]
fn test_migrate_token_request_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<MigrateTokenRequest<'_>>();
}
#[test]
fn test_session_from_refresh_response_populates_all_fields() {
let shop = ShopDomain::new("test-shop").unwrap();
let response = AccessTokenResponse {
access_token: "new-access-token".to_string(),
scope: "read_products,write_orders".to_string(),
expires_in: Some(86400),
associated_user_scope: None,
associated_user: None,
session: Some("shopify-session-id".to_string()),
refresh_token: Some("new-refresh-token".to_string()),
refresh_token_expires_in: Some(2592000),
};
let session = Session::from_access_token_response(shop, &response);
assert_eq!(session.access_token, "new-access-token");
assert_eq!(session.refresh_token, Some("new-refresh-token".to_string()));
assert!(session.expires.is_some());
assert!(session.refresh_token_expires_at.is_some());
assert!(!session.is_online);
assert_eq!(session.id, "offline_test-shop.myshopify.com");
}
}