shopify_app 17.0.5 → 17.1.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug-report.md +63 -0
  3. data/.github/ISSUE_TEMPLATE/config.yml +1 -0
  4. data/.github/ISSUE_TEMPLATE/feature-request.md +33 -0
  5. data/.github/PULL_REQUEST_TEMPLATE.md +17 -1
  6. data/CHANGELOG.md +5 -0
  7. data/CONTRIBUTING.md +76 -0
  8. data/Gemfile.lock +61 -61
  9. data/README.md +72 -593
  10. data/app/controllers/concerns/shopify_app/shop_access_scopes_verification.rb +32 -0
  11. data/app/controllers/shopify_app/callback_controller.rb +18 -2
  12. data/docs/Quickstart.md +15 -77
  13. data/docs/Upgrading.md +110 -0
  14. data/docs/shopify_app/authentication.md +124 -0
  15. data/docs/shopify_app/engine.md +82 -0
  16. data/docs/shopify_app/generators.md +127 -0
  17. data/docs/shopify_app/handling-access-scopes-changes.md +8 -0
  18. data/docs/shopify_app/script-tags.md +28 -0
  19. data/docs/shopify_app/session-repository.md +88 -0
  20. data/docs/shopify_app/testing.md +38 -0
  21. data/docs/shopify_app/webhooks.md +72 -0
  22. data/lib/generators/shopify_app/home_controller/templates/home_controller.rb +2 -0
  23. data/lib/generators/shopify_app/home_controller/templates/unauthenticated_home_controller.rb +1 -0
  24. data/lib/generators/shopify_app/install/install_generator.rb +30 -1
  25. data/lib/generators/shopify_app/install/templates/omniauth.rb +1 -0
  26. data/lib/generators/shopify_app/install/templates/shopify_app.rb.tt +5 -2
  27. data/lib/generators/shopify_app/install/templates/shopify_provider.rb.tt +8 -0
  28. data/lib/generators/shopify_app/shop_model/shop_model_generator.rb +27 -0
  29. data/lib/generators/shopify_app/shop_model/templates/db/migrate/add_shop_access_scopes_column.erb +5 -0
  30. data/lib/generators/shopify_app/shop_model/templates/shop.rb +1 -1
  31. data/lib/generators/shopify_app/shopify_app_generator.rb +1 -1
  32. data/lib/generators/shopify_app/user_model/templates/db/migrate/add_user_access_scopes_column.erb +5 -0
  33. data/lib/generators/shopify_app/user_model/templates/user.rb +1 -1
  34. data/lib/generators/shopify_app/user_model/user_model_generator.rb +27 -0
  35. data/lib/shopify_app.rb +10 -0
  36. data/lib/shopify_app/access_scopes/noop_strategy.rb +13 -0
  37. data/lib/shopify_app/access_scopes/shop_strategy.rb +24 -0
  38. data/lib/shopify_app/access_scopes/user_strategy.rb +41 -0
  39. data/lib/shopify_app/configuration.rb +22 -0
  40. data/lib/shopify_app/omniauth/omniauth_configuration.rb +64 -0
  41. data/lib/shopify_app/session/in_memory_shop_session_store.rb +9 -7
  42. data/lib/shopify_app/session/in_memory_user_session_store.rb +9 -7
  43. data/lib/shopify_app/session/shop_session_storage_with_scopes.rb +58 -0
  44. data/lib/shopify_app/session/user_session_storage_with_scopes.rb +58 -0
  45. data/lib/shopify_app/utils.rb +12 -0
  46. data/lib/shopify_app/version.rb +1 -1
  47. data/package.json +1 -1
  48. data/shopify_app.gemspec +1 -1
  49. metadata +27 -8
  50. data/.github/ISSUE_TEMPLATE.md +0 -19
  51. data/docs/install-on-dev-shop.png +0 -0
  52. data/docs/test-your-app.png +0 -0
  53. data/lib/generators/shopify_app/install/templates/shopify_provider.rb +0 -20
