use crate::auth::associated_user::AssociatedUser;
use crate::auth::AuthScopes;
use crate::config::ShopDomain;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
const REFRESH_TOKEN_EXPIRY_BUFFER_SECONDS: i64 = 60;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub shop: ShopDomain,
pub access_token: String,
pub scopes: AuthScopes,
pub is_online: bool,
pub expires: Option<DateTime<Utc>>,
pub state: Option<String>,
pub shopify_session_id: Option<String>,
pub associated_user: Option<AssociatedUser>,
pub associated_user_scopes: Option<AuthScopes>,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub refresh_token_expires_at: Option<DateTime<Utc>>,
}
impl Session {
#[must_use]
pub const fn new(
id: String,
shop: ShopDomain,
access_token: String,
scopes: AuthScopes,
is_online: bool,
expires: Option<DateTime<Utc>>,
) -> Self {
Self {
id,
shop,
access_token,
scopes,
is_online,
expires,
state: None,
shopify_session_id: None,
associated_user: None,
associated_user_scopes: None,
refresh_token: None,
refresh_token_expires_at: None,
}
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub const fn with_user(
id: String,
shop: ShopDomain,
access_token: String,
scopes: AuthScopes,
expires: Option<DateTime<Utc>>,
associated_user: AssociatedUser,
associated_user_scopes: Option<AuthScopes>,
) -> Self {
Self {
id,
shop,
access_token,
scopes,
is_online: true,
expires,
state: None,
shopify_session_id: None,
associated_user: Some(associated_user),
associated_user_scopes,
refresh_token: None,
refresh_token_expires_at: None,
}
}
#[must_use]
pub fn generate_offline_id(shop: &ShopDomain) -> String {
format!("offline_{}", shop.as_ref())
}
#[must_use]
pub fn generate_online_id(shop: &ShopDomain, user_id: u64) -> String {
format!("{}_{}", shop.as_ref(), user_id)
}
#[must_use]
pub fn from_access_token_response(shop: ShopDomain, response: &AccessTokenResponse) -> Self {
let is_online = response.associated_user.is_some();
let id = response.associated_user.as_ref().map_or_else(
|| Self::generate_offline_id(&shop),
|user| Self::generate_online_id(&shop, user.id),
);
let scopes: AuthScopes = response.scope.parse().unwrap_or_default();
let expires = response
.expires_in
.map(|secs| Utc::now() + Duration::seconds(i64::from(secs)));
let associated_user_scopes = response
.associated_user_scope
.as_ref()
.and_then(|s| s.parse().ok());
let associated_user = response.associated_user.as_ref().map(|u| AssociatedUser {
id: u.id,
first_name: u.first_name.clone(),
last_name: u.last_name.clone(),
email: u.email.clone(),
email_verified: u.email_verified,
account_owner: u.account_owner,
locale: u.locale.clone(),
collaborator: u.collaborator,
});
let refresh_token = response.refresh_token.clone();
let refresh_token_expires_at = response
.refresh_token_expires_in
.map(|secs| Utc::now() + Duration::seconds(i64::from(secs)));
Self {
id,
shop,
access_token: response.access_token.clone(),
scopes,
is_online,
expires,
state: None,
shopify_session_id: response.session.clone(),
associated_user,
associated_user_scopes,
refresh_token,
refresh_token_expires_at,
}
}
#[must_use]
pub fn expired(&self) -> bool {
self.expires.is_some_and(|expires| Utc::now() > expires)
}
#[must_use]
pub fn is_active(&self) -> bool {
!self.access_token.is_empty() && !self.expired()
}
#[must_use]
pub fn refresh_token_expired(&self) -> bool {
self.refresh_token_expires_at.is_some_and(|expires_at| {
let buffer = Duration::seconds(REFRESH_TOKEN_EXPIRY_BUFFER_SECONDS);
Utc::now() + buffer > expires_at
})
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct AccessTokenResponse {
pub access_token: String,
pub scope: String,
pub expires_in: Option<u32>,
pub associated_user_scope: Option<String>,
pub associated_user: Option<AssociatedUserResponse>,
#[serde(rename = "session")]
pub session: Option<String>,
pub refresh_token: Option<String>,
pub refresh_token_expires_in: Option<u32>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AssociatedUserResponse {
pub id: u64,
pub first_name: String,
pub last_name: String,
pub email: String,
pub email_verified: bool,
pub account_owner: bool,
pub locale: String,
pub collaborator: bool,
}
const _: fn() = || {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Session>();
};
#[cfg(test)]
mod tests {
use super::*;
fn sample_shop() -> ShopDomain {
ShopDomain::new("my-store").unwrap()
}
fn sample_scopes() -> AuthScopes {
"read_products,write_orders".parse().unwrap()
}
fn sample_user() -> AssociatedUser {
AssociatedUser::new(
12345,
"Jane".to_string(),
"Doe".to_string(),
"jane@example.com".to_string(),
true,
true,
"en".to_string(),
false,
)
}
#[test]
fn test_session_expired() {
let expired = Session::new(
"id".to_string(),
ShopDomain::new("shop").unwrap(),
"token".to_string(),
AuthScopes::new(),
false,
Some(Utc::now() - Duration::hours(1)),
);
assert!(expired.expired());
let valid = Session::new(
"id".to_string(),
ShopDomain::new("shop").unwrap(),
"token".to_string(),
AuthScopes::new(),
false,
Some(Utc::now() + Duration::hours(1)),
);
assert!(!valid.expired());
let no_expiry = Session::new(
"id".to_string(),
ShopDomain::new("shop").unwrap(),
"token".to_string(),
AuthScopes::new(),
false,
None,
);
assert!(!no_expiry.expired());
}
#[test]
fn test_session_is_active() {
let active = Session::new(
"id".to_string(),
ShopDomain::new("shop").unwrap(),
"token".to_string(),
AuthScopes::new(),
false,
None,
);
assert!(active.is_active());
let no_token = Session::new(
"id".to_string(),
ShopDomain::new("shop").unwrap(),
String::new(),
AuthScopes::new(),
false,
None,
);
assert!(!no_token.is_active());
let expired = Session::new(
"id".to_string(),
ShopDomain::new("shop").unwrap(),
"token".to_string(),
AuthScopes::new(),
false,
Some(Utc::now() - Duration::hours(1)),
);
assert!(!expired.is_active());
}
#[test]
fn test_session_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Session>();
}
#[test]
fn test_session_with_associated_user_field() {
let user = sample_user();
let session = Session::with_user(
"test-session".to_string(),
sample_shop(),
"access-token".to_string(),
sample_scopes(),
Some(Utc::now() + Duration::hours(1)),
user.clone(),
None,
);
assert!(session.associated_user.is_some());
let stored_user = session.associated_user.unwrap();
assert_eq!(stored_user.id, 12345);
assert_eq!(stored_user.first_name, "Jane");
assert_eq!(stored_user.email, "jane@example.com");
}
#[test]
fn test_session_with_associated_user_scopes_field() {
let user = sample_user();
let user_scopes: AuthScopes = "read_products".parse().unwrap();
let session = Session::with_user(
"test-session".to_string(),
sample_shop(),
"access-token".to_string(),
sample_scopes(),
None,
user,
Some(user_scopes.clone()),
);
assert!(session.associated_user_scopes.is_some());
let stored_scopes = session.associated_user_scopes.unwrap();
assert!(stored_scopes.iter().any(|s| s == "read_products"));
}
#[test]
fn test_session_serialization_to_json() {
let session = Session::new(
"offline_my-store.myshopify.com".to_string(),
sample_shop(),
"access-token".to_string(),
sample_scopes(),
false,
None,
);
let json = serde_json::to_string(&session).unwrap();
assert!(json.contains("offline_my-store.myshopify.com"));
assert!(json.contains("access-token"));
assert!(json.contains("my-store.myshopify.com"));
}
#[test]
fn test_session_deserialization_from_json() {
let json = r#"{
"id": "test-session",
"shop": "test-shop.myshopify.com",
"access_token": "token123",
"scopes": "read_products",
"is_online": false,
"expires": null,
"state": null,
"shopify_session_id": null,
"associated_user": null,
"associated_user_scopes": null
}"#;
let session: Session = serde_json::from_str(json).unwrap();
assert_eq!(session.id, "test-session");
assert_eq!(session.access_token, "token123");
assert!(!session.is_online);
assert!(session.associated_user.is_none());
assert!(session.refresh_token.is_none());
assert!(session.refresh_token_expires_at.is_none());
}
#[test]
fn test_session_equality_comparison() {
let session1 = Session::new(
"id".to_string(),
sample_shop(),
"token".to_string(),
sample_scopes(),
false,
None,
);
let session2 = Session::new(
"id".to_string(),
sample_shop(),
"token".to_string(),
sample_scopes(),
false,
None,
);
assert_eq!(session1, session2);
let session3 = Session::new(
"different-id".to_string(),
sample_shop(),
"token".to_string(),
sample_scopes(),
false,
None,
);
assert_ne!(session1, session3);
}
#[test]
fn test_session_clone_preserves_all_fields() {
let user = sample_user();
let session = Session::with_user(
"test-id".to_string(),
sample_shop(),
"token".to_string(),
sample_scopes(),
Some(Utc::now() + Duration::hours(1)),
user,
Some("read_products".parse().unwrap()),
);
let cloned = session.clone();
assert_eq!(session.id, cloned.id);
assert_eq!(session.shop, cloned.shop);
assert_eq!(session.access_token, cloned.access_token);
assert_eq!(session.scopes, cloned.scopes);
assert_eq!(session.is_online, cloned.is_online);
assert_eq!(session.expires, cloned.expires);
assert_eq!(session.associated_user, cloned.associated_user);
assert_eq!(
session.associated_user_scopes,
cloned.associated_user_scopes
);
}
#[test]
fn test_generate_offline_id_produces_correct_format() {
let shop = ShopDomain::new("my-store").unwrap();
let id = Session::generate_offline_id(&shop);
assert_eq!(id, "offline_my-store.myshopify.com");
}
#[test]
fn test_generate_online_id_produces_correct_format() {
let shop = ShopDomain::new("my-store").unwrap();
let id = Session::generate_online_id(&shop, 12345);
assert_eq!(id, "my-store.myshopify.com_12345");
}
#[test]
fn test_from_access_token_response_with_offline_response() {
let shop = ShopDomain::new("my-store").unwrap();
let response = AccessTokenResponse {
access_token: "offline-token".to_string(),
scope: "read_products,write_orders".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!(!session.is_online);
assert_eq!(session.id, "offline_my-store.myshopify.com");
assert_eq!(session.access_token, "offline-token");
assert!(session.associated_user.is_none());
assert!(session.expires.is_none());
}
#[test]
fn test_from_access_token_response_with_online_response() {
let shop = ShopDomain::new("my-store").unwrap();
let response = AccessTokenResponse {
access_token: "online-token".to_string(),
scope: "read_products".to_string(),
expires_in: Some(3600),
associated_user_scope: Some("read_products".to_string()),
associated_user: Some(AssociatedUserResponse {
id: 12345,
first_name: "Jane".to_string(),
last_name: "Doe".to_string(),
email: "jane@example.com".to_string(),
email_verified: true,
account_owner: true,
locale: "en".to_string(),
collaborator: false,
}),
session: Some("shopify-session-id".to_string()),
refresh_token: None,
refresh_token_expires_in: None,
};
let session = Session::from_access_token_response(shop, &response);
assert!(session.is_online);
assert_eq!(session.id, "my-store.myshopify.com_12345");
assert_eq!(session.access_token, "online-token");
assert!(session.associated_user.is_some());
assert!(session.expires.is_some());
assert_eq!(
session.shopify_session_id,
Some("shopify-session-id".to_string())
);
let user = session.associated_user.unwrap();
assert_eq!(user.id, 12345);
assert_eq!(user.email, "jane@example.com");
}
#[test]
fn test_from_access_token_response_calculates_expires() {
let shop = ShopDomain::new("my-store").unwrap();
let response = AccessTokenResponse {
access_token: "token".to_string(),
scope: "read_products".to_string(),
expires_in: Some(3600), associated_user_scope: None,
associated_user: Some(AssociatedUserResponse {
id: 1,
first_name: "Test".to_string(),
last_name: "User".to_string(),
email: "test@example.com".to_string(),
email_verified: true,
account_owner: false,
locale: "en".to_string(),
collaborator: false,
}),
session: None,
refresh_token: None,
refresh_token_expires_in: None,
};
let before = Utc::now();
let session = Session::from_access_token_response(shop, &response);
let after = Utc::now();
assert!(session.expires.is_some());
let expires = session.expires.unwrap();
let expected_min = before + Duration::seconds(3600);
let expected_max = after + Duration::seconds(3600);
assert!(expires >= expected_min && expires <= expected_max);
}
#[test]
fn test_from_access_token_response_parses_scopes() {
let shop = ShopDomain::new("my-store").unwrap();
let response = AccessTokenResponse {
access_token: "token".to_string(),
scope: "read_products,write_orders".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!(session.scopes.iter().any(|s| s == "read_products"));
assert!(session.scopes.iter().any(|s| s == "write_orders"));
assert!(session.scopes.iter().any(|s| s == "read_orders"));
}
#[test]
fn test_from_access_token_response_sets_is_online_correctly() {
let shop = ShopDomain::new("my-store").unwrap();
let offline_response = AccessTokenResponse {
access_token: "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 offline_session = Session::from_access_token_response(shop.clone(), &offline_response);
assert!(!offline_session.is_online);
let online_response = AccessTokenResponse {
access_token: "token".to_string(),
scope: "read_products".to_string(),
expires_in: Some(3600),
associated_user_scope: None,
associated_user: Some(AssociatedUserResponse {
id: 1,
first_name: "Test".to_string(),
last_name: "User".to_string(),
email: "test@example.com".to_string(),
email_verified: true,
account_owner: false,
locale: "en".to_string(),
collaborator: false,
}),
session: None,
refresh_token: None,
refresh_token_expires_in: None,
};
let online_session = Session::from_access_token_response(shop, &online_response);
assert!(online_session.is_online);
}
#[test]
fn test_session_serialization_includes_refresh_token_field() {
let mut session = Session::new(
"offline_my-store.myshopify.com".to_string(),
sample_shop(),
"access-token".to_string(),
sample_scopes(),
false,
None,
);
session.refresh_token = Some("refresh-token-123".to_string());
let json = serde_json::to_string(&session).unwrap();
assert!(json.contains("refresh_token"));
assert!(json.contains("refresh-token-123"));
}
#[test]
fn test_session_serialization_includes_refresh_token_expires_at_field() {
let mut session = Session::new(
"offline_my-store.myshopify.com".to_string(),
sample_shop(),
"access-token".to_string(),
sample_scopes(),
false,
None,
);
session.refresh_token_expires_at = Some(Utc::now() + Duration::days(30));
let json = serde_json::to_string(&session).unwrap();
assert!(json.contains("refresh_token_expires_at"));
}
#[test]
fn test_session_deserialization_handles_missing_refresh_token_fields_backward_compat() {
let json = r#"{
"id": "test-session",
"shop": "test-shop.myshopify.com",
"access_token": "token123",
"scopes": "read_products",
"is_online": false,
"expires": null,
"state": null,
"shopify_session_id": null,
"associated_user": null,
"associated_user_scopes": null
}"#;
let session: Session = serde_json::from_str(json).unwrap();
assert!(session.refresh_token.is_none());
assert!(session.refresh_token_expires_at.is_none());
}
#[test]
fn test_refresh_token_expired_returns_false_when_expires_at_is_none() {
let session = Session::new(
"id".to_string(),
sample_shop(),
"token".to_string(),
sample_scopes(),
false,
None,
);
assert!(!session.refresh_token_expired());
}
#[test]
fn test_refresh_token_expired_returns_false_when_expires_at_is_in_future_more_than_60s() {
let mut session = Session::new(
"id".to_string(),
sample_shop(),
"token".to_string(),
sample_scopes(),
false,
None,
);
session.refresh_token_expires_at = Some(Utc::now() + Duration::hours(2));
assert!(!session.refresh_token_expired());
}
#[test]
fn test_refresh_token_expired_returns_true_when_expires_at_is_within_60_seconds() {
let mut session = Session::new(
"id".to_string(),
sample_shop(),
"token".to_string(),
sample_scopes(),
false,
None,
);
session.refresh_token_expires_at = Some(Utc::now() + Duration::seconds(30));
assert!(session.refresh_token_expired());
}
#[test]
fn test_refresh_token_expired_returns_true_when_already_expired() {
let mut session = Session::new(
"id".to_string(),
sample_shop(),
"token".to_string(),
sample_scopes(),
false,
None,
);
session.refresh_token_expires_at = Some(Utc::now() - Duration::hours(1));
assert!(session.refresh_token_expired());
}
#[test]
fn test_from_access_token_response_populates_refresh_token_fields() {
let shop = ShopDomain::new("my-store").unwrap();
let response = AccessTokenResponse {
access_token: "access-token".to_string(),
scope: "read_products".to_string(),
expires_in: Some(86400), associated_user_scope: None,
associated_user: None,
session: None,
refresh_token: Some("refresh-token-xyz".to_string()),
refresh_token_expires_in: Some(2592000), };
let before = Utc::now();
let session = Session::from_access_token_response(shop, &response);
let after = Utc::now();
assert_eq!(session.refresh_token, Some("refresh-token-xyz".to_string()));
assert!(session.refresh_token_expires_at.is_some());
let expires_at = session.refresh_token_expires_at.unwrap();
let expected_min = before + Duration::seconds(2592000);
let expected_max = after + Duration::seconds(2592000);
assert!(expires_at >= expected_min && expires_at <= expected_max);
}
#[test]
fn test_access_token_response_deserializes_refresh_token_field() {
let json = r#"{
"access_token": "test-token",
"scope": "read_products",
"refresh_token": "refresh-abc"
}"#;
let response: AccessTokenResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.refresh_token, Some("refresh-abc".to_string()));
}
#[test]
fn test_access_token_response_deserializes_refresh_token_expires_in_field() {
let json = r#"{
"access_token": "test-token",
"scope": "read_products",
"refresh_token_expires_in": 2592000
}"#;
let response: AccessTokenResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.refresh_token_expires_in, Some(2592000));
}
#[test]
fn test_access_token_response_handles_missing_optional_refresh_token_fields() {
let json = r#"{
"access_token": "test-token",
"scope": "read_products"
}"#;
let response: AccessTokenResponse = serde_json::from_str(json).unwrap();
assert!(response.refresh_token.is_none());
assert!(response.refresh_token_expires_in.is_none());
}
#[test]
fn test_refresh_token_expired_at_boundary_61_seconds_is_false() {
let mut session = Session::new(
"id".to_string(),
sample_shop(),
"token".to_string(),
sample_scopes(),
false,
None,
);
session.refresh_token_expires_at = Some(Utc::now() + Duration::seconds(61));
assert!(!session.refresh_token_expired());
}
#[test]
fn test_refresh_token_expired_at_58_seconds_is_true() {
let mut session = Session::new(
"id".to_string(),
sample_shop(),
"token".to_string(),
sample_scopes(),
false,
None,
);
session.refresh_token_expires_at = Some(Utc::now() + Duration::seconds(58));
assert!(session.refresh_token_expired());
}
#[test]
fn test_session_roundtrip_serialization_with_refresh_token() {
let mut original = Session::new(
"offline_test-shop.myshopify.com".to_string(),
sample_shop(),
"access-token-123".to_string(),
sample_scopes(),
false,
None,
);
original.refresh_token = Some("refresh-token-xyz".to_string());
original.refresh_token_expires_at = Some(Utc::now() + Duration::days(30));
let json = serde_json::to_string(&original).unwrap();
let restored: Session = serde_json::from_str(&json).unwrap();
assert_eq!(original.refresh_token, restored.refresh_token);
assert_eq!(
original.refresh_token_expires_at,
restored.refresh_token_expires_at
);
}
}