Skip to content

Commit

Permalink
Stop using the term invalidate to not assume the app always has a cache
Browse files Browse the repository at this point in the history
  • Loading branch information
anakinj committed Jul 11, 2022
1 parent 6828861 commit 9e5f96a
Show file tree
Hide file tree
Showing 4 changed files with 78 additions and 32 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 30 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/jwt/jwk/key_finder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 46 additions & 12 deletions spec/integration/readme_examples_spec.rb
Original file line number Diff line number Diff line change
@@ -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' } }
Expand Down Expand Up @@ -273,21 +275,53 @@
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')

payload = { data: 'data' }
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
JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwk_loader })

expect(logger_output.string.chomp).to match(/^I, .* : Invalidating JWK cache. new-kid not found from previous cache/)
end
end

it 'JWK with thumbprint as kid via symbol' do
Expand Down

0 comments on commit 9e5f96a

Please sign in to comment.