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.
- 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
|