shopify_app 12.0.7 → 13.2.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/.rubocop.yml +12 -6
  3. data/.travis.yml +4 -3
  4. data/CHANGELOG.md +33 -0
  5. data/Gemfile +3 -0
  6. data/README.md +98 -42
  7. data/Rakefile +1 -0
  8. data/app/controllers/concerns/shopify_app/authenticated.rb +1 -1
  9. data/app/controllers/shopify_app/authenticated_controller.rb +1 -0
  10. data/app/controllers/shopify_app/callback_controller.rb +50 -17
  11. data/app/controllers/shopify_app/sessions_controller.rb +36 -10
  12. data/app/controllers/shopify_app/webhooks_controller.rb +6 -5
  13. data/config/locales/fi.yml +1 -1
  14. data/config/locales/nl.yml +7 -7
  15. data/config/routes.rb +1 -0
  16. data/docs/Releasing.md +1 -0
  17. data/lib/generators/shopify_app/add_after_authenticate_job/add_after_authenticate_job_generator.rb +5 -3
  18. data/lib/generators/shopify_app/add_after_authenticate_job/templates/after_authenticate_job.rb +1 -0
  19. data/lib/generators/shopify_app/add_marketing_activity_extension/add_marketing_activity_extension_generator.rb +2 -1
  20. data/lib/generators/shopify_app/add_marketing_activity_extension/templates/marketing_activities_controller.rb +4 -4
  21. data/lib/generators/shopify_app/add_webhook/add_webhook_generator.rb +5 -4
  22. data/lib/generators/shopify_app/add_webhook/templates/{webhook_job.rb → webhook_job.rb.tt} +5 -0
  23. data/lib/generators/shopify_app/app_proxy_controller/app_proxy_controller_generator.rb +4 -3
  24. data/lib/generators/shopify_app/app_proxy_controller/templates/app_proxy_controller.rb +3 -3
  25. data/lib/generators/shopify_app/app_proxy_controller/templates/app_proxy_route.rb +10 -9
  26. data/lib/generators/shopify_app/controllers/controllers_generator.rb +1 -0
  27. data/lib/generators/shopify_app/home_controller/home_controller_generator.rb +4 -3
  28. data/lib/generators/shopify_app/home_controller/templates/index.html.erb +1 -1
  29. data/lib/generators/shopify_app/install/install_generator.rb +10 -9
  30. data/lib/generators/shopify_app/install/templates/embedded_app.html.erb +1 -1
  31. data/lib/generators/shopify_app/install/templates/omniauth.rb +2 -1
  32. data/lib/generators/shopify_app/install/templates/{shopify_app.rb → shopify_app.rb.tt} +1 -1
  33. data/lib/generators/shopify_app/install/templates/shopify_provider.rb +1 -1
  34. data/lib/generators/shopify_app/install/templates/user_agent.rb +2 -1
  35. data/lib/generators/shopify_app/routes/routes_generator.rb +1 -0
  36. data/lib/generators/shopify_app/routes/templates/routes.rb +10 -9
  37. data/lib/generators/shopify_app/shop_model/shop_model_generator.rb +12 -7
  38. data/lib/generators/shopify_app/shop_model/templates/shop.rb +2 -1
  39. data/lib/generators/shopify_app/shopify_app_generator.rb +4 -3
  40. data/lib/generators/shopify_app/user_model/templates/user.rb +2 -1
  41. data/lib/generators/shopify_app/user_model/user_model_generator.rb +12 -7
  42. data/lib/generators/shopify_app/views/views_generator.rb +1 -0
  43. data/lib/shopify_app.rb +11 -4
  44. data/lib/shopify_app/configuration.rb +21 -11
  45. data/lib/shopify_app/controller_concerns/app_proxy_verification.rb +3 -2
  46. data/lib/shopify_app/controller_concerns/embedded_app.rb +3 -2
  47. data/lib/shopify_app/controller_concerns/localization.rb +1 -0
  48. data/lib/shopify_app/controller_concerns/login_protection.rb +71 -29
  49. data/lib/shopify_app/controller_concerns/webhook_verification.rb +2 -1
  50. data/lib/shopify_app/engine.rb +5 -0
  51. data/lib/shopify_app/jobs/scripttags_manager_job.rb +1 -1
  52. data/lib/shopify_app/jobs/webhooks_manager_job.rb +1 -1
  53. data/lib/shopify_app/managers/scripttags_manager.rb +4 -3
  54. data/lib/shopify_app/managers/webhooks_manager.rb +4 -3
  55. data/lib/shopify_app/middleware/jwt_middleware.rb +41 -0
  56. data/lib/shopify_app/middleware/same_site_cookie_middleware.rb +2 -1
  57. data/lib/shopify_app/session/in_memory_session_store.rb +7 -3
  58. data/lib/shopify_app/session/in_memory_shop_session_store.rb +14 -0
  59. data/lib/shopify_app/session/in_memory_user_session_store.rb +14 -0
  60. data/lib/shopify_app/session/jwt.rb +61 -0
  61. data/lib/shopify_app/session/null_user_session_store.rb +22 -0
  62. data/lib/shopify_app/session/session_repository.rb +36 -14
  63. data/lib/shopify_app/session/session_storage.rb +1 -10
  64. data/lib/shopify_app/session/shop_session_storage.rb +42 -0
  65. data/lib/shopify_app/session/user_session_storage.rb +42 -0
  66. data/lib/shopify_app/test_helpers/all.rb +2 -0
  67. data/lib/shopify_app/test_helpers/webhook_verification_helper.rb +17 -0
  68. data/lib/shopify_app/utils.rb +6 -5
  69. data/lib/shopify_app/version.rb +2 -1
  70. data/package-lock.json +1231 -1210
  71. data/package.json +1 -1
  72. data/shopify_app.gemspec +13 -8
  73. data/yarn.lock +3 -3
  74. metadata +50 -14
  75. data/lib/shopify_app/session/storage_strategies/shop_storage_strategy.rb +0 -23
  76. data/lib/shopify_app/session/storage_strategies/user_storage_strategy.rb +0 -24
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  module WebhookVerification
3
4
  extend ActiveSupport::Concern
