Skip to content

Commit 41f68ba

Browse files
committed
Add auto token refresh logic to ShopSessionStorage
1 parent 43c028d commit 41f68ba

File tree

4 files changed

+311
-0
lines changed

4 files changed

+311
-0
lines changed

lib/shopify_app/errors.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,6 @@ class MissingWebhookJobError < StandardError; end
3131
class ShopifyDomainNotFound < StandardError; end
3232

3333
class ShopifyHostNotFound < StandardError; end
34+
35+
class RefreshTokenExpiredError < StandardError; end
3436
end

lib/shopify_app/session/shop_session_storage.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,25 @@ module ShopSessionStorage
99
validates :shopify_domain, presence: true, uniqueness: { case_sensitive: false }
1010
end
1111

12+
def with_shopify_session(auto_refresh: true, &block)
13+
refresh_token_if_expired! if auto_refresh
14+
super(&block)
15+
end
16+
17+
def refresh_token_if_expired!
18+
return unless should_refresh?
19+
raise RefreshTokenExpiredError if refresh_token_expired?
20+
21+
# Acquire row lock to prevent concurrent refreshes
22+
with_lock do
23+
reload
24+
# Check again after lock - token might have been refreshed by another process
25+
return unless should_refresh?
26+
27+
perform_token_refresh!
28+
end
29+
end
30+
1231
class_methods do
1332
def store(auth_session, *_args)
1433
shop = find_or_initialize_by(shopify_domain: auth_session.shop)
@@ -77,5 +96,33 @@ def construct_session(shop)
7796
ShopifyAPI::Auth::Session.new(**session_attrs)
7897
end
7998
end
99+
100+
def perform_token_refresh!
101+
new_session = ShopifyAPI::Auth::RefreshToken.refresh_access_token(
102+
shop: shopify_domain,
103+
refresh_token: refresh_token,
104+
)
105+
106+
update!(
107+
shopify_token: new_session.access_token,
108+
expires_at: new_session.expires,
109+
refresh_token: new_session.refresh_token,
110+
refresh_token_expires_at: new_session.refresh_token_expires,
111+
)
112+
end
113+
114+
def should_refresh?
115+
return false unless has_attribute?(:expires_at) && expires_at.present?
116+
return false unless has_attribute?(:refresh_token) && refresh_token.present?
117+
return false unless has_attribute?(:refresh_token_expires_at) && refresh_token_expires_at.present?
118+
119+
expires_at <= Time.now
120+
end
121+
122+
def refresh_token_expired?
123+
return false unless has_attribute?(:refresh_token_expires_at) && refresh_token_expires_at.present?
124+
125+
refresh_token_expires_at <= Time.now
126+
end
80127
end
81128
end

