shopify_app 13.0.0 → 13.3.0

Sign up to get free protection for your applications and to get access to all the features.
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