@@ -11,7 +12,7 @@ module ShopifyApp
11
12
 
12
13
  def verify_request
13
14
  data = request.raw_post
14
- return head :unauthorized unless hmac_valid?(data)
15
+ return head(:unauthorized) unless hmac_valid?(data)
15
16
  end
16
17
 
17
18
  def hmac_valid?(data)
@@ -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,41 @@
1
+ module ShopifyApp
2
+ class JWTMiddleware
3
+ TOKEN_REGEX = /^Bearer\s+(.*?)$/
4
+
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ return call_next(env) unless authorization_header(env)
11
+
12
+ token = extract_token(env)
13
+ return call_next(env) unless token
14
+
15
+ set_env_variables(token, env)
16
+ call_next(env)
17
+ end
18
+
19
+ private
20
+
21
+ def call_next(env)
22
+ @app.call(env)
23
+ end
24
+
25
+ def authorization_header(env)
26
+ env['HTTP_AUTHORIZATION']
27
+ end
28
+
29
+ def extract_token(env)
30
+ match = authorization_header(env).match(TOKEN_REGEX)
31
+ match && match[1]
32
+ end
33
+
34
+ def set_env_variables(token, env)
35
+ jwt = ShopifyApp::JWT.new(token)
36
+
37
+ env['jwt.shopify_domain'] = jwt.shopify_domain
38
+ env['jwt.shopify_user_id'] = jwt.shopify_user_id
39
+ end
40
+ end
41
+ 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
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ module ShopifyApp
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
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ module ShopifyApp
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
13
+ end
14
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+ module ShopifyApp
3
+ class JWT
4
+ class InvalidDestinationError < StandardError; end
5
+ class MismatchedHostsError < StandardError; end
6
+ class InvalidAudienceError < StandardError; end
7
+
8
+ WARN_EXCEPTIONS = [
9
+ ::JWT::DecodeError,
10
+ ::JWT::ExpiredSignature,
11
+ ::JWT::ImmatureSignature,
12
+ ::JWT::VerificationError,
13
+ InvalidAudienceError,
14
+ InvalidDestinationError,
15
+ MismatchedHostsError,
16
+ ]
17
+
18
+ def initialize(token)
19
+ @token = token
20
+ set_payload
21
+ end
22
+
23
+ def shopify_domain
24
+ @payload && ShopifyApp::Utils.sanitize_shop_domain(@payload['dest'])
25
+ end
26
+
27
+ def shopify_user_id
28
+ @payload && @payload['sub']
29
+ end
30
+
31
+ private
32
+
33
+ def set_payload
34
+ payload, _ = parse_token_data(ShopifyApp.configuration&.secret, ShopifyApp.configuration&.old_secret)
35
+ @payload = validate_payload(payload)
36
+ rescue *WARN_EXCEPTIONS => error
37
+ Rails.logger.warn("[ShopifyApp::JWT] Failed to validate JWT: [#{error.class}] #{error}")
38
+ nil
39
+ end
40
+
41
+ def parse_token_data(secret, old_secret)
42
+ ::JWT.decode(@token, secret, true, { algorithm: 'HS256' })
43
+ rescue ::JWT::VerificationError
44
+ raise unless old_secret
45
+
46
+ ::JWT.decode(@token, old_secret, true, { algorithm: 'HS256' })
47
+ end
48
+
49
+ def validate_payload(payload)
50
+ dest_host = ShopifyApp::Utils.sanitize_shop_domain(payload['dest'])
51
+ iss_host = ShopifyApp::Utils.sanitize_shop_domain(payload['iss'])
52
+ api_key = ShopifyApp.configuration.api_key
53
+
54
+ raise InvalidAudienceError, "'aud' claim does not match api_key" unless payload['aud'] == api_key
55
+ raise InvalidDestinationError, "'dest' claim host not a valid shopify host" unless dest_host
56
+ raise MismatchedHostsError, "'dest' claim host does not match 'iss' claim host" unless dest_host == iss_host
57
+
58
+ payload
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ module ShopifyApp
3
+ class NullUserSessionStore
4
+ class << self
5
+ def retrieve(_)
6
+ nil
7
+ end
8
+
9
+ def store(_, _)
10
+ raise SessionRepository::ConfigurationError, 'user_storage is not configured'
11
+ end
12
+
13
+ def retrieve_by_shopify_user_id(_)
14
+ nil
15
+ end
16
+
17
+ def blank?
18
+ true
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,33 +1,55 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class SessionRepository
3
4
  class ConfigurationError < StandardError; end
