shopify_app 13.0.0 → 13.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rubocop.yml +28 -0
  3. data/.rubocop.yml +13 -6
  4. data/.travis.yml +4 -3
  5. data/CHANGELOG.md +33 -0
  6. data/Gemfile +5 -0
  7. data/README.md +67 -38
  8. data/Rakefile +1 -0
  9. data/SECURITY.md +59 -0
  10. data/app/controllers/concerns/shopify_app/require_known_shop.rb +39 -0
  11. data/app/controllers/shopify_app/authenticated_controller.rb +1 -0
  12. data/app/controllers/shopify_app/callback_controller.rb +39 -8
  13. data/app/controllers/shopify_app/extension_verification_controller.rb +2 -7
  14. data/app/controllers/shopify_app/sessions_controller.rb +9 -6
  15. data/app/controllers/shopify_app/webhooks_controller.rb +6 -5
  16. data/config/locales/fi.yml +1 -1
  17. data/config/locales/nl.yml +7 -7
  18. data/config/routes.rb +1 -0
  19. data/docs/Quickstart.md +5 -14
  20. data/docs/Releasing.md +1 -0
  21. data/lib/generators/shopify_app/add_after_authenticate_job/add_after_authenticate_job_generator.rb +5 -3
  22. data/lib/generators/shopify_app/add_after_authenticate_job/templates/after_authenticate_job.rb +1 -0
  23. data/lib/generators/shopify_app/add_marketing_activity_extension/add_marketing_activity_extension_generator.rb +2 -1
  24. data/lib/generators/shopify_app/add_marketing_activity_extension/templates/marketing_activities_controller.rb +4 -4
  25. data/lib/generators/shopify_app/add_webhook/add_webhook_generator.rb +5 -4
  26. data/lib/generators/shopify_app/add_webhook/templates/{webhook_job.rb → webhook_job.rb.tt} +5 -0
  27. data/lib/generators/shopify_app/app_proxy_controller/app_proxy_controller_generator.rb +4 -3
  28. data/lib/generators/shopify_app/app_proxy_controller/templates/app_proxy_controller.rb +3 -3
  29. data/lib/generators/shopify_app/app_proxy_controller/templates/app_proxy_route.rb +10 -9
  30. data/lib/generators/shopify_app/controllers/controllers_generator.rb +1 -0
  31. data/lib/generators/shopify_app/home_controller/home_controller_generator.rb +4 -3
  32. data/lib/generators/shopify_app/install/install_generator.rb +10 -9
  33. data/lib/generators/shopify_app/install/templates/omniauth.rb +2 -1
  34. data/lib/generators/shopify_app/install/templates/{shopify_app.rb → shopify_app.rb.tt} +1 -1
  35. data/lib/generators/shopify_app/install/templates/user_agent.rb +2 -1
  36. data/lib/generators/shopify_app/routes/routes_generator.rb +1 -0
  37. data/lib/generators/shopify_app/routes/templates/routes.rb +10 -9
  38. data/lib/generators/shopify_app/shop_model/shop_model_generator.rb +12 -7
  39. data/lib/generators/shopify_app/shop_model/templates/shop.rb +1 -0
  40. data/lib/generators/shopify_app/shopify_app_generator.rb +4 -3
  41. data/lib/generators/shopify_app/user_model/templates/user.rb +1 -0
  42. data/lib/generators/shopify_app/user_model/user_model_generator.rb +12 -7
  43. data/lib/generators/shopify_app/views/views_generator.rb +1 -0
  44. data/lib/shopify_app.rb +11 -5
  45. data/lib/shopify_app/configuration.rb +15 -8
  46. data/lib/shopify_app/controller_concerns/app_proxy_verification.rb +3 -3
  47. data/lib/shopify_app/controller_concerns/embedded_app.rb +3 -2
  48. data/lib/shopify_app/controller_concerns/localization.rb +1 -0
  49. data/lib/shopify_app/controller_concerns/login_protection.rb +52 -15
  50. data/lib/shopify_app/controller_concerns/payload_verification.rb +24 -0
  51. data/lib/shopify_app/controller_concerns/webhook_verification.rb +3 -18
  52. data/lib/shopify_app/engine.rb +5 -0
  53. data/lib/shopify_app/jobs/scripttags_manager_job.rb +1 -1
  54. data/lib/shopify_app/jobs/webhooks_manager_job.rb +1 -1
  55. data/lib/shopify_app/managers/scripttags_manager.rb +4 -3
  56. data/lib/shopify_app/managers/webhooks_manager.rb +4 -3
  57. data/lib/shopify_app/middleware/jwt_middleware.rb +42 -0
  58. data/lib/shopify_app/middleware/same_site_cookie_middleware.rb +2 -1
  59. data/lib/shopify_app/session/in_memory_session_store.rb +7 -3
  60. data/lib/shopify_app/session/in_memory_shop_session_store.rb +10 -0
  61. data/lib/shopify_app/session/in_memory_user_session_store.rb +10 -0
  62. data/lib/shopify_app/session/jwt.rb +61 -0
  63. data/lib/shopify_app/session/null_user_session_store.rb +22 -0
  64. data/lib/shopify_app/session/session_repository.rb +13 -16
  65. data/lib/shopify_app/session/session_storage.rb +1 -0
  66. data/lib/shopify_app/session/shop_session_storage.rb +21 -9
  67. data/lib/shopify_app/session/user_session_storage.rb +19 -8
  68. data/lib/shopify_app/test_helpers/all.rb +2 -0
  69. data/lib/shopify_app/test_helpers/webhook_verification_helper.rb +17 -0
  70. data/lib/shopify_app/utils.rb +6 -5
  71. data/lib/shopify_app/version.rb +2 -1
  72. data/package-lock.json +4 -4
  73. data/package.json +1 -1
  74. data/shopify_app.gemspec +12 -7
  75. data/yarn.lock +3 -3
  76. metadata +48 -10
