omniauth_openid_connect 0.5.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34218d7218e2aca766623a1252b641baa20d81330cfd38c67a733f9221d76aad
4
- data.tar.gz: 54f874832122f3cc1444280d8820d222a4dff7ef4c3580eb899d4da1efb38051
3
+ metadata.gz: d5ccd20bf84d71220597692301b64b1c3b0f8bf19faf1d788a6f1185bf42bfcb
4
+ data.tar.gz: 9336782a2449bfdefc07521f79bcd8030bf4cc2a6f094a01d9613dbcc21727f9
5
5
  SHA512:
6
- metadata.gz: e4465213a06e82fd61d9997d0ec1b9f993620dead092add6ba2f07b34aa08dca53bcb63d92a256839c7310478c89ecff9359778e5721cf1cc6dcc300e5d780f8
7
- data.tar.gz: 8fdbda1f579f271f6273a6380a7a9cf581b4b1239b589a1e2cb98799b8731056c3ec222c519e445d95387749ab62b58839e4005906938e0e1f76b9d396e76565
6
+ metadata.gz: 07b23acf7a852a2b5a1d7a4b6852108562fc7ce2c7f00ead2fe91989f00d736643c8e9f510ff8029956a0e55006d70b4671006ed4d2ca8a5c9a74f641a0c0e60
7
+ data.tar.gz: e22f6c174179f08acf046f05a50da341a01d576353a929fca9bd4c49f6d5ceaa5a761ce7ddaa4cfa6dae7470923bd0a646d634f6b7f563dca93df436c9b60a05
data/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ # v0.6.0 (21.01.2023)
2
+
3
+ - Support verification of HS256-signed JWTs (https://github.com/omniauth/omniauth_openid_connect/pull/134)
4
+
1
5
  # v0.5.0 (26.12.2022)
2
6
 
3
7
  - Support the "nonce" parameter forwarding without a session [#130](https://github.com/omniauth/omniauth_openid_connect/pull/130)
data/README.md CHANGED
@@ -69,6 +69,7 @@ config.omniauth :openid_connect, {
69
69
  | pkce_verifier | Specify a custom PKCE verifier code. | no | A random 128-char string | Proc.new { SecureRandom.hex(64) } |
70
70
  | pkce_options | Specify a custom implementation of the PKCE code challenge/method. | no | SHA256(code_challenge) in hex | Proc to customise the code challenge generation |
71
71
  | client_options | A hash of client options detailed in its own section | yes | | |
72
+ | jwt_secret_base64 | For HMAC with SHA2 (e.g. HS256) signing algorithms, specify the base64-encoded secret used to sign the JWT token. Defaults to the OAuth2 client secret if not specified. | no | client_options.secret | "bXlzZWNyZXQ=\n"
72
73
 
73
74
  ### Client Config Options
74
75
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OmniAuth
4
4
  module OpenIDConnect
5
- VERSION = '0.5.0'
5
+ VERSION = '0.6.0'
6
6
  end
7
7
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'base64'
3
4
  require 'timeout'
4
5
  require 'net/http'
5
6
  require 'open-uri'
@@ -36,6 +37,7 @@ module OmniAuth
36
37
  option :issuer
37
38
  option :discovery, false
38
39
  option :client_signing_alg
40
+ option :jwt_secret_base64
39
41
  option :client_jwk_signing_key
40
42
  option :client_x509_signing_key
41
43
  option :scope, [:openid]
@@ -200,13 +202,19 @@ module OmniAuth
200
202
  def public_key
201
203
  @public_key ||= if options.discovery
202
204
  config.jwks
203
- elsif key_or_secret
204
- key_or_secret
205
+ elsif configured_public_key
206
+ configured_public_key
205
207
  elsif client_options.jwks_uri
206
208
  fetch_key
207
209
  end
208
210
  end
209
211
 
212
+ # Some OpenID providers use the OAuth2 client secret as the shared secret, but
213
+ # Keycloak uses a separate key that's stored inside the database.
214
+ def secret
215
+ base64_decoded_jwt_secret || client_options.secret
216
+ end
217
+
210
218
  def pkce_authorize_params(verifier)
211
219
  # NOTE: see https://tools.ietf.org/html/rfc7636#appendix-A
212
220
  {
@@ -221,6 +229,12 @@ module OmniAuth
221
229
  @fetch_key ||= parse_jwk_key(::OpenIDConnect.http_client.get_content(client_options.jwks_uri))
222
230
  end
223
231
 
232
+ def base64_decoded_jwt_secret
233
+ return unless options.jwt_secret_base64
234
+
235
+ Base64.decode64(options.jwt_secret_base64)
236
+ end
237
+
224
238
  def issuer
225
239
  resource = "#{ client_options.scheme }://#{ client_options.host }"
226
240
  resource = "#{ resource }:#{ client_options.port }" if client_options.port
@@ -265,8 +279,75 @@ module OmniAuth
265
279
  @access_token
266
280
  end
267
281
 
282
+ # Unlike ::OpenIDConnect::ResponseObject::IdToken.decode, this
283
+ # method splits the decoding and verification of JWT into two
284
+ # steps. First, we decode the JWT without verifying it to
285
+ # determine the algorithm used to sign. Then, we verify it using
286
+ # the appropriate public key (e.g. if algorithm is RS256) or
287
+ # shared secret (e.g. if algorithm is HS256). This works around a
288
+ # limitation in the openid_connect gem:
289
+ # https://github.com/nov/openid_connect/issues/61
268
290
  def decode_id_token(id_token)
269
- ::OpenIDConnect::ResponseObject::IdToken.decode(id_token, public_key)
291
+ decoded = JSON::JWT.decode(id_token, :skip_verification)
292
+ algorithm = decoded.algorithm.to_sym
293
+
294
+ validate_client_algorithm!(algorithm)
295
+
296
+ keyset =
297
+ case algorithm
298
+ when :HS256, :HS384, :HS512
299
+ secret
300
+ else
301
+ public_key
302
+ end
303
+
304
+ decoded.verify!(keyset)
305
+ ::OpenIDConnect::ResponseObject::IdToken.new(decoded)
306
+ rescue JSON::JWK::Set::KidNotFound
307
+ # If the JWT has a key ID (kid), then we know that the set of
308
+ # keys supplied doesn't contain the one we want, and we're
309
+ # done. However, if there is no kid, then we try each key
310
+ # individually to see if one works:
311
+ # https://github.com/nov/json-jwt/pull/92#issuecomment-824654949
312
+ raise if decoded&.header&.key?('kid')
313
+
314
+ decoded = decode_with_each_key!(id_token, keyset)
315
+
316
+ raise unless decoded
317
+
318
+ decoded
319
+ end
320
+
321
+ # If client_signing_alg is specified, we check that the returned JWT
322
+ # matches the expected algorithm. If not, we reject it.
323
+ def validate_client_algorithm!(algorithm)
324
+ client_signing_alg = options.client_signing_alg&.to_sym
325
+
326
+ return unless client_signing_alg
327
+ return if algorithm == client_signing_alg
328
+
329
+ reason = "Received JWT is signed with #{algorithm}, but client_singing_alg is configured for #{client_signing_alg}"
330
+ raise CallbackError, error: :invalid_jwt_algorithm, reason: reason, uri: params['error_uri']
331
+ end
332
+
333
+ def decode!(id_token, key)
334
+ ::OpenIDConnect::ResponseObject::IdToken.decode(id_token, key)
335
+ end
336
+
337
+ def decode_with_each_key!(id_token, keyset)
338
+ return unless keyset.is_a?(JSON::JWK::Set)
339
+
340
+ keyset.each do |key|
341
+ begin
342
+ decoded = decode!(id_token, key)
343
+ rescue JSON::JWS::VerificationFailed, JSON::JWS::UnexpectedAlgorithm, JSON::JWS::UnknownAlgorithm
344
+ next
345
+ end
346
+
347
+ return decoded if decoded
348
+ end
349
+
350
+ nil
270
351
  end
271
352
 
272
353
  def client_options
@@ -308,18 +389,12 @@ module OmniAuth
308
389
  super
309
390
  end
310
391
 
311
- def key_or_secret
312
- @key_or_secret ||=
313
- case options.client_signing_alg&.to_sym
314
- when :HS256, :HS384, :HS512
315
- client_options.secret
316
- when :RS256, :RS384, :RS512
317
- if options.client_jwk_signing_key
318
- parse_jwk_key(options.client_jwk_signing_key)
319
- elsif options.client_x509_signing_key
320
- parse_x509_key(options.client_x509_signing_key)
321
- end
322
- end
392
+ def configured_public_key
393
+ @configured_public_key ||= if options.client_jwk_signing_key
394
+ parse_jwk_key(options.client_jwk_signing_key)
395
+ elsif options.client_x509_signing_key
396
+ parse_x509_key(options.client_x509_signing_key)
397
+ end
323
398
  end
324
399
 
325
400
  def parse_x509_key(key)
@@ -269,6 +269,138 @@ module OmniAuth
269
269
  strategy.callback_phase
270
270
  end
271
271
 
272
+ def test_callback_phase_with_id_token_no_kid
273
+ other_rsa_private = OpenSSL::PKey::RSA.generate(2048)
274
+
275
+ key = JSON::JWK.new(private_key)
276
+ other_key = JSON::JWK.new(other_rsa_private)
277
+ state = SecureRandom.hex(16)
278
+ request.stubs(:params).returns('id_token' => jwt.to_s, 'state' => state)
279
+ request.stubs(:path_info).returns('')
280
+
281
+ strategy.options.issuer = issuer
282
+ strategy.options.client_signing_alg = :RS256
283
+ strategy.options.client_jwk_signing_key = { 'keys' => [other_key, key] }.to_json
284
+ strategy.options.response_type = 'id_token'
285
+
286
+ strategy.unstub(:user_info)
287
+ strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce })
288
+ strategy.callback_phase
289
+ end
290
+
291
+ def test_callback_phase_with_id_token_with_kid
292
+ other_rsa_private = OpenSSL::PKey::RSA.generate(2048)
293
+
294
+ key = JSON::JWK.new(private_key)
295
+ other_key = JSON::JWK.new(other_rsa_private)
296
+ state = SecureRandom.hex(16)
297
+ jwt_with_kid = JSON::JWT.new(payload).sign(key, :RS256)
298
+ request.stubs(:params).returns('id_token' => jwt_with_kid.to_s, 'state' => state)
299
+ request.stubs(:path_info).returns('')
300
+
301
+ strategy.options.issuer = issuer
302
+ strategy.options.client_signing_alg = :RS256
303
+ strategy.options.client_jwk_signing_key = { 'keys' => [other_key, key] }.to_json
304
+ strategy.options.response_type = 'id_token'
305
+
306
+ strategy.unstub(:user_info)
307
+ strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce })
308
+ strategy.callback_phase
309
+ end
310
+
311
+ def test_callback_phase_with_id_token_with_kid_and_no_matching_kid
312
+ other_rsa_private = OpenSSL::PKey::RSA.generate(2048)
313
+
314
+ key = JSON::JWK.new(private_key)
315
+ other_key = JSON::JWK.new(other_rsa_private)
316
+ state = SecureRandom.hex(16)
317
+ jwt_with_kid = JSON::JWT.new(payload).sign(key, :RS256)
318
+ request.stubs(:params).returns('id_token' => jwt_with_kid.to_s, 'state' => state)
319
+ request.stubs(:path_info).returns('')
320
+
321
+ strategy.options.issuer = issuer
322
+ strategy.options.client_signing_alg = :RS256
323
+ # We use private_key here instead of the wrapped key, which contains a kid
324
+ strategy.options.client_jwk_signing_key = { 'keys' => [other_key, private_key] }.to_json
325
+ strategy.options.response_type = 'id_token'
326
+
327
+ strategy.unstub(:user_info)
328
+ strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce })
329
+
330
+ assert_raises JSON::JWK::Set::KidNotFound do
331
+ strategy.callback_phase
332
+ end
333
+ end
334
+
335
+ def test_callback_phase_with_id_token_with_hs256
336
+ state = SecureRandom.hex(16)
337
+ request.stubs(:params).returns('id_token' => jwt_with_hs256.to_s, 'state' => state)
338
+ request.stubs(:path_info).returns('')
339
+
340
+ strategy.options.issuer = issuer
341
+ strategy.options.client_options.secret = hmac_secret
342
+ strategy.options.client_signing_alg = :HS256
343
+ strategy.options.response_type = 'id_token'
344
+
345
+ strategy.unstub(:user_info)
346
+ strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce })
347
+ strategy.callback_phase
348
+ end
349
+
350
+ def test_callback_phase_with_hs256_base64_jwt_secret
351
+ state = SecureRandom.hex(16)
352
+ request.stubs(:params).returns('id_token' => jwt_with_hs256.to_s, 'state' => state)
353
+ request.stubs(:path_info).returns('')
354
+
355
+ strategy.options.issuer = issuer
356
+ strategy.options.jwt_secret_base64 = Base64.encode64(hmac_secret)
357
+ strategy.options.response_type = 'id_token'
358
+
359
+ strategy.unstub(:user_info)
360
+ strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce })
361
+ strategy.callback_phase
362
+ end
363
+
364
+ def test_callback_phase_with_mismatched_signing_algorithm
365
+ state = SecureRandom.hex(16)
366
+ request.stubs(:params).returns('id_token' => jwt_with_hs512.to_s, 'state' => state)
367
+ request.stubs(:path_info).returns('')
368
+
369
+ strategy.options.issuer = issuer
370
+ strategy.options.client_options.secret = hmac_secret
371
+ strategy.options.client_signing_alg = :HS256
372
+ strategy.options.response_type = 'id_token'
373
+
374
+ strategy.unstub(:user_info)
375
+ strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce })
376
+
377
+ strategy.expects(:fail!).with(:invalid_jwt_algorithm, is_a(OmniAuth::Strategies::OpenIDConnect::CallbackError))
378
+ strategy.callback_phase
379
+ end
380
+
381
+ def test_callback_phase_with_id_token_no_matching_key
382
+ rsa_private = OpenSSL::PKey::RSA.generate(2048)
383
+ other_rsa_private = OpenSSL::PKey::RSA.generate(2048)
384
+
385
+ other_key = JSON::JWK.new(other_rsa_private)
386
+ token = JSON::JWT.new(payload).sign(rsa_private, :RS256).to_s
387
+ state = SecureRandom.hex(16)
388
+ request.stubs(:params).returns('id_token' => token, 'state' => state)
389
+ request.stubs(:path_info).returns('')
390
+
391
+ strategy.options.issuer = issuer
392
+ strategy.options.client_signing_alg = :RS256
393
+ strategy.options.client_jwk_signing_key = { 'keys' => [other_key] }.to_json
394
+ strategy.options.response_type = 'id_token'
395
+
396
+ strategy.unstub(:user_info)
397
+ strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce })
398
+
399
+ assert_raises JSON::JWK::Set::KidNotFound do
400
+ strategy.callback_phase
401
+ end
402
+ end
403
+
272
404
  def test_callback_phase_with_discovery # rubocop:disable Metrics/AbcSize
