ginjo-omniauth-slack 2.4.0

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