gitlab-omniauth-openid-connect 0.4.0

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