273
405
  state = SecureRandom.hex(16)
274
406
 
@@ -699,7 +831,7 @@ module OmniAuth
699
831
  def test_public_key_with_hmac
700
832
  strategy.options.client_options.secret = 'secret'
701
833
  strategy.options.client_signing_alg = :HS256
702
- assert_equal strategy.options.client_options.secret, strategy.public_key
834
+ assert_equal strategy.options.client_options.secret, strategy.secret
703
835
  end
704
836
 
705
837
  def test_id_token_auth_hash
@@ -37,6 +37,18 @@ class StrategyTestCase < MiniTest::Test
37
37
  @jwt ||= JSON::JWT.new(payload).sign(private_key, :RS256)
38
38
  end
39
39
 
40
+ def hmac_secret
41
+ @hmac_secret ||= SecureRandom.hex(16)
42
+ end
43
+
44
+ def jwt_with_hs256
45
+ @jwt_with_hs256 ||= JSON::JWT.new(payload).sign(hmac_secret, :HS256)
46
+ end
47
+
48
+ def jwt_with_hs512
49
+ @jwt_with_hs512 ||= JSON::JWT.new(payload).sign(hmac_secret, :HS512)
50
+ end
51
+
40
52
  def jwks
41
53
  @jwks ||= begin
42
54
  key = JSON::JWK.new(private_key)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omniauth_openid_connect
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Bohn
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2022-12-26 00:00:00.000000000 Z
12
+ date: 2023-01-22 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: omniauth
@@ -219,8 +219,8 @@ licenses:
219
219
  metadata:
220
220
  bug_tracker_uri: https://github.com/m0n9oose/omniauth_openid_connect/issues
221
221
  changelog_uri: https://github.com/m0n9oose/omniauth_openid_connect/releases
222
- documentation_uri: https://github.com/m0n9oose/omniauth_openid_connect/tree/v0.5.0#readme
223
- source_code_uri: https://github.com/m0n9oose/omniauth_openid_connect/tree/v0.5.0
222
+ documentation_uri: https://github.com/m0n9oose/omniauth_openid_connect/tree/v0.6.0#readme
223
+ source_code_uri: https://github.com/m0n9oose/omniauth_openid_connect/tree/v0.6.0
224
224
  rubygems_mfa_required: 'true'
225
225
  post_install_message:
226
226
  rdoc_options: []
@@ -237,7 +237,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
237
237
  - !ruby/object:Gem::Version
238
238
  version: '0'
239
239
  requirements: []
240
- rubygems_version: 3.3.26
240
+ rubygems_version: 3.4.3
241
241
  signing_key:
242
242
  specification_version: 4
243
243
  summary: OpenID Connect Strategy for OmniAuth