omniauth-duodealer-oauth2 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,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7ff6856a264f3b30185124b802f35d4b48a305455ec9cc0346c5cb52e809fd05
4
+ data.tar.gz: d7d8d48b5f7055c0c680ee73ddfd6416fff76e76f9b45ec01bd1a5297ab74c72
5
+ SHA512:
6
+ metadata.gz: 680c5d8f690f20e308479c16741f58dcb7c99a840ce4b97a0f0d125dd62bad985d494c306f599e450359f52a2716aa73e0b1caec6f9f44dccb57b89a451449d8
7
+ data.tar.gz: df3498079be1c82c0f76742be0beae204f784e7736aa31be3d1469f5a042ca6534619e639d7cda66c22b1746b0096ca8f698440bf88116f6ae3d6b4e88af807e
@@ -0,0 +1,2 @@
1
+ enabled:
2
+ - cla
@@ -0,0 +1,2 @@
1
+ pkg/*
2
+ Gemfile.lock
@@ -0,0 +1 @@
1
+ 2.6.4
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.1.9
4
+ - 2.2.2
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2.2")
6
+ gem 'rack', '~> 1.6'
7
+ end
8
+
9
+ gem 'fakeweb', github: 'junaruga/fakeweb'
@@ -0,0 +1,9 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ task :default => :test
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.pattern = 'test/**/*_test.rb'
8
+ t.verbose = true
9
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org/'
4
+
5
+ gem 'rack', '~> 1.6'
6
+
7
+ gem 'omniauth-duodealer-oauth2', path: '../'
8
+ gem 'sinatra', '~> 1.4'
@@ -0,0 +1,67 @@
1
+ require 'bundler/setup'
2
+ require 'sinatra/base'
3
+ require 'omniauth-duodealer-oauth2'
4
+
5
+ SCOPE = 'read_products,read_orders,read_customers,write_shipping'
6
+ DUODEALER_API_KEY = ENV['DUODEALER_API_KEY']
7
+ DUODEALER_SHARED_SECRET = ENV['DUODEALER_SHARED_SECRET']
8
+
9
+ unless DUODEALER_API_KEY && DUODEALER_SHARED_SECRET
10
+ abort("DUODEALER_API_KEY and DUODEALER_SHARED_SECRET environment variables must be set")
11
+ end
12
+
13
+ class App < Sinatra::Base
14
+ get '/' do
15
+ <<-HTML
16
+ <html>
17
+ <head>
18
+ <title>DuoDealer Oauth2</title>
19
+ </head>
20
+ <body>
21
+ <form action="/auth/duodealer" method="get">
22
+ <label for="shop">Enter your store's URL:</label>
23
+ <input type="text" name="shop" placeholder="your-shop-url.duodealer.com">
24
+ <button type="submit">Log In</button>
25
+ </form>
26
+ </body>
27
+ </html>
28
+ HTML
29
+ end
30
+
31
+ get '/auth/:provider/callback' do
32
+ <<-HTML
33
+ <html>
34
+ <head>
35
+ <title>DuoDealer Oauth2</title>
36
+ </head>
37
+ <body>
38
+ <h3>Authorized</h3>
39
+ <p>Shop: #{request.env['omniauth.auth'].uid}</p>
40
+ <p>Token: #{request.env['omniauth.auth']['credentials']['token']}</p>
41
+ </body>
42
+ </html>
43
+ HTML
44
+ end
45
+
46
+ get '/auth/failure' do
47
+ <<-HTML
48
+ <html>
49
+ <head>
50
+ <title>DuoDealer Oauth2</title>
51
+ </head>
52
+ <body>
53
+ <h3>Failed Authorization</h3>
54
+ <p>Message: #{params[:message]}</p>
55
+ </body>
56
+ </html>
57
+ HTML
58
+ end
59
+ end
60
+
61
+ use Rack::Session::Cookie, secret: SecureRandom.hex(64)
62
+
63
+ use OmniAuth::Builder do
64
+ provider :duodealer, DUODEALER_API_KEY, DUODEALER_SHARED_SECRET, :scope => SCOPE
65
+ end
66
+
67
+ run App.new
@@ -0,0 +1 @@
1
+ require 'omniauth/duodealer'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'omniauth/duodealer/version'
4
+ require 'omniauth/strategies/duodealer'
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAuth
4
+ module Duodealer
5
+ VERSION = '1.0.0'
6
+ end
7
+ end
@@ -0,0 +1,160 @@
1
+ require 'omniauth/strategies/oauth2'
2
+
3
+ module OmniAuth
4
+ module Strategies
5
+ class Duodealer < OmniAuth::Strategies::OAuth2
6
+ # Available scopes: content themes products customers orders script_tags shipping
7
+ # read_* or write_*
8
+ DEFAULT_SCOPE = 'read_products'
9
+ SCOPE_DELIMITER = ','
10
+ MINUTE = 60
11
+ CODE_EXPIRES_AFTER = 10 * MINUTE
12
+
13
+ option :name, "duodealer"
14
+
15
+ option :client_options, {
16
+ :authorize_url => '/admin/oauth/authorize',
17
+ :token_url => '/admin/oauth/access_token'
18
+ }
19
+
20
+ option :callback_url
21
+ option :duodealer_domain, 'duodealer.com'
22
+ option :old_client_secret
23
+
24
+ # When `true`, the user's permission level will apply (in addition to
25
+ # the requested access scope) when making API requests to duodealer.
26
+ option :per_user_permissions, false
27
+
28
+ option :setup, proc { |env|
29
+ strategy = env['omniauth.strategy']
30
+
31
+ duodealer_auth_params = strategy.session['duodealer.omniauth_params'] && strategy.session['duodealer.omniauth_params'].with_indifferent_access
32
+ shop = if duodealer_auth_params && duodealer_auth_params['shop']
33
+ "https://#{duodealer_auth_params['shop']}"
34
+ else
35
+ ''
36
+ end
37
+
38
+ strategy.options[:client_options][:site] = shop
39
+ }
40
+
41
+ uid { URI.parse(options[:client_options][:site]).host }
42
+
43
+ extra do
44
+ if access_token
45
+ {
46
+ 'associated_user' => access_token['associated_user'],
47
+ 'associated_user_scope' => access_token['associated_user_scope'],
48
+ 'scope' => access_token['scope'],
49
+ 'session' => access_token['session']
50
+ }
51
+ end
52
+ end
53
+
54
+ def valid_site?
55
+ !!(/\A(https|http)\:\/\/[a-zA-Z0-9][a-zA-Z0-9\-]*\.#{Regexp.quote(options[:duodealer_domain])}[\/]?\z/ =~ options[:client_options][:site])
56
+ end
57
+
58
+ def valid_signature?
59
+ return false unless request.POST.empty?
60
+
61
+ params = request.GET
62
+ signature = params['hmac']
63
+ timestamp = params['timestamp']
64
+ return false unless signature && timestamp
65
+
66
+ return false unless timestamp.to_i > Time.now.to_i - CODE_EXPIRES_AFTER
67
+
68
+ new_secret = options.client_secret
69
+ old_secret = options.old_client_secret
70
+
71
+ validate_signature(new_secret) || (old_secret && validate_signature(old_secret))
72
+ end
73
+
74
+ def valid_scope?(token)
75
+ params = options.authorize_params.merge(options_for("authorize"))
76
+ return false unless token && params[:scope] && token['scope']
77
+ expected_scope = normalized_scopes(params[:scope]).sort
78
+ (expected_scope == token['scope'].split(SCOPE_DELIMITER).sort)
79
+ end
80
+
81
+ def normalized_scopes(scopes)
82
+ scope_list = scopes.to_s.split(SCOPE_DELIMITER).map(&:strip).reject(&:empty?).uniq
83
+ ignore_scopes = scope_list.map { |scope| scope =~ /\Awrite_(.*)\z/ && "read_#{$1}" }.compact
84
+ scope_list - ignore_scopes
85
+ end
86
+
87
+ def self.encoded_params_for_signature(params)
88
+ params = params.dup
89
+ params.delete('hmac')
90
+ params.delete('signature') # deprecated signature
91
+ Rack::Utils.build_query(params.sort)
92
+ end
93
+
94
+ def self.hmac_sign(encoded_params, secret)
95
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, secret, encoded_params)
96
+ end
97
+
98
+ def valid_permissions?(token)
99
+ token && (options[:per_user_permissions] == !token['associated_user'].nil?)
100
+ end
101
+
102
+ def fix_https
103
+ options[:client_options][:site] = options[:client_options][:site].gsub(/\Ahttp\:/, 'https:')
104
+ end
105
+
106
+ def setup_phase
107
+ super
108
+ fix_https
109
+ end
110
+
111
+ def request_phase
112
+ if valid_site?
113
+ super
114
+ else
115
+ fail!(:invalid_site)
116
+ end
117
+ end
118
+
119
+ def callback_phase
120
+ return fail!(:invalid_site, CallbackError.new(:invalid_site, "OAuth endpoint is not a duodealer site.")) unless valid_site?
121
+ return fail!(:invalid_signature, CallbackError.new(:invalid_signature, "Signature does not match, it may have been tampered with.")) unless valid_signature?
122
+
123
+ token = build_access_token
124
+ unless valid_scope?(token)
125
+ return fail!(:invalid_scope, CallbackError.new(:invalid_scope, "Scope does not match, it may have been tampered with."))
126
+ end
127
+ unless valid_permissions?(token)
128
+ return fail!(:invalid_permissions, CallbackError.new(:invalid_permissions, "Requested API access mode does not match."))
129
+ end
130
+
131
+ super
132
+ rescue ::OAuth2::Error => e
133
+ fail!(:invalid_credentials, e)
134
+ end
135
+
136
+ def build_access_token
137
+ @built_access_token ||= super
138
+ end
139
+
140
+ def authorize_params
141
+ super.tap do |params|
142
+ params[:scope] = normalized_scopes(params[:scope] || DEFAULT_SCOPE).join(SCOPE_DELIMITER)
143
+ params[:grant_options] = ['per-user'] if options[:per_user_permissions]
144
+ end
145
+ end
146
+
147
+ def callback_url
148
+ options[:callback_url] || full_host + script_name + callback_path
149
+ end
150
+
151
+ private
152
+
153
+ def validate_signature(secret)
154
+ params = request.GET
155
+ calculated_signature = self.class.hmac_sign(self.class.encoded_params_for_signature(params), secret)
156
+ Rack::Utils.secure_compare(calculated_signature, params['hmac'])
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.push File.expand_path('lib', __dir__)
4
+ require 'omniauth/duodealer/version'
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = 'omniauth-duodealer-oauth2'
8
+ s.version = OmniAuth::Duodealer::VERSION
9
+ s.authors = ['Eric Raio']
10
+ s.email = ['eric@duodealer.com']
11
+ s.summary = 'duodealer strategy for OmniAuth'
12
+ s.homepage = 'https://gitlab.com/duodealer/omniauth-duodealer-oauth2'
13
+ s.license = 'MIT'
14
+
15
+ s.files = `git ls-files`.split("\n")
16
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
17
+ s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
18
+ s.require_paths = ['lib']
19
+ s.required_ruby_version = '>= 2.1.9'
20
+
21
+ s.add_runtime_dependency 'activesupport'
22
+ s.add_runtime_dependency 'omniauth-oauth2', '~> 1.5.0'
23
+
24
+ s.add_development_dependency 'minitest', '~> 5.6'
25
+ s.add_development_dependency 'rake'
26
+ end
@@ -0,0 +1 @@
1
+ # using the default shipit config
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'omniauth-duodealer-oauth2'
5
+ require 'base64'
6
+
7
+ describe OmniAuth::Strategies::Duodealer do
8
+ before :each do
9
+ @request = double('Request',
10
+ env: {})
11
+ @request.stub(:params) { {} }
12
+ @request.stub(:cookies) { {} }
13
+
14
+ @client_id = '123'
15
+ @client_secret = '53cr3tz'
16
+ @options = { client_options: { site: 'https://example.duodealer.com' } }
17
+ end
18
+
19
+ subject do
20
+ args = [@client_id, @client_secret, @options].compact
21
+ OmniAuth::Strategies::Duodealer.new(nil, *args).tap do |strategy|
22
+ strategy.stub(:request) { @request }
23
+ strategy.stub(:session) { {} }
24
+ end
25
+ end
26
+
27
+ describe '#fix_https' do
28
+ it 'replaces http scheme by https' do
29
+ @options = { client_options: { site: 'http://foo.bar/' } }
30
+ subject.fix_https
31
+ subject.options[:client_options][:site].should eq('https://foo.bar/')
32
+ end
33
+
34
+ it 'replaces http scheme by https with an immutable string' do
35
+ @options = { client_options: { site: 'http://foo.bar/' } }
36
+ subject.fix_https
37
+ subject.options[:client_options][:site].should eq('https://foo.bar/')
38
+ end
39
+
40
+ it 'does not replace https scheme' do
41
+ @options = { client_options: { site: 'https://foo.bar/' } }
42
+ subject.fix_https
43
+ subject.options[:client_options][:site].should eq('https://foo.bar/')
44
+ end
45
+ end
46
+
47
+ describe '#client' do
48
+ it 'has correct duodealer site' do
49
+ subject.client.site.should eq('https://example.duodealer.com')
50
+ end
51
+
52
+ it 'has correct authorize url' do
53
+ subject.client.options[:authorize_url].should eq('/admin/oauth/authorize')
54
+ end
55
+
56
+ it 'has correct token url' do
57
+ subject.client.options[:token_url].should eq('/admin/oauth/access_token')
58
+ end
59
+ end
60
+
61
+ describe '#callback_url' do
62
+ it 'returns value from #callback_url' do
63
+ url = 'http://auth.myapp.com/auth/callback'
64
+ @options = { callback_url: url }
65
+ subject.callback_url.should eq(url)
66
+ end
67
+
68
+ it 'defaults to callback' do
69
+ url_base = 'http://auth.request.com'
70
+ @request.stub(:url) { "#{url_base}/page/path" }
71
+ @request.stub(:scheme) { 'http' }
72
+ subject.stub(:script_name) { '' } # to not depend from Rack env
73
+ subject.callback_url.should eq("#{url_base}/auth/duodealer/callback")
74
+ end
75
+ end
76
+
77
+ describe '#authorize_params' do
78
+ it 'includes default scope for read_products' do
79
+ subject.authorize_params.should be_a(Hash)
80
+ subject.authorize_params[:scope].should eq('read_products')
81
+ end
82
+
83
+ it 'includes custom scope' do
84
+ @options = { scope: 'write_products' }
85
+ subject.authorize_params.should be_a(Hash)
86
+ subject.authorize_params[:scope].should eq('write_products')
87
+ end
88
+ end
89
+
90
+ describe '#uid' do
91
+ it 'returns the shop' do
92
+ subject.uid.should eq('example.duodealer.com')
93
+ end
94
+ end
95
+
96
+ describe '#credentials' do
97
+ before :each do
98
+ @access_token = double('OAuth2::AccessToken')
99
+ @access_token.stub(:token)
100
+ @access_token.stub(:expires?)
101
+ @access_token.stub(:expires_at)
102
+ @access_token.stub(:refresh_token)
103
+ subject.stub(:access_token) { @access_token }
104
+ end
105
+
106
+ it 'returns a Hash' do
107
+ subject.credentials.should be_a(Hash)
108
+ end
109
+
110
+ it 'returns the token' do
111
+ @access_token.stub(:token) { '123' }
112
+ subject.credentials['token'].should eq('123')
113
+ end
114
+
115
+ it 'returns the expiry status' do
116
+ @access_token.stub(:expires?) { true }
117
+ subject.credentials['expires'].should eq(true)
118
+
119
+ @access_token.stub(:expires?) { false }
120
+ subject.credentials['expires'].should eq(false)
121
+ end
122
+ end
123
+
124
+ describe '#valid_site?' do
125
+ it 'returns true if the site contains .duodealer.com' do
126
+ @options = { client_options: { site: 'http://foo.duodealer.com/' } }
127
+ subject.valid_site?.should eq(true)
128
+ end
129
+
130
+ it 'returns false if the site does not contain .duodealer.com' do
131
+ @options = { client_options: { site: 'http://foo.example.com/' } }
132
+ subject.valid_site?.should eq(false)
133
+ end
134
+
135
+ it 'uses configurable option for duodealer_domain' do
136
+ @options = { client_options: { site: 'http://foo.example.com/' }, duodealer_domain: 'example.com' }
137
+ subject.valid_site?.should eq(true)
138
+ end
139
+
140
+ it 'allows custom port for duodealer_domain' do
141
+ @options = { client_options: { site: 'http://foo.example.com:3456/' }, duodealer_domain: 'example.com:3456' }
142
+ subject.valid_site?.should eq(true)
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,454 @@
1
+ require_relative 'test_helper'
2
+
3
+ class IntegrationTest < Minitest::Test
4
+ def setup
5
+ build_app(scope: OmniAuth::Strategies::Duodealer::DEFAULT_SCOPE)
6
+ end
7
+
8
+ def teardown
9
+ FakeWeb.clean_registry
10
+ FakeWeb.last_request = nil
11
+ end
12
+
13
+ def test_authorize
14
+ response = authorize('snowdevil.duodealer.com')
15
+ assert_equal 302, response.status
16
+ assert_match %r{\A#{Regexp.quote(duodealer_authorize_url)}}, response.location
17
+ redirect_params = Rack::Utils.parse_query(URI(response.location).query)
18
+ assert_equal "123", redirect_params['client_id']
19
+ assert_equal "https://app.example.com/auth/duodealer/callback", redirect_params['redirect_uri']
20
+ assert_equal "read_products", redirect_params['scope']
21
+ assert_nil redirect_params['grant_options']
22
+ end
23
+
24
+ def test_authorize_includes_auth_type_when_per_user_permissions_are_requested
25
+ build_app(per_user_permissions: true)
26
+ response = authorize('snowdevil.duodealer.com')
27
+ redirect_params = Rack::Utils.parse_query(URI(response.location).query)
28
+ assert_equal 'per-user', redirect_params['grant_options[]']
29
+ end
30
+
31
+ def test_authorize_overrides_site_with_https_scheme
32
+ build_app setup: lambda { |env|
33
+ params = Rack::Utils.parse_query(env['QUERY_STRING'])
34
+ env['omniauth.strategy'].options[:client_options][:site] = "http://#{params['shop']}"
35
+ }
36
+
37
+ response = request.get('https://app.example.com/auth/duodealer?shop=snowdevil.duodealer.com')
38
+ assert_match %r{\A#{Regexp.quote(duodealer_authorize_url)}}, response.location
39
+ end
40
+
41
+ def test_site_validation
42
+ code = SecureRandom.hex(16)
43
+
44
+ [
45
+ 'foo.example.com', # shop doesn't end with .duodealer.com
46
+ 'http://snowdevil.duodealer.com', # shop contains protocol
47
+ 'snowdevil.duodealer.com/path', # shop contains path
48
+ 'user@snowdevil.duodealer.com', # shop contains user
49
+ 'snowdevil.duodealer.com:22', # shop contains port
50
+ ].each do |shop, valid|
51
+ @shop = shop
52
+ response = authorize(shop)
53
+ assert_auth_failure(response, 'invalid_site')
54
+
55
+ response = callback(sign_with_new_secret(shop: shop, code: code))
56
+ assert_auth_failure(response, 'invalid_site')
57
+ end
58
+ end
59
+
60
+ def test_callback
61
+ access_token = SecureRandom.hex(16)
62
+ code = SecureRandom.hex(16)
63
+ expect_access_token_request(access_token, OmniAuth::Strategies::Duodealer::DEFAULT_SCOPE)
64
+
65
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code, state: opts["rack.session"]["omniauth.state"]))
66
+
67
+ assert_callback_success(response, access_token, code)
68
+ end
69
+
70
+ def test_callback_with_legacy_signature
71
+ build_app scope: OmniAuth::Strategies::Duodealer::DEFAULT_SCOPE
72
+ access_token = SecureRandom.hex(16)
73
+ code = SecureRandom.hex(16)
74
+ expect_access_token_request(access_token, OmniAuth::Strategies::Duodealer::DEFAULT_SCOPE)
75
+
76
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code, state: opts["rack.session"]["omniauth.state"]).merge(signature: 'ignored'))
77
+
78
+ assert_callback_success(response, access_token, code)
79
+ end
80
+
81
+ def test_callback_custom_params
82
+ access_token = SecureRandom.hex(16)
83
+ code = SecureRandom.hex(16)
84
+
85
+ expect_access_token_request(access_token, OmniAuth::Strategies::Duodealer::DEFAULT_SCOPE)
86
+
87
+ now = Time.now.to_i
88
+ params = { shop: 'snowdevil.duodealer.com', code: code, timestamp: now, next: '/products?page=2&q=red%20shirt', state: opts["rack.session"]["omniauth.state"] }
89
+ encoded_params = "code=#{code}&next=%2Fproducts%3Fpage%3D2%26q%3Dred%2520shirt&shop=snowdevil.duodealer.com&state=#{opts["rack.session"]["omniauth.state"]}&timestamp=#{now}"
90
+ params[:hmac] = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, @secret, encoded_params)
91
+
92
+ response = callback(params)
93
+
94
+ assert_callback_success(response, access_token, code)
95
+ end
96
+
97
+ def test_callback_with_spaces_in_scope
98
+ build_app scope: 'write_products, read_orders'
99
+ access_token = SecureRandom.hex(16)
100
+ code = SecureRandom.hex(16)
101
+ expect_access_token_request(access_token, 'read_orders,write_products')
102
+
103
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code, state: opts["rack.session"]["omniauth.state"]))
104
+
105
+ assert_callback_success(response, access_token, code)
106
+ end
107
+
108
+ def test_callback_rejects_invalid_hmac
109
+ @secret = 'wrong_secret'
110
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: SecureRandom.hex(16)))
111
+
112
+ assert_auth_failure(response, 'invalid_signature')
113
+ end
114
+
115
+ def test_callback_rejects_old_timestamps
116
+ expired_timestamp = Time.now.to_i - OmniAuth::Strategies::Duodealer::CODE_EXPIRES_AFTER - 1
117
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: SecureRandom.hex(16), timestamp: expired_timestamp))
118
+
119
+ assert_auth_failure(response, 'invalid_signature')
120
+ end
121
+
122
+ def test_callback_rejects_missing_hmac
123
+ code = SecureRandom.hex(16)
124
+
125
+ response = callback(shop: 'snowdevil.duodealer.com', code: code, timestamp: Time.now.to_i)
126
+
127
+ assert_auth_failure(response, 'invalid_signature')
128
+ end
129
+
130
+ def test_callback_rejects_body_params
131
+ code = SecureRandom.hex(16)
132
+ params = sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code)
133
+ body = Rack::Utils.build_nested_query(unsigned: 'value')
134
+
135
+ response = request.get("https://app.example.com/auth/duodealer/callback?#{Rack::Utils.build_query(params)}",
136
+ input: body,
137
+ "CONTENT_TYPE" => 'application/x-www-form-urlencoded',
138
+ 'rack.session' => {
139
+ 'duodealer.omniauth_params' => { shop: 'snowdevil.duodealer.com' }
140
+ })
141
+
142
+ assert_auth_failure(response, 'invalid_signature')
143
+ end
144
+
145
+ def test_provider_options
146
+ build_app scope: 'read_products,read_orders,write_content',
147
+ callback_path: '/admin/auth/legacy/callback',
148
+ duodealer_domain: 'duodealer.dev:3000',
149
+ setup: lambda { |env|
150
+ shop = Rack::Request.new(env).GET['shop']
151
+ shop += ".duodealer.dev:3000" unless shop.include?(".")
152
+ env['omniauth.strategy'].options[:client_options][:site] = "https://#{shop}"
153
+ }
154
+
155
+ response = request.get("https://app.example.com/auth/duodealer?shop=snowdevil.duodealer.dev:3000")
156
+ assert_equal 302, response.status
157
+ assert_match %r{\A#{Regexp.quote("https://snowdevil.duodealer.dev:3000/admin/oauth/authorize?")}}, response.location
158
+ redirect_params = Rack::Utils.parse_query(URI(response.location).query)
159
+ assert_equal 'read_products,read_orders,write_content', redirect_params['scope']
160
+ assert_equal 'https://app.example.com/admin/auth/legacy/callback', redirect_params['redirect_uri']
161
+ end
162
+
163
+ def test_default_setup_reads_shop_from_session
164
+ build_app
165
+ response = authorize('snowdevil.duodealer.com')
166
+ assert_equal 302, response.status
167
+ assert_match %r{\A#{Regexp.quote("https://snowdevil.duodealer.com/admin/oauth/authorize?")}}, response.location
168
+ redirect_params = Rack::Utils.parse_query(URI(response.location).query)
169
+ assert_equal 'https://app.example.com/auth/duodealer/callback', redirect_params['redirect_uri']
170
+ end
171
+
172
+ def test_unnecessary_read_scopes_are_removed
173
+ build_app scope: 'read_content,read_products,write_products',
174
+ callback_path: '/admin/auth/legacy/callback',
175
+ duodealer_domain: 'duodealer.dev:3000',
176
+ setup: lambda { |env|
177
+ shop = Rack::Request.new(env).GET['shop']
178
+ env['omniauth.strategy'].options[:client_options][:site] = "https://#{shop}"
179
+ }
180
+
181
+ response = request.get("https://app.example.com/auth/duodealer?shop=snowdevil.duodealer.dev:3000")
182
+ assert_equal 302, response.status
183
+ redirect_params = Rack::Utils.parse_query(URI(response.location).query)
184
+ assert_equal 'read_content,write_products', redirect_params['scope']
185
+ end
186
+
187
+ def test_callback_with_invalid_state_fails
188
+ access_token = SecureRandom.hex(16)
189
+ code = SecureRandom.hex(16)
190
+ expect_access_token_request(access_token, OmniAuth::Strategies::Duodealer::DEFAULT_SCOPE)
191
+
192
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code, state: 'invalid'))
193
+
194
+ assert_equal 302, response.status
195
+ assert_equal '/auth/failure?message=csrf_detected&strategy=duodealer', response.location
196
+ end
197
+
198
+ def test_callback_with_mismatching_scope_fails
199
+ access_token = SecureRandom.hex(16)
200
+ code = SecureRandom.hex(16)
201
+ expect_access_token_request(access_token, 'some_invalid_scope', nil)
202
+
203
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code, state: opts["rack.session"]["omniauth.state"]))
204
+
205
+ assert_equal 302, response.status
206
+ assert_equal '/auth/failure?message=invalid_scope&strategy=duodealer', response.location
207
+ end
208
+
209
+ def test_callback_with_no_scope_fails
210
+ access_token = SecureRandom.hex(16)
211
+ code = SecureRandom.hex(16)
212
+ expect_access_token_request(access_token, nil)
213
+
214
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code, state: opts["rack.session"]["omniauth.state"]))
215
+
216
+ assert_equal 302, response.status
217
+ assert_equal '/auth/failure?message=invalid_scope&strategy=duodealer', response.location
218
+ end
219
+
220
+ def test_callback_with_missing_access_scope_fails
221
+ build_app scope: 'first_scope,second_scope'
222
+
223
+ access_token = SecureRandom.hex(16)
224
+ code = SecureRandom.hex(16)
225
+ expect_access_token_request(access_token, 'first_scope')
226
+
227
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code, state: opts["rack.session"]["omniauth.state"]))
228
+
229
+ assert_equal 302, response.status
230
+ assert_equal '/auth/failure?message=invalid_scope&strategy=duodealer', response.location
231
+ end
232
+
233
+ def test_callback_with_extra_access_scope_fails
234
+ build_app scope: 'first_scope,second_scope'
235
+
236
+ access_token = SecureRandom.hex(16)
237
+ code = SecureRandom.hex(16)
238
+ expect_access_token_request(access_token, 'second_scope,first_scope,third_scope')
239
+
240
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code, state: opts["rack.session"]["omniauth.state"]))
241
+
242
+ assert_equal 302, response.status
243
+ assert_equal '/auth/failure?message=invalid_scope&strategy=duodealer', response.location
244
+ end
245
+
246
+ def test_callback_with_scopes_out_of_order_works
247
+ build_app scope: 'first_scope,second_scope'
248
+
249
+ access_token = SecureRandom.hex(16)
250
+ code = SecureRandom.hex(16)
251
+ expect_access_token_request(access_token, 'second_scope,first_scope')
252
+
253
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code, state: opts["rack.session"]["omniauth.state"]))
254
+
255
+ assert_callback_success(response, access_token, code)
256
+ end
257
+
258
+ def test_callback_with_extra_coma_works
259
+ build_app scope: 'read_content,,write_products,'
260
+
261
+ access_token = SecureRandom.hex(16)
262
+ code = SecureRandom.hex(16)
263
+ expect_access_token_request(access_token, 'read_content,write_products')
264
+
265
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code, state: opts["rack.session"]["omniauth.state"]))
266
+
267
+ assert_callback_success(response, access_token, code)
268
+ end
269
+
270
+ def test_callback_when_per_user_permissions_are_present_but_not_requested
271
+ build_app(scope: 'scope', per_user_permissions: false)
272
+
273
+ access_token = SecureRandom.hex(16)
274
+ code = SecureRandom.hex(16)
275
+ expect_access_token_request(access_token, 'scope', { id: 1, email: 'bob@bobsen.com'})
276
+
277
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code, state: opts["rack.session"]["omniauth.state"]))
278
+
279
+ assert_equal 302, response.status
280
+ assert_equal '/auth/failure?message=invalid_permissions&strategy=duodealer', response.location
281
+ end
282
+
283
+ def test_callback_when_per_user_permissions_are_not_present_but_requested
284
+ build_app(scope: 'scope', per_user_permissions: true)
285
+
286
+ access_token = SecureRandom.hex(16)
287
+ code = SecureRandom.hex(16)
288
+ expect_access_token_request(access_token, 'scope', nil)
289
+
290
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code, state: opts["rack.session"]["omniauth.state"]))
291
+
292
+ assert_equal 302, response.status
293
+ assert_equal '/auth/failure?message=invalid_permissions&strategy=duodealer', response.location
294
+ end
295
+
296
+ def test_callback_works_when_per_user_permissions_are_present_and_requested
297
+ build_app(scope: 'scope', per_user_permissions: true)
298
+
299
+ access_token = SecureRandom.hex(16)
300
+ code = SecureRandom.hex(16)
301
+ expect_access_token_request(access_token, 'scope', { id: 1, email: 'bob@bobsen.com'})
302
+
303
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code, state: opts["rack.session"]["omniauth.state"]))
304
+
305
+ assert_equal 200, response.status
306
+ end
307
+
308
+ def test_callback_when_a_session_is_present
309
+ build_app(scope: 'scope', per_user_permissions: true)
310
+
311
+ access_token = SecureRandom.hex(16)
312
+ code = SecureRandom.hex(16)
313
+ session = SecureRandom.hex
314
+ expect_access_token_request(access_token, 'scope', { id: 1, email: 'bob@bobsen.com'}, session)
315
+
316
+ response = callback(sign_with_new_secret(shop: 'snowdevil.duodealer.com', code: code, state: opts["rack.session"]["omniauth.state"]))
317
+
318
+ assert_equal 200, response.status
319
+ end
320
+
321
+ def test_callback_works_with_old_secret
322
+ build_app scope: OmniAuth::Strategies::Duodealer::DEFAULT_SCOPE
323
+ access_token = SecureRandom.hex(16)
324
+ code = SecureRandom.hex(16)
325
+ expect_access_token_request(access_token, OmniAuth::Strategies::Duodealer::DEFAULT_SCOPE)
326
+
327
+ signed_params = sign_with_old_secret(
328
+ shop: 'snowdevil.duodealer.com',
329
+ code: code,
330
+ state: opts["rack.session"]["omniauth.state"]
331
+ )
332
+
333
+ response = callback(signed_params)
334
+
335
+ assert_callback_success(response, access_token, code)
336
+ end
337
+
338
+ def test_callback_when_creds_are_invalid
339
+ build_app scope: OmniAuth::Strategies::Duodealer::DEFAULT_SCOPE
340
+
341
+ FakeWeb.register_uri(
342
+ :post,
343
+ "https://snowdevil.duodealer.com/admin/oauth/access_token",
344
+ status: [ "401", "Invalid token" ],
345
+ body: "Token is invalid or has already been requested"
346
+ )
347
+
348
+ signed_params = sign_with_new_secret(
349
+ shop: 'snowdevil.duodealer.com',
350
+ code: SecureRandom.hex(16),
351
+ state: opts["rack.session"]["omniauth.state"]
352
+ )
353
+
354
+ response = callback(signed_params)
355
+
356
+ assert_equal 302, response.status
357
+ assert_equal '/auth/failure?message=invalid_credentials&strategy=duodealer', response.location
358
+ end
359
+
360
+ private
361
+
362
+ def sign_with_old_secret(params)
363
+ params = add_time(params)
364
+ encoded_params = OmniAuth::Strategies::Duodealer.encoded_params_for_signature(params)
365
+ params['hmac'] = OmniAuth::Strategies::Duodealer.hmac_sign(encoded_params, @old_secret)
366
+ params
367
+ end
368
+
369
+ def add_time(params)
370
+ params = params.dup
371
+ params[:timestamp] ||= Time.now.to_i
372
+ params
373
+ end
374
+
375
+ def sign_with_new_secret(params)
376
+ params = add_time(params)
377
+ encoded_params = OmniAuth::Strategies::Duodealer.encoded_params_for_signature(params)
378
+ params['hmac'] = OmniAuth::Strategies::Duodealer.hmac_sign(encoded_params, @secret)
379
+ params
380
+ end
381
+
382
+ def expect_access_token_request(access_token, scope, associated_user=nil, session=nil)
383
+ FakeWeb.register_uri(:post, "https://snowdevil.duodealer.com/admin/oauth/access_token",
384
+ body: JSON.dump(
385
+ access_token: access_token,
386
+ scope: scope,
387
+ associated_user: associated_user,
388
+ session: session,
389
+ ),
390
+ content_type: 'application/json')
391
+ end
392
+
393
+ def assert_callback_success(response, access_token, code)
394
+ token_request_params = Rack::Utils.parse_query(FakeWeb.last_request.body)
395
+ assert_equal token_request_params['client_id'], '123'
396
+ assert_equal token_request_params['client_secret'], @secret
397
+ assert_equal token_request_params['code'], code
398
+
399
+ assert_equal 'snowdevil.duodealer.com', @omniauth_result.uid
400
+ assert_equal access_token, @omniauth_result.credentials.token
401
+ assert_equal false, @omniauth_result.credentials.expires
402
+
403
+ assert_equal 200, response.status
404
+ assert_equal "OK", response.body
405
+ end
406
+
407
+ def assert_auth_failure(response, reason)
408
+ assert_nil FakeWeb.last_request
409
+ assert_equal 302, response.status
410
+ assert_match %r{\A#{Regexp.quote("/auth/failure?message=#{reason}")}}, response.location
411
+ end
412
+
413
+ def build_app(options={})
414
+ @old_secret = '12d34s1'
415
+ @secret = '53cr3tz'
416
+ options.merge!(old_client_secret: @old_secret)
417
+ app = proc { |env|
418
+ @omniauth_result = env['omniauth.auth']
419
+ [200, {Rack::CONTENT_TYPE => "text/plain"}, "OK"]
420
+ }
421
+
422
+ opts["rack.session"]["omniauth.state"] = SecureRandom.hex(32)
423
+ app = OmniAuth::Builder.new(app) do
424
+ provider :duodealer, '123', '53cr3tz' , options
425
+ end
426
+ @app = Rack::Session::Cookie.new(app, secret: SecureRandom.hex(64))
427
+ end
428
+
429
+ def shop
430
+ @shop ||= 'snowdevil.duodealer.com'
431
+ end
432
+
433
+ def authorize(shop)
434
+ @opts['rack.session']['duodealer.omniauth_params'] = { shop: shop }
435
+ request.get('https://app.example.com/auth/duodealer', opts)
436
+ end
437
+
438
+ def callback(params)
439
+ @opts['rack.session']['duodealer.omniauth_params'] = { shop: shop }
440
+ request.get("https://app.example.com/auth/duodealer/callback?#{Rack::Utils.build_query(params)}", opts)
441
+ end
442
+
443
+ def opts
444
+ @opts ||= { "rack.session" => {} }
445
+ end
446
+
447
+ def request
448
+ Rack::MockRequest.new(@app)
449
+ end
450
+
451
+ def duodealer_authorize_url
452
+ "https://snowdevil.duodealer.com/admin/oauth/authorize?"
453
+ end
454
+ end
@@ -0,0 +1,11 @@
1
+ $: << File.expand_path("../../lib", __FILE__)
2
+ require 'bundler/setup'
3
+ require 'omniauth-duodealer-oauth2'
4
+
5
+ require 'minitest/autorun'
6
+ require 'fakeweb'
7
+ require 'json'
8
+ require 'active_support/core_ext/hash'
9
+
10
+ OmniAuth.config.logger = Logger.new(nil)
11
+ FakeWeb.allow_net_connect = false
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omniauth-duodealer-oauth2
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Eric Raio
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-02-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: omniauth-oauth2
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 1.5.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 1.5.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.6'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.6'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description:
70
+ email:
71
+ - eric@duodealer.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - ".github/probots.yml"
77
+ - ".gitignore"
78
+ - ".ruby-version"
79
+ - ".travis.yml"
80
+ - Gemfile
81
+ - Rakefile
82
+ - example/Gemfile
83
+ - example/config.ru
84
+ - lib/omniauth-duodealer-oauth2.rb
85
+ - lib/omniauth/duodealer.rb
86
+ - lib/omniauth/duodealer/version.rb
87
+ - lib/omniauth/strategies/duodealer.rb
88
+ - omniauth-duodealer-oauth2.gemspec
89
+ - shipit.rubygems.yml
90
+ - spec/omniauth/strategies/duodealer_spec.rb
91
+ - test/integration_test.rb
92
+ - test/test_helper.rb
93
+ homepage: https://gitlab.com/duodealer/omniauth-duodealer-oauth2
94
+ licenses:
95
+ - MIT
96
+ metadata: {}
97
+ post_install_message:
98
+ rdoc_options: []
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: 2.1.9
106
+ required_rubygems_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubygems_version: 3.0.3
113
+ signing_key:
114
+ specification_version: 4
115
+ summary: duodealer strategy for OmniAuth
116
+ test_files:
117
+ - spec/omniauth/strategies/duodealer_spec.rb
118
+ - test/integration_test.rb
119
+ - test/test_helper.rb