ginjo-omniauth-slack 2.4.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,394 @@
1
+ require 'omniauth/strategies/oauth2'
2
+ require 'thread'
3
+
4
+ module OmniAuth
5
+ module Strategies
6
+
7
+ class Slack < OmniAuth::Strategies::OAuth2
8
+ option :name, 'slack'
9
+
10
+ option :authorize_options, [:scope, :team, :team_domain, :redirect_uri]
11
+
12
+ option :client_options, {
13
+ site: 'https://slack.com',
14
+ token_url: '/api/oauth.access'
15
+ }
16
+
17
+ # Add team_domain to site subdomain if provided in auth url or provider options.
18
+ option :setup, lambda{|env|
19
+ strategy = env['omniauth.strategy']
20
+ team_domain = strategy.request.params['team_domain'] || strategy.options[:team_domain]
21
+ site = strategy.options[:client_options]['site']
22
+ strategy.options[:client_options].site = (
23
+ !team_domain.to_s.empty? ? site.sub(/\:\\\\/, "://#{team_domain}.") : site
24
+ )
25
+ }
26
+
27
+ option :auth_token_params, {
28
+ mode: :query,
29
+ param_name: 'token'
30
+ }
31
+
32
+ option :preload_data_with_threads, 0
33
+
34
+ option :include_data, []
35
+
36
+ option :exclude_data, []
37
+
38
+ option :additional_data, {}
39
+
40
+ # User ID is not guaranteed to be globally unique across all Slack users.
41
+ # The combination of user ID and team ID, on the other hand, is guaranteed
42
+ # to be globally unique.
43
+ uid { "#{user_id}-#{team_id}" }
44
+
45
+ credentials do
46
+ {
47
+ token: auth['token'],
48
+ scope: (is_app_token? ? all_scopes : auth['scope']),
49
+ expires: false
50
+ }
51
+ end
52
+
53
+ info do
54
+ define_additional_data
55
+ semaphore
56
+
57
+ num_threads = options.preload_data_with_threads.to_i
58
+ if num_threads > 0 && !skip_info?
59
+ preload_data_with_threads(num_threads)
60
+ end
61
+
62
+ # Start with only what we can glean from the authorization response.
63
+ hash = {
64
+ name: auth['user'].to_h['name'],
65
+ email: auth['user'].to_h['email'],
66
+ user_id: user_id,
67
+ team_name: auth['team_name'] || auth['team'].to_h['name'],
68
+ team_id: team_id,
69
+ image: auth['team'].to_h['image_48']
70
+ }
71
+
72
+ # Now add everything else, using further calls to the api, if necessary.
73
+ unless skip_info?
74
+ %w(first_name last_name phone skype avatar_hash real_name real_name_normalized).each do |key|
75
+ hash[key.to_sym] = (
76
+ user_info['user'].to_h['profile'] ||
77
+ user_profile['profile']
78
+ ).to_h[key]
79
+ end
80
+
81
+ %w(deleted status color tz tz_label tz_offset is_admin is_owner is_primary_owner is_restricted is_ultra_restricted is_bot has_2fa).each do |key|
82
+ hash[key.to_sym] = user_info['user'].to_h[key]
83
+ end
84
+
85
+ more_info = {
86
+ image: (
87
+ hash[:image] ||
88
+ user_identity.to_h['image_48'] ||
89
+ user_info['user'].to_h['profile'].to_h['image_48'] ||
90
+ user_profile['profile'].to_h['image_48']
91
+ ),
92
+ name:(
93
+ hash[:name] ||
94
+ user_identity['name'] ||
95
+ user_info['user'].to_h['real_name'] ||
96
+ user_profile['profile'].to_h['real_name']
97
+ ),
98
+ email:(
99
+ hash[:email] ||
100
+ user_identity.to_h['email'] ||
101
+ user_info['user'].to_h['profile'].to_h['email'] ||
102
+ user_profile['profile'].to_h['email']
103
+ ),
104
+ team_name:(
105
+ hash[:team_name] ||
106
+ team_identity.to_h['name'] ||
107
+ team_info['team'].to_h['name']
108
+ ),
109
+ team_domain:(
110
+ auth['team'].to_h['domain'] ||
111
+ team_identity.to_h['domain'] ||
112
+ team_info['team'].to_h['domain']
113
+ ),
114
+ team_image:(
115
+ auth['team'].to_h['image_44'] ||
116
+ team_identity.to_h['image_44'] ||
117
+ team_info['team'].to_h['icon'].to_h['image_44']
118
+ ),
119
+ team_email_domain:(
120
+ team_info['team'].to_h['email_domain']
121
+ ),
122
+ nickname:(
123
+ user_info.to_h['user'].to_h['name'] ||
124
+ auth['user'].to_h['name'] ||
125
+ user_identity.to_h['name']
126
+ ),
127
+ }
128
+
129
+ hash.merge!(more_info)
130
+ end
131
+ hash
132
+ end
133
+
134
+ extra do
135
+ {
136
+ scopes_requested: (env['omniauth.params'] && env['omniauth.params']['scope']) || \
137
+ (env['omniauth.strategy'] && env['omniauth.strategy'].options && env['omniauth.strategy'].options.scope),
138
+ web_hook_info: web_hook_info,
139
+ bot_info: auth['bot'] || bot_info['bot'],
140
+ auth: auth,
141
+ identity: identity,
142
+ user_info: user_info,
143
+ user_profile: user_profile,
144
+ team_info: team_info,
145
+ apps_permissions_users_list: apps_permissions_users_list,
146
+ additional_data: get_additional_data,
147
+ raw_info: @raw_info
148
+ }
149
+ end
150
+
151
+ # Pass on certain authorize_params to the Slack authorization GET request.
152
+ # See https://github.com/omniauth/omniauth/issues/390
153
+ def authorize_params
154
+ super.tap do |params|
155
+ %w(scope team redirect_uri).each do |v|
156
+ if !request.params[v].to_s.empty?
157
+ params[v.to_sym] = request.params[v]
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+ # Get a new OAuth2::Client and define custom capabilities.
164
+ # * verrides super :client method.
165
+ #
166
+ # * Log API requests with OmniAuth.logger
167
+ # * Add API responses to @raw_info hash
168
+ #
169
+ def client
170
+ st_raw_info = raw_info
171
+ new_client = super
172
+ log(:debug, "New client #{new_client}.")
173
+
174
+ new_client.instance_eval do
175
+ define_singleton_method(:request) do |*args|
176
+ OmniAuth.logger.send(:debug, "(slack) API request #{args[0..1]}; in thread #{Thread.current.object_id}.")
177
+ request_output = super(*args)
178
+ uri = args[1].to_s.gsub(/^.*\/([^\/]+)/, '\1') # use single-quote or double-back-slash for replacement.
179
+ st_raw_info[uri.to_s]= request_output
180
+ request_output
181
+ end
182
+ end
183
+ new_client
184
+ end
185
+
186
+ # Dropping query_string from callback_url prevents some errors in call to /api/oauth.access.
187
+ def callback_url
188
+ full_host + script_name + callback_path
189
+ end
190
+
191
+ def identity
192
+ return {} unless !skip_info? && has_scope?(identity: ['identity.basic','identity:read:user']) && is_not_excluded?
193
+ semaphore.synchronize {
194
+ @identity_raw ||= access_token.get('/api/users.identity', headers: {'X-Slack-User' => user_id})
195
+ @identity ||= @identity_raw.parsed
196
+ }
197
+ end
198
+
199
+
200
+ private
201
+
202
+ def initialize(*args)
203
+ super
204
+ @main_semaphore = Mutex.new
205
+ @semaphores = {}
206
+ end
207
+
208
+ # Get a mutex specific to the calling method.
209
+ # This operation is synchronized with its own mutex.
210
+ def semaphore(method_name = caller[0][/`([^']*)'/, 1])
211
+ @main_semaphore.synchronize {
212
+ @semaphores[method_name] ||= Mutex.new
213
+ }
214
+ end
215
+
216
+ def active_methods
217
+ @active_methods ||= (
218
+ includes = [options.include_data].flatten.compact
219
+ excludes = [options.exclude_data].flatten.compact unless includes.size > 0
220
+ method_list = %w(apps_permissions_users_list identity user_info user_profile team_info bot_info) #.concat(options[:additional_data].keys)
221
+ if includes.size > 0
222
+ method_list.keep_if {|m| includes.include?(m.to_s) || includes.include?(m.to_s.to_sym)}
223
+ elsif excludes.size > 0
224
+ method_list.delete_if {|m| excludes.include?(m.to_s) || excludes.include?(m.to_s.to_sym)}
225
+ end
226
+ log :debug, "Activated API calls: #{method_list}."
227
+ log :debug, "Activated additional_data calls: #{options.additional_data.keys}."
228
+ method_list
229
+ )
230
+ end
231
+
232
+ def is_not_excluded?(method_name = caller[0][/`([^']*)'/, 1])
233
+ active_methods.include?(method_name.to_s) || active_methods.include?(method_name.to_s.to_sym)
234
+ end
235
+
236
+ # Preload additional api calls with a pool of threads.
237
+ def preload_data_with_threads(num_threads)
238
+ return unless num_threads > 0
239
+ preload_methods = active_methods.concat(options[:additional_data].keys)
240
+ log :info, "Preloading (#{preload_methods.size}) data requests using (#{num_threads}) threads."
241
+ work_q = Queue.new
242
+ preload_methods.each{|x| work_q.push x }
243
+ workers = num_threads.to_i.times.map do
244
+ Thread.new do
245
+ begin
246
+ while x = work_q.pop(true)
247
+ log :debug, "Preloading #{x}."
248
+ send x
249
+ end
250
+ rescue ThreadError
251
+ end
252
+ end
253
+ end
254
+ workers.map(&:join); "ok"
255
+ end
256
+
257
+ # Define methods for addional data from :additional_data option
258
+ def define_additional_data
259
+ hash = options[:additional_data]
260
+ if !hash.to_h.empty?
261
+ hash.each do |k,v|
262
+ define_singleton_method(k) do
263
+ instance_variable_get(:"@#{k}") ||
264
+ instance_variable_set(:"@#{k}", v.respond_to?(:call) ? v.call(env) : v)
265
+ end
266
+ end
267
+ end
268
+ end
269
+
270
+ def get_additional_data
271
+ if skip_info?
272
+ {}
273
+ else
274
+ options[:additional_data].inject({}) do |hash,tupple|
275
+ hash[tupple[0].to_s] = send(tupple[0].to_s)
276
+ hash
277
+ end
278
+ end
279
+ end
280
+
281
+ # Parsed data returned from /slack/oauth.access api call.
282
+ def auth
283
+ @auth ||= access_token.params.to_h.merge({'token' => access_token.token})
284
+ end
285
+
286
+ def user_identity
287
+ @user_identity ||= identity['user'].to_h
288
+ end
289
+
290
+ def team_identity
291
+ @team_identity ||= identity['team'].to_h
292
+ end
293
+
294
+ def user_info
295
+ return {} unless !skip_info? && has_scope?(identity: 'users:read', team: 'users:read') && is_not_excluded?
296
+ semaphore.synchronize {
297
+ @user_info_raw ||= access_token.get('/api/users.info', params: {user: user_id}, headers: {'X-Slack-User' => user_id})
298
+ @user_info ||= @user_info_raw.parsed
299
+ }
300
+ end
301
+
302
+ def user_profile
303
+ return {} unless !skip_info? && has_scope?(identity: 'users.profile:read', team: 'users.profile:read') && is_not_excluded?
304
+ semaphore.synchronize {
305
+ @user_profile_raw ||= access_token.get('/api/users.profile.get', params: {user: user_id}, headers: {'X-Slack-User' => user_id})
306
+ @user_profile ||= @user_profile_raw.parsed
307
+ }
308
+ end
309
+
310
+ def team_info
311
+ return {} unless !skip_info? && has_scope?(identity: 'team:read', team: 'team:read') && is_not_excluded?
312
+ semaphore.synchronize {
313
+ @team_info_raw ||= access_token.get('/api/team.info')
314
+ @team_info ||= @team_info_raw.parsed
315
+ }
316
+ end
317
+
318
+ def web_hook_info
319
+ return {} unless auth.key? 'incoming_webhook'
320
+ auth['incoming_webhook']
321
+ end
322
+
323
+ def bot_info
324
+ return {} unless !skip_info? && has_scope?(identity: 'users:read') && is_not_excluded?
325
+ semaphore.synchronize {
326
+ @bot_info_raw ||= access_token.get('/api/bots.info')
327
+ @bot_info ||= @bot_info_raw.parsed
328
+ }
329
+ end
330
+
331
+ def user_id
332
+ auth['user_id'] || auth['user'].to_h['id'] || auth['authorizing_user'].to_h['user_id']
333
+ end
334
+
335
+ def team_id
336
+ auth['team_id'] || auth['team'].to_h['id']
337
+ end
338
+
339
+ # API call to get user permissions for workspace token.
340
+ # This is needed because workspace token 'sign-in-with-slack' is missing scopes
341
+ # in the :scope field (acknowledged issue in developer preview).
342
+ #
343
+ # Returns [<id>: <resource>]
344
+ def apps_permissions_users_list
345
+ return {} unless !skip_info? && is_app_token?
346
+ semaphore.synchronize {
347
+ @apps_permissions_users_list_raw ||= access_token.get('/api/apps.permissions.users.list')
348
+ @apps_permissions_users_list ||= @apps_permissions_users_list_raw.parsed['resources'].inject({}){|h,i| h[i['id']] = i; h}
349
+ }
350
+ end
351
+
352
+ def raw_info
353
+ @raw_info ||= {}
354
+ end
355
+
356
+ # Is this a workspace app token?
357
+ def is_app_token?
358
+ auth['token_type'].to_s == 'app'
359
+ end
360
+
361
+ # Scopes come from at least 3 different places now.
362
+ # * The classic :scope field (string)
363
+ # * New workshop token :scopes field (hash)
364
+ # * Separate call to apps.permissions.users.list (array)
365
+ #
366
+ # This returns hash of workspace scopes, with classic & new identity scopes in :identity.
367
+ # Lists of scopes are in array form.
368
+ def all_scopes
369
+ @all_scopes ||=
370
+ {'identity' => (auth['scope'] || apps_permissions_users_list[user_id].to_h['scopes'].to_a.join(',')).to_s.split(',')}
371
+ .merge(auth['scopes'].to_h)
372
+ end
373
+
374
+ # Determine if given scopes exist in current authorization.
375
+ # Scopes is hash where
376
+ # key == scope type <identity|app_hope|team|channel|group|mpim|im>
377
+ # val == array or string of individual scopes.
378
+ def has_scope?(**scopes_hash)
379
+ scopes_hash.detect do |section, scopes|
380
+ test_scopes = case
381
+ when scopes.is_a?(String); scopes.split(',')
382
+ when scopes.is_a?(Array); scopes
383
+ else raise "Scope must be a string or array"
384
+ end
385
+ test_scopes.detect do |scope|
386
+ all_scopes[section.to_s].to_a.include?(scope.to_s)
387
+ end
388
+ end
389
+ end
390
+
391
+ end
392
+ end
393
+ end
394
+
@@ -0,0 +1,5 @@
1
+ module OmniAuth
2
+ module Slack
3
+ VERSION = "2.4.0"
4
+ end
5
+ end
@@ -0,0 +1,2 @@
1
+ require 'omniauth-slack/version'
2
+ require 'omniauth/strategies/slack'
@@ -0,0 +1,25 @@
1
+ # coding: utf-8
2
+ require File.expand_path('../lib/omniauth-slack/version', __FILE__)
3
+
4
+ Gem::Specification.new do |spec|
5
+ spec.name = 'ginjo-omniauth-slack'
6
+ spec.version = OmniAuth::Slack::VERSION
7
+ spec.authors = ['kimura', 'ginjo']
8
+ spec.email = ['kimura@enigmo.co.jp', 'wbr@mac.com']
9
+ spec.description = %q{OmniAuth strategy for Slack}
10
+ spec.summary = %q{OmniAuth strategy for Slack, based on the oauth2 and omniauth-oauth2 gems}
11
+ spec.homepage = 'https://github.com/kmrshntr/omniauth-slack.git'
12
+ spec.license = 'MIT'
13
+
14
+ spec.files = `git ls-files`.split($/)
15
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
16
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
17
+ spec.require_paths = ['lib']
18
+
19
+ spec.add_runtime_dependency 'omniauth-oauth2', ['~> 1.4', '~> 1.5']
20
+
21
+ spec.add_development_dependency 'bundler', '>= 1.11.2'
22
+ spec.add_development_dependency 'rake'
23
+ spec.add_development_dependency 'minitest'
24
+ spec.add_development_dependency 'mocha'
25
+ end
data/test/helper.rb ADDED
@@ -0,0 +1,57 @@
1
+ require "bundler/setup"
2
+ require "minitest/autorun"
3
+ require "mocha/setup"
4
+ require "omniauth/strategies/slack"
5
+
6
+ OmniAuth.config.test_mode = true
7
+
8
+ module BlockTestHelper
9
+ def test(name, &blk)
10
+ method_name = "test_#{name.gsub(/\s+/, "_")}"
11
+ raise "Method already defined: #{method_name}" if instance_methods.include?(method_name.to_sym)
12
+ define_method method_name, &blk
13
+ end
14
+ end
15
+
16
+ module CustomAssertions
17
+ def assert_has_key(key, hash, msg = nil)
18
+ msg = message(msg) { "Expected #{hash.inspect} to have key #{key.inspect}" }
19
+ assert hash.has_key?(key), msg
20
+ end
21
+
22
+ def refute_has_key(key, hash, msg = nil)
23
+ msg = message(msg) { "Expected #{hash.inspect} not to have key #{key.inspect}" }
24
+ refute hash.has_key?(key), msg
25
+ end
26
+ end
27
+
28
+ class TestCase < Minitest::Test
29
+ extend BlockTestHelper
30
+ include CustomAssertions
31
+ end
32
+
33
+ class StrategyTestCase < TestCase
34
+ def setup
35
+ @request = stub("Request")
36
+ @request.stubs(:params).returns({})
37
+ @request.stubs(:cookies).returns({})
38
+ @request.stubs(:env).returns({})
39
+ @request.stubs(:scheme).returns({})
40
+ @request.stubs(:ssl?).returns(false)
41
+
42
+ @client_id = "123"
43
+ @client_secret = "53cr3tz"
44
+ @options = {}
45
+ end
46
+
47
+ def strategy
48
+ @strategy ||= begin
49
+ args = [@client_id, @client_secret, @options].compact
50
+ OmniAuth::Strategies::Slack.new(nil, *args).tap do |strategy|
51
+ strategy.stubs(:request).returns(@request)
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ Dir[File.expand_path("../support/**/*", __FILE__)].each(&method(:require))
@@ -0,0 +1,85 @@
1
+ # NOTE it would be useful if this lived in omniauth-oauth2 eventually
2
+ module OAuth2StrategyTests
3
+ def self.included(base)
4
+ base.class_eval do
5
+ include ClientTests
6
+ include AuthorizeParamsTests
7
+ include CSRFAuthorizeParamsTests
8
+ include TokenParamsTests
9
+ end
10
+ end
11
+
12
+ module ClientTests
13
+ extend BlockTestHelper
14
+
15
+ test "should be initialized with symbolized client_options" do
16
+ @options = { :client_options => { "authorize_url" => "https://example.com" } }
17
+ assert_equal "https://example.com", strategy.client.options[:authorize_url]
18
+ end
19
+ end
20
+
21
+ module AuthorizeParamsTests
22
+ extend BlockTestHelper
23
+
24
+ test "should include any authorize params passed in the :authorize_params option" do
25
+ @options = { :authorize_params => { :foo => "bar", :baz => "zip" } }
26
+ assert_equal "bar", strategy.authorize_params["foo"]
27
+ assert_equal "zip", strategy.authorize_params["baz"]
28
+ end
29
+
30
+ test "should include top-level options that are marked as :authorize_options" do
31
+ @options = { :authorize_options => [:scope, :foo], :scope => "bar", :foo => "baz" }
32
+ assert_equal "bar", strategy.authorize_params["scope"]
33
+ assert_equal "baz", strategy.authorize_params["foo"]
34
+ end
35
+
36
+ test "should exclude top-level options that are not passed" do
37
+ @options = { :authorize_options => [:bar] }
38
+ refute_has_key :bar, strategy.authorize_params
39
+ refute_has_key "bar", strategy.authorize_params
40
+ end
41
+ end
42
+
43
+ module CSRFAuthorizeParamsTests
44
+ extend BlockTestHelper
45
+
46
+ test "should store random state in the session when none is present in authorize or request params" do
47
+ assert_includes strategy.authorize_params.keys, "state"
48
+ refute_empty strategy.authorize_params["state"]
49
+ refute_empty strategy.session["omniauth.state"]
50
+ assert_equal strategy.authorize_params["state"], strategy.session["omniauth.state"]
51
+ end
52
+
53
+ test "should not store state in the session when present in authorize params vs. a random one" do
54
+ @options = { :authorize_params => { :state => "bar" } }
55
+ refute_empty strategy.authorize_params["state"]
56
+ refute_equal "bar", strategy.authorize_params[:state]
57
+ refute_empty strategy.session["omniauth.state"]
58
+ refute_equal "bar", strategy.session["omniauth.state"]
59
+ end
60
+
61
+ test "should not store state in the session when present in request params vs. a random one" do
62
+ @request.stubs(:params).returns({ "state" => "foo" })
63
+ refute_empty strategy.authorize_params["state"]
64
+ refute_equal "foo", strategy.authorize_params[:state]
65
+ refute_empty strategy.session["omniauth.state"]
66
+ refute_equal "foo", strategy.session["omniauth.state"]
67
+ end
68
+ end
69
+
70
+ module TokenParamsTests
71
+ extend BlockTestHelper
72
+
73
+ test "should include any authorize params passed in the :token_params option" do
74
+ @options = { :token_params => { :foo => "bar", :baz => "zip" } }
75
+ assert_equal "bar", strategy.token_params["foo"]
76
+ assert_equal "zip", strategy.token_params["baz"]
77
+ end
78
+
79
+ test "should include top-level options that are marked as :token_options" do
80
+ @options = { :token_options => [:scope, :foo], :scope => "bar", :foo => "baz" }
81
+ assert_equal "bar", strategy.token_params["scope"]
82
+ assert_equal "baz", strategy.token_params["foo"]
83
+ end
84
+ end
85
+ end