4
5
 
5
6
  class << self
6
- def storage=(storage)
7
- @storage = storage
7
+ attr_writer :shop_storage
8
8
 
9
- unless storage.nil? || self.storage.respond_to?(:store) && self.storage.respond_to?(:retrieve)
10
- raise ArgumentError, "storage must respond to :store and :retrieve"
11
- end
9
+ attr_writer :user_storage
10
+
11
+ def retrieve_shop_session(id)
12
+ shop_storage.retrieve(id)
13
+ end
14
+
15
+ def retrieve_user_session(id)
16
+ user_storage.retrieve(id)
12
17
  end
13
18
 
14
- def retrieve(id)
15
- storage.retrieve(id)
19
+ def retrieve_shop_session_by_shopify_domain(shopify_domain)
20
+ shop_storage.retrieve_by_shopify_domain(shopify_domain)
16
21
  end
17
22
 
18
- def store(session, *args)
19
- storage.store(session, *args)
23
+ def retrieve_user_session_by_shopify_user_id(user_id)
24
+ user_storage.retrieve_by_shopify_user_id(user_id)
20
25
  end
21
26
 
22
- def storage
23
- load_storage || raise(ConfigurationError.new("ShopifySessionRepository.storage is not configured!"))
27
+ def store_shop_session(session)
28
+ shop_storage.store(session)
29
+ end
30
+
31
+ def store_user_session(session, user)
32
+ user_storage.store(session, user)
33
+ end
34
+
35
+ def shop_storage
36
+ load_shop_storage || raise(ConfigurationError, "ShopifySessionRepository.shop_storage is not configured!")
37
+ end
38
+
39
+ def user_storage
40
+ load_user_storage
24
41
  end
