use crate::auth::oauth::jwt_payload::JwtPayload;
use crate::auth::oauth::OAuthError;
use crate::auth::session::AccessTokenResponse;
use crate::auth::Session;
use crate::config::{ShopDomain, ShopifyConfig};
use serde::{Deserialize, Serialize};
const TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
const ID_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:id_token";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum RequestedTokenType {
OnlineAccessToken,
OfflineAccessToken,
}
impl RequestedTokenType {
pub(super) const fn as_urn(self) -> &'static str {
match self {
Self::OnlineAccessToken => "urn:shopify:params:oauth:token-type:online-access-token",
Self::OfflineAccessToken => "urn:shopify:params:oauth:token-type:offline-access-token",
}
}
}
#[derive(Debug, Serialize)]
struct TokenExchangeRequest<'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,
}
#[derive(Debug, Deserialize)]
struct TokenExchangeErrorResponse {
error: Option<String>,
}
pub async fn exchange_online_token(
config: &ShopifyConfig,
shop: &ShopDomain,
session_token: &str,
) -> Result<Session, OAuthError> {
exchange_token(
config,
shop,
session_token,
RequestedTokenType::OnlineAccessToken,
)
.await
}
pub async fn exchange_offline_token(
config: &ShopifyConfig,
shop: &ShopDomain,
session_token: &str,
) -> Result<Session, OAuthError> {
exchange_token(
config,
shop,
session_token,
RequestedTokenType::OfflineAccessToken,
)
.await
}
async fn exchange_token(
config: &ShopifyConfig,
shop: &ShopDomain,
session_token: &str,
requested_token_type: RequestedTokenType,
) -> Result<Session, OAuthError> {
if !config.is_embedded() {
return Err(OAuthError::NotEmbeddedApp);
}
let _jwt_payload = JwtPayload::decode(session_token, config)?;
let token_url = format!("https://{}/admin/oauth/access_token", shop.as_ref());
let request_body = TokenExchangeRequest {
client_id: config.api_key().as_ref(),
client_secret: config.api_secret_key().as_ref(),
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
subject_token: session_token,
subject_token_type: ID_TOKEN_TYPE,
requested_token_type: requested_token_type.as_urn(),
};
let client = reqwest::Client::new();
let response = client
.post(&token_url)
.json(&request_body)
.send()
.await
.map_err(|e| OAuthError::TokenExchangeFailed {
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();
if status == 400 {
if let Ok(error_response) =
serde_json::from_str::<TokenExchangeErrorResponse>(&error_body)
{
if error_response.error.as_deref() == Some("invalid_subject_token") {
return Err(OAuthError::InvalidJwt {
reason: "Session token was rejected by token exchange".to_string(),
});
}
}
}
return Err(OAuthError::TokenExchangeFailed {
status,
message: error_body,
});
}
let token_response: AccessTokenResponse =
response
.json()
.await
.map_err(|e| OAuthError::TokenExchangeFailed {
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::<RequestedTokenType>();
};
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ApiKey, ApiSecretKey};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[derive(Debug, Serialize)]
struct TestJwtClaims {
iss: String,
dest: String,
aud: String,
sub: Option<String>,
exp: i64,
nbf: i64,
iat: i64,
jti: String,
sid: Option<String>,
}
fn current_timestamp() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
fn create_embedded_config(secret: &str) -> ShopifyConfig {
ShopifyConfig::builder()
.api_key(ApiKey::new("test-api-key").unwrap())
.api_secret_key(ApiSecretKey::new(secret).unwrap())
.is_embedded(true)
.build()
.unwrap()
}
fn create_non_embedded_config(secret: &str) -> ShopifyConfig {
ShopifyConfig::builder()
.api_key(ApiKey::new("test-api-key").unwrap())
.api_secret_key(ApiSecretKey::new(secret).unwrap())
.is_embedded(false)
.build()
.unwrap()
}
fn create_valid_jwt(shop: &str, secret: &str) -> String {
let now = current_timestamp();
let claims = TestJwtClaims {
iss: format!("https://{shop}/admin"),
dest: format!("https://{shop}"),
aud: "test-api-key".to_string(),
sub: Some("12345".to_string()),
exp: now + 300,
nbf: now - 10,
iat: now,
jti: "unique-jwt-id".to_string(),
sid: Some("session-id".to_string()),
};
let header = Header::new(Algorithm::HS256);
let key = EncodingKey::from_secret(secret.as_bytes());
encode(&header, &claims, &key).unwrap()
}
#[tokio::test]
async fn test_not_embedded_app_error_when_config_is_not_embedded() {
let config = create_non_embedded_config("test-secret");
let shop = ShopDomain::new("test-shop").unwrap();
let token = create_valid_jwt("test-shop.myshopify.com", "test-secret");
let result = exchange_offline_token(&config, &shop, &token).await;
assert!(matches!(result, Err(OAuthError::NotEmbeddedApp)));
}
#[tokio::test]
async fn test_invalid_jwt_error_when_session_token_is_invalid() {
let config = create_embedded_config("test-secret");
let shop = ShopDomain::new("test-shop").unwrap();
let result = exchange_offline_token(&config, &shop, "invalid-token").await;
assert!(matches!(result, Err(OAuthError::InvalidJwt { .. })));
}
#[tokio::test]
async fn test_successful_offline_token_exchange_returns_correct_session() {
let mock_server = MockServer::start().await;
let shop_domain = format!("localhost:{}", mock_server.address().port());
let secret = "test-secret";
let config = create_embedded_config(secret);
let now = current_timestamp();
let claims = TestJwtClaims {
iss: format!("https://{shop_domain}/admin"),
dest: format!("https://{shop_domain}"),
aud: "test-api-key".to_string(),
sub: None,
exp: now + 300,
nbf: now - 10,
iat: now,
jti: "unique-jwt-id".to_string(),
sid: Some("session-id".to_string()),
};
let header = Header::new(Algorithm::HS256);
let key = EncodingKey::from_secret(secret.as_bytes());
let session_token = encode(&header, &claims, &key).unwrap();
Mock::given(method("POST"))
.and(path("/admin/oauth/access_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "offline-access-token",
"scope": "read_products,write_orders"
})))
.mount(&mock_server)
.await;
let shop = ShopDomain::new("test-shop").unwrap();
let result = exchange_offline_token(&config, &shop, &session_token).await;
assert!(matches!(
result,
Err(OAuthError::TokenExchangeFailed { .. })
));
}
#[tokio::test]
async fn test_successful_online_token_exchange_returns_session_with_associated_user() {
let secret = "test-secret";
let config = create_embedded_config(secret);
let shop = ShopDomain::new("test-shop").unwrap();
let session_token = create_valid_jwt("test-shop.myshopify.com", secret);
let result = exchange_online_token(&config, &shop, &session_token).await;
assert!(matches!(
result,
Err(OAuthError::TokenExchangeFailed { .. })
));
}
#[tokio::test]
async fn test_http_400_with_invalid_subject_token_maps_to_invalid_jwt() {
let error_json = r#"{"error": "invalid_subject_token"}"#;
let parsed: Result<TokenExchangeErrorResponse, _> = serde_json::from_str(error_json);
assert!(parsed.is_ok());
let error_response = parsed.unwrap();
assert_eq!(
error_response.error,
Some("invalid_subject_token".to_string())
);
}
#[tokio::test]
async fn test_other_http_errors_map_to_token_exchange_failed() {
let secret = "test-secret";
let config = create_embedded_config(secret);
let shop = ShopDomain::new("test-shop").unwrap();
let session_token = create_valid_jwt("test-shop.myshopify.com", secret);
let result = exchange_offline_token(&config, &shop, &session_token).await;
assert!(matches!(
result,
Err(OAuthError::TokenExchangeFailed { .. })
));
}
#[tokio::test]
async fn test_request_body_contains_correct_grant_type_and_token_types() {
assert_eq!(
TOKEN_EXCHANGE_GRANT_TYPE,
"urn:ietf:params:oauth:grant-type:token-exchange"
);
assert_eq!(ID_TOKEN_TYPE, "urn:ietf:params:oauth:token-type:id_token");
assert_eq!(
RequestedTokenType::OnlineAccessToken.as_urn(),
"urn:shopify:params:oauth:token-type:online-access-token"
);
assert_eq!(
RequestedTokenType::OfflineAccessToken.as_urn(),
"urn:shopify:params:oauth:token-type:offline-access-token"
);
}
#[tokio::test]
async fn test_session_created_using_from_access_token_response() {
let shop = ShopDomain::new("test-shop").unwrap();
let response = AccessTokenResponse {
access_token: "test-token".to_string(),
scope: "read_products".to_string(),
expires_in: None,
associated_user_scope: None,
associated_user: None,
session: None,
refresh_token: None,
refresh_token_expires_in: None,
};
let session = Session::from_access_token_response(shop, &response);
assert_eq!(session.access_token, "test-token");
assert!(!session.is_online);
}
#[test]
fn test_requested_token_type_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<RequestedTokenType>();
}
}