omniauth-duodealer-oauth2 1.0.0

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