@@ -0,0 +1,8 @@
1
+ provider :shopify,
2
+ ShopifyApp.configuration.api_key,
3
+ ShopifyApp.configuration.secret,
4
+ scope: ShopifyApp.configuration.scope,
5
+ setup: lambda { |env|
6
+ configuration = ShopifyApp::OmniAuthConfiguration.new(env['omniauth.strategy'], Rack::Request.new(env))
7
+ configuration.build_options
8
+ }
@@ -8,6 +8,8 @@ module ShopifyApp
8
8
  include Rails::Generators::Migration
9
9
  source_root File.expand_path('../templates', __FILE__)
10
10
 
11
+ class_option :new_shopify_cli_app, type: :boolean, default: false
12
+
11
13
  def create_shop_model
12
14
  copy_file('shop.rb', 'app/models/shop.rb')
13
15
  end
@@ -16,6 +18,27 @@ module ShopifyApp
16
18
  migration_template('db/migrate/create_shops.erb', 'db/migrate/create_shops.rb')
17
19
  end
18
20
 
21
+ def create_shop_with_access_scopes_migration
22
+ scopes_column_prompt = <<~PROMPT
23
+ It is highly recommended that apps record the access scopes granted by \
24
+ merchants during app installation. See app/models/shop.rb to modify how \
25
+ access scopes are stored and retrieved.
26
+
27
+ [WARNING] You will need to update the access_scopes accessors in the Shop model \
28
+ to allow shopify_app to store and retrieve scopes when going through OAuth.
29
+
30
+ The following migration will add an `access_scopes` column to the Shop model. \
31
+ Do you want to include this migration? [y/n]
32
+ PROMPT
33
+
34
+ if new_shopify_cli_app? || Rails.env.test? || yes?(scopes_column_prompt)
35
+ migration_template(
36
+ 'db/migrate/add_shop_access_scopes_column.erb',
37
+ 'db/migrate/add_shop_access_scopes_column.rb'
38
+ )
39
+ end
40
+ end
41
+
19
42
  def update_shopify_app_initializer
20
43
  gsub_file('config/initializers/shopify_app.rb', 'ShopifyApp::InMemoryShopSessionStore', 'Shop')
21
44
  end
@@ -26,6 +49,10 @@ module ShopifyApp
26
49
 
27
50
  private
28
51
 
52
+ def new_shopify_cli_app?
53
+ options['new_shopify_cli_app']
54
+ end
55
+
29
56
  def rails_migration_version
30
57
  Rails.version.match(/\d\.\d/)[0]
31
58
  end
@@ -0,0 +1,5 @@
1
+ class AddShopAccessScopesColumn < ActiveRecord::Migration[<%= rails_migration_version %>]
2
+ def change
3
+ add_column :shops, :access_scopes, :string
4
+ end
5
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  class Shop < ActiveRecord::Base
3
- include ShopifyApp::ShopSessionStorage
3
+ include ShopifyApp::ShopSessionStorageWithScopes
4
4
 
5
5
  def api_version
6
6
  ShopifyApp.configuration.api_version
@@ -9,7 +9,7 @@ module ShopifyApp
9
9
 
10
10
  def run_all_generators
11
11
  generate("shopify_app:install #{@opts.join(' ')}")
12
- generate("shopify_app:shop_model")
12
+ generate("shopify_app:shop_model #{@opts.join(' ')}")
13
13
  generate("shopify_app:authenticated_controller")
14
14
  generate("shopify_app:home_controller #{@opts.join(' ')}")
15
15
  end
@@ -0,0 +1,5 @@
1
+ class AddUserAccessScopesColumn < ActiveRecord::Migration[<%= rails_migration_version %>]
2
+ def change
3
+ add_column :users, :access_scopes, :string
4
+ end
5
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  class User < ActiveRecord::Base
3
- include ShopifyApp::UserSessionStorage
3
+ include ShopifyApp::UserSessionStorageWithScopes
4
4
 
5
5
  def api_version
6
6
  ShopifyApp.configuration.api_version
@@ -8,6 +8,8 @@ module ShopifyApp
8
8
  include Rails::Generators::Migration
9
9
  source_root File.expand_path('../templates', __FILE__)
10
10
 
