omniauth-shopify-app 1.0.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.
@@ -0,0 +1,310 @@
1
+ require "oauth2"
2
+ require "omniauth"
3
+ require "securerandom"
4
+ require "socket" # for SocketError
5
+ require "timeout" # for Timeout::Error
6
+
7
+ module OmniAuth
8
+ module Strategies
9
+ # Authentication strategy for connecting with APIs constructed using
10
+ # the [OAuth 2.0 Specification](http://tools.ietf.org/html/draft-ietf-oauth-v2-10).
11
+ # You must generally register your application with the provider and
12
+ # utilize an application id and secret in order to authenticate using
13
+ # OAuth 2.0.
14
+ class Shopify
15
+
16
+ include OmniAuth::Strategy
17
+
18
+ def self.inherited(subclass)
19
+ OmniAuth::Strategy.included(subclass)
20
+ end
21
+
22
+ # An error that is indicated in the OAuth 2.0 callback.
23
+ # This could be a `redirect_uri_mismatch` or other
24
+ class CallbackError < StandardError
25
+ attr_accessor :error, :error_reason, :error_uri
26
+
27
+ def initialize(error, error_reason = nil, error_uri = nil)
28
+ self.error = error
29
+ self.error_reason = error_reason
30
+ self.error_uri = error_uri
31
+ end
32
+
33
+ def message
34
+ [error, error_reason, error_uri].compact.join(" | ")
35
+ end
36
+ end
37
+
38
+ # Available scopes: content themes products customers orders script_tags shipping
39
+ # read_* or write_*
40
+ DEFAULT_SCOPE = 'read_products'
41
+ SCOPE_DELIMITER = ','
42
+ MINUTE = 60
43
+ CODE_EXPIRES_AFTER = 10 * MINUTE
44
+
45
+ args %i[client_id client_secret]
46
+
47
+ option :client_id, nil
48
+ option :client_secret, nil
49
+ option :client_options, {}
50
+ option :authorize_params, {}
51
+ option :authorize_options, %i[scope state]
52
+ option :token_params, {}
53
+ option :token_options, []
54
+ option :auth_token_params, {}
55
+ option :provider_ignores_state, false
56
+ option :pkce, false
57
+ option :pkce_verifier, nil
58
+ option :pkce_options, {
59
+ :code_challenge => proc { |verifier|
60
+ Base64.urlsafe_encode64(
61
+ Digest::SHA2.digest(verifier),
62
+ :padding => false,
63
+ )
64
+ },
65
+ :code_challenge_method => "S256",
66
+ }
67
+ option :oauth_session_key, OmniAuth::Shopify::OAuthSession::KEY
68
+
69
+ attr_accessor :access_token
70
+
71
+ option :client_options, {
72
+ :authorize_url => '/admin/oauth/authorize',
73
+ :token_url => '/admin/oauth/access_token'
74
+ }
75
+
76
+ option :callback_url
77
+ option :myshopify_domain, 'myshopify.com'
78
+ option :old_client_secret
79
+
80
+ # When `true`, the user's permission level will apply (in addition to
81
+ # the requested access scope) when making API requests to Shopify.
82
+ option :per_user_permissions, false
83
+
84
+ option :setup, proc { |env|
85
+ strategy = env['omniauth.strategy']
86
+
87
+ shopify_auth_params = strategy.session['shopify.omniauth_params'] ||
88
+ strategy.session['omniauth.params'] ||
89
+ strategy.request.params
90
+
91
+ shopify_auth_params = shopify_auth_params && shopify_auth_params.with_indifferent_access
92
+ shop = if shopify_auth_params && shopify_auth_params['shop']
93
+ "https://#{shopify_auth_params['shop']}"
94
+ else
95
+ ''
96
+ end
97
+
98
+ strategy.options[:client_options][:site] = shop
99
+ }
100
+
101
+ uid { URI.parse(options[:client_options][:site]).host }
102
+
103
+ extra do
104
+ if access_token
105
+ {
106
+ 'associated_user' => access_token['associated_user'],
107
+ 'associated_user_scope' => access_token['associated_user_scope'],
108
+ 'scope' => access_token['scope'],
109
+ 'session' => access_token['session']
110
+ }
111
+ end
112
+ end
113
+
114
+ def client
115
+ ::OAuth2::Client.new(options.client_id, options.client_secret, deep_symbolize(options.client_options))
116
+ end
117
+
118
+ credentials do
119
+ hash = {"token" => access_token.token}
120
+ hash["refresh_token"] = access_token.refresh_token if access_token.expires? && access_token.refresh_token
121
+ hash["expires_at"] = access_token.expires_at if access_token.expires?
122
+ hash["expires"] = access_token.expires?
123
+ hash
124
+ end
125
+
126
+ def token_params
127
+ options.token_params.merge(options_for("token")).merge(pkce_token_params)
128
+ end
129
+
130
+ def valid_site?
131
+ !!(/\A(https|http)\:\/\/[a-zA-Z0-9][a-zA-Z0-9\-]*\.#{Regexp.quote(options[:myshopify_domain])}[\/]?\z/ =~ options[:client_options][:site])
132
+ end
133
+
134
+ def valid_signature?
135
+ return false unless request.POST.empty?
136
+
137
+ params = request.GET
138
+ signature = params['hmac']
139
+ timestamp = params['timestamp']
140
+ return false unless signature && timestamp
141
+
142
+ return false unless timestamp.to_i > Time.now.to_i - CODE_EXPIRES_AFTER
143
+
144
+ new_secret = options.client_secret
145
+ old_secret = options.old_client_secret
146
+
147
+ validate_signature(new_secret) || (old_secret && validate_signature(old_secret))
148
+ end
149
+
150
+ def normalized_scopes(scopes)
151
+ scope_list = scopes.to_s.split(SCOPE_DELIMITER).map(&:strip).reject(&:empty?).uniq
152
+ ignore_scopes = scope_list.map { |scope| scope =~ /\A(unauthenticated_)?write_(.*)\z/ && "#{$1}read_#{$2}" }.compact
153
+ scope_list - ignore_scopes
154
+ end
155
+
156
+ def self.encoded_params_for_signature(params)
157
+ params = params.dup
158
+ params.delete('hmac')
159
+ params.delete('signature') # deprecated signature
160
+ Rack::Utils.build_query(params.sort)
161
+ end
162
+
163
+ def self.hmac_sign(encoded_params, secret)
164
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, secret, encoded_params)
165
+ end
166
+
167
+ def valid_permissions?(token)
168
+ return false unless token
169
+
170
+ return true if options[:per_user_permissions] && token['associated_user']
171
+ return true if !options[:per_user_permissions] && !token['associated_user']
172
+
173
+ false
174
+ end
175
+
176
+ def fix_https
177
+ options[:client_options][:site] = options[:client_options][:site].gsub(/\Ahttp\:/, 'https:')
178
+ end
179
+
180
+ def setup_phase
181
+ super
182
+ fix_https
183
+ end
184
+
185
+ def request_phase
186
+ if valid_site?
187
+ redirect client.auth_code.authorize_url({:redirect_uri => callback_url}.merge(authorize_params))
188
+ else
189
+ fail!(:invalid_site)
190
+ end
191
+ end
192
+
193
+ def callback_phase
194
+ return fail!(:invalid_site, CallbackError.new(:invalid_site, "OAuth endpoint is not a myshopify site.")) unless valid_site?
195
+ return fail!(:invalid_signature, CallbackError.new(:invalid_signature, "Signature does not match, it may have been tampered with.")) unless valid_signature?
196
+
197
+ token = build_access_token
198
+ unless valid_permissions?(token)
199
+ return fail!(:invalid_permissions, CallbackError.new(:invalid_permissions, "Requested API access mode does not match."))
200
+ end
201
+
202
+ error = request.params["error_reason"] || request.params["error"]
203
+ if !options.provider_ignores_state && (request.params["state"].to_s.empty? || request.params["state"] != fetch_and_delete_omniauth_state)
204
+ fail!(:csrf_detected, CallbackError.new(:csrf_detected, "CSRF detected"))
205
+ elsif error
206
+ fail!(error, CallbackError.new(request.params["error"], request.params["error_description"] || request.params["error_reason"], request.params["error_uri"]))
207
+ else
208
+ self.access_token = build_access_token
209
+ self.access_token = access_token.refresh! if access_token.expired?
210
+ super
211
+ end
212
+ rescue ::OAuth2::Error, CallbackError => e
213
+ fail!(:invalid_credentials, e)
214
+ rescue ::Timeout::Error, ::Errno::ETIMEDOUT => e
215
+ fail!(:timeout, e)
216
+ rescue ::SocketError => e
217
+ fail!(:failed_to_connect, e)
218
+ end
219
+
220
+ def authorize_params
221
+ options.authorize_params[:state] = SecureRandom.hex(24)
222
+
223
+ if OmniAuth.config.test_mode
224
+ @env ||= {}
225
+ @env["rack.session"] ||= {}
226
+ end
227
+
228
+ params = options.authorize_params
229
+ .merge(options_for("authorize"))
230
+ .merge(pkce_authorize_params)
231
+
232
+ session["omniauth.pkce.verifier"] = options.pkce_verifier if options.pkce
233
+ session["omniauth.state"] = params[:state]
234
+ oauth_session["omniauth.state"] = params[:state]
235
+
236
+ params[:scope] = normalized_scopes(params[:scope] || DEFAULT_SCOPE).join(SCOPE_DELIMITER)
237
+ params[:grant_options] = ['per-user'] if options[:per_user_permissions]
238
+
239
+ params
240
+ end
241
+
242
+ def callback_url
243
+ options[:callback_url] || full_host + script_name + callback_path
244
+ end
245
+
246
+ private
247
+
248
+ def validate_signature(secret)
249
+ params = request.GET
250
+ calculated_signature = self.class.hmac_sign(self.class.encoded_params_for_signature(params), secret)
251
+ Rack::Utils.secure_compare(calculated_signature, params['hmac'])
252
+ end
253
+
254
+ def pkce_authorize_params
255
+ return {} unless options.pkce
256
+
257
+ options.pkce_verifier = SecureRandom.hex(64)
258
+
259
+ # NOTE: see https://tools.ietf.org/html/rfc7636#appendix-A
260
+ {
261
+ :code_challenge => options.pkce_options[:code_challenge]
262
+ .call(options.pkce_verifier),
263
+ :code_challenge_method => options.pkce_options[:code_challenge_method],
264
+ }
265
+ end
266
+
267
+ def pkce_token_params
268
+ return {} unless options.pkce
269
+
270
+ {:code_verifier => session.delete("omniauth.pkce.verifier")}
271
+ end
272
+
273
+ def build_access_token
274
+ return @built_access_token unless @built_access_token.nil?
275
+
276
+ verifier = request.params["code"]
277
+ @built_access_token = client.auth_code.get_token(verifier, {:redirect_uri => callback_url}.merge(token_params.to_hash(:symbolize_keys => true)), deep_symbolize(options.auth_token_params))
278
+ end
279
+
280
+ def deep_symbolize(options)
281
+ options.each_with_object({}) do |(key, value), hash|
282
+ hash[key.to_sym] = value.is_a?(Hash) ? deep_symbolize(value) : value
283
+ end
284
+ end
285
+
286
+ def options_for(option)
287
+ hash = {}
288
+ options.send(:"#{option}_options").select { |key| options[key] }.each do |key|
289
+ hash[key.to_sym] = if options[key].respond_to?(:call)
290
+ options[key].call(env)
291
+ else
292
+ options[key]
293
+ end
294
+ end
295
+ hash
296
+ end
297
+
298
+ def oauth_session
299
+ env["rack.#{options.oauth_session_key}"] || {}
300
+ end
301
+
302
+ def fetch_and_delete_omniauth_state
303
+ state_from_session = session.delete("omniauth.state")
304
+ state_from_oauth_session = oauth_session.delete("omniauth.state")
305
+ state_from_oauth_session || state_from_session
306
+ end
307
+
308
+ end
309
+ end
310
+ end
@@ -0,0 +1 @@
1
+ require 'omniauth/shopify'
@@ -0,0 +1,32 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path('../lib', __FILE__)
3
+ require 'omniauth/shopify/version'
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = 'omniauth-shopify-app'
7
+ s.version = OmniAuth::Shopify::VERSION
8
+ s.authors = ['Hopper Gee']
9
+ s.email = ['hopper.gee@hey.com']
10
+ s.summary = 'Shopify strategy for OmniAuth'
11
+ s.homepage = 'https://github.com/OldWayShopifyDev/omniauth-shopify-app'
12
+ s.license = 'MIT'
13
+
14
+ s.metadata['allowed_push_host'] = 'https://rubygems.org'
15
+
16
+ s.files = `git ls-files`.split("\n")
17
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
+ s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
19
+ s.require_paths = ['lib']
20
+ s.required_ruby_version = '>= 2.1.9'
21
+
22
+ s.add_dependency "oauth2", [">= 1.4", "< 3"]
23
+ s.add_dependency "omniauth", "~> 2.0"
24
+ s.add_runtime_dependency 'activesupport'
25
+
26
+ s.add_development_dependency 'minitest', '~> 5.6'
27
+ s.add_development_dependency 'rspec', '~> 3.9.0'
28
+ s.add_development_dependency 'fakeweb', '~> 1.3'
29
+ s.add_development_dependency 'rack-session', '~> 2.0'
30
+ s.add_development_dependency 'rake'
31
+ s.add_development_dependency 'minitest-focus'
32
+ end