diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd6e510..7e06e0c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ **Fixes and enhancements:** - Bring back the old Base64 (RFC2045) deocode mechanisms [#488](https://github.com/jwt/ruby-jwt/pull/488) ([@anakinj](https://github.com/anakinj)). - Rescue RbNaCl exception for EdDSA wrong key [#491](https://github.com/jwt/ruby-jwt/pull/491) ([@n-studio](https://github.com/n-studio)). +- New parameter name for cases when kid is not found using JWK key loader proc [#501](https://github.com/jwt/ruby-jwt/pull/501) ([@anakinj](https://github.com/anakinj)). - Your contribution here ## [v2.4.1](https://github.com/jwt/ruby-jwt/tree/v2.4.1) (2022-06-07) diff --git a/README.md b/README.md index 828d49a1..955c7ec9 100644 --- a/README.md +++ b/README.md @@ -546,30 +546,41 @@ end ### JSON Web Key (JWK) -JWK is a JSON structure representing a cryptographic key. Currently only supports RSA, EC and HMAC keys. +JWK is a JSON structure representing a cryptographic key. Currently only supports RSA, EC and HMAC keys. The `jwks` option can be given as a lambda that evaluates every time a kid is resolved. -```ruby -jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), "optional-kid") -payload, headers = { data: 'data' }, { kid: jwk.kid } - -token = JWT.encode(payload, jwk.keypair, 'RS512', headers) +If the kid is not found from the given set the loader will be called a second time with the `kid_not_found` option set to `true`. The application can choose to implement some kind of JWK cache invalidation or other mechanism to handle such cases. -# The jwk loader would fetch the set of JWKs from a trusted source -jwk_loader = ->(options) do - @cached_keys = nil if options[:invalidate] # need to reload the keys - @cached_keys ||= { keys: [jwk.export] } -end +```ruby + jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), 'optional-kid') + payload = { data: 'data' } + headers = { kid: jwk.kid } + + token = JWT.encode(payload, jwk.keypair, 'RS512', headers) + + # The jwk loader would fetch the set of JWKs from a trusted source, + # to avoid malicious requests triggering cache invalidations there needs to be some kind of grace time or other logic for determining the validity of the invalidation. + # This example only allows cache invalidations every 5 minutes. + jwk_loader = ->(options) do + if options[:kid_not_found] && @cache_last_update < Time.now.to_i - 300 + logger.info("Invalidating JWK cache. #{options[:kid]} not found from previous cache") + @cached_keys = nil + end + @cached_keys ||= begin + @cache_last_update = Time.now.to_i + { keys: [jwk.export] } + end + end -begin - JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwk_loader}) -rescue JWT::JWKError - # Handle problems with the provided JWKs -rescue JWT::DecodeError - # Handle other decode related issues e.g. no kid in header, no matching public key found etc. -end + begin + JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwk_loader }) + rescue JWT::JWKError + # Handle problems with the provided JWKs + rescue JWT::DecodeError + # Handle other decode related issues e.g. no kid in header, no matching public key found etc. + end ``` -or by passing JWK as a simple Hash +or by passing the JWKs as a simple Hash ``` jwks = { keys: [{ ... }] } # keys accepts both of string and symbol diff --git a/lib/jwt/jwk/key_finder.rb b/lib/jwt/jwk/key_finder.rb index 19f567da..dbce9b89 100644 --- a/lib/jwt/jwk/key_finder.rb +++ b/lib/jwt/jwk/key_finder.rb @@ -28,7 +28,7 @@ def resolve_key(kid) return jwk if jwk if reloadable? - load_keys(invalidate: true) + load_keys(invalidate: true, kid_not_found: true, kid: kid) # invalidate for backwards compatibility return find_key(kid) end diff --git a/spec/integration/readme_examples_spec.rb b/spec/integration/readme_examples_spec.rb index a80282b0..b40086f0 100644 --- a/spec/integration/readme_examples_spec.rb +++ b/spec/integration/readme_examples_spec.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'logger' + RSpec.describe 'README.md code test' do context 'algorithm usage' do let(:payload) { { data: 'test' } } @@ -273,21 +275,55 @@ end.not_to raise_error end - it 'JWK' do - jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048)) - payload = { data: 'data' } - headers = { kid: jwk.kid } + context 'The JWK loader example' do + let(:logger_output) { StringIO.new } + let(:logger) { Logger.new(logger_output) } + + it 'works as expected' do + jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), 'optional-kid') + payload = { data: 'data' } + headers = { kid: jwk.kid } + + token = JWT.encode(payload, jwk.keypair, 'RS512', headers) + + # The jwk loader would fetch the set of JWKs from a trusted source, + # to avoid malicious invalidations some kind of protection needs to be implemented. + # This example only allows cache invalidations every 5 minutes. + jwk_loader = ->(options) do + if options[:kid_not_found] && @cache_last_update < Time.now.to_i - 300 + logger.info("Invalidating JWK cache. #{options[:kid]} not found from previous cache") + @cached_keys = nil + end + @cached_keys ||= begin + @cache_last_update = Time.now.to_i + { keys: [jwk.export] } + end + end - token = JWT.encode(payload, jwk.keypair, 'RS512', headers) + begin + JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwk_loader }) + rescue JWT::JWKError + # Handle problems with the provided JWKs + rescue JWT::DecodeError + # Handle other decode related issues e.g. no kid in header, no matching public key found etc. + end + + ## This is not in the example but verifies that the cache is invalidated after 5 minutes + jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), 'new-kid') + + headers = { kid: jwk.kid } + + token = JWT.encode(payload, jwk.keypair, 'RS512', headers) + @cache_last_update = Time.now.to_i - 301 - # The jwk loader would fetch the set of JWKs from a trusted source - jwk_loader = ->(options) do - @cached_keys = nil if options[:invalidate] # need to reload the keys - @cached_keys ||= { keys: [jwk.export] } - end - expect do JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwk_loader }) - end.not_to raise_error + expect(logger_output.string.chomp).to match(/^I, .* : Invalidating JWK cache. new-kid not found from previous cache/) + + jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), 'yet-another-new-kid') + headers = { kid: jwk.kid } + token = JWT.encode(payload, jwk.keypair, 'RS512', headers) + expect { JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwk_loader }) }.to raise_error(JWT::DecodeError, 'Could not find public key for kid yet-another-new-kid') + end end it 'JWK with thumbprint as kid via symbol' do