11
+ class_option :new_shopify_cli_app, type: :boolean, default: false
12
+
11
13
  def create_user_model
12
14
  copy_file('user.rb', 'app/models/user.rb')
13
15
  end
@@ -16,6 +18,27 @@ module ShopifyApp
16
18
  migration_template('db/migrate/create_users.erb', 'db/migrate/create_users.rb')
17
19
  end
18
20
 
21
+ def create_scopes_storage_in_user_model
22
+ scopes_column_prompt = <<~PROMPT
23
+ It is highly recommended that apps record the access scopes granted by \
24
+ merchants during app installation. See app/models/user.rb to modify how \
25
+ access scopes are stored and retrieved.
26
+
27
+ [WARNING] You will need to update the access_scopes accessors in the User model \
28
+ to allow shopify_app to store and retrieve scopes when going through OAuth.
29
+
30
+ The following migration will add an `access_scopes` column to the User model. \
31
+ Do you want to include this migration? [y/n]
32
+ PROMPT
33
+
34
+ if new_shopify_cli_app? || Rails.env.test? || yes?(scopes_column_prompt)
35
+ migration_template(
36
+ 'db/migrate/add_user_access_scopes_column.erb',
37
+ 'db/migrate/add_user_access_scopes_column.rb'
38
+ )
39
+ end
40
+ end
41
+
19
42
  def update_shopify_app_initializer
20
43
  gsub_file('config/initializers/shopify_app.rb', 'ShopifyApp::InMemoryUserSessionStore', 'User')
21
44
  end
@@ -26,6 +49,10 @@ module ShopifyApp
26
49
 
27
50
  private
28
51
 
52
+ def new_shopify_cli_app?
53
+ options['new_shopify_cli_app']
54
+ end
55
+
29
56
  def rails_migration_version
30
57
  Rails.version.match(/\d\.\d/)[0]
31
58
  end
data/lib/shopify_app.rb CHANGED
@@ -57,5 +57,15 @@ module ShopifyApp
57
57
  require 'shopify_app/session/session_repository'
58
58
  require 'shopify_app/session/session_storage'
59
59
  require 'shopify_app/session/shop_session_storage'
60
+ require 'shopify_app/session/shop_session_storage_with_scopes'
60
61
  require 'shopify_app/session/user_session_storage'
62
+ require 'shopify_app/session/user_session_storage_with_scopes'
63
+
64
+ # access scopes strategies
65
+ require 'shopify_app/access_scopes/shop_strategy'
66
+ require 'shopify_app/access_scopes/user_strategy'
67
+ require 'shopify_app/access_scopes/noop_strategy'
68
+
69
+ # omniauth_configuration
70
+ require 'shopify_app/omniauth/omniauth_configuration'
61
71
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyApp
4
+ module AccessScopes
5
+ class NoopStrategy
6
+ class << self
7
+ def update_access_scopes?(*_args)
8
+ false
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyApp
4
+ module AccessScopes
5
+ class ShopStrategy
6
+ class << self
7
+ def update_access_scopes?(shop_domain)
8
+ shop_access_scopes = shop_access_scopes(shop_domain)
9
+ configuration_access_scopes != shop_access_scopes
10
+ end
11
+
12
+ private
13
+
14
+ def shop_access_scopes(shop_domain)
15
+ ShopifyApp::SessionRepository.retrieve_shop_session_by_shopify_domain(shop_domain)&.access_scopes
16
+ end
17
+
18
+ def configuration_access_scopes
19
+ ShopifyAPI::ApiAccess.new(ShopifyApp.configuration.shop_access_scopes)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyApp
4
+ module AccessScopes
5
+ class UserStrategy
6
+ class InvalidInput < StandardError; end
7
+
8
+ class << self
9
+ def update_access_scopes?(user_id: nil, shopify_user_id: nil)
10
+ return update_access_scopes_for_user_id?(user_id) if user_id
11
+ return update_access_scopes_for_shopify_user_id?(shopify_user_id) if shopify_user_id
12
+ raise(InvalidInput, '#update_access_scopes? requires user_id or shopify_user_id parameter inputs')
13
+ end
14
+
15
+ private
16
+
17
+ def update_access_scopes_for_user_id?(user_id)
18
+ user_access_scopes = user_access_scopes_by_user_id(user_id)
19
+ configuration_access_scopes != user_access_scopes
20
+ end
21
+
22
+ def update_access_scopes_for_shopify_user_id?(shopify_user_id)
23
+ user_access_scopes = user_access_scopes_by_shopify_user_id(shopify_user_id)
24
+ configuration_access_scopes != user_access_scopes
25
+ end
26
+
27
+ def user_access_scopes_by_user_id(user_id)
28
+ ShopifyApp::SessionRepository.retrieve_user_session(user_id)&.access_scopes
29
+ end
30
+
31
+ def user_access_scopes_by_shopify_user_id(shopify_user_id)
32
+ ShopifyApp::SessionRepository.retrieve_user_session_by_shopify_user_id(shopify_user_id)&.access_scopes
33
+ end
34
+
35
+ def configuration_access_scopes
36
+ ShopifyAPI::ApiAccess.new(ShopifyApp.configuration.user_access_scopes)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -9,6 +9,8 @@ module ShopifyApp
9
9
  attr_accessor :secret
