diff --git a/CHANGELOG.md b/CHANGELOG.md index e3df4226..388d6742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ **Features:** +- 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:** diff --git a/README.md b/README.md index be5921a1..d928b8b2 100644 --- a/README.md +++ b/README.md @@ -644,13 +644,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` 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` or `x5t`) are rejected by default. This behaviour may be overwritten by setting the `allow_nil_kid` option for `decode` to `true`. ```ruby diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index 77323a84..9a8a0a60 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -65,7 +65,14 @@ 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] + if @options[:jwks] + @key = ::JWT::JWK::KeyFinder.new( + jwks: @options[:jwks], + allow_nil_kid: @options[:allow_nil_kid], + key_fields: @options[:key_fields] + ).call(token) + end + return unless (x5c_options = @options[:x5c]) @key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(token.header['x5c']) diff --git a/lib/jwt/jwk/key_finder.rb b/lib/jwt/jwk/key_finder.rb index 80a2e7fe..c7387841 100644 --- a/lib/jwt/jwk/key_finder.rb +++ b/lib/jwt/jwk/key_finder.rb @@ -9,6 +9,9 @@ class KeyFinder # @param [Hash] options the options to create a KeyFinder with # @option options [Proc, JWT::JWK::Set] :jwks the jwks or a loader proc # @option options [Boolean] :allow_nil_kid whether to allow nil kid + # @option options [Array] :key_fields the fields to use for key matching, + # the order of the fields are used to determine + # the priority of the keys. def initialize(options) @allow_nil_kid = options[:allow_nil_kid] jwks_or_loader = options[:jwks] @@ -18,15 +21,16 @@ def initialize(options) else ->(_options) { jwks_or_loader } end + + @key_fields = options[:key_fields] || %i[kid] end # 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 @@ -37,22 +41,31 @@ 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']) + @key_fields.each do |key_field| + field_value = token.header[key_field.to_s] + + return key_for(field_value, key_field) if field_value + end + + raise ::JWT::DecodeError, 'No key id (kid) or x5t found from token headers' unless @allow_nil_kid + + kid = token.header['kid'] + key_for(kid) 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 diff --git a/lib/jwt/jwk/rsa.rb b/lib/jwt/jwk/rsa.rb index 8bf5e2fa..c4918602 100644 --- a/lib/jwt/jwk/rsa.rb +++ b/lib/jwt/jwk/rsa.rb @@ -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 diff --git a/spec/jwt/jwk/decode_with_jwk_spec.rb b/spec/jwt/jwk/decode_with_jwk_spec.rb index dfbde197..9e657d57 100644 --- a/spec/jwt/jwk/decode_with_jwk_spec.rb +++ b/spec/jwt/jwk/decode_with_jwk_spec.rb @@ -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' } @@ -38,6 +39,26 @@ 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, key_fields: [:x5t] }) + expect(payload).to eq(token_payload) + end + end + + context 'and both kid 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, kid: 'NOT_A_MATCH' } } + it 'is able to decode the token based on the priority of the key defined in key_fields' do + payload, _header = described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks, key_fields: %i[x5t kid] }) + 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 @@ -51,7 +72,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