gitlab-omniauth-openid-connect 0.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.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new do |t|
5
+ t.libs << 'test'
6
+ t.test_files = FileList['test/lib/omniauth/**/*_test.rb']
7
+ t.verbose = true
8
+ end
9
+
10
+ task default: :test
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'omniauth/openid_connect/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'gitlab-omniauth-openid-connect'
9
+ spec.version = OmniAuth::OpenIDConnect::VERSION
10
+ spec.authors = ['John Bohn', 'Ilya Shcherbinin']
11
+ spec.email = ['jjbohn@gmail.com', 'm0n9oose@gmail.com']
12
+ spec.summary = 'OpenID Connect Strategy for OmniAuth'
13
+ spec.description = 'OpenID Connect Strategy for OmniAuth.'
14
+ spec.homepage = 'https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect'
15
+ spec.license = 'MIT'
16
+
17
+ spec.files = `git ls-files -z`.split("\x0")
18
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
19
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
20
+ spec.require_paths = ['lib']
21
+
22
+ spec.add_dependency 'addressable', '~> 2.7'
23
+ spec.add_dependency 'omniauth', '~> 1.9'
24
+ spec.add_dependency 'openid_connect', '~> 1.2'
25
+ spec.add_development_dependency 'coveralls', '~> 0.8'
26
+ spec.add_development_dependency 'faker', '~> 2.17'
27
+ spec.add_development_dependency 'guard', '~> 2.14'
28
+ spec.add_development_dependency 'guard-bundler', '~> 3.0'
29
+ spec.add_development_dependency 'guard-minitest', '~> 2.4'
30
+ spec.add_development_dependency 'minitest', '~> 5.14'
31
+ spec.add_development_dependency 'mocha', '~> 1.12'
32
+ spec.add_development_dependency 'rake', '~> 13.0'
33
+ spec.add_development_dependency 'rubocop', '~> 1.12'
34
+ spec.add_development_dependency 'simplecov', '~> 0.16'
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'omniauth/openid_connect/errors'
4
+ require 'omniauth/openid_connect/version'
5
+ require 'omniauth/strategies/openid_connect'
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAuth
4
+ module OpenIDConnect
5
+ class Error < RuntimeError; end
6
+ class MissingCodeError < Error; end
7
+ class MissingIdTokenError < Error; end
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAuth
4
+ module OpenIDConnect
5
+ VERSION = '0.4.0'
6
+ end
7
+ end
@@ -0,0 +1,406 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'addressable/uri'
4
+ require 'timeout'
5
+ require 'net/http'
6
+ require 'open-uri'
7
+ require 'omniauth'
8
+ require 'openid_connect'
9
+ require 'forwardable'
10
+
11
+ module OmniAuth
12
+ module Strategies
13
+ class OpenIDConnect
14
+ include OmniAuth::Strategy
15
+ extend Forwardable
16
+
17
+ RESPONSE_TYPE_EXCEPTIONS = {
18
+ 'id_token' => { exception_class: OmniAuth::OpenIDConnect::MissingIdTokenError, key: :missing_id_token }.freeze,
19
+ 'code' => { exception_class: OmniAuth::OpenIDConnect::MissingCodeError, key: :missing_code }.freeze,
20
+ }.freeze
21
+
22
+ def_delegator :request, :params
23
+
24
+ option :name, 'openid_connect'
25
+ option(:client_options, identifier: nil,
26
+ secret: nil,
27
+ redirect_uri: nil,
28
+ scheme: 'https',
29
+ host: nil,
30
+ port: 443,
31
+ authorization_endpoint: '/authorize',
32
+ token_endpoint: '/token',
33
+ userinfo_endpoint: '/userinfo',
34
+ jwks_uri: '/jwk',
35
+ end_session_endpoint: nil)
36
+
37
+ option :issuer
38
+ option :discovery, false
39
+ option :client_signing_alg
40
+ option :client_jwk_signing_key
41
+ option :client_x509_signing_key
42
+ option :scope, [:openid]
43
+ option :response_type, 'code' # ['code', 'id_token']
44
+ option :state
45
+ option :response_mode # [:query, :fragment, :form_post, :web_message]
46
+ option :display, nil # [:page, :popup, :touch, :wap]
47
+ option :prompt, nil # [:none, :login, :consent, :select_account]
48
+ option :hd, nil
49
+ option :max_age
50
+ option :ui_locales
51
+ option :id_token_hint
52
+ option :acr_values
53
+ option :send_nonce, true
54
+ option :send_scope_to_token_endpoint, true
55
+ option :client_auth_method
56
+ option :post_logout_redirect_uri
57
+ option :extra_authorize_params, {}
58
+ option :uid_field, 'sub'
59
+
60
+ def uid
61
+ user_info.raw_attributes[options.uid_field.to_sym] || user_info.sub
62
+ end
63
+
64
+ info do
65
+ {
66
+ name: user_info.name,
67
+ email: user_info.email,
68
+ nickname: user_info.preferred_username,
69
+ first_name: user_info.given_name,
70
+ last_name: user_info.family_name,
71
+ gender: user_info.gender,
72
+ image: user_info.picture,
73
+ phone: user_info.phone_number,
74
+ urls: { website: user_info.website },
75
+ }
76
+ end
77
+
78
+ extra do
79
+ { raw_info: user_info.raw_attributes }
80
+ end
81
+
82
+ credentials do
83
+ {
84
+ id_token: access_token.id_token,
85
+ token: access_token.access_token,
86
+ refresh_token: access_token.refresh_token,
87
+ expires_in: access_token.expires_in,
88
+ scope: access_token.scope,
89
+ }
90
+ end
91
+
92
+ def client
93
+ @client ||= ::OpenIDConnect::Client.new(client_options)
94
+ end
95
+
96
+ def config
97
+ @config ||= ::OpenIDConnect::Discovery::Provider::Config.discover!(options.issuer)
98
+ end
99
+
100
+ def request_phase
101
+ options.issuer = issuer if options.issuer.to_s.empty?
102
+ discover!
103
+ redirect authorize_uri
104
+ end
105
+
106
+ def callback_phase
107
+ error = params['error_reason'] || params['error']
108
+ error_description = params['error_description'] || params['error_reason']
109
+ invalid_state = params['state'].to_s.empty? || params['state'] != stored_state
110
+
111
+ raise CallbackError, error: params['error'], reason: error_description, uri: params['error_uri'] if error
112
+ raise CallbackError, error: :csrf_detected, reason: "Invalid 'state' parameter" if invalid_state
113
+
114
+ return unless valid_response_type?
115
+
116
+ options.issuer = issuer if options.issuer.nil? || options.issuer.empty?
117
+
118
+ verify_id_token!(params['id_token']) if configured_response_type == 'id_token'
119
+ discover!
120
+ client.redirect_uri = redirect_uri
121
+
122
+ return id_token_callback_phase if configured_response_type == 'id_token'
123
+
124
+ client.authorization_code = authorization_code
125
+ access_token
126
+ super
127
+ rescue CallbackError => e
128
+ fail!(e.error, e)
129
+ rescue ::Rack::OAuth2::Client::Error => e
130
+ fail!(e.response[:error], e)
131
+ rescue ::Timeout::Error, ::Errno::ETIMEDOUT => e
132
+ fail!(:timeout, e)
133
+ rescue ::SocketError => e
134
+ fail!(:failed_to_connect, e)
135
+ end
136
+
137
+ def other_phase
138
+ if logout_path_pattern.match?(current_path)
139
+ options.issuer = issuer if options.issuer.to_s.empty?
140
+ discover!
141
+ return redirect(end_session_uri) if end_session_uri
142
+ end
143
+ call_app!
144
+ end
145
+
146
+ def authorization_code
147
+ params['code']
148
+ end
149
+
150
+ def end_session_uri
151
+ return unless end_session_endpoint_is_valid?
152
+
153
+ end_session_uri = URI(client_options.end_session_endpoint)
154
+ end_session_uri.query = encoded_post_logout_redirect_uri
155
+ end_session_uri.to_s
156
+ end
157
+
158
+ def authorize_uri
159
+ client.redirect_uri = redirect_uri
160
+ opts = {
161
+ response_type: options.response_type,
162
+ response_mode: options.response_mode,
163
+ scope: options.scope,
164
+ state: new_state,
165
+ login_hint: params['login_hint'],
166
+ ui_locales: params['ui_locales'],
167
+ claims_locales: params['claims_locales'],
168
+ prompt: options.prompt,
169
+ nonce: (new_nonce if options.send_nonce),
170
+ hd: options.hd,
171
+ acr_values: options.acr_values,
172
+ }
173
+
174
+ opts.merge!(options.extra_authorize_params) unless options.extra_authorize_params.empty?
175
+
176
+ client.authorization_uri(opts.reject { |_k, v| v.nil? })
177
+ end
178
+
179
+ def public_key
180
+ @public_key ||= begin
181
+ if options.discovery
182
+ config.jwks
183
+ elsif key_or_secret
184
+ key_or_secret
185
+ elsif client_options.jwks_uri
186
+ fetch_key
187
+ end
188
+ end
189
+ end
190
+
191
+ private
192
+
193
+ def fetch_key
194
+ @fetch_key ||= parse_jwk_key(::OpenIDConnect.http_client.get_content(client_options.jwks_uri))
195
+ end
196
+
197
+ def issuer
198
+ resource = "#{ client_options.scheme }://#{ client_options.host }"
199
+ resource = "#{ resource }:#{ client_options.port }" if client_options.port
200
+ ::OpenIDConnect::Discovery::Provider.discover!(resource).issuer
201
+ end
202
+
203
+ def discover!
204
+ return unless options.discovery
205
+
206
+ client_options.authorization_endpoint = config.authorization_endpoint
207
+ client_options.token_endpoint = config.token_endpoint
208
+ client_options.userinfo_endpoint = config.userinfo_endpoint
209
+ client_options.jwks_uri = config.jwks_uri
210
+ client_options.end_session_endpoint = config.end_session_endpoint if config.respond_to?(:end_session_endpoint)
211
+ end
212
+
213
+ def user_info
214
+ return @user_info if @user_info
215
+
216
+ if access_token.id_token
217
+ decoded = decode_id_token(access_token.id_token).raw_attributes
218
+
219
+ @user_info = ::OpenIDConnect::ResponseObject::UserInfo.new access_token.userinfo!.raw_attributes.merge(decoded)
220
+ else
221
+ @user_info = access_token.userinfo!
222
+ end
223
+ end
224
+
225
+ def access_token
226
+ return @access_token if @access_token
227
+
228
+ @access_token = client.access_token!(
229
+ scope: (options.scope if options.send_scope_to_token_endpoint),
230
+ client_auth_method: options.client_auth_method
231
+ )
232
+
233
+ verify_id_token!(@access_token.id_token) if configured_response_type == 'code'
234
+
235
+ @access_token
236
+ end
237
+
238
+ def decode_id_token(id_token)
239
+ decode!(id_token, public_key)
240
+ rescue JSON::JWK::Set::KidNotFound
241
+ # Either the JWT doesn't have kid specified or the set of keys doesn't
242
+ # have a matching key. Since we can't tell the first case from the second,
243
+ # try each key individually to see if one works.
244
+ # https://github.com/nov/json-jwt/pull/92#issuecomment-824654949
245
+ decoded = decode_with_each_key!(id_token)
246
+
247
+ raise unless decoded
248
+
249
+ decoded
250
+ end
251
+
252
+ def decode!(id_token, key)
253
+ ::OpenIDConnect::ResponseObject::IdToken.decode(id_token, key)
254
+ end
255
+
256
+ def decode_with_each_key!(id_token)
257
+ return unless public_key.is_a?(JSON::JWK::Set)
258
+
259
+ public_key.each do |key|
260
+ begin
261
+ decoded = decode!(id_token, key)
262
+ rescue JSON::JWK::Set::KidNotFound, JSON::JWS::VerificationFailed
263
+ next
264
+ end
265
+
266
+ return decoded if decoded
267
+ end
268
+
269
+ nil
270
+ end
271
+
272
+ def client_options
273
+ options.client_options
274
+ end
275
+
276
+ def new_state
277
+ state = if options.state.respond_to?(:call)
278
+ if options.state.arity == 1
279
+ options.state.call(env)
280
+ else
281
+ options.state.call
282
+ end
283
+ end
284
+ session['omniauth.state'] = state || SecureRandom.hex(16)
285
+ end
286
+
287
+ def stored_state
288
+ session.delete('omniauth.state')
289
+ end
290
+
291
+ def new_nonce
292
+ session['omniauth.nonce'] = SecureRandom.hex(16)
293
+ end
294
+
295
+ def stored_nonce
296
+ session.delete('omniauth.nonce')
297
+ end
298
+
299
+ def session
300
+ return {} if @env.nil?
301
+
302
+ super
303
+ end
304
+
305
+ def key_or_secret
306
+ @key_or_secret ||=
307
+ case options.client_signing_alg
308
+ when :HS256, :HS384, :HS512
309
+ client_options.secret
310
+ when :RS256, :RS384, :RS512
311
+ if options.client_jwk_signing_key
312
+ parse_jwk_key(options.client_jwk_signing_key)
313
+ elsif options.client_x509_signing_key
314
+ parse_x509_key(options.client_x509_signing_key)
315
+ end
316
+ end
317
+ end
318
+
319
+ def parse_x509_key(key)
320
+ OpenSSL::X509::Certificate.new(key).public_key
321
+ end
322
+
323
+ def parse_jwk_key(key)
324
+ json = JSON.parse(key)
325
+ return JSON::JWK::Set.new(json['keys']) if json.key?('keys')
326
+
327
+ JSON::JWK.new(json)
328
+ end
329
+
330
+ def decode(str)
331
+ UrlSafeBase64.decode64(str).unpack1('B*').to_i(2).to_s
332
+ end
333
+
334
+ def redirect_uri
335
+ return client_options.redirect_uri unless params['redirect_uri']
336
+
337
+ "#{ client_options.redirect_uri }?redirect_uri=#{ CGI.escape(params['redirect_uri']) }"
338
+ end
339
+
340
+ def encoded_post_logout_redirect_uri
341
+ return unless options.post_logout_redirect_uri
342
+
343
+ URI.encode_www_form(
344
+ post_logout_redirect_uri: options.post_logout_redirect_uri
345
+ )
346
+ end
347
+
348
+ def end_session_endpoint_is_valid?
349
+ client_options.end_session_endpoint &&
350
+ client_options.end_session_endpoint =~ URI::DEFAULT_PARSER.make_regexp
351
+ end
352
+
353
+ def logout_path_pattern
354
+ @logout_path_pattern ||= %r{\A#{Regexp.quote(request_path)}(/logout)}
355
+ end
356
+
357
+ def id_token_callback_phase
358
+ user_data = decode_id_token(params['id_token']).raw_attributes
359
+ env['omniauth.auth'] = AuthHash.new(
360
+ provider: name,
361
+ uid: user_data['sub'],
362
+ info: { name: user_data['name'], email: user_data['email'] },
363
+ extra: { raw_info: user_data }
364
+ )
365
+ call_app!
366
+ end
367
+
368
+ def valid_response_type?
369
+ return true if params.key?(configured_response_type)
370
+
371
+ error_attrs = RESPONSE_TYPE_EXCEPTIONS[configured_response_type]
372
+ fail!(error_attrs[:key], error_attrs[:exception_class].new(params['error']))
373
+
374
+ false
375
+ end
376
+
377
+ def configured_response_type
378
+ @configured_response_type ||= options.response_type.to_s
379
+ end
380
+
381
+ def verify_id_token!(id_token)
382
+ return unless id_token
383
+
384
+ decode_id_token(id_token).verify!(issuer: options.issuer,
385
+ client_id: client_options.identifier,
386
+ nonce: stored_nonce)
387
+ end
388
+
389
+ class CallbackError < StandardError
390
+ attr_accessor :error, :error_reason, :error_uri
391
+
392
+ def initialize(data)
393
+ self.error = data[:error]
394
+ self.error_reason = data[:reason]
395
+ self.error_uri = data[:uri]
396
+ end
397
+
398
+ def message
399
+ [error, error_reason, error_uri].compact.join(' | ')
400
+ end
401
+ end
402
+ end
403
+ end
404
+ end
405
+
406
+ OmniAuth.config.add_camelization 'openid_connect', 'OpenIDConnect'