test/shopify_app/session/shop_session_storage_test.rb

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,5 +301,247 @@ class ShopSessionStorageTest < ActiveSupport::TestCase
301301
assert_nil session.refresh_token
302302
assert_nil session.refresh_token_expires
303303
end
304+
305+
test "#refresh_token_if_expired! does nothing when token is not expired" do
306+
shop = MockShopInstance.new(
307+
shopify_domain: TEST_SHOPIFY_DOMAIN,
308+
shopify_token: TEST_SHOPIFY_TOKEN,
309+
expires_at: 1.day.from_now,
310+
refresh_token: "refresh-token",
311+
refresh_token_expires_at: 30.days.from_now,
312+
available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at],
313+
)
314+
315+
ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never
316+
shop.expects(:update!).never
317+
318+
shop.refresh_token_if_expired!
319+
320+
# Token should remain unchanged
321+
assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token
322+
end
323+
324+
test "#refresh_token_if_expired! refreshes when token is expired" do
325+
expired_time = 1.hour.ago
326+
new_expiry = 1.day.from_now
327+
new_refresh_token_expiry = 30.days.from_now
328+
329+
shop = MockShopInstance.new(
330+
shopify_domain: TEST_SHOPIFY_DOMAIN,
331+
shopify_token: "old-token",
332+
expires_at: expired_time,
333+
refresh_token: "refresh-token",
334+
refresh_token_expires_at: 30.days.from_now,
335+
available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at],
336+
)
337+
338+
# Mock the refresh response
339+
new_session = mock
340+
new_session.stubs(:access_token).returns("new-token")
341+
new_session.stubs(:expires).returns(new_expiry)
342+
new_session.stubs(:refresh_token).returns("new-refresh-token")
343+
new_session.stubs(:refresh_token_expires).returns(new_refresh_token_expiry)
344+
345+
ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token)
346+
.with(shop: TEST_SHOPIFY_DOMAIN, refresh_token: "refresh-token")
347+
.returns(new_session)
348+
349+
shop.refresh_token_if_expired!
350+
351+
# Verify the token was updated
352+
assert_equal "new-token", shop.shopify_token
353+
assert_equal new_expiry, shop.expires_at
354+
assert_equal "new-refresh-token", shop.refresh_token
355+
assert_equal new_refresh_token_expiry, shop.refresh_token_expires_at
356+
end
357+
358+
test "#refresh_token_if_expired! raises error when refresh token is expired" do
359+
shop = MockShopInstance.new(
360+
shopify_domain: TEST_SHOPIFY_DOMAIN,
361+
shopify_token: "old-token",
362+
expires_at: 1.hour.ago,
363+
refresh_token: "refresh-token",
364+
refresh_token_expires_at: 1.hour.ago,
365+
available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at],
366+
)
367+
368+
ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never
369+
370+
assert_raises(ShopifyApp::RefreshTokenExpiredError) do
371+
shop.refresh_token_if_expired!
372+
end
373+
end
374+
375+
test "#refresh_token_if_expired! does nothing when refresh_token column doesn't exist" do
376+
shop = MockShopInstance.new(
377+
shopify_domain: TEST_SHOPIFY_DOMAIN,
378+
shopify_token: TEST_SHOPIFY_TOKEN,
379+
expires_at: 1.hour.ago,
380+
refresh_token_expires_at: 30.days.from_now,
381+
available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token_expires_at],
382+
)
383+
384+
ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never
385+
shop.expects(:update!).never
386+
387+
shop.refresh_token_if_expired!
388+
389+
assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token
390+
end
391+
392+
test "#refresh_token_if_expired! does nothing when refresh_token is empty" do
393+
shop = MockShopInstance.new(
394+
shopify_domain: TEST_SHOPIFY_DOMAIN,
395+
shopify_token: TEST_SHOPIFY_TOKEN,
396+
expires_at: 1.hour.ago,
397+
refresh_token: "",
398+
refresh_token_expires_at: 30.days.from_now,
399+
available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at],
400+
)
401+
402+
ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never
403+
shop.expects(:update!).never
404+
405+
shop.refresh_token_if_expired!
406+
407+
assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token
408+
end
409+
410+
test "#refresh_token_if_expired! does nothing when expires_at column doesn't exist" do
411+
shop = MockShopInstance.new(
412+
shopify_domain: TEST_SHOPIFY_DOMAIN,
413+
shopify_token: TEST_SHOPIFY_TOKEN,
414+
refresh_token: "refresh-token",
415+
refresh_token_expires_at: 30.days.from_now,
416+
available_attributes: [:shopify_domain, :shopify_token, :refresh_token, :refresh_token_expires_at],
417+
)
418+
419+
ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never
420+
shop.expects(:update!).never
421+
422+
shop.refresh_token_if_expired!
423+
424+
assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token
425+
end
426+
427+
test "#refresh_token_if_expired! does nothing when expires_at is nil" do
428+
shop = MockShopInstance.new(
429+
shopify_domain: TEST_SHOPIFY_DOMAIN,
430+
shopify_token: TEST_SHOPIFY_TOKEN,
431+
expires_at: nil,
432+
refresh_token: "refresh-token",
433+
refresh_token_expires_at: 30.days.from_now,
434+
available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at],
435+
)
436+
437+
ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never
438+
shop.expects(:update!).never
439+
440+
shop.refresh_token_if_expired!
441+
442+
assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token
443+
end
444+
445+
test "#refresh_token_if_expired! does nothing when refresh_token_expires_at column doesn't exist" do
446+
shop = MockShopInstance.new(
447+
shopify_domain: TEST_SHOPIFY_DOMAIN,
448+
shopify_token: TEST_SHOPIFY_TOKEN,
449+
expires_at: 1.hour.ago,
450+
refresh_token: "refresh-token",
451+
available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token],
452+
)
453+
454+
ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never
455+
shop.expects(:update!).never
456+
457+
shop.refresh_token_if_expired!
458+
459+
assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token
460+
end
461+
462+
test "#refresh_token_if_expired! does nothing when refresh_token_expires_at is nil" do
463+
shop = MockShopInstance.new(
464+
shopify_domain: TEST_SHOPIFY_DOMAIN,
465+
shopify_token: TEST_SHOPIFY_TOKEN,
466+
expires_at: 1.hour.ago,
467+
refresh_token: "refresh-token",
468+
refresh_token_expires_at: nil,
469+
available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at],
470+
)
471+
472+
ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never
473+
shop.expects(:update!).never
474+
475+
shop.refresh_token_if_expired!
476+
477+
assert_equal TEST_SHOPIFY_TOKEN, shop.shopify_token
478+
end
479+
480+
test "#refresh_token_if_expired! handles race condition with double-check" do
481+
expired_time = 1.hour.ago
482+
refreshed_time = 1.day.from_now
483+
484+
shop = MockShopInstance.new(
485+
shopify_domain: TEST_SHOPIFY_DOMAIN,
486+
shopify_token: "old-token",
487+
expires_at: expired_time,
488+
refresh_token: "refresh-token",
489+
refresh_token_expires_at: 30.days.from_now,
490+
available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token, :refresh_token_expires_at],
491+
)
492+
493+
# Simulate another process already refreshed the token
494+
shop.expects(:reload).once.with do
495+
shop.expires_at = refreshed_time
496+
shop.shopify_token = "already-refreshed-token"
497+
true
498+
end.returns(shop)
499+
ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never
500+
501+
shop.refresh_token_if_expired!
502+
503+
assert_equal "already-refreshed-token", shop.shopify_token
504+
end
505+
506+
test "#with_shopify_session calls refresh_token_if_expired! by default" do
507+
shop = MockShopInstance.new(
508+
shopify_domain: TEST_SHOPIFY_DOMAIN,
509+
shopify_token: TEST_SHOPIFY_TOKEN,
510+
available_attributes: [:shopify_domain, :shopify_token],
511+
)
512+
513+
shop.expects(:refresh_token_if_expired!).once
514+
515+
block_executed = false
516+
shop.with_shopify_session do
517+
block_executed = true
518+
end
519+
520+
assert block_executed, "Block should have been executed"
521+
end
522+
523+
test "#with_shopify_session skips refresh when auto_refresh is false" do
524+
expired_time = 1.hour.ago
525+
526+
shop = MockShopInstance.new(
527+
shopify_domain: TEST_SHOPIFY_DOMAIN,
528+
shopify_token: "old-token",
529+
expires_at: expired_time,
530+
refresh_token: "refresh-token",
531+
available_attributes: [:shopify_domain, :shopify_token, :expires_at, :refresh_token],
532+
)
533+
534+
# Should NOT refresh even though token is expired
535+
shop.expects(:refresh_token_if_expired!).never
536+
ShopifyAPI::Auth::RefreshToken.expects(:refresh_access_token).never
537+
538+
block_executed = false
539+
540+
shop.with_shopify_session(auto_refresh: false) do
541+
block_executed = true
542+
end
543+
544+
assert block_executed, "Block should have been executed"
545+
end
304546
end
305547
end

test/support/session_store_strategy_test_helpers.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
module SessionStoreStrategyTestHelpers
44
class MockShopInstance
5+
# Stub ActiveRecord validation method before including concern
6+
def self.validates(*args); end
7+
8+
include ShopifyApp::ShopSessionStorage
9+
510
attr_reader :id,
611
:shopify_domain,
712
:shopify_token,
@@ -37,6 +42,21 @@ def initialize(
3742
def has_attribute?(attribute)
3843
@available_attributes.include?(attribute.to_sym)
3944
end
45+
46+
# Stub ActiveRecord methods that the concern uses
47+
def with_lock
48+
yield
49+
end
50+
51+
def reload
52+
self
53+
end
54+
55+
def update!(attrs)
56+
attrs.each do |key, value|
57+
send("#{key}=", value)
58+
end
59+
end
4060
end
4161

4262
class MockUserInstance

0 commit comments

Comments
 (0)