Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Take a look at the [upgrade guide](UPGRADING.md) for more details.
- JWT::EncodedToken#verify! method that bundles signature and claim validation [#647](https://github.com/jwt/ruby-jwt/pull/647) ([@anakinj](https://github.com/anakinj))
- Do not override the alg header if already given [#659](https://github.com/jwt/ruby-jwt/pull/659) ([@anakinj](https://github.com/anakinj))
- Make `JWK::KeyFinder` compatible with `JWT::EncodedToken` [#663](https://github.com/jwt/ruby-jwt/pull/663) ([@anakinj](https://github.com/anakinj))
- Add support for x5t header parameter for X.509 certificate thumbprint verification [#669](https://github.com/jwt/ruby-jwt/pull/669) ([@hieuk09](https://github.com/hieuk09))
- Your contribution here

**Fixes and enhancements:**
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -674,13 +674,14 @@ algorithms = jwks.map { |key| key[:alg] }.compact.uniq
JWT.decode(token, nil, true, algorithms: algorithms, jwks: jwks)
```

The `jwks` option can also be given as a lambda that evaluates every time a kid is resolved.
The `jwks` option can also be given as a lambda that evaluates every time a key identifier is resolved.
This can be used to implement caching of remotely fetched JWK Sets.

If the requested `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`.
Key identifiers can be specified using `kid`, `x5t` or `x5c` header parameters.
If the requested identifier 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.

Tokens without a specified `kid` are rejected by default.
Tokens without a specified key identifier (`kid`, `x5t` or `x5c`) are rejected by default.
This behaviour may be overwritten by setting the `allow_nil_kid` option for `decode` to `true`.

```ruby
Expand Down
6 changes: 6 additions & 0 deletions lib/jwt/base64.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ def url_encode(str)
::Base64.urlsafe_encode64(str, padding: false)
end

# Encode a string with Base64 complying with RFC 4648 (padded).
# @api private
def strict_encode(str)
::Base64.strict_encode64(str)
end

# Decode a string with URL-safe Base64 complying with RFC 4648.
# @api private
def url_decode(str)
Expand Down
3 changes: 2 additions & 1 deletion lib/jwt/decode.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ def verify_algo

def set_key
@key = find_key(&@keyfinder) if @keyfinder
@key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks], allow_nil_kid: @options[:allow_nil_kid]).key_for(token.header['kid']) if @options[:jwks]
@key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks], allow_nil_kid: @options[:allow_nil_kid]).call(token) if @options[:jwks]

return unless (x5c_options = @options[:x5c])

@key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(token.header['x5c'])
Expand Down
31 changes: 22 additions & 9 deletions lib/jwt/jwk/key_finder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@ def initialize(options)

# Returns the verification key for the given kid
# @param [String] kid the key id
def key_for(kid)
raise ::JWT::DecodeError, 'No key id (kid) found from token headers' unless kid || @allow_nil_kid
raise ::JWT::DecodeError, 'Invalid type for kid header parameter' unless kid.nil? || kid.is_a?(String)
def key_for(kid, key_field = :kid)
raise ::JWT::DecodeError, "Invalid type for #{key_field} header parameter" unless kid.nil? || kid.is_a?(String)

jwk = resolve_key(kid)
jwk = resolve_key(kid, key_field)

raise ::JWT::DecodeError, 'No keys found in jwks' unless @jwks.any?
raise ::JWT::DecodeError, "Could not find public key for kid #{kid}" unless jwk
Expand All @@ -37,22 +36,36 @@ def key_for(kid)
# Returns the key for the given token
# @param [JWT::EncodedToken] token the token
def call(token)
key_for(token.header['kid'])
kid = token.header['kid']
x5t = token.header['x5t']
x5c = token.header['x5c']

if kid
key_for(kid, :kid)
elsif x5t
key_for(x5t, :x5t)
elsif x5c
key_for(x5c, :x5c)
elsif @allow_nil_kid
key_for(kid)
else
raise ::JWT::DecodeError, 'No key id (kid) or x5t found from token headers'
end
end

private

def resolve_key(kid)
key_matcher = ->(key) { (kid.nil? && @allow_nil_kid) || key[:kid] == kid }
def resolve_key(kid, key_field)
key_matcher = ->(key) { (kid.nil? && @allow_nil_kid) || key[key_field] == kid }

# First try without invalidation to facilitate application caching
@jwks ||= JWT::JWK::Set.new(@jwks_loader.call(kid: kid))
@jwks ||= JWT::JWK::Set.new(@jwks_loader.call(key_field => kid))
jwk = @jwks.find { |key| key_matcher.call(key) }

return jwk if jwk

# Second try, invalidate for backwards compatibility
@jwks = JWT::JWK::Set.new(@jwks_loader.call(invalidate: true, kid_not_found: true, kid: kid))
@jwks = JWT::JWK::Set.new(@jwks_loader.call(invalidate: true, kid_not_found: true, key_field => kid))
@jwks.find { |key| key_matcher.call(key) }
end
end
Expand Down
3 changes: 2 additions & 1 deletion lib/jwt/jwk/rsa.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def verify_key
def export(options = {})
exported = parameters.clone
exported.reject! { |k, _| RSA_PRIVATE_KEY_ELEMENTS.include? k } unless private? && options[:include_private] == true

exported
end

Expand All @@ -67,7 +68,7 @@ def key_digest
def []=(key, value)
raise ArgumentError, 'cannot overwrite cryptographic key attributes' if RSA_KEY_ELEMENTS.include?(key.to_sym)

super(key, value)
super
end

private
Expand Down
15 changes: 13 additions & 2 deletions spec/jwt/jwk/decode_with_jwk_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
describe '.decode for JWK usecase' do
let(:keypair) { test_pkey('rsa-2048-private.pem') }
let(:jwk) { JWT::JWK.new(keypair) }
let(:public_jwks) { { keys: [jwk.export, { kid: 'not_the_correct_one', kty: 'oct', k: 'secret' }] } }
let(:valid_key) { jwk.export }
let(:public_jwks) { { keys: [valid_key, { kid: 'not_the_correct_one', kty: 'oct', k: 'secret' }] } }
let(:token_payload) { { 'data' => 'something' } }
let(:token_headers) { { kid: jwk.kid } }
let(:algorithm) { 'RS512' }
Expand Down Expand Up @@ -38,6 +39,16 @@
end
end

context 'and x5t is in the set' do
let(:x5t) { Base64.urlsafe_encode64(OpenSSL::Digest::SHA1.new(keypair.to_der).digest, padding: false) }
let(:valid_key) { jwk.export.merge({ x5t: x5t }) }
let(:token_headers) { { x5t: x5t } }
it 'is able to decode the token' do
payload, _header = described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks })
expect(payload).to eq(token_payload)
end
end

context 'no keys are found in the set' do
let(:public_jwks) { { keys: [] } }
it 'raises an exception' do
Expand All @@ -51,7 +62,7 @@
let(:token_headers) { {} }
it 'raises an exception' do
expect { described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks }) }.to raise_error(
JWT::DecodeError, 'No key id (kid) found from token headers'
JWT::DecodeError, 'No key id (kid) or x5t found from token headers'
)
end
end
Expand Down
Loading