10
10
  attr_accessor :old_secret
11
11
  attr_accessor :scope
12
+ attr_writer :shop_access_scopes
13
+ attr_writer :user_access_scopes
12
14
  attr_accessor :embedded_app
13
15
  alias_method :embedded_app?, :embedded_app
14
16
  attr_accessor :webhooks
@@ -16,6 +18,8 @@ module ShopifyApp
16
18
  attr_accessor :after_authenticate_job
17
19
  attr_accessor :api_version
18
20
 
21
+ attr_accessor :reauth_on_access_scope_changes
22
+
19
23
  # customise urls
20
24
  attr_accessor :root_url
21
25
  attr_writer :login_url
@@ -70,6 +74,16 @@ module ShopifyApp
70
74
  ShopifyApp::SessionRepository.shop_storage
71
75
  end
72
76
 
77
+ def shop_access_scopes_strategy
78
+ return ShopifyApp::AccessScopes::NoopStrategy unless reauth_on_access_scope_changes
79
+ ShopifyApp::AccessScopes::ShopStrategy
80
+ end
81
+
82
+ def user_access_scopes_strategy
83
+ return ShopifyApp::AccessScopes::NoopStrategy unless reauth_on_access_scope_changes
84
+ ShopifyApp::AccessScopes::UserStrategy
85
+ end
86
+
73
87
  def has_webhooks?
74
88
  webhooks.present?
75
89
  end
@@ -81,6 +95,14 @@ module ShopifyApp
81
95
  def enable_same_site_none
82
96
  !Rails.env.test? && (@enable_same_site_none.nil? ? embedded_app? : @enable_same_site_none)
83
97
  end
98
+
99
+ def shop_access_scopes
100
+ @shop_access_scopes || scope
101
+ end
102
+
103
+ def user_access_scopes
104
+ @user_access_scopes || scope
105
+ end
84
106
  end
85
107
 
86
108
  def self.configuration
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyApp
4
+ class OmniAuthConfiguration
5
+ attr_reader :strategy, :request
6
+ attr_writer :client_options_site, :scopes, :per_user_permissions
7
+
8
+ def initialize(strategy, request)
9
+ @strategy = strategy
10
+ @request = request
11
+ end
12
+
13
+ def build_options
14
+ strategy.options[:client_options][:site] = client_options_site
15
+ strategy.options[:scope] = scopes
16
+ strategy.options[:old_client_secret] = ShopifyApp.configuration.old_secret
17
+ strategy.options[:per_user_permissions] = request_online_tokens?
18
+ end
19
+
20
+ private
21
+
22
+ def request_online_tokens?
23
+ return @per_user_permissions unless @per_user_permissions.nil?
24
+ default_request_online_tokens?
25
+ end
26
+
27
+ def scopes
28
+ @scopes || default_scopes
29
+ end
30
+
31
+ def client_options_site
32
+ @client_options_site || default_client_options_site
33
+ end
34
+
35
+ def default_scopes
36
+ if request_online_tokens?
37
+ ShopifyApp.configuration.user_access_scopes
38
+ else
39
+ ShopifyApp.configuration.shop_access_scopes
40
+ end
41
+ end
42
+
43
+ def default_client_options_site
44
+ return '' unless shop_domain.present?
45
+ "https://#{shopify_auth_params[:shop]}"
46
+ end
47
+
48
+ def default_request_online_tokens?
49
+ strategy.session[:user_tokens] && !update_shop_scopes?
50
+ end
51
+
52
+ def update_shop_scopes?
53
+ ShopifyApp.configuration.shop_access_scopes_strategy.update_access_scopes?(shop_domain)
54
+ end
55
+
56
+ def shop_domain
57
+ request.params['shop'] || (shopify_auth_params && shopify_auth_params['shop'])
58
+ end
59
+
60
+ def shopify_auth_params
61
+ strategy.session['shopify.omniauth_params']&.with_indifferent_access
62
+ end
63
+ end
64
+ end
@@ -1,14 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
  module ShopifyApp