@@ -1,14 +1,14 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  module AppProxyVerification
3
4
  extend ActiveSupport::Concern
4
-
5
5
  included do
6
6
  skip_before_action :verify_authenticity_token, raise: false
7
7
  before_action :verify_proxy_request
8
8
  end
9
9
 
10
10
  def verify_proxy_request
11
- return head :forbidden unless query_string_valid?(request.query_string)
11
+ return head(:forbidden) unless query_string_valid?(request.query_string)
12
12
  end
13
13
 
14
14
  private
@@ -26,7 +26,7 @@ module ShopifyApp
26
26
  end
27
27
 
28
28
  def calculated_signature(query_hash_without_signature)
29
- sorted_params = query_hash_without_signature.collect{|k,v| "#{k}=#{Array(v).join(',')}"}.sort.join
29
+ sorted_params = query_hash_without_signature.collect { |k, v| "#{k}=#{Array(v).join(',')}" }.sort.join
30
30
 
31
31
  OpenSSL::HMAC.hexdigest(
32
32
  OpenSSL::Digest.new('sha256'),
@@ -1,11 +1,12 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  module EmbeddedApp
3
4
  extend ActiveSupport::Concern
4
5
 
5
6
  included do
6
7
  if ShopifyApp.configuration.embedded_app?
7
- after_action :set_esdk_headers
8
- layout 'embedded_app'
8
+ after_action(:set_esdk_headers)
9
+ layout('embedded_app')
9
10
  end
10
11
  end
11
12
 
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  module Localization
3
4
  extend ActiveSupport::Concern
@@ -11,7 +11,7 @@ module ShopifyApp
11
11
 
12
12
  included do
13
13
  after_action :set_test_cookie
14
- rescue_from ActiveResource::UnauthorizedAccess, :with => :close_session
14
+ rescue_from ActiveResource::UnauthorizedAccess, with: :close_session
15
15
  end
16
16
 
17
17
  def activate_shopify_session
@@ -27,20 +27,38 @@ module ShopifyApp
27
27
  end
28
28
 
29
29
  def current_shopify_session
30
- if session[:user_id].present?
31
- @current_shopify_session ||= user_session
32
- else
33
- @current_shopify_session ||= shop_session
30
+ @current_shopify_session ||= begin
31
+ user_session || shop_session
34
32
  end
35
33
  end
36
34
 
37
35
  def user_session
38
- return if session[:user_id].blank?
36
+ user_session_by_jwt || user_session_by_cookie
37
+ end
38
+
39
+ def user_session_by_jwt
40
+ return unless ShopifyApp.configuration.allow_jwt_authentication
41
+ return unless jwt_shopify_user_id
42
+ ShopifyApp::SessionRepository.retrieve_user_session_by_shopify_user_id(jwt_shopify_user_id)
43
+ end
44
+
45
+ def user_session_by_cookie
46
+ return unless session[:user_id].present?
39
47
  ShopifyApp::SessionRepository.retrieve_user_session(session[:user_id])
40
48
  end
41
49
 
42
50
  def shop_session
43
- return if session[:shop_id].blank?
51
+ shop_session_by_jwt || shop_session_by_cookie
52
+ end
53
+
54
+ def shop_session_by_jwt
55
+ return unless ShopifyApp.configuration.allow_jwt_authentication
56
+ return unless jwt_shopify_domain
57
+ ShopifyApp::SessionRepository.retrieve_shop_session_by_shopify_domain(jwt_shopify_domain)
58
+ end
59
+
60
+ def shop_session_by_cookie
61
+ return unless session[:shop_id].present?
44
62
  ShopifyApp::SessionRepository.retrieve_shop_session(session[:shop_id])
45
63
  end
46
64
 
@@ -50,7 +68,9 @@ module ShopifyApp
50
68
 
51
69
  end
52
70
 
53
- if current_shopify_session && params[:shop] && params[:shop].is_a?(String) && (current_shopify_session.domain != params[:shop])
71
+ if current_shopify_session &&
72
+ params[:shop] && params[:shop].is_a?(String) &&
73
+ (current_shopify_session.domain != params[:shop])
54
74
  clear_session = true
55
75
  end
56
76
 
@@ -62,9 +82,17 @@ module ShopifyApp
62
82
 
63
83
  protected
64
84
 
85
+ def jwt_shopify_domain
86
+ request.env['jwt.shopify_domain']
87
+ end
88
+
89
+ def jwt_shopify_user_id
90
+ request.env['jwt.shopify_user_id']
91
+ end
92
+
65
93
  def redirect_to_login
66
94
  if request.xhr?
67
- head :unauthorized
95
+ head(:unauthorized)
68
96
  else
69
97
  if request.get?
70
98
  path = request.path
@@ -74,7 +102,7 @@ module ShopifyApp
74
102
  path = referer.path
75
103
  query = "#{referer.query}&#{sanitized_params.to_query}"
76
104
  end
77
- session[:return_to] = "#{path}?#{query}"
105
+ session[:return_to] = query.blank? ? path.to_s : "#{path}?#{query}"
78
106
  redirect_to(login_url_with_optional_shop)
79
107
  end
80
108
  end
@@ -105,7 +133,7 @@ module ShopifyApp
105
133
  query_params = {}
106
134
  query_params[:shop] = sanitized_params[:shop] if params[:shop].present?
107
135
 
108
- return_to = session[:return_to] || params[:return_to]
136
+ return_to = RedirectSafely.make_safe(session[:return_to] || params[:return_to], nil)
109
137
 
110
138
  if return_to.present? && return_to_param_required?
111
139
  query_params[:return_to] = return_to
@@ -128,14 +156,18 @@ module ShopifyApp
128
156
 
129
157
  def fullpage_redirect_to(url)
130
158
  if ShopifyApp.configuration.embedded_app?
131
- render 'shopify_app/shared/redirect', layout: false, locals: { url: url, current_shopify_domain: current_shopify_domain }
159
+ render('shopify_app/shared/redirect', layout: false,
160
+ locals: { url: url, current_shopify_domain: current_shopify_domain })
132
161
  else
133
- redirect_to url
162
+ redirect_to(url)
134
163
  end
135
164
  end
136
165
 
137
166
  def current_shopify_domain
138
- shopify_domain = sanitized_shop_name || session[:shopify_domain]
167
+ shopify_domain = sanitized_shop_name ||
168
+ jwt_shopify_domain ||
169
+ session[:shopify_domain]
170
+
139
171
  return shopify_domain if shopify_domain.present?
140
172
 
141
173
  raise ShopifyDomainNotFound
@@ -170,11 +202,16 @@ module ShopifyApp
170
202
  end
171
203
 
172
204
  def return_address
205
+ return base_return_address unless ShopifyApp.configuration.allow_jwt_authentication
206
+ return_address_with_params(shop: current_shopify_domain)
207
+ end
208
+
209
+ def base_return_address
173
210
  session.delete(:return_to) || ShopifyApp.configuration.root_url
174
211
  end
175
212
 
176
213
  def return_address_with_params(params)
177
- uri = URI(return_address)
214
+ uri = URI(base_return_address)
178
215
  uri.query = CGI.parse(uri.query.to_s)
179
216
  .symbolize_keys
180
217
  .transform_values { |v| v.one? ? v.first : v }
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ module ShopifyApp
3
+ module PayloadVerification
4
+ extend ActiveSupport::Concern
5
+
6
+ private
7
+
8
+ def shopify_hmac
9
+ request.headers['HTTP_X_SHOPIFY_HMAC_SHA256']
10
+ end
11
+
12
+ def hmac_valid?(data)
13
+ secrets = [ShopifyApp.configuration.secret, ShopifyApp.configuration.old_secret].reject(&:blank?)
14
+
15
+ secrets.any? do |secret|
16
+ digest = OpenSSL::Digest.new('sha256')
17
+ ActiveSupport::SecurityUtils.secure_compare(
18
+ shopify_hmac,
19
+ Base64.strict_encode64(OpenSSL::HMAC.digest(digest, secret, data))
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  module WebhookVerification
3
4
  extend ActiveSupport::Concern
5
+ include ShopifyApp::PayloadVerification
4
6
 
5
7
  included do
6
8
  skip_before_action :verify_authenticity_token, raise: false
@@ -11,28 +13,11 @@ module ShopifyApp
11
13
 
12
14
  def verify_request
13
15
  data = request.raw_post
14
- return head :unauthorized unless hmac_valid?(data)
15
- end
16
-
17
- def hmac_valid?(data)
18
- secrets = [ShopifyApp.configuration.secret, ShopifyApp.configuration.old_secret].reject(&:blank?)
19
-
20
- secrets.any? do |secret|
21
- digest = OpenSSL::Digest.new('sha256')
22
-
23
- ActiveSupport::SecurityUtils.secure_compare(
24
- shopify_hmac,
25
- Base64.strict_encode64(OpenSSL::HMAC.digest(digest, secret, data))
26
- )
27
- end
16
+ return head(:unauthorized) unless hmac_valid?(data)
28
17
  end
29
18
 
30
19
  def shop_domain
31
20
  request.headers['HTTP_X_SHOPIFY_SHOP_DOMAIN']
32
21
  end
33
-
34
- def shopify_hmac
35
- request.headers['HTTP_X_SHOPIFY_HMAC_SHA256']
36
- end
37
22
  end
38
23
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class Engine < Rails::Engine
3
4
  engine_name 'shopify_app'
@@ -15,6 +16,10 @@ module ShopifyApp
15
16
 
16
17
  initializer "shopify_app.middleware" do |app|
17
18
  app.config.middleware.insert_after(::Rack::Runtime, ShopifyApp::SameSiteCookieMiddleware)
19
+
20
+ if ShopifyApp.configuration.allow_jwt_authentication
21
+ app.config.middleware.insert_after(ShopifyApp::SameSiteCookieMiddleware, ShopifyApp::JWTMiddleware)
22
+ end
18
23
  end
19
24
  end
20
25
  end
@@ -1,6 +1,6 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class ScripttagsManagerJob < ActiveJob::Base
3
-
4
4
  queue_as do
5
5
  ShopifyApp.configuration.scripttags_manager_queue_name
6
6
  end
@@ -1,6 +1,6 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class WebhooksManagerJob < ActiveJob::Base
3
-
4
4
  queue_as do
5
5
  ShopifyApp.configuration.webhooks_manager_queue_name
6
6
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class ScripttagsManager
3
4
  class CreationFailed < StandardError; end
@@ -43,7 +44,7 @@ module ShopifyApp
43
44
  def destroy_scripttags
44
45
  scripttags = expanded_scripttags
45
46
  ShopifyAPI::ScriptTag.all.each do |tag|
46
- ShopifyAPI::ScriptTag.delete(tag.id) if is_required_scripttag?(scripttags, tag)
47
+ ShopifyAPI::ScriptTag.delete(tag.id) if required_scripttag?(scripttags, tag)
47
48
  end
48
49
 
49
50
  @current_scripttags = nil
@@ -55,8 +56,8 @@ module ShopifyApp
55
56
  self.class.build_src(required_scripttags, shop_domain)
56
57
  end
57
58
 
58
- def is_required_scripttag?(scripttags, tag)
59
- scripttags.map{ |w| w[:src] }.include? tag.src
59
+ def required_scripttag?(scripttags, tag)
60
+ scripttags.map { |w| w[:src] }.include?(tag.src)
60
61
  end
61
62
 
62
63
  def create_scripttag(attributes)
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class WebhooksManager
3
4
  class CreationFailed < StandardError; end
@@ -31,7 +32,7 @@ module ShopifyApp
31
32
 
32
33
  def destroy_webhooks
33
34
  ShopifyAPI::Webhook.all.to_a.each do |webhook|
34
- ShopifyAPI::Webhook.delete(webhook.id) if is_required_webhook?(webhook)
35
+ ShopifyAPI::Webhook.delete(webhook.id) if required_webhook?(webhook)
35
36
  end
36
37
 
37
38
  @current_webhooks = nil
@@ -39,8 +40,8 @@ module ShopifyApp
39
40
 
40
41
  private
41
42
 
42
- def is_required_webhook?(webhook)
43
- required_webhooks.map{ |w| w[:address] }.include? webhook.address
43
+ def required_webhook?(webhook)
44
+ required_webhooks.map { |w| w[:address] }.include?(webhook.address)
44
45
  end
45
46
 
46
47
  def create_webhook(attributes)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+ module ShopifyApp
3
+ class JWTMiddleware
4
+ TOKEN_REGEX = /^Bearer\s+(.*?)$/
5
+
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ return call_next(env) unless authorization_header(env)
12
+
13
+ token = extract_token(env)
14
+ return call_next(env) unless token
15
+
16
+ set_env_variables(token, env)
17
+ call_next(env)
18
+ end
19
+
20
+ private
21
+
22
+ def call_next(env)
23
+ @app.call(env)
24
+ end
25
+
26
+ def authorization_header(env)
27
+ env['HTTP_AUTHORIZATION']
28
+ end
29
+
30
+ def extract_token(env)
31
+ match = authorization_header(env).match(TOKEN_REGEX)
32
+ match && match[1]
33
+ end
34
+
35
+ def set_env_variables(token, env)
36
+ jwt = ShopifyApp::JWT.new(token)
37
+
38
+ env['jwt.shopify_domain'] = jwt.shopify_domain
39
+ env['jwt.shopify_user_id'] = jwt.shopify_user_id
40
+ end
41
+ end
42
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class SameSiteCookieMiddleware
3
4
  COOKIE_SEPARATOR = "\n"
@@ -19,7 +20,7 @@ module ShopifyApp
19
20
  .split(COOKIE_SEPARATOR)
20
21
  .compact
21
22
  .map do |cookie|
22
- cookie << '; Secure' if not cookie =~ /;\s*secure/i
23
+ cookie << '; Secure' unless cookie =~ /;\s*secure/i
23
24
  cookie << '; SameSite=None' unless cookie =~ /;\s*samesite=/i
24
25
  cookie
25
26
  end
@@ -1,4 +1,7 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
3
+ # rubocop:disable Style/ClassVars
4
+ # Class var repo is needed here in order to share data between the 2 child classes.
2
5
  class InMemorySessionStore
3
6
  class EnvironmentError < StandardError; end
4
7
 
@@ -6,7 +9,7 @@ module ShopifyApp
6
9
  repo[id]
7
10
  end
8
11
 
9
- def self.store(session, *args)
12
+ def self.store(session, *_args)
10
13
  id = SecureRandom.uuid
11
14
  repo[id] = session
12
15
  id
@@ -18,10 +21,11 @@ module ShopifyApp
18
21
 
19
22
  def self.repo
20
23
  if Rails.env.production?
21
- raise EnvironmentError.new("Cannot use InMemorySessionStore in a Production environment. \
22
- Please initialize ShopifyApp with a model that can store and retrieve sessions")
24
+ raise EnvironmentError, "Cannot use InMemorySessionStore in a Production environment. \
25
+ Please initialize ShopifyApp with a model that can store and retrieve sessions"
23
26
  end
24
27
  @@repo ||= {}
25
28
  end
26
29
  end
30
+ # rubocop:enable Style/ClassVars
27
31
  end
@@ -1,4 +1,14 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class InMemoryShopSessionStore < InMemorySessionStore
4
+ def self.store(session, *args)
5
+ id = super
6
+ repo[session.domain] = session
7
+ id
8
+ end
9
+
10
+ def self.retrieve_by_shopify_domain(shopify_domain)
11
+ repo[shopify_domain]
12
+ end
3
13
  end
4
14
  end
@@ -1,4 +1,14 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class InMemoryUserSessionStore < InMemorySessionStore
4
+ def self.store(session, user)
5
+ id = super
6
+ repo[user.shopify_user_id] = session
7
+ id
8
+ end
9
+
10
+ def self.retrieve_by_shopify_user_id(user_id)
11
+ repo[user_id]
12
+ end
3
13
  end
4
14
  end