use crate::auth::oauth::OAuthError;
use crate::config::ShopifyConfig;
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::Deserialize;
const JWT_LEEWAY_SECS: u64 = 10;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct JwtPayload {
pub iss: String,
pub dest: String,
pub aud: String,
pub sub: Option<String>,
pub exp: i64,
pub nbf: i64,
pub iat: i64,
pub jti: String,
pub sid: Option<String>,
}
impl JwtPayload {
pub fn decode(token: &str, config: &ShopifyConfig) -> Result<Self, OAuthError> {
let payload = match Self::decode_with_key(token, config.api_secret_key().as_ref()) {
Ok(payload) => payload,
Err(primary_err) => {
if let Some(old_key) = config.old_api_secret_key() {
Self::decode_with_key(token, old_key.as_ref()).map_err(|_| {
OAuthError::InvalidJwt {
reason: format!("Error decoding session token: {primary_err}"),
}
})?
} else {
return Err(OAuthError::InvalidJwt {
reason: format!("Error decoding session token: {primary_err}"),
});
}
}
};
if payload.aud != config.api_key().as_ref() {
return Err(OAuthError::InvalidJwt {
reason: "Session token had invalid API key".to_string(),
});
}
Ok(payload)
}
fn decode_with_key(token: &str, secret: &str) -> Result<Self, jsonwebtoken::errors::Error> {
let mut validation = Validation::new(Algorithm::HS256);
validation.leeway = JWT_LEEWAY_SECS;
validation.validate_aud = false;
let key = DecodingKey::from_secret(secret.as_bytes());
let token_data = decode::<Self>(token, &key, &validation)?;
Ok(token_data.claims)
}
#[must_use]
#[allow(dead_code)] pub fn shop(&self) -> &str {
self.dest
.strip_prefix("https://")
.unwrap_or(self.dest.as_str())
}
#[must_use]
#[allow(dead_code)] pub fn shopify_user_id(&self) -> Option<u64> {
if !self.is_admin_session_token() {
return None;
}
self.sub.as_ref().and_then(|sub| {
if Self::is_numeric(sub) {
sub.parse().ok()
} else {
None
}
})
}
fn is_admin_session_token(&self) -> bool {
self.iss.ends_with("/admin")
}
fn is_numeric(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
}
}
const _: fn() = || {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<JwtPayload>();
};
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ApiKey, ApiSecretKey};
use jsonwebtoken::{encode, EncodingKey, Header};
use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH};
#[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_test_config(secret: &str) -> ShopifyConfig {
ShopifyConfig::builder()
.api_key(ApiKey::new("test-api-key").unwrap())
.api_secret_key(ApiSecretKey::new(secret).unwrap())
.build()
.unwrap()
}
fn create_config_with_old_key(primary_secret: &str, old_secret: &str) -> ShopifyConfig {
ShopifyConfig::builder()
.api_key(ApiKey::new("test-api-key").unwrap())
.api_secret_key(ApiSecretKey::new(primary_secret).unwrap())
.old_api_secret_key(ApiSecretKey::new(old_secret).unwrap())
.build()
.unwrap()
}
fn create_valid_claims() -> TestJwtClaims {
let now = current_timestamp();
TestJwtClaims {
iss: "https://test-shop.myshopify.com/admin".to_string(),
dest: "https://test-shop.myshopify.com".to_string(),
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()),
}
}
fn encode_jwt(claims: &TestJwtClaims, secret: &str) -> String {
let header = Header::new(Algorithm::HS256);
let key = EncodingKey::from_secret(secret.as_bytes());
encode(&header, claims, &key).unwrap()
}
#[test]
fn test_successful_jwt_decode_with_valid_token_and_secret() {
let secret = "test-secret-key";
let config = create_test_config(secret);
let claims = create_valid_claims();
let token = encode_jwt(&claims, secret);
let result = JwtPayload::decode(&token, &config);
assert!(result.is_ok());
let payload = result.unwrap();
assert_eq!(payload.iss, claims.iss);
assert_eq!(payload.dest, claims.dest);
assert_eq!(payload.aud, claims.aud);
assert_eq!(payload.sub, claims.sub);
assert_eq!(payload.jti, claims.jti);
}
#[test]
fn test_dual_key_fallback_fails_primary_succeeds_with_old_key() {
let primary_secret = "new-secret-key";
let old_secret = "old-secret-key";
let config = create_config_with_old_key(primary_secret, old_secret);
let claims = create_valid_claims();
let token = encode_jwt(&claims, old_secret);
let result = JwtPayload::decode(&token, &config);
assert!(result.is_ok());
let payload = result.unwrap();
assert_eq!(payload.aud, "test-api-key");
}
#[test]
fn test_invalid_jwt_error_when_both_keys_fail() {
let primary_secret = "new-secret-key";
let old_secret = "old-secret-key";
let config = create_config_with_old_key(primary_secret, old_secret);
let claims = create_valid_claims();
let token = encode_jwt(&claims, "wrong-secret-key");
let result = JwtPayload::decode(&token, &config);
assert!(matches!(result, Err(OAuthError::InvalidJwt { .. })));
if let Err(OAuthError::InvalidJwt { reason }) = result {
assert!(reason.contains("Error decoding session token"));
}
}
#[test]
fn test_invalid_jwt_error_when_aud_claim_doesnt_match_api_key() {
let secret = "test-secret-key";
let config = create_test_config(secret);
let mut claims = create_valid_claims();
claims.aud = "wrong-api-key".to_string();
let token = encode_jwt(&claims, secret);
let result = JwtPayload::decode(&token, &config);
assert!(matches!(result, Err(OAuthError::InvalidJwt { .. })));
if let Err(OAuthError::InvalidJwt { reason }) = result {
assert_eq!(reason, "Session token had invalid API key");
}
}
#[test]
fn test_shop_method_extracts_domain_from_dest_claim() {
let secret = "test-secret-key";
let config = create_test_config(secret);
let claims = create_valid_claims();
let token = encode_jwt(&claims, secret);
let payload = JwtPayload::decode(&token, &config).unwrap();
assert_eq!(payload.shop(), "test-shop.myshopify.com");
}
#[test]
fn test_shopify_user_id_returns_some_for_numeric_sub_when_iss_ends_with_admin() {
let secret = "test-secret-key";
let config = create_test_config(secret);
let mut claims = create_valid_claims();
claims.iss = "https://test-shop.myshopify.com/admin".to_string();
claims.sub = Some("12345".to_string());
let token = encode_jwt(&claims, secret);
let payload = JwtPayload::decode(&token, &config).unwrap();
assert_eq!(payload.shopify_user_id(), Some(12345));
}
#[test]
fn test_shopify_user_id_returns_none_for_non_numeric_sub() {
let secret = "test-secret-key";
let config = create_test_config(secret);
let mut claims = create_valid_claims();
claims.iss = "https://test-shop.myshopify.com/admin".to_string();
claims.sub = Some("not-a-number".to_string());
let token = encode_jwt(&claims, secret);
let payload = JwtPayload::decode(&token, &config).unwrap();
assert_eq!(payload.shopify_user_id(), None);
}
#[test]
fn test_shopify_user_id_returns_none_when_iss_doesnt_end_with_admin() {
let secret = "test-secret-key";
let config = create_test_config(secret);
let mut claims = create_valid_claims();
claims.iss = "https://test-shop.myshopify.com".to_string(); claims.sub = Some("12345".to_string());
let token = encode_jwt(&claims, secret);
let payload = JwtPayload::decode(&token, &config).unwrap();
assert_eq!(payload.shopify_user_id(), None);
}
#[test]
fn test_jwt_payload_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<JwtPayload>();
}
#[test]
fn test_expired_token_fails_validation() {
let secret = "test-secret-key";
let config = create_test_config(secret);
let mut claims = create_valid_claims();
claims.exp = current_timestamp() - 3600;
let token = encode_jwt(&claims, secret);
let result = JwtPayload::decode(&token, &config);
assert!(matches!(result, Err(OAuthError::InvalidJwt { .. })));
}
#[test]
fn test_token_within_leeway_is_accepted() {
let secret = "test-secret-key";
let config = create_test_config(secret);
let mut claims = create_valid_claims();
claims.exp = current_timestamp() - 5;
let token = encode_jwt(&claims, secret);
let result = JwtPayload::decode(&token, &config);
assert!(result.is_ok());
}
#[test]
fn test_shopify_user_id_returns_none_when_sub_is_none() {
let secret = "test-secret-key";
let config = create_test_config(secret);
let mut claims = create_valid_claims();
claims.iss = "https://test-shop.myshopify.com/admin".to_string();
claims.sub = None;
let token = encode_jwt(&claims, secret);
let payload = JwtPayload::decode(&token, &config).unwrap();
assert_eq!(payload.shopify_user_id(), None);
}
}