shopify_app 12.0.7 → 13.2.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/.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