gitlab-omniauth-openid-connect 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/config/rubocop_linter_action.yml +59 -0
- data/.github/stale.yml +17 -0
- data/.github/workflows/rubocop.yml +22 -0
- data/.gitignore +20 -0
- data/.gitlab-ci.yml +27 -0
- data/.rubocop.yml +58 -0
- data/.travis.yml +9 -0
- data/CHANGELOG.md +53 -0
- data/Gemfile +4 -0
- data/Guardfile +16 -0
- data/LICENSE.txt +22 -0
- data/README.md +130 -0
- data/Rakefile +10 -0
- data/gitlab-omniauth-openid-connect.gemspec +35 -0
- data/lib/omniauth/openid_connect.rb +5 -0
- data/lib/omniauth/openid_connect/errors.rb +9 -0
- data/lib/omniauth/openid_connect/version.rb +7 -0
- data/lib/omniauth/strategies/openid_connect.rb +406 -0
- data/lib/omniauth_openid_connect.rb +3 -0
- data/test/fixtures/id_token.txt +1 -0
- data/test/fixtures/jwks.json +8 -0
- data/test/fixtures/test.crt +19 -0
- data/test/lib/omniauth/strategies/openid_connect_test.rb +684 -0
- data/test/strategy_test_case.rb +64 -0
- data/test/test_helper.rb +16 -0
- metadata +258 -0
data/Rakefile
ADDED
@@ -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,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'
|