25
42
 
26
43
  private
27
44
 
28
- def load_storage
29
- return unless @storage
30
- @storage.respond_to?(:safe_constantize) ? @storage.safe_constantize : @storage
45
+ def load_shop_storage
46
+ return unless @shop_storage
47
+ @shop_storage.respond_to?(:safe_constantize) ? @shop_storage.safe_constantize : @shop_storage
48
+ end
49
+
50
+ def load_user_storage
51
+ return NullUserSessionStore unless @user_storage
52
+ @user_storage.respond_to?(:safe_constantize) ? @user_storage.safe_constantize : @user_storage
31
53
  end
32
54
  end
33
55
  end
@@ -1,20 +1,11 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  module SessionStorage
3
4
  extend ActiveSupport::Concern
4
5
 
5
6
  included do
6
- if ShopifyApp.configuration.per_user_tokens?
7
- extend ShopifyApp::SessionStorage::UserStorageStrategy
8
- else
9
- extend ShopifyApp::SessionStorage::ShopStorageStrategy
10
- end
11
-
12
7
  validates :shopify_token, presence: true
13
8
  validates :api_version, presence: true
14
- validates :shopify_domain, presence: true,
15
- if: Proc.new {|_| ShopifyApp.configuration.per_user_tokens? }
16
- validates :shopify_domain, presence: true, uniqueness: { case_sensitive: false },
17
- if: Proc.new {|_| !ShopifyApp.configuration.per_user_tokens? }
18
9
  end
19
10
 
20
11
  def with_shopify_session(&block)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+ module ShopifyApp
3
+ module ShopSessionStorage
4
+ extend ActiveSupport::Concern
5
+ include ::ShopifyApp::SessionStorage
6
+
7
+ included do
8
+ validates :shopify_domain, presence: true, uniqueness: { case_sensitive: false }
9
+ end
10
+
11
+ class_methods do
12
+ def store(auth_session, *_args)
13
+ shop = find_or_initialize_by(shopify_domain: auth_session.domain)
14
+ shop.shopify_token = auth_session.token
15
+ shop.save!
16
+ shop.id
17
+ end
18
+
19
+ def retrieve(id)
20
+ shop = find_by(id: id)
21
+ construct_session(shop)
22
+ end
23
+
24
+ def retrieve_by_shopify_domain(domain)
25
+ shop = find_by(shopify_domain: domain)
26
+ construct_session(shop)
27
+ end
28
+
29
+ private
30
+
31
+ def construct_session(shop)
32
+ return unless shop
33
+
34
+ ShopifyAPI::Session.new(
35
+ domain: shop.shopify_domain,
36
+ token: shop.shopify_token,
37
+ api_version: shop.api_version,
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+ module ShopifyApp
3
+ module UserSessionStorage
4
+ extend ActiveSupport::Concern
5
+ include ::ShopifyApp::SessionStorage
6
+
7
+ included do
8
+ validates :shopify_domain, presence: true
9
+ end
10
+
11
+ class_methods do
12
+ def store(auth_session, user)
13
+ user = find_or_initialize_by(shopify_user_id: user[:id])
14
+ user.shopify_token = auth_session.token
15
+ user.shopify_domain = auth_session.domain
16
+ user.save!
17
+ user.id
18
+ end
19
+
20
+ def retrieve(id)
21
+ user = find_by(id: id)
22
+ construct_session(user)
23
+ end
24
+
25
+ def retrieve_by_shopify_user_id(user_id)
26
+ user = find_by(shopify_user_id: user_id)
27
+ construct_session(user)
28
+ end
29
+
30
+ private
31
+
32
+ def construct_session(user)
33
+ return unless user
34
+ ShopifyAPI::Session.new(
35
+ domain: user.shopify_domain,
36
+ token: user.shopify_token,
37
+ api_version: user.api_version,
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end