omniauth-shopify-app 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/build.yml +25 -0
- data/.gitignore +4 -0
- data/Gemfile +8 -0
- data/README.md +107 -0
- data/Rakefile +9 -0
- data/example/Gemfile +6 -0
- data/example/config.ru +68 -0
- data/lib/omniauth/shopify/encryptor.rb +121 -0
- data/lib/omniauth/shopify/oauth_session.rb +81 -0
- data/lib/omniauth/shopify/version.rb +5 -0
- data/lib/omniauth/shopify.rb +3 -0
- data/lib/omniauth/strategies/shopify.rb +310 -0
- data/lib/omniauth-shopify-app.rb +1 -0
- data/omniauth-shopify-app.gemspec +32 -0
- data/test/integration_test.rb +486 -0
- data/test/test_helper.rb +16 -0
- metadata +194 -0
@@ -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
|