3
3
  class InMemoryShopSessionStore < InMemorySessionStore
4
- def self.store(session, *args)
5
- id = super
6
- repo[session.domain] = session
7
- id
8
- end
4
+ class << self
5
+ def store(session, *args)
6
+ id = super
7
+ repo[session.domain] = session
8
+ id
9
+ end
9
10
 
10
- def self.retrieve_by_shopify_domain(shopify_domain)
11
- repo[shopify_domain]
11
+ def retrieve_by_shopify_domain(shopify_domain)
12
+ repo[shopify_domain]
13
+ end
12
14
  end
13
15
  end
14
16
  end
@@ -1,14 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
  module ShopifyApp
3
3
  class InMemoryUserSessionStore < InMemorySessionStore
4
- def self.store(session, user)
5
- id = super
6
- repo[user.shopify_user_id] = session
7
- id
8
- end
4
+ class << self
5
+ def store(session, user)
6
+ id = super
7
+ repo[user.shopify_user_id] = session
8
+ id
9
+ end
9
10
 
10
- def self.retrieve_by_shopify_user_id(user_id)
11
- repo[user_id]
11
+ def retrieve_by_shopify_user_id(user_id)
12
+ repo[user_id]
13
+ end
12
14
  end
13
15
  end
14
16
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyApp
4
+ module ShopSessionStorageWithScopes
5
+ extend ActiveSupport::Concern
6
+ include ::ShopifyApp::SessionStorage
7
+
8
+ included do
9
+ validates :shopify_domain, presence: true, uniqueness: { case_sensitive: false }
10
+ end
11
+
12
+ class_methods do
13
+ def store(auth_session, *_args)
14
+ shop = find_or_initialize_by(shopify_domain: auth_session.domain)
15
+ shop.shopify_token = auth_session.token
16
+ shop.access_scopes = auth_session.access_scopes
17
+
18
+ shop.save!
19
+ shop.id
20
+ end
21
+
22
+ def retrieve(id)
23
+ shop = find_by(id: id)
24
+ construct_session(shop)
25
+ end
26
+
27
+ def retrieve_by_shopify_domain(domain)
28
+ shop = find_by(shopify_domain: domain)
29
+ construct_session(shop)
30
+ end
31
+
32
+ private
33
+
34
+ def construct_session(shop)
35
+ return unless shop
36
+
37
+ ShopifyAPI::Session.new(
38
+ domain: shop.shopify_domain,
39
+ token: shop.shopify_token,
40
+ api_version: shop.api_version,
41
+ access_scopes: shop.access_scopes
42
+ )
43
+ end
44
+ end
45
+
46
+ def access_scopes=(scopes)
47
+ super(scopes)
48
+ rescue NotImplementedError, NoMethodError
49
+ raise NotImplementedError, "#access_scopes= must be defined to handle storing access scopes: #{scopes}"
50
+ end
51
+
52
+ def access_scopes
53
+ super
54
+ rescue NotImplementedError, NoMethodError
55
+ raise NotImplementedError, "#access_scopes= must be defined to hook into stored access scopes"
56
+ end
57
+ end
58
+ end