omniauth-shopify-app 1.0.0

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