From 838f6b8d7fbbe159577be173b1cb1deff9bd512c Mon Sep 17 00:00:00 2001 From: Zoey Lan Date: Wed, 12 Nov 2025 15:07:18 -0700 Subject: [PATCH 1/9] Store new column values to Shop table --- .../session/shop_session_storage.rb | 39 +++- test/dummy/config/initializers/shopify_app.rb | 2 +- .../session/shop_session_storage_test.rb | 218 ++++++++++++++++++ .../session_store_strategy_test_helpers.rb | 18 +- 4 files changed, 271 insertions(+), 6 deletions(-) diff --git a/lib/shopify_app/session/shop_session_storage.rb b/lib/shopify_app/session/shop_session_storage.rb index f3cc08171..f58f937a0 100644 --- a/lib/shopify_app/session/shop_session_storage.rb +++ b/lib/shopify_app/session/shop_session_storage.rb @@ -13,6 +13,23 @@ module ShopSessionStorage def store(auth_session, *_args) shop = find_or_initialize_by(shopify_domain: auth_session.shop) shop.shopify_token = auth_session.access_token + + if shop.has_attribute?(:access_scopes) + shop.access_scopes = auth_session.scope.to_s + end + + if shop.has_attribute?(:expires_at) + shop.expires_at = auth_session.expires + end + + if shop.has_attribute?(:refresh_token) + shop.refresh_token = auth_session.refresh_token + end + + if shop.has_attribute?(:refresh_token_expires_at) + shop.refresh_token_expires_at = auth_session.refresh_token_expires + end + shop.save! shop.id end @@ -36,10 +53,28 @@ def destroy_by_shopify_domain(domain) def construct_session(shop) return unless shop - ShopifyAPI::Auth::Session.new( + session_attrs = { shop: shop.shopify_domain, access_token: shop.shopify_token, - ) + } + + if shop.has_attribute?(:access_scopes) + session_attrs[:scope] = shop.access_scopes + end + + if shop.has_attribute?(:expires_at) + session_attrs[:expires] = shop.expires_at + end + + if shop.has_attribute?(:refresh_token) + session_attrs[:refresh_token] = shop.refresh_token + end + + if shop.has_attribute?(:refresh_token_expires_at) + session_attrs[:refresh_token_expires] = shop.refresh_token_expires_at + end + + ShopifyAPI::Auth::Session.new(**session_attrs) end end end diff --git a/test/dummy/config/initializers/shopify_app.rb b/test/dummy/config/initializers/shopify_app.rb index 9907f8de3..28220ea3f 100644 --- a/test/dummy/config/initializers/shopify_app.rb +++ b/test/dummy/config/initializers/shopify_app.rb @@ -11,7 +11,7 @@ def self.call config.user_access_scopes = nil config.embedded_app = true config.myshopify_domain = "myshopify.com" - config.api_version = ShopifyAPI::LATEST_SUPPORTED_ADMIN_VERSION + config.api_version = ShopifyAPI::SUPPORTED_ADMIN_VERSIONS[2] config.billing = nil config.script_tags = nil config.embedded_redirect_url = nil diff --git a/test/shopify_app/session/shop_session_storage_test.rb b/test/shopify_app/session/shop_session_storage_test.rb index 02bfc6524..144027a87 100644 --- a/test/shopify_app/session/shop_session_storage_test.rb +++ b/test/shopify_app/session/shop_session_storage_test.rb @@ -76,5 +76,223 @@ class ShopSessionStorageTest < ActiveSupport::TestCase refute ShopMockSessionStore.retrieve_by_shopify_domain(shop_domain) end + + test ".store saves access_scopes when column exists" do + mock_shop_instance = MockShopInstance.new( + id: 12345, + scopes: nil, + available_attributes: [:id, :shopify_domain, :shopify_token, :access_scopes], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:scope).returns(ShopifyAPI::Auth::AuthScopes.new("read_products,write_orders")) + + ShopMockSessionStore.store(mock_auth_hash) + + assert_equal "read_products,write_orders", mock_shop_instance.access_scopes + end + + test ".store does not save access_scopes when column does not exist" do + mock_shop_instance = MockShopInstance.new( + id: 12345, + scopes: "old_scopes", + available_attributes: [:id, :shopify_domain, :shopify_token], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:scope).returns(ShopifyAPI::Auth::AuthScopes.new("read_products,write_orders")) + + ShopMockSessionStore.store(mock_auth_hash) + + # access_scopes should remain unchanged since column doesn't exist + assert_equal "old_scopes", mock_shop_instance.access_scopes + end + + test ".store saves expires_at when column exists" do + expiry_time = Time.now + 1.day + mock_shop_instance = MockShopInstance.new( + id: 12345, + expires_at: nil, + available_attributes: [:id, :shopify_domain, :shopify_token, :expires_at], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:expires).returns(expiry_time) + + ShopMockSessionStore.store(mock_auth_hash) + + assert_equal expiry_time, mock_shop_instance.expires_at + end + + test ".store does not save expires_at when column does not exist" do + old_expiry = Time.now + 2.days + mock_shop_instance = MockShopInstance.new( + id: 12345, + expires_at: old_expiry, + available_attributes: [:id, :shopify_domain, :shopify_token], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:expires).returns(Time.now + 1.day) + + ShopMockSessionStore.store(mock_auth_hash) + + # expires_at should remain unchanged since column doesn't exist + assert_equal old_expiry, mock_shop_instance.expires_at + end + + test ".store saves refresh_token when column exists" do + mock_shop_instance = MockShopInstance.new( + id: 12345, + refresh_token: nil, + available_attributes: [:id, :shopify_domain, :shopify_token, :refresh_token], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:refresh_token).returns("new-refresh-token") + + ShopMockSessionStore.store(mock_auth_hash) + + assert_equal "new-refresh-token", mock_shop_instance.refresh_token + end + + test ".store does not save refresh_token when column does not exist" do + mock_shop_instance = MockShopInstance.new( + id: 12345, + refresh_token: "old-refresh-token", + available_attributes: [:id, :shopify_domain, :shopify_token], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:refresh_token).returns("new-refresh-token") + + ShopMockSessionStore.store(mock_auth_hash) + + # refresh_token should remain unchanged since column doesn't exist + assert_equal "old-refresh-token", mock_shop_instance.refresh_token + end + + test ".store saves refresh_token_expires_at when column exists" do + refresh_expiry_time = Time.now + 7.days + mock_shop_instance = MockShopInstance.new( + id: 12345, + refresh_token_expires_at: nil, + available_attributes: [:id, :shopify_domain, :shopify_token, :refresh_token_expires_at], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:refresh_token_expires).returns(refresh_expiry_time) + + ShopMockSessionStore.store(mock_auth_hash) + + assert_equal refresh_expiry_time, mock_shop_instance.refresh_token_expires_at + end + + test ".store does not save refresh_token_expires_at when column does not exist" do + old_refresh_expiry = Time.now + 14.days + mock_shop_instance = MockShopInstance.new( + id: 12345, + refresh_token_expires_at: old_refresh_expiry, + available_attributes: [:id, :shopify_domain, :shopify_token], + ) + mock_shop_instance.stubs(:save!).returns(true) + + ShopMockSessionStore.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(TEST_SHOPIFY_DOMAIN) + mock_auth_hash.stubs(:access_token).returns("a-new-token!") + mock_auth_hash.stubs(:refresh_token_expires).returns(Time.now + 7.days) + + ShopMockSessionStore.store(mock_auth_hash) + + # refresh_token_expires_at should remain unchanged since column doesn't exist + assert_equal old_refresh_expiry, mock_shop_instance.refresh_token_expires_at + end + + test ".retrieve constructs session with all optional attributes when columns exist" do + expiry_time = Time.now + 1.day + refresh_expiry_time = Time.now + 7.days + + mock_shop_instance = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + scopes: "read_products,write_orders", + expires_at: expiry_time, + refresh_token: "refresh-token-value", + refresh_token_expires_at: refresh_expiry_time, + available_attributes: [:shopify_domain, :shopify_token, :access_scopes, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + ShopMockSessionStore.stubs(:find_by).with(id: 1).returns(mock_shop_instance) + + session = ShopMockSessionStore.retrieve(1) + + assert_equal TEST_SHOPIFY_DOMAIN, session.shop + assert_equal TEST_SHOPIFY_TOKEN, session.access_token + assert_equal "read_products,write_orders", session.scope.to_s + assert_equal expiry_time, session.expires + assert_equal "refresh-token-value", session.refresh_token + assert_equal refresh_expiry_time, session.refresh_token_expires + end + + test ".retrieve constructs session without optional attributes when columns do not exist" do + mock_shop_instance = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + available_attributes: [:shopify_domain, :shopify_token], + scopes: "old_scopes", + expires_at: Time.now + 2.days, + refresh_token: "old-refresh-token", + refresh_token_expires_at: Time.now + 14.days, + ) + + ShopMockSessionStore.stubs(:find_by).with(id: 1).returns(mock_shop_instance) + + session = ShopMockSessionStore.retrieve(1) + + assert_equal TEST_SHOPIFY_DOMAIN, session.shop + assert_equal TEST_SHOPIFY_TOKEN, session.access_token + # Optional attributes should not be present + assert_empty session.scope.to_a + assert_nil session.expires + assert_nil session.refresh_token + assert_nil session.refresh_token_expires + end end end diff --git a/test/support/session_store_strategy_test_helpers.rb b/test/support/session_store_strategy_test_helpers.rb index ceebe937d..4a1df0895 100644 --- a/test/support/session_store_strategy_test_helpers.rb +++ b/test/support/session_store_strategy_test_helpers.rb @@ -2,21 +2,33 @@ module SessionStoreStrategyTestHelpers class MockShopInstance - attr_reader :id, :shopify_domain, :shopify_token, :api_version, :access_scopes - attr_writer :shopify_token, :access_scopes + attr_reader :id, :shopify_domain, :shopify_token, :api_version, :access_scopes, :expires_at, :refresh_token, :refresh_token_expires_at + attr_writer :shopify_token, :access_scopes, :expires_at, :refresh_token, :refresh_token_expires_at def initialize( id: 1, shopify_domain: "example.myshopify.com", shopify_token: "abcd-shop-token", api_version: ShopifyApp.configuration.api_version, - scopes: "read_products" + scopes: "read_products", + expires_at: nil, + refresh_token: nil, + refresh_token_expires_at: nil, + available_attributes: [:id, :shopify_domain, :shopify_token, :api_version] ) @id = id @shopify_domain = shopify_domain @shopify_token = shopify_token @api_version = api_version @access_scopes = scopes + @expires_at = expires_at + @refresh_token = refresh_token + @refresh_token_expires_at = refresh_token_expires_at + @available_attributes = available_attributes + end + + def has_attribute?(attribute) + @available_attributes.include?(attribute.to_sym) end end From 43c028d07a72b7eca1f85913853bb0e9946613d1 Mon Sep 17 00:00:00 2001 From: Zoey Lan Date: Wed, 12 Nov 2025 16:34:52 -0700 Subject: [PATCH 2/9] Make UserSessionStorage more adaptable --- .../session/user_session_storage.rb | 23 ++- .../session/shop_session_storage_test.rb | 9 +- .../session/user_session_storage_test.rb | 131 ++++++++++++++++++ .../session_store_strategy_test_helpers.rb | 17 ++- 4 files changed, 175 insertions(+), 5 deletions(-) diff --git a/lib/shopify_app/session/user_session_storage.rb b/lib/shopify_app/session/user_session_storage.rb index 1cba9b2bf..3fc32c89c 100644 --- a/lib/shopify_app/session/user_session_storage.rb +++ b/lib/shopify_app/session/user_session_storage.rb @@ -14,6 +14,15 @@ def store(auth_session, user) user = find_or_initialize_by(shopify_user_id: user.id) user.shopify_token = auth_session.access_token user.shopify_domain = auth_session.shop + + if user.has_attribute?(:access_scopes) + user.access_scopes = auth_session.scope.to_s + end + + if user.has_attribute?(:expires_at) + user.expires_at = auth_session.expires + end + user.save! user.id end @@ -48,11 +57,21 @@ def construct_session(user) collaborator: false, ) - ShopifyAPI::Auth::Session.new( + session_attrs = { shop: user.shopify_domain, access_token: user.shopify_token, associated_user: associated_user, - ) + } + + if user.has_attribute?(:access_scopes) + session_attrs[:scope] = user.access_scopes + end + + if user.has_attribute?(:expires_at) + session_attrs[:expires] = user.expires_at + end + + ShopifyAPI::Auth::Session.new(**session_attrs) end end end diff --git a/test/shopify_app/session/shop_session_storage_test.rb b/test/shopify_app/session/shop_session_storage_test.rb index 144027a87..9d2dd543a 100644 --- a/test/shopify_app/session/shop_session_storage_test.rb +++ b/test/shopify_app/session/shop_session_storage_test.rb @@ -256,7 +256,14 @@ class ShopSessionStorageTest < ActiveSupport::TestCase expires_at: expiry_time, refresh_token: "refresh-token-value", refresh_token_expires_at: refresh_expiry_time, - available_attributes: [:shopify_domain, :shopify_token, :access_scopes, :expires_at, :refresh_token, :refresh_token_expires_at], + available_attributes: [ + :shopify_domain, + :shopify_token, + :access_scopes, + :expires_at, + :refresh_token, + :refresh_token_expires_at, + ], ) ShopMockSessionStore.stubs(:find_by).with(id: 1).returns(mock_shop_instance) diff --git a/test/shopify_app/session/user_session_storage_test.rb b/test/shopify_app/session/user_session_storage_test.rb index 6f1037209..bc866e238 100644 --- a/test/shopify_app/session/user_session_storage_test.rb +++ b/test/shopify_app/session/user_session_storage_test.rb @@ -84,6 +84,128 @@ class UserSessionStorageTest < ActiveSupport::TestCase refute UserMockSessionStore.retrieve_by_shopify_user_id(user_id) end + test ".store saves access_scopes when column exists" do + mock_user_instance = MockUserInstance.new( + id: 12345, + shopify_user_id: TEST_SHOPIFY_USER_ID, + scopes: nil, + available_attributes: [:id, :shopify_user_id, :shopify_domain, :shopify_token, :access_scopes], + ) + mock_user_instance.stubs(:save!).returns(true) + + UserMockSessionStore.stubs(:find_or_initialize_by).returns(mock_user_instance) + + UserMockSessionStore.store( + mock_session(shop: TEST_SHOPIFY_DOMAIN, scope: "read_products,write_orders"), + mock_associated_user, + ) + + assert_equal "read_products,write_orders", mock_user_instance.access_scopes + end + + test ".store does not save access_scopes when column does not exist" do + mock_user_instance = MockUserInstance.new( + id: 12345, + shopify_user_id: TEST_SHOPIFY_USER_ID, + scopes: "old_scopes", + available_attributes: [:id, :shopify_user_id, :shopify_domain, :shopify_token], + ) + mock_user_instance.stubs(:save!).returns(true) + + UserMockSessionStore.stubs(:find_or_initialize_by).returns(mock_user_instance) + + UserMockSessionStore.store( + mock_session(shop: TEST_SHOPIFY_DOMAIN, scope: "read_products,write_orders"), + mock_associated_user, + ) + + # access_scopes should remain unchanged since column doesn't exist + assert_equal "old_scopes", mock_user_instance.access_scopes + end + + test ".store saves expires_at when column exists" do + expiry_time = Time.now + 1.day + mock_user_instance = MockUserInstance.new( + id: 12345, + shopify_user_id: TEST_SHOPIFY_USER_ID, + expires_at: nil, + available_attributes: [:id, :shopify_user_id, :shopify_domain, :shopify_token, :expires_at], + ) + mock_user_instance.stubs(:save!).returns(true) + + UserMockSessionStore.stubs(:find_or_initialize_by).returns(mock_user_instance) + + mock_auth_session = mock_session(shop: TEST_SHOPIFY_DOMAIN, expires: expiry_time) + + UserMockSessionStore.store(mock_auth_session, mock_associated_user) + + assert_equal expiry_time, mock_user_instance.expires_at + end + + test ".store does not save expires_at when column does not exist" do + old_expiry = Time.now + 2.days + mock_user_instance = MockUserInstance.new( + id: 12345, + shopify_user_id: TEST_SHOPIFY_USER_ID, + expires_at: old_expiry, + available_attributes: [:id, :shopify_user_id, :shopify_domain, :shopify_token], + ) + mock_user_instance.stubs(:save!).returns(true) + + UserMockSessionStore.stubs(:find_or_initialize_by).returns(mock_user_instance) + + UserMockSessionStore.store( + mock_session(shop: TEST_SHOPIFY_DOMAIN, expires: Time.now + 1.day), + mock_associated_user, + ) + + # expires_at should remain unchanged since column doesn't exist + assert_equal old_expiry, mock_user_instance.expires_at + end + + test ".retrieve constructs session with all optional attributes when columns exist" do + expiry_time = Time.now + 1.day + + mock_user_instance = MockUserInstance.new( + shopify_user_id: TEST_SHOPIFY_USER_ID, + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_USER_TOKEN, + scopes: "read_products,write_orders", + expires_at: expiry_time, + available_attributes: [:shopify_user_id, :shopify_domain, :shopify_token, :access_scopes, :expires_at], + ) + + UserMockSessionStore.stubs(:find_by).with(id: 1).returns(mock_user_instance) + + session = UserMockSessionStore.retrieve(1) + + assert_equal TEST_SHOPIFY_DOMAIN, session.shop + assert_equal TEST_SHOPIFY_USER_TOKEN, session.access_token + assert_equal "read_products,write_orders", session.scope.to_s + assert_equal expiry_time, session.expires + end + + test ".retrieve constructs session without optional attributes when columns do not exist" do + mock_user_instance = MockUserInstance.new( + shopify_user_id: TEST_SHOPIFY_USER_ID, + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_USER_TOKEN, + available_attributes: [:shopify_user_id, :shopify_domain, :shopify_token], + scopes: "old_scopes", + expires_at: Time.now + 2.days, + ) + + UserMockSessionStore.stubs(:find_by).with(id: 1).returns(mock_user_instance) + + session = UserMockSessionStore.retrieve(1) + + assert_equal TEST_SHOPIFY_DOMAIN, session.shop + assert_equal TEST_SHOPIFY_USER_TOKEN, session.access_token + # Optional attributes should not be present + assert_empty session.scope.to_a + assert_nil session.expires + end + private def mock_associated_user @@ -98,5 +220,14 @@ def mock_associated_user collaborator: true, ) end + + def mock_session(shop:, scope: nil, expires: nil) + mock_auth_hash = mock + mock_auth_hash.stubs(:shop).returns(shop) + mock_auth_hash.stubs(:access_token).returns("a-new-user_token!") + mock_auth_hash.stubs(:scope).returns(scope.is_a?(String) ? ShopifyAPI::Auth::AuthScopes.new(scope) : scope) + mock_auth_hash.stubs(:expires).returns(expires) + mock_auth_hash + end end end diff --git a/test/support/session_store_strategy_test_helpers.rb b/test/support/session_store_strategy_test_helpers.rb index 4a1df0895..b802635d7 100644 --- a/test/support/session_store_strategy_test_helpers.rb +++ b/test/support/session_store_strategy_test_helpers.rb @@ -2,7 +2,14 @@ module SessionStoreStrategyTestHelpers class MockShopInstance - attr_reader :id, :shopify_domain, :shopify_token, :api_version, :access_scopes, :expires_at, :refresh_token, :refresh_token_expires_at + attr_reader :id, + :shopify_domain, + :shopify_token, + :api_version, + :access_scopes, + :expires_at, + :refresh_token, + :refresh_token_expires_at attr_writer :shopify_token, :access_scopes, :expires_at, :refresh_token, :refresh_token_expires_at def initialize( @@ -43,7 +50,8 @@ def initialize( shopify_token: "1234-user-token", api_version: ShopifyApp.configuration.api_version, scopes: "read_products", - expires_at: nil + expires_at: nil, + available_attributes: [:id, :shopify_user_id, :shopify_domain, :shopify_token, :api_version] ) @id = id @shopify_user_id = shopify_user_id @@ -52,6 +60,11 @@ def initialize( @api_version = api_version @access_scopes = scopes @expires_at = expires_at + @available_attributes = available_attributes + end + + def has_attribute?(attribute) + @available_attributes.include?(attribute.to_sym) end end end From 41f68bab1d8b97e108c10b612334dbfcf38c463f Mon Sep 17 00:00:00 2001 From: Zoey Lan Date: Mon, 17 Nov 2025 15:51:06 -0700 Subject: [PATCH 3/9] Add auto token refresh logic to ShopSessionStorage --- lib/shopify_app/errors.rb | 2 + .../session/shop_session_storage.rb | 47 ++++ .../session/shop_session_storage_test.rb | 242 ++++++++++++++++++ .../session_store_strategy_test_helpers.rb | 20 ++ 4 files changed, 311 insertions(+) diff --git a/lib/shopify_app/errors.rb b/lib/shopify_app/errors.rb index 4f8068fc5..7669e983a 100644 --- a/lib/shopify_app/errors.rb +++ b/lib/shopify_app/errors.rb @@ -31,4 +31,6 @@ class MissingWebhookJobError < StandardError; end class ShopifyDomainNotFound < StandardError; end class ShopifyHostNotFound < StandardError; end + + class RefreshTokenExpiredError < StandardError; end end diff --git a/lib/shopify_app/session/shop_session_storage.rb b/lib/shopify_app/session/shop_session_storage.rb index f58f937a0..66541c7c0 100644 --- a/lib/shopify_app/session/shop_session_storage.rb +++ b/lib/shopify_app/session/shop_session_storage.rb @@ -9,6 +9,25 @@ module ShopSessionStorage validates :shopify_domain, presence: true, uniqueness: { case_sensitive: false } end + def with_shopify_session(auto_refresh: true, &block) + refresh_token_if_expired! if auto_refresh + super(&block) + end + + def refresh_token_if_expired! + return unless should_refresh? + raise RefreshTokenExpiredError if refresh_token_expired? + + # Acquire row lock to prevent concurrent refreshes + with_lock do + reload + # Check again after lock - token might have been refreshed by another process + return unless should_refresh? + + perform_token_refresh! + end + end + class_methods do def store(auth_session, *_args) shop = find_or_initialize_by(shopify_domain: auth_session.shop) @@ -77,5 +96,33 @@ def construct_session(shop) ShopifyAPI::Auth::Session.new(**session_attrs) end end + + def perform_token_refresh! + new_session = ShopifyAPI::Auth::RefreshToken.refresh_access_token( + shop: shopify_domain, + refresh_token: refresh_token, + ) + + update!( + shopify_token: new_session.access_token, + expires_at: new_session.expires, + refresh_token: new_session.refresh_token, + refresh_token_expires_at: new_session.refresh_token_expires, + ) + end + + def should_refresh? + return false unless has_attribute?(:expires_at) && expires_at.present? + return false unless has_attribute?(:refresh_token) && refresh_token.present? + return false unless has_attribute?(:refresh_token_expires_at) && refresh_token_expires_at.present? + + expires_at <= Time.now + end + + def refresh_token_expired? + return false unless has_attribute?(:refresh_token_expires_at) && refresh_token_expires_at.present? + + refresh_token_expires_at <= Time.now + end end end diff --git a/test/shopify_app/session/shop_session_storage_test.rb b/test/shopify_app/session/shop_session_storage_test.rb index 9d2dd543a..c1bea8c94 100644 --- a/test/shopify_app/session/shop_session_storage_test.rb +++ b/test/shopify_app/session/shop_session_storage_test.rb @@ -301,5 +301,247 @@ class ShopSessionStorageTest < ActiveSupport::TestCase assert_nil session.refresh_token assert_nil session.refresh_token_expires end + + test "#refresh_token_if_expired! does nothing when token is not expired" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + expires_at: 1.day.from_now, + refresh_token: "refresh-token", + refresh_token_expires_at: 30.days.from_now, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + shop.expects(:update!).never + + shop.refresh_token_if_expired! + + # Token should remain unchanged + assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token + end + + test "#refresh_token_if_expired! refreshes when token is expired" do + expired_time = 1.hour.ago + new_expiry = 1.day.from_now + new_refresh_token_expiry = 30.days.from_now + + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: "old-token", + expires_at: expired_time, + refresh_token: "refresh-token", + refresh_token_expires_at: 30.days.from_now, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + # Mock the refresh response + new_session = mock + new_session.stubs(:access_token).returns("new-token") + new_session.stubs(:expires).returns(new_expiry) + new_session.stubs(:refresh_token).returns("new-refresh-token") + new_session.stubs(:refresh_token_expires).returns(new_refresh_token_expiry) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token) + .with(shop: TEST_SHOPIFY_DOMAIN, refresh_token: "refresh-token") + .returns(new_session) + + shop.refresh_token_if_expired! + + # Verify the token was updated + assert_equal "new-token", shop.shopify_token + assert_equal new_expiry, shop.expires_at + assert_equal "new-refresh-token", shop.refresh_token + assert_equal new_refresh_token_expiry, shop.refresh_token_expires_at + end + + test "#refresh_token_if_expired! raises error when refresh token is expired" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: "old-token", + expires_at: 1.hour.ago, + refresh_token: "refresh-token", + refresh_token_expires_at: 1.hour.ago, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + + assert_raises(ShopifyApp::RefreshTokenExpiredError) do + shop.refresh_token_if_expired! + end + end + + test "#refresh_token_if_expired! does nothing when refresh_token column doesn't exist" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + expires_at: 1.hour.ago, + refresh_token_expires_at: 30.days.from_now, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token_expires_at], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + shop.expects(:update!).never + + shop.refresh_token_if_expired! + + assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token + end + + test "#refresh_token_if_expired! does nothing when refresh_token is empty" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + expires_at: 1.hour.ago, + refresh_token: "", + refresh_token_expires_at: 30.days.from_now, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + shop.expects(:update!).never + + shop.refresh_token_if_expired! + + assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token + end + + test "#refresh_token_if_expired! does nothing when expires_at column doesn't exist" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + refresh_token: "refresh-token", + refresh_token_expires_at: 30.days.from_now, + available_attributes: [:shopify_domain, :shopify_token, :refresh_token, :refresh_token_expires_at], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + shop.expects(:update!).never + + shop.refresh_token_if_expired! + + assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token + end + + test "#refresh_token_if_expired! does nothing when expires_at is nil" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + expires_at: nil, + refresh_token: "refresh-token", + refresh_token_expires_at: 30.days.from_now, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + shop.expects(:update!).never + + shop.refresh_token_if_expired! + + assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token + end + + test "#refresh_token_if_expired! does nothing when refresh_token_expires_at column doesn't exist" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + expires_at: 1.hour.ago, + refresh_token: "refresh-token", + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + shop.expects(:update!).never + + shop.refresh_token_if_expired! + + assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token + end + + test "#refresh_token_if_expired! does nothing when refresh_token_expires_at is nil" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + expires_at: 1.hour.ago, + refresh_token: "refresh-token", + refresh_token_expires_at: nil, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + shop.expects(:update!).never + + shop.refresh_token_if_expired! + + assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token + end + + test "#refresh_token_if_expired! handles race condition with double-check" do + expired_time = 1.hour.ago + refreshed_time = 1.day.from_now + + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: "old-token", + expires_at: expired_time, + refresh_token: "refresh-token", + refresh_token_expires_at: 30.days.from_now, + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at], + ) + + # Simulate another process already refreshed the token + shop.expects(:reload).once.with do + shop.expires_at = refreshed_time + shop.shopify_token = "already-refreshed-token" + true + end.returns(shop) + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + + shop.refresh_token_if_expired! + + assert_equal "already-refreshed-token", shop.shopify_token + end + + test "#with_shopify_session calls refresh_token_if_expired! by default" do + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: TEST_SHOPIFY_TOKEN, + available_attributes: [:shopify_domain, :shopify_token], + ) + + shop.expects(:refresh_token_if_expired!).once + + block_executed = false + shop.with_shopify_session do + block_executed = true + end + + assert block_executed, "Block should have been executed" + end + + test "#with_shopify_session skips refresh when auto_refresh is false" do + expired_time = 1.hour.ago + + shop = MockShopInstance.new( + shopify_domain: TEST_SHOPIFY_DOMAIN, + shopify_token: "old-token", + expires_at: expired_time, + refresh_token: "refresh-token", + available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token], + ) + + # Should NOT refresh even though token is expired + shop.expects(:refresh_token_if_expired!).never + ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never + + block_executed = false + + shop.with_shopify_session(auto_refresh: false) do + block_executed = true + end + + assert block_executed, "Block should have been executed" + end end end diff --git a/test/support/session_store_strategy_test_helpers.rb b/test/support/session_store_strategy_test_helpers.rb index b802635d7..d2d51ac52 100644 --- a/test/support/session_store_strategy_test_helpers.rb +++ b/test/support/session_store_strategy_test_helpers.rb @@ -2,6 +2,11 @@ module SessionStoreStrategyTestHelpers class MockShopInstance + # Stub ActiveRecord validation method before including concern + def self.validates(*args); end + + include ShopifyApp::ShopSessionStorage + attr_reader :id, :shopify_domain, :shopify_token, @@ -37,6 +42,21 @@ def initialize( def has_attribute?(attribute) @available_attributes.include?(attribute.to_sym) end + + # Stub ActiveRecord methods that the concern uses + def with_lock + yield + end + + def reload + self + end + + def update!(attrs) + attrs.each do |key, value| + send("#{key}=", value) + end + end end class MockUserInstance From ddf823101ff303cc7624009f7e60023d3f48daea Mon Sep 17 00:00:00 2001 From: Zoey Lan Date: Mon, 17 Nov 2025 16:30:04 -0700 Subject: [PATCH 4/9] Add deprecation message to Shop and UserSessionStorageWithScopes concerns --- lib/shopify_app/session/shop_session_storage_with_scopes.rb | 6 ++++++ lib/shopify_app/session/user_session_storage_with_scopes.rb | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/lib/shopify_app/session/shop_session_storage_with_scopes.rb b/lib/shopify_app/session/shop_session_storage_with_scopes.rb index 24458e421..e85aef3fe 100644 --- a/lib/shopify_app/session/shop_session_storage_with_scopes.rb +++ b/lib/shopify_app/session/shop_session_storage_with_scopes.rb @@ -6,6 +6,12 @@ module ShopSessionStorageWithScopes include ::ShopifyApp::SessionStorage included do + ShopifyApp::Logger.deprecated( + "ShopSessionStorageWithScopes is deprecated and will be removed in v23.0.0. " \ + "Use ShopSessionStorage instead, which now handles access_scopes, expires_at, " \ + "refresh_token, and refresh_token_expires_at automatically.", + "23.0.0", + ) validates :shopify_domain, presence: true, uniqueness: { case_sensitive: false } end diff --git a/lib/shopify_app/session/user_session_storage_with_scopes.rb b/lib/shopify_app/session/user_session_storage_with_scopes.rb index cf19a166d..80b6a877a 100644 --- a/lib/shopify_app/session/user_session_storage_with_scopes.rb +++ b/lib/shopify_app/session/user_session_storage_with_scopes.rb @@ -6,6 +6,12 @@ module UserSessionStorageWithScopes include ::ShopifyApp::SessionStorage included do + ShopifyApp::Logger.deprecated( + "UserSessionStorageWithScopes is deprecated and will be removed in v23.0.0. " \ + "Use UserSessionStorage instead, which now handles access_scopes and expires_at automatically.", + "23.0.0", + ) + validates :shopify_domain, presence: true end From b3871b4364bb7efd45596a299d0268622b60274d Mon Sep 17 00:00:00 2001 From: Zoey Lan Date: Mon, 17 Nov 2025 16:51:57 -0700 Subject: [PATCH 5/9] Add docs and changelog --- CHANGELOG.md | 7 +++ docs/Upgrading.md | 47 +++++++++++++++ docs/shopify_app/sessions.md | 111 ++++++++++++++++++++++++++--------- 3 files changed, 138 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e800cf741..8659718ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ Unreleased - ⚠️ [Breaking] Removes `ShopifyApp::JWTMiddleware` and `ShopifyApp::JWT` See [Upgrading](/docs/Upgrading.md) for more migration. [1960](https://github.com/Shopify/shopify_app/pull/1960) - ⚠️ [Breaking] Removed deprecated `CallbackController` methods. `perform_after_authenticate_job`, `install_webhooks`, and `perform_post_authenticate_jobs` have been removed. [#1961](https://github.com/Shopify/shopify_app/pull/1961) - ⚠️ [Breaking] Bumps minimum supported Ruby version to 3.1 [#1959](https://github.com/Shopify/shopify_app/pull/1959) +- Adds automatic offline access token refresh support for `ShopSessionStorage`. When using `with_shopify_session`, expired offline access tokens will automatically be refreshed using refresh tokens. [#XXXX](https://github.com/Shopify/shopify_app/pull/XXXX) + - Adds `refresh_token_if_expired!` public method to `ShopSessionStorage` for manual token refresh + - Adds `RefreshTokenExpiredError` exception raised when refresh token itself is expired + - `ShopSessionStorage` now automatically stores `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` from auth sessions + - `UserSessionStorage` now automatically stores `access_scopes` and `expires_at` from auth sessions (refresh tokens not applicable for online/user tokens) + - See [Sessions documentation](/docs/shopify_app/sessions.md#automatic-access-token-refresh) for migration guide +- Deprecates `ShopSessionStorageWithScopes` and `UserSessionStorageWithScopes` in favor of `ShopSessionStorage` and `UserSessionStorage`, which now handle session attributes automatically. Will be removed in v23.0.0. [#XXXX](https://github.com/Shopify/shopify_app/pull/XXXX) - Adds a `script_tag_manager` that will automatically create script tags when the app is installed. [1948](https://github.com/Shopify/shopify_app/pull/1948) - Handle invalid token when adding redirection headers [#1945](https://github.com/Shopify/shopify_app/pull/1945) - Handle invalid record error for concurrent token exchange calls [#1966](https://github.com/Shopify/shopify_app/pull/1966) diff --git a/docs/Upgrading.md b/docs/Upgrading.md index 7571bc5e7..a2875f09a 100644 --- a/docs/Upgrading.md +++ b/docs/Upgrading.md @@ -47,6 +47,53 @@ If you do run into issues, we recommend looking at our [debugging tips.](https:/ #### (v23.0.0) Drops support for Ruby 3.0 The minimum ruby version is now 3.1 +#### (v23.0.0) - ShopSessionStorageWithScopes and UserSessionStorageWithScopes are deprecated + +`ShopSessionStorageWithScopes` and `UserSessionStorageWithScopes` are now deprecated in favor of `ShopSessionStorage` and `UserSessionStorage`, which handle all session attributes automatically (including `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` for shops). + +**Migration:** + +1. Update your Shop model to use `ShopSessionStorage`: +```ruby +# Before +class Shop < ActiveRecord::Base + include ShopifyApp::ShopSessionStorageWithScopes +end + +# After +class Shop < ActiveRecord::Base + include ShopifyApp::ShopSessionStorage +end +``` + +2. Update your User model to use `UserSessionStorage`: +```ruby +# Before +class User < ActiveRecord::Base + include ShopifyApp::UserSessionStorageWithScopes +end + +# After +class User < ActiveRecord::Base + include ShopifyApp::UserSessionStorage +end +``` + +3. **Optional but recommended:** Add columns to enable automatic token refresh for shops: +```ruby +class AddTokenRefreshFieldsToShops < ActiveRecord::Migration[7.0] + def change + add_column :shops, :expires_at, :datetime + add_column :shops, :refresh_token, :string + add_column :shops, :refresh_token_expires_at, :datetime + end +end +``` + +With these columns, `ShopSessionStorage#with_shopify_session` will automatically refresh expired offline access tokens. See the [Sessions documentation](/docs/shopify_app/sessions.md#automatic-access-token-refresh) for more details. + +**Note:** If you had custom `access_scopes=` or `access_scopes` methods in your models, these are no longer needed. The base concerns now handle these attributes automatically. + #### (v23.0.0) - Deprecated methods in CallbackController The following methods from `ShopifyApp::CallbackController` have been deprecated in `v23.0.0` - `perform_after_authenticate_job` diff --git a/docs/shopify_app/sessions.md b/docs/shopify_app/sessions.md index b9390ae9d..6bb5529d5 100644 --- a/docs/shopify_app/sessions.md +++ b/docs/shopify_app/sessions.md @@ -129,11 +129,11 @@ These methods are already implemented as a part of the `User` and `Shop` models Simply include these concerns if you want to use the implementation, and overwrite methods for custom implementation - `Shop` storage - - [ShopSessionStorageWithScopes](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/shop_session_storage_with_scopes.rb) + - [ShopSessionStorageWithScopes](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/shop_session_storage_with_scopes.rb) (Deprecated in 23.0.0) - [ShopSessionStorage](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/shop_session_storage.rb) - `User` storage - - [UserSessionStorageWithScopes](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/user_session_storage_with_scopes.rb) + - [UserSessionStorageWithScopes](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/user_session_storage_with_scopes.rb) (Deprecated in 23.0.0) - [UserSessionStorage](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/user_session_storage.rb) ### Loading Sessions @@ -206,6 +206,60 @@ user.with_shopify_session do end ``` +##### Automatic Access Token Refresh + +`ShopSessionStorage` includes automatic token refresh for expired offline access tokens. When using `with_shopify_session` on a Shop model, the gem will automatically refresh the access token if it has expired, using the refresh token stored in the database. + +**Requirements:** +- Shop model must include `ShopSessionStorage` concern +- Database must have the following columns: + - `expires_at` (datetime) - when the access token expires + - `refresh_token` (string) - the refresh token + - `refresh_token_expires_at` (datetime) - when the refresh token expires + +**Migration Example:** +```ruby +class AddTokenRefreshFieldsToShops < ActiveRecord::Migration[7.0] + def change + add_column :shops, :expires_at, :datetime + add_column :shops, :refresh_token, :string + add_column :shops, :refresh_token_expires_at, :datetime + end +end +``` + +**Usage:** +```ruby +shop = Shop.find_by(shopify_domain: "example.myshopify.com") + +# Automatic refresh (default behavior) +shop.with_shopify_session do + # If the token is expired, it will be automatically refreshed before making API calls + ShopifyAPI::Product.all +end + +# Disable automatic refresh if needed +shop.with_shopify_session(auto_refresh: false) do + # Token will NOT be refreshed even if expired + ShopifyAPI::Product.all +end + +# Manual refresh +begin + shop.refresh_token_if_expired! +rescue ShopifyApp::RefreshTokenExpiredError + # Handle case where refresh token itself has expired + # App needs to go through OAuth flow again +end +``` + +**Error Handling:** +- `ShopifyApp::RefreshTokenExpiredError` is raised when the refresh token itself is expired +- When this happens, the shop must go through the OAuth flow again to get new tokens +- The refresh process uses database row-level locking to prevent race conditions from concurrent requests + +**Note:** Refresh tokens are only available for offline (shop) access tokens. Online (user) access tokens do not support refresh and must be re-authorized through OAuth when expired. + #### Re-fetching an access token when API returns Unauthorized When using `ShopifyApp::EnsureHasSession` and the `new_embedded_auth_strategy` configuration, any **unhandled** Unauthorized `ShopifyAPI::Errors::HttpResponseError` will cause the app to perform token exchange to fetch a new access token from Shopify and the action to be executed again. This will update and store the new access token to the current session instance. @@ -300,39 +354,42 @@ class MyController < ApplicationController end ``` -## Access scopes -If you want to customize how access scopes are stored for shops and users, you can implement the `access_scopes` getters and setters in the models that include `ShopifyApp::ShopSessionStorageWithScopes` and `ShopifyApp::UserSessionStorageWithScopes` as shown: +## Expiry date +When the configuration flag `check_session_expiry_date` is set to true, the session expiry date will be checked to trigger a re-auth and get a fresh user token when it is expired. +This requires the `ShopifyAPI::Auth::Session` `expires` attribute to be stored. -### `ShopifyApp::ShopSessionStorageWithScopes` -```ruby -class Shop < ActiveRecord::Base - include ShopifyApp::ShopSessionStorageWithScopes +### Online access tokens +When the `User` model includes the `UserSessionStorage` concern, a DB migration can be generated with `rails generate shopify_app:user_model --skip` to add the `expires_at` attribute to the model. - def access_scopes=(scopes) - # Store access scopes - end - def access_scopes - # Find access scopes - end -end -``` +Online access tokens can not be refreshed, so when the token is expired, the user must go through the OAuth flow again to get a new token. + +### Offline access tokens + +**Optional Configuration:** By default, offline access tokens do not expire. However, you can opt-in to expiring offline access tokens for enhanced security by configuring it through `ShopifyAPI::Context`: -### `ShopifyApp::UserSessionStorageWithScopes` ```ruby -class User < ActiveRecord::Base - include ShopifyApp::UserSessionStorageWithScopes +# config/initializers/shopify_app.rb +ShopifyApp.configure do |config| + # ... other configuration - def access_scopes=(scopes) - # Store access scopes - end - def access_scopes - # Find access scopes - end + # Enable checking session expiry dates + config.check_session_expiry_date = true end + +# For ShopifyAPI Context - enable expiring offline tokens +ShopifyAPI::Context.setup( + # ... other configuration + offline_access_token_expires: true, # Opt-in to expiring offline tokens +) ``` -## Expiry date -When the configuration flag `check_session_expiry_date` is set to true, the user session expiry date will be checked to trigger a re-auth and get a fresh user token when it is expired. This requires the `ShopifyAPI::Auth::Session` `expires` attribute to be stored. When the `User` model includes the `UserSessionStorageWithScopes` concern, a DB migration can be generated with `rails generate shopify_app:user_model --skip` to add the `expires_at` attribute to the model. +When expiring offline tokens are enabled, Shopify will issue offline access tokens with an expiration date and a refresh token. Your app can then automatically refresh these tokens when they expire. + +**Database Setup:** When the `Shop` model includes the `ShopSessionStorage` concern, a DB migration can be generated with `rails generate shopify_app:shop_model --skip` to add the `expires_at`, `refresh_token`, and `refresh_token_expires_at` attributes to the model. + +**Automatic Refresh:** Offline access tokens can be automatically refreshed using the stored refresh token when expired. See [Automatic Access Token Refresh](#automatic-access-token-refresh) for more details. + +**Note:** If you choose not to enable expiring offline tokens, the `expires_at`, `refresh_token`, and `refresh_token_expires_at` columns will remain `NULL` and no automatic refresh will occur. ## Migrating from shop-based to user-based token strategy From 80a20543b8147fcd14d12b1bc7223297f647a3a2 Mon Sep 17 00:00:00 2001 From: Zoey Lan Date: Tue, 18 Nov 2025 10:55:13 -0700 Subject: [PATCH 6/9] Add DB migration template to generator --- CHANGELOG.md | 9 +- README.md | 4 +- docs/Upgrading.md | 13 +- docs/shopify_app/sessions.md | 145 +++++++++++------- .../shop_model/shop_model_generator.rb | 18 +++ .../add_shop_access_token_expiry_columns.erb | 7 + .../shopify_app/shop_model/templates/shop.rb | 2 +- .../shopify_app/user_model/templates/user.rb | 2 +- .../shop_session_storage_with_scopes.rb | 4 +- .../user_session_storage_with_scopes.rb | 2 +- test/generators/shop_model_generator_test.rb | 24 ++- test/generators/user_model_generator_test.rb | 2 +- 12 files changed, 146 insertions(+), 86 deletions(-) create mode 100644 lib/generators/shopify_app/shop_model/templates/db/migrate/add_shop_access_token_expiry_columns.erb diff --git a/CHANGELOG.md b/CHANGELOG.md index 8659718ef..bd2c65b61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,12 @@ Unreleased - ⚠️ [Breaking] Removes `ShopifyApp::JWTMiddleware` and `ShopifyApp::JWT` See [Upgrading](/docs/Upgrading.md) for more migration. [1960](https://github.com/Shopify/shopify_app/pull/1960) - ⚠️ [Breaking] Removed deprecated `CallbackController` methods. `perform_after_authenticate_job`, `install_webhooks`, and `perform_post_authenticate_jobs` have been removed. [#1961](https://github.com/Shopify/shopify_app/pull/1961) - ⚠️ [Breaking] Bumps minimum supported Ruby version to 3.1 [#1959](https://github.com/Shopify/shopify_app/pull/1959) -- Adds automatic offline access token refresh support for `ShopSessionStorage`. When using `with_shopify_session`, expired offline access tokens will automatically be refreshed using refresh tokens. [#XXXX](https://github.com/Shopify/shopify_app/pull/XXXX) - - Adds `refresh_token_if_expired!` public method to `ShopSessionStorage` for manual token refresh - - Adds `RefreshTokenExpiredError` exception raised when refresh token itself is expired +- Adds support for expiring offline access tokens with automatic refresh for `ShopSessionStorage`. Apps can now opt-in to expiring offline tokens via `ShopifyAPI::Context.setup(offline_access_token_expires: true)`, and `ShopSessionStorage` will automatically refresh expired tokens when using `with_shopify_session`. **See [migration guide](/docs/shopify_app/sessions.md#migrating-to-expiring-offline-access-tokens) for setup instructions.** [#2027](https://github.com/Shopify/shopify_app/pull/2027) - `ShopSessionStorage` now automatically stores `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` from auth sessions - `UserSessionStorage` now automatically stores `access_scopes` and `expires_at` from auth sessions (refresh tokens not applicable for online/user tokens) - - See [Sessions documentation](/docs/shopify_app/sessions.md#automatic-access-token-refresh) for migration guide -- Deprecates `ShopSessionStorageWithScopes` and `UserSessionStorageWithScopes` in favor of `ShopSessionStorage` and `UserSessionStorage`, which now handle session attributes automatically. Will be removed in v23.0.0. [#XXXX](https://github.com/Shopify/shopify_app/pull/XXXX) + - Adds `refresh_token_if_expired!` public method to `ShopSessionStorage` for manual token refresh + - Adds `RefreshTokenExpiredError` exception raised when refresh token itself is expired + - Mark deprecation for `ShopSessionStorageWithScopes` and `UserSessionStorageWithScopes` in favor of `ShopSessionStorage` and `UserSessionStorage`. - Adds a `script_tag_manager` that will automatically create script tags when the app is installed. [1948](https://github.com/Shopify/shopify_app/pull/1948) - Handle invalid token when adding redirection headers [#1945](https://github.com/Shopify/shopify_app/pull/1945) - Handle invalid record error for concurrent token exchange calls [#1966](https://github.com/Shopify/shopify_app/pull/1966) diff --git a/README.md b/README.md index f00677c2f..ed3809542 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,6 @@ You can find documentation on gem usage, concepts, mixins, installation, and mor * [Controller Concerns](/docs/shopify_app/controller-concerns.md) * [Generators](/docs/shopify_app/generators.md) * [Sessions](/docs/shopify_app/sessions.md) - * [Handling changes in access scopes](/docs/shopify_app/handling-access-scopes-changes.md) * [Testing](/docs/shopify_app/testing.md) * [Webhooks](/docs/shopify_app/webhooks.md) * [Content Security Policy](/docs/shopify_app/content-security-policy.md) @@ -243,8 +242,7 @@ ShopifyApp.configure do |config| config.embedded_app = true config.new_embedded_auth_strategy = true - # If your app is configured to use online sessions, you can enable session expiry date check so a new access token - # is fetched automatically when the session expires. + # You can enable session expiry date check so a new access token is fetched automatically when the session expires. # See expiry date check docs: https://github.com/Shopify/shopify_app/blob/main/docs/shopify_app/sessions.md#expiry-date config.check_session_expiry_date = true ... diff --git a/docs/Upgrading.md b/docs/Upgrading.md index a2875f09a..616a95b3c 100644 --- a/docs/Upgrading.md +++ b/docs/Upgrading.md @@ -79,18 +79,7 @@ class User < ActiveRecord::Base end ``` -3. **Optional but recommended:** Add columns to enable automatic token refresh for shops: -```ruby -class AddTokenRefreshFieldsToShops < ActiveRecord::Migration[7.0] - def change - add_column :shops, :expires_at, :datetime - add_column :shops, :refresh_token, :string - add_column :shops, :refresh_token_expires_at, :datetime - end -end -``` - -With these columns, `ShopSessionStorage#with_shopify_session` will automatically refresh expired offline access tokens. See the [Sessions documentation](/docs/shopify_app/sessions.md#automatic-access-token-refresh) for more details. +3. **Optional:** You can now opt-in to using expiring offline access tokens with automatic refresh. See the [Sessions documentation](/docs/shopify_app/sessions.md#offline-access-tokens) for setup instructions. **Note:** If you had custom `access_scopes=` or `access_scopes` methods in your models, these are no longer needed. The base concerns now handle these attributes automatically. diff --git a/docs/shopify_app/sessions.md b/docs/shopify_app/sessions.md index 6bb5529d5..b1313cd3e 100644 --- a/docs/shopify_app/sessions.md +++ b/docs/shopify_app/sessions.md @@ -23,6 +23,7 @@ Sessions are used to make contextual API calls for either a shop (offline sessio - [Access scopes](#access-scopes) - [`ShopifyApp::ShopSessionStorageWithScopes`](#shopifyappshopsessionstoragewithscopes) - [`ShopifyApp::UserSessionStorageWithScopes`](#shopifyappusersessionstoragewithscopes) + - [Migrating to Expiring Offline Access Tokens](#migrating-to-expiring-offline-access-tokens) - [Migrating from shop-based to user-based token strategy](#migrating-from-shop-based-to-user-based-token-strategy) - [Migrating from `ShopifyApi::Auth::SessionStorage` to `ShopifyApp::SessionStorage`](#migrating-from-shopifyapiauthsessionstorage-to-shopifyappsessionstorage) @@ -206,60 +207,18 @@ user.with_shopify_session do end ``` -##### Automatic Access Token Refresh +**Automatic Token Refresh for Shop Sessions:** -`ShopSessionStorage` includes automatic token refresh for expired offline access tokens. When using `with_shopify_session` on a Shop model, the gem will automatically refresh the access token if it has expired, using the refresh token stored in the database. +When using `Shop` models with [expiring offline access tokens](#migrating-to-expiring-offline-access-tokens) configured, `with_shopify_session` will automatically refresh expired tokens before executing the block. This ensures your API calls always use valid credentials without manual intervention. -**Requirements:** -- Shop model must include `ShopSessionStorage` concern -- Database must have the following columns: - - `expires_at` (datetime) - when the access token expires - - `refresh_token` (string) - the refresh token - - `refresh_token_expires_at` (datetime) - when the refresh token expires +To disable automatic refresh, pass `auto_refresh: false`: -**Migration Example:** ```ruby -class AddTokenRefreshFieldsToShops < ActiveRecord::Migration[7.0] - def change - add_column :shops, :expires_at, :datetime - add_column :shops, :refresh_token, :string - add_column :shops, :refresh_token_expires_at, :datetime - end -end -``` - -**Usage:** -```ruby -shop = Shop.find_by(shopify_domain: "example.myshopify.com") - -# Automatic refresh (default behavior) -shop.with_shopify_session do - # If the token is expired, it will be automatically refreshed before making API calls - ShopifyAPI::Product.all -end - -# Disable automatic refresh if needed shop.with_shopify_session(auto_refresh: false) do # Token will NOT be refreshed even if expired - ShopifyAPI::Product.all -end - -# Manual refresh -begin - shop.refresh_token_if_expired! -rescue ShopifyApp::RefreshTokenExpiredError - # Handle case where refresh token itself has expired - # App needs to go through OAuth flow again end ``` -**Error Handling:** -- `ShopifyApp::RefreshTokenExpiredError` is raised when the refresh token itself is expired -- When this happens, the shop must go through the OAuth flow again to get new tokens -- The refresh process uses database row-level locking to prevent race conditions from concurrent requests - -**Note:** Refresh tokens are only available for offline (shop) access tokens. Online (user) access tokens do not support refresh and must be re-authorized through OAuth when expired. - #### Re-fetching an access token when API returns Unauthorized When using `ShopifyApp::EnsureHasSession` and the `new_embedded_auth_strategy` configuration, any **unhandled** Unauthorized `ShopifyAPI::Errors::HttpResponseError` will cause the app to perform token exchange to fetch a new access token from Shopify and the action to be executed again. This will update and store the new access token to the current session instance. @@ -355,41 +314,109 @@ end ``` ## Expiry date -When the configuration flag `check_session_expiry_date` is set to true, the session expiry date will be checked to trigger a re-auth and get a fresh user token when it is expired. -This requires the `ShopifyAPI::Auth::Session` `expires` attribute to be stored. +When the configuration flag `check_session_expiry_date` is set to true, the session expiry date will be checked to trigger a re-auth and get a fresh token when it is expired. +This requires the `ShopifyAPI::Auth::Session` `expires` attribute to be stored. -### Online access tokens -When the `User` model includes the `UserSessionStorage` concern, a DB migration can be generated with `rails generate shopify_app:user_model --skip` to add the `expires_at` attribute to the model. +**Online access tokens (User sessions):** +- When the `User` model includes the `UserSessionStorage` concern, a DB migration can be generated with `rails generate shopify_app:user_model --skip` to add the `expires_at` attribute to the model +- Online access tokens cannot be refreshed, so when the token is expired, the user must go through the OAuth flow again to get a new token -Online access tokens can not be refreshed, so when the token is expired, the user must go through the OAuth flow again to get a new token. +**Offline access tokens (Shop sessions):** +- Offline access tokens can optionally be configured to expire and support automatic refresh. See [Migrating to Expiring Offline Access Tokens](#migrating-to-expiring-offline-access-tokens) for detailed setup instructions -### Offline access tokens +## Migrating to Expiring Offline Access Tokens -**Optional Configuration:** By default, offline access tokens do not expire. However, you can opt-in to expiring offline access tokens for enhanced security by configuring it through `ShopifyAPI::Context`: +You can opt-in to expiring offline access tokens for enhanced security. When enabled, Shopify will issue offline access tokens with an expiration date and a refresh token. `ShopSessionStorage` will then automatically refresh expired tokens when using `with_shopify_session`. + +**1. Database Migration:** + +Run the shop model generator (use `--skip` to avoid regenerating the Shop model if it already exists): + +```bash +rails generate shopify_app:shop_model --skip +``` + +The generator will prompt you to create a migration that adds the `expires_at`, `refresh_token`, and `refresh_token_expires_at` columns. Alternatively, you can create the migration manually: + +```ruby +class AddShopAccessTokenExpiryColumns < ActiveRecord::Migration[7.0] + def change + add_column :shops, :expires_at, :datetime + add_column :shops, :refresh_token, :string + add_column :shops, :refresh_token_expires_at, :datetime + end +end +``` + +**2. Update Model Concern:** + +If your Shop model is using the deprecated `ShopSessionStorageWithScopes` concern, update it to use `ShopSessionStorage`: + +```ruby +# app/models/shop.rb +class Shop < ActiveRecord::Base + include ShopifyApp::ShopSessionStorage # Change from ShopSessionStorageWithScopes +end +``` + +`ShopSessionStorage` now automatically handles `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` - no additional concerns needed. + +**3. Configuration:** ```ruby # config/initializers/shopify_app.rb ShopifyApp.configure do |config| # ... other configuration - # Enable checking session expiry dates + # Enable automatic reauthentication when session is expired config.check_session_expiry_date = true end -# For ShopifyAPI Context - enable expiring offline tokens +# For ShopifyAPI Context - enable requesting expiring offline tokens ShopifyAPI::Context.setup( # ... other configuration - offline_access_token_expires: true, # Opt-in to expiring offline tokens + offline_access_token_expires: true, # Opt-in to start requesting expiring offline tokens ) ``` -When expiring offline tokens are enabled, Shopify will issue offline access tokens with an expiration date and a refresh token. Your app can then automatically refresh these tokens when they expire. +**4. Refreshing Expired Tokens:** + +With the configuration enabled, expired tokens are automatically handled differently based on the flow: + +**For user-facing requests (OAuth/Token Exchange flow):** +When `check_session_expiry_date` is enabled, expired sessions trigger automatic re-authentication through the OAuth flow. This happens transparently when using controller concerns like `EnsureHasSession`. + +**For background jobs and non-user interactions:** +Tokens are automatically refreshed when using `with_shopify_session` from `ShopSessionStorage`: -**Database Setup:** When the `Shop` model includes the `ShopSessionStorage` concern, a DB migration can be generated with `rails generate shopify_app:shop_model --skip` to add the `expires_at`, `refresh_token`, and `refresh_token_expires_at` attributes to the model. +```ruby +shop = Shop.find_by(shopify_domain: "example.myshopify.com") -**Automatic Refresh:** Offline access tokens can be automatically refreshed using the stored refresh token when expired. See [Automatic Access Token Refresh](#automatic-access-token-refresh) for more details. +# Automatic refresh (default behavior) +shop.with_shopify_session do + # If the token is expired, it will be automatically refreshed before making API calls +end + +# Disable automatic refresh if needed +shop.with_shopify_session(auto_refresh: false) do + # Token will NOT be refreshed even if expired +end + +# Manual refresh +begin + shop.refresh_token_if_expired! +rescue ShopifyApp::RefreshTokenExpiredError + # Handle case where refresh token itself has expired + # App needs to go through OAuth flow again +end +``` + +**Error Handling:** +- `ShopifyApp::RefreshTokenExpiredError` is raised when the refresh token itself is expired +- When this happens, the user must interact with the app to go through the OAuth flow again to get new tokens +- The refresh process uses database row-level locking to prevent race conditions from concurrent requests -**Note:** If you choose not to enable expiring offline tokens, the `expires_at`, `refresh_token`, and `refresh_token_expires_at` columns will remain `NULL` and no automatic refresh will occur. +**Note:** If you choose not to enable expiring offline tokens, the `expires_at`, `refresh_token`, and `refresh_token_expires_at` columns will remain `NULL` and no automatic refresh will occur. Refresh tokens are only available for offline (shop) access tokens. Online (user) access tokens do not support refresh and must be re-authorized through OAuth when expired. ## Migrating from shop-based to user-based token strategy diff --git a/lib/generators/shopify_app/shop_model/shop_model_generator.rb b/lib/generators/shopify_app/shop_model/shop_model_generator.rb index f3caacf31..7f5693776 100644 --- a/lib/generators/shopify_app/shop_model/shop_model_generator.rb +++ b/lib/generators/shopify_app/shop_model/shop_model_generator.rb @@ -40,6 +40,24 @@ def create_shop_with_access_scopes_migration end end + def create_shop_with_token_refresh_migration + token_refresh_prompt = <<~PROMPT + To support expiring offline access token with refresh, your Shop model needs to store \ + token expiration dates and refresh tokens. + + The following migration will add `expires_at`, `refresh_token`, and \ + `refresh_token_expires_at` columns to the Shop model. \ + Do you want to include this migration? [y/n] + PROMPT + + if new_shopify_cli_app? || Rails.env.test? || yes?(token_refresh_prompt) + migration_template( + "db/migrate/add_shop_access_token_expiry_columns.erb", + "db/migrate/add_shop_access_token_expiry_columns.rb", + ) + end + end + def update_shopify_app_initializer gsub_file("config/initializers/shopify_app.rb", "ShopifyApp::InMemoryShopSessionStore", "Shop") end diff --git a/lib/generators/shopify_app/shop_model/templates/db/migrate/add_shop_access_token_expiry_columns.erb b/lib/generators/shopify_app/shop_model/templates/db/migrate/add_shop_access_token_expiry_columns.erb new file mode 100644 index 000000000..25d8b1bbb --- /dev/null +++ b/lib/generators/shopify_app/shop_model/templates/db/migrate/add_shop_access_token_expiry_columns.erb @@ -0,0 +1,7 @@ +class AddShopAccessTokenExpiryColumns < ActiveRecord::Migration[<%= rails_migration_version %>] + def change + add_column :shops, :expires_at, :datetime + add_column :shops, :refresh_token, :string + add_column :shops, :refresh_token_expires_at, :datetime + end +end diff --git a/lib/generators/shopify_app/shop_model/templates/shop.rb b/lib/generators/shopify_app/shop_model/templates/shop.rb index 4a23866b8..59ba7692f 100644 --- a/lib/generators/shopify_app/shop_model/templates/shop.rb +++ b/lib/generators/shopify_app/shop_model/templates/shop.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Shop < ActiveRecord::Base - include ShopifyApp::ShopSessionStorageWithScopes + include ShopifyApp::ShopSessionStorage def api_version ShopifyApp.configuration.api_version diff --git a/lib/generators/shopify_app/user_model/templates/user.rb b/lib/generators/shopify_app/user_model/templates/user.rb index 2ed254a41..f847d9983 100644 --- a/lib/generators/shopify_app/user_model/templates/user.rb +++ b/lib/generators/shopify_app/user_model/templates/user.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class User < ActiveRecord::Base - include ShopifyApp::UserSessionStorageWithScopes + include ShopifyApp::UserSessionStorage def api_version ShopifyApp.configuration.api_version diff --git a/lib/shopify_app/session/shop_session_storage_with_scopes.rb b/lib/shopify_app/session/shop_session_storage_with_scopes.rb index e85aef3fe..9309a27bc 100644 --- a/lib/shopify_app/session/shop_session_storage_with_scopes.rb +++ b/lib/shopify_app/session/shop_session_storage_with_scopes.rb @@ -8,8 +8,8 @@ module ShopSessionStorageWithScopes included do ShopifyApp::Logger.deprecated( "ShopSessionStorageWithScopes is deprecated and will be removed in v23.0.0. " \ - "Use ShopSessionStorage instead, which now handles access_scopes, expires_at, " \ - "refresh_token, and refresh_token_expires_at automatically.", + "Use ShopSessionStorage instead, which now handles access_scopes, expires_at, " \ + "refresh_token, and refresh_token_expires_at automatically.", "23.0.0", ) validates :shopify_domain, presence: true, uniqueness: { case_sensitive: false } diff --git a/lib/shopify_app/session/user_session_storage_with_scopes.rb b/lib/shopify_app/session/user_session_storage_with_scopes.rb index 80b6a877a..eedaad0d3 100644 --- a/lib/shopify_app/session/user_session_storage_with_scopes.rb +++ b/lib/shopify_app/session/user_session_storage_with_scopes.rb @@ -8,7 +8,7 @@ module UserSessionStorageWithScopes included do ShopifyApp::Logger.deprecated( "UserSessionStorageWithScopes is deprecated and will be removed in v23.0.0. " \ - "Use UserSessionStorage instead, which now handles access_scopes and expires_at automatically.", + "Use UserSessionStorage instead, which now handles access_scopes and expires_at automatically.", "23.0.0", ) diff --git a/test/generators/shop_model_generator_test.rb b/test/generators/shop_model_generator_test.rb index 54208c2bd..7af6ddded 100644 --- a/test/generators/shop_model_generator_test.rb +++ b/test/generators/shop_model_generator_test.rb @@ -16,7 +16,7 @@ class ShopModelGeneratorTest < Rails::Generators::TestCase run_generator assert_file "app/models/shop.rb" do |shop| assert_match "class Shop < ActiveRecord::Base", shop - assert_match "include ShopifyApp::ShopSessionStorageWithScopes", shop + assert_match "include ShopifyApp::ShopSessionStorage", shop assert_match(/def api_version\n\s*ShopifyApp\.configuration\.api_version\n\s*end/, shop) end end @@ -46,6 +46,28 @@ class ShopModelGeneratorTest < Rails::Generators::TestCase end end + test "create shop with access token expiry columns migration in a test environment" do + run_generator + assert_migration "db/migrate/add_shop_access_token_expiry_columns.rb" do |migration| + assert_match "add_column :shops, :expires_at, :datetime", migration + assert_match "add_column :shops, :refresh_token, :string", migration + assert_match "add_column :shops, :refresh_token_expires_at, :datetime", migration + end + end + + test "create shop with access token expiry columns migration with --new-shopify-cli-app flag provided" do + Rails.env = "mock_environment" + + run_generator ["--new-shopify-cli-app"] + Rails.env = "test" # Change this back for subsequent tests + + assert_migration "db/migrate/add_shop_access_token_expiry_columns.rb" do |migration| + assert_match "add_column :shops, :expires_at, :datetime", migration + assert_match "add_column :shops, :refresh_token, :string", migration + assert_match "add_column :shops, :refresh_token_expires_at, :datetime", migration + end + end + test "updates the shopify_app initializer" do run_generator assert_file "config/initializers/shopify_app.rb" do |file| diff --git a/test/generators/user_model_generator_test.rb b/test/generators/user_model_generator_test.rb index 35896f37e..76cabf1d0 100644 --- a/test/generators/user_model_generator_test.rb +++ b/test/generators/user_model_generator_test.rb @@ -16,7 +16,7 @@ class UserModelGeneratorTest < Rails::Generators::TestCase run_generator assert_file "app/models/user.rb" do |user| assert_match "class User < ActiveRecord::Base", user - assert_match "include ShopifyApp::UserSessionStorageWithScopes", user + assert_match "include ShopifyApp::UserSessionStorage", user assert_match(/def api_version\n\s*ShopifyApp\.configuration\.api_version\n\s*end/, user) end end From 36bdc2e61b3484649d10f22397c50fd7b004f4f2 Mon Sep 17 00:00:00 2001 From: Zoey Lan Date: Tue, 18 Nov 2025 16:27:45 -0700 Subject: [PATCH 7/9] Provide example for migrating non expiring offline tokens for expiring ones --- docs/shopify_app/sessions.md | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/shopify_app/sessions.md b/docs/shopify_app/sessions.md index b1313cd3e..4492d104a 100644 --- a/docs/shopify_app/sessions.md +++ b/docs/shopify_app/sessions.md @@ -416,6 +416,49 @@ end - When this happens, the user must interact with the app to go through the OAuth flow again to get new tokens - The refresh process uses database row-level locking to prevent race conditions from concurrent requests +**5. Migrating Existing Shop Installations:** + +⚠️ **Important:** When you enable `offline_access_token_expires: true`, only **new shop installations** will automatically receive expiring tokens during the OAuth flow. Existing shop installations with non-expiring tokens will continue using their current tokens until manually migrated. + +To migrate existing shops to expiring tokens, use the `ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token` method. Here's an example background job to migrate all existing shops: + +```ruby +# app/jobs/migrate_shops_to_expiring_tokens_job.rb +class MigrateShopsToExpiringTokensJob < ActiveJob::Base + queue_as :default + + def perform + # Find shops that haven't been migrated yet (no refresh_token or expires_at) + shops_to_migrate = Shop.where(expires_at: nil, refresh_token: nil, refresh_token_expires_at: nil) + + shops_to_migrate.find_each do |shop| + begin + # Migrate to expiring token + new_session = ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token( + shop: shop.shopify_domain, + non_expiring_offline_token: shop.shopify_token + ) + + # Store the new session with expiring token and refresh token + Shop.store(new_session) + + Rails.logger.info("Successfully migrated #{shop.shopify_domain} to expiring token") + rescue ShopifyAPI::Errors::HttpResponseError => e + # Handle migration errors (e.g., shop uninstalled, network issues) + Rails.logger.error("Failed to migrate #{shop.shopify_domain}: #{e.message}") + rescue => e + Rails.logger.error("Unexpected error migrating #{shop.shopify_domain}: #{e.message}") + end + end + end +end +``` + +**Migration notes:** +- This is a **one-time, irreversible operation** per shop +- The shop must have the app installed and have a valid access token +- After migration, the shop's offline token will have an expiration date and a refresh token + **Note:** If you choose not to enable expiring offline tokens, the `expires_at`, `refresh_token`, and `refresh_token_expires_at` columns will remain `NULL` and no automatic refresh will occur. Refresh tokens are only available for offline (shop) access tokens. Online (user) access tokens do not support refresh and must be re-authorized through OAuth when expired. ## Migrating from shop-based to user-based token strategy From b1fc552bcb87c9008384aad6daaaa7dc4eb1ebfc Mon Sep 17 00:00:00 2001 From: Zoey Lan Date: Tue, 2 Dec 2025 15:34:07 -0700 Subject: [PATCH 8/9] Update deprecation version --- docs/Upgrading.md | 2 +- lib/shopify_app/session/shop_session_storage_with_scopes.rb | 2 +- lib/shopify_app/session/user_session_storage_with_scopes.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/Upgrading.md b/docs/Upgrading.md index 616a95b3c..397747c3a 100644 --- a/docs/Upgrading.md +++ b/docs/Upgrading.md @@ -49,7 +49,7 @@ The minimum ruby version is now 3.1 #### (v23.0.0) - ShopSessionStorageWithScopes and UserSessionStorageWithScopes are deprecated -`ShopSessionStorageWithScopes` and `UserSessionStorageWithScopes` are now deprecated in favor of `ShopSessionStorage` and `UserSessionStorage`, which handle all session attributes automatically (including `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` for shops). +`ShopSessionStorageWithScopes` and `UserSessionStorageWithScopes` are now marked as deprecated and will be removed in v24.0.0 in favor of `ShopSessionStorage` and `UserSessionStorage`, which handle all session attributes automatically (including `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` for shops). **Migration:** diff --git a/lib/shopify_app/session/shop_session_storage_with_scopes.rb b/lib/shopify_app/session/shop_session_storage_with_scopes.rb index 9309a27bc..57031f1f5 100644 --- a/lib/shopify_app/session/shop_session_storage_with_scopes.rb +++ b/lib/shopify_app/session/shop_session_storage_with_scopes.rb @@ -7,7 +7,7 @@ module ShopSessionStorageWithScopes included do ShopifyApp::Logger.deprecated( - "ShopSessionStorageWithScopes is deprecated and will be removed in v23.0.0. " \ + "ShopSessionStorageWithScopes is deprecated and will be removed in v24.0.0. " \ "Use ShopSessionStorage instead, which now handles access_scopes, expires_at, " \ "refresh_token, and refresh_token_expires_at automatically.", "23.0.0", diff --git a/lib/shopify_app/session/user_session_storage_with_scopes.rb b/lib/shopify_app/session/user_session_storage_with_scopes.rb index eedaad0d3..c7f804000 100644 --- a/lib/shopify_app/session/user_session_storage_with_scopes.rb +++ b/lib/shopify_app/session/user_session_storage_with_scopes.rb @@ -7,7 +7,7 @@ module UserSessionStorageWithScopes included do ShopifyApp::Logger.deprecated( - "UserSessionStorageWithScopes is deprecated and will be removed in v23.0.0. " \ + "UserSessionStorageWithScopes is deprecated and will be removed in v24.0.0. " \ "Use UserSessionStorage instead, which now handles access_scopes and expires_at automatically.", "23.0.0", ) From a60430d5b4cdf2a5a5cf18037a367c61e3d4aaf7 Mon Sep 17 00:00:00 2001 From: Zoey Lan Date: Mon, 8 Dec 2025 08:34:49 -0700 Subject: [PATCH 9/9] Fix name in docs --- docs/shopify_app/sessions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/shopify_app/sessions.md b/docs/shopify_app/sessions.md index 4492d104a..1351b81ee 100644 --- a/docs/shopify_app/sessions.md +++ b/docs/shopify_app/sessions.md @@ -375,7 +375,7 @@ end # For ShopifyAPI Context - enable requesting expiring offline tokens ShopifyAPI::Context.setup( # ... other configuration - offline_access_token_expires: true, # Opt-in to start requesting expiring offline tokens + expiring_offline_access_tokens: true, # Opt-in to start requesting expiring offline tokens ) ```