From 4b6645369f12857e976b177a06cf4d158fdaae27 Mon Sep 17 00:00:00 2001 From: Samuel Cochran Date: Tue, 29 Sep 2026 04:39:18 +0000 Subject: [PATCH 1/3] Enforce JWK metadata when finding verification keys Amp-Thread-ID: https://ampcode.com/threads/T-01a0eb70-d1e6-71dd-b774-7a79333b11f8 Co-authored-by: Amp --- CHANGELOG.md | 1 + README.md | 15 +++-- lib/jwt/jwk/key_finder.rb | 21 ++++++- spec/jwt/jwk/key_finder_spec.rb | 97 +++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 spec/jwt/jwk/key_finder_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b1cb9840e..9a9cc1312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ **Fixes and enhancements:** +- Enforce optional JWK `alg`, `use`, and `key_ops` metadata when selecting a verification key from a JWKS [#769](https://github.com/jwt/ruby-jwt/pull/769) - [@sj26](https://github.com/sj26). - Test that `JWT::Claims::Required` raises `JWT::MissingRequiredClaim` for a non-Hash payload [#768](https://github.com/jwt/ruby-jwt/pull/768) ([@renanmpimentel](https://github.com/renanmpimentel)) - Your contribution here diff --git a/README.md b/README.md index fc8cd18c5..f0514887c 100644 --- a/README.md +++ b/README.md @@ -667,11 +667,20 @@ To decode a JWT using a trusted entity's JSON Web Key Set (JWKS): ```ruby jwks = JWT::JWK::Set.new(jwks_hash) -jwks.filter! {|key| key[:use] == 'sig' } # Signing keys only! algorithms = jwks.map { |key| key[:alg] }.compact.uniq JWT.decode(token, nil, true, algorithms: algorithms, jwks: jwks) ``` +The `jwks` option and `JWT::JWK::KeyFinder` check the selected key's optional metadata before verification: + +- `alg`, when present, must match the token's `alg` header exactly (case-sensitive). +- `use`, when present, must be `sig`. +- `key_ops`, when present, must contain `verify`. + +Omitted metadata does not restrict the key. Incompatible metadata raises `JWT::VerificationKeyError`. +The caller must still supply the allowed algorithms; JWK metadata does not replace that allowlist. +These checks do not apply when an application extracts a raw key with `jwk.verify_key` itself. + 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. @@ -695,9 +704,7 @@ jwks_loader = ->(options) do @cached_keys ||= begin @cache_last_update = Time.now.to_i # Replace with your own JWKS fetching routine - jwks = JWT::JWK::Set.new(jwks_hash) - jwks.select! { |key| key[:use] == 'sig' } # Signing Keys only - jwks + JWT::JWK::Set.new(jwks_hash) end end diff --git a/lib/jwt/jwk/key_finder.rb b/lib/jwt/jwk/key_finder.rb index 6fd18bb8e..7b4c69b72 100644 --- a/lib/jwt/jwk/key_finder.rb +++ b/lib/jwt/jwk/key_finder.rb @@ -27,7 +27,9 @@ def initialize(options) # Returns the verification key for the given kid # @param [String] kid the key id - def key_for(kid, key_field = :kid) + # @param [Symbol] key_field the JWK field to match + # @param [String, nil] algorithm the token's algorithm, when available + def key_for(kid, key_field = :kid, algorithm: nil) raise ::JWT::MalformedTokenError, "Invalid type for #{key_field} header parameter" unless kid.nil? || kid.is_a?(String) jwk = resolve_key(kid, key_field) @@ -35,6 +37,8 @@ def key_for(kid, key_field = :kid) raise ::JWT::SignatureError, 'No keys found in jwks' unless @jwks.any? raise ::JWT::SignatureError, "Could not find public key for kid #{kid}" unless jwk + validate_key_usage!(jwk, algorithm) + jwk.verify_key end @@ -44,17 +48,28 @@ def call(token) @key_fields.each do |key_field| field_value = token.header[key_field.to_s] - return key_for(field_value, key_field) if field_value + return key_for(field_value, key_field, algorithm: token.header['alg']) if field_value end raise ::JWT::SignatureError, 'No key id (kid) or x5t found from token headers' unless @allow_nil_kid kid = token.header['kid'] - key_for(kid) + key_for(kid, algorithm: token.header['alg']) end private + def validate_key_usage!(jwk, algorithm) + raise JWT::VerificationKeyError, 'JWK alg does not match the token algorithm' if algorithm && jwk.parameters.fetch(:alg, algorithm) != algorithm + + raise JWT::VerificationKeyError, 'JWK use does not permit signature verification' if jwk.parameters.fetch(:use, 'sig') != 'sig' + + key_ops = jwk.parameters.fetch(:key_ops, ['verify']) + return if key_ops.is_a?(Array) && key_ops.include?('verify') + + raise JWT::VerificationKeyError, 'JWK key_ops does not permit signature verification' + end + def resolve_key(kid, key_field) key_matcher = ->(key) { (kid.nil? && @allow_nil_kid) || key[key_field] == kid } diff --git a/spec/jwt/jwk/key_finder_spec.rb b/spec/jwt/jwk/key_finder_spec.rb new file mode 100644 index 000000000..95282ab0b --- /dev/null +++ b/spec/jwt/jwk/key_finder_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +RSpec.describe JWT::JWK::KeyFinder do + let(:jwk) { JWT::JWK.new('a-secret-for-HMAC') } + let(:metadata) { {} } + let(:parameters) { jwk.export(include_private: true).merge(metadata) } + let(:finder) { described_class.new(jwks: { keys: [parameters] }) } + + describe '#key_for' do + it 'preserves lookup without an algorithm' do + expect(finder.key_for(jwk.kid)).to eq(jwk.verify_key) + end + + it 'preserves lookup by an explicit key field' do + parameters[:x5t] = 'thumbprint' + expect(finder.key_for('thumbprint', :x5t)).to eq(jwk.verify_key) + end + + it 'checks the algorithm when supplied' do + parameters[:alg] = 'HS512' + expect { finder.key_for(jwk.kid, algorithm: 'HS256') }.to raise_error(JWT::VerificationKeyError) + end + + it 'checks key usage even without an algorithm' do + parameters[:use] = 'enc' + expect { finder.key_for(jwk.kid) }.to raise_error(JWT::VerificationKeyError) + end + end + + [ + ['HS256', 'HS512', nil], + ['RS512', 'RS256', 'rsa-2048-private.pem'], + ['PS256', 'PS512', 'rsa-2048-private.pem'], + ['ES256', 'ES384', 'ec256-private.pem'] + ].each do |algorithm, other_algorithm, key_file| + context "when verifying #{algorithm}" do + let(:jwk) { JWT::JWK.new(key_file ? test_pkey(key_file) : 'a-secret-for-HMAC') } + let(:parameters) { jwk.export(include_private: key_file.nil?).merge(metadata) } + let(:payload) { { 'data' => 'signed payload' } } + let(:headers) { { kid: jwk.kid } } + let(:jwt) { JWT.encode(payload, jwk.signing_key, algorithm, headers) } + let(:token) { JWT::EncodedToken.new(jwt) } + let(:algorithms) { [algorithm, other_algorithm] } + + [{}, { alg: algorithm }, { use: 'sig' }, { key_ops: ['verify'] }, + { alg: algorithm, use: 'sig', key_ops: %w[sign verify] }].each do |metadata| + context "with compatible metadata #{metadata.inspect}" do + let(:metadata) { metadata } + + it 'decodes through the jwks option' do + expect(JWT.decode(jwt, nil, true, algorithms: algorithms, jwks: { keys: [parameters] }).first).to eq(payload) + end + + it 'verifies through the token key finder' do + expect(token.verify_signature!(algorithm: algorithms, key_finder: finder)).to be_nil + end + end + end + + [{ alg: other_algorithm }, { alg: algorithm.downcase }, { use: 'enc' }, { key_ops: ['encrypt'] }, + { key_ops: ['sign'] }, { key_ops: [] }, + { use: 'enc', key_ops: ['verify'] }, { use: 'sig', key_ops: ['encrypt'] }].each do |metadata| + context "with incompatible metadata #{metadata.inspect}" do + let(:metadata) { metadata } + + it 'rejects decoding through the jwks option' do + expect { JWT.decode(jwt, nil, true, algorithms: algorithms, jwks: { keys: [parameters] }) }.to raise_error(JWT::VerificationKeyError) + end + + it 'rejects verification through the token key finder' do + expect { token.verify_signature!(algorithm: algorithms, key_finder: finder) }.to raise_error(JWT::VerificationKeyError) + end + end + end + end + end + + context 'when looking up a token without a kid' do + let(:metadata) { { alg: 'HS512' } } + let(:finder) { described_class.new(jwks: { keys: [parameters] }, allow_nil_kid: true) } + let(:token) { JWT::EncodedToken.new(JWT.encode({}, jwk.signing_key, 'HS256')) } + + it 'still enforces the JWK algorithm' do + expect { finder.call(token) }.to raise_error(JWT::VerificationKeyError) + end + end + + context 'when looking up JSON keys by x5t' do + let(:metadata) { { alg: 'HS512', x5t: 'thumbprint' } } + let(:finder) { described_class.new(jwks: JSON.parse(JSON.generate(keys: [parameters])), key_fields: [:x5t]) } + let(:token) { JWT::EncodedToken.new(JWT.encode({}, jwk.signing_key, 'HS256', x5t: 'thumbprint')) } + + it 'still enforces the JWK algorithm' do + expect { finder.call(token) }.to raise_error(JWT::VerificationKeyError) + end + end +end From 3ae8e532385d4834d32f5de4d68e937c14522abf Mon Sep 17 00:00:00 2001 From: Samuel Cochran Date: Thu, 22 Oct 2026 03:51:56 +0000 Subject: [PATCH 2/3] Respect RSA-PSS availability in JWK metadata specs Amp-Thread-ID: https://ampcode.com/threads/T-01a0eb70-d1e6-71dd-b774-7a79333b11f8 Co-authored-by: Amp --- spec/jwt/jwk/key_finder_spec.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/jwt/jwk/key_finder_spec.rb b/spec/jwt/jwk/key_finder_spec.rb index 95282ab0b..34395065a 100644 --- a/spec/jwt/jwk/key_finder_spec.rb +++ b/spec/jwt/jwk/key_finder_spec.rb @@ -34,6 +34,10 @@ ['ES256', 'ES384', 'ec256-private.pem'] ].each do |algorithm, other_algorithm, key_file| context "when verifying #{algorithm}" do + before do + skip 'OpenSSL gem missing RSA-PSS support' if algorithm.start_with?('PS') && !OpenSSL::PKey::RSA.method_defined?(:sign_pss) + end + let(:jwk) { JWT::JWK.new(key_file ? test_pkey(key_file) : 'a-secret-for-HMAC') } let(:parameters) { jwk.export(include_private: key_file.nil?).merge(metadata) } let(:payload) { { 'data' => 'signed payload' } } From 4b50a36f072d4af986c1fa65de2be149982e4de0 Mon Sep 17 00:00:00 2001 From: Samuel Cochran Date: Thu, 22 Oct 2026 04:03:40 +0000 Subject: [PATCH 3/3] Bind JWK verification to trusted metadata Filter incompatible candidates before reusing the loader invalidation path. Keep algorithm-bearing JWKs intact for verification, and enforce usage restrictions on direct JWK verification. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a0eb70-d1e6-71dd-b774-7a79333b11f8 --- CHANGELOG.md | 2 +- README.md | 12 +++-- lib/jwt/jwa.rb | 2 +- lib/jwt/jwk/key_base.rb | 10 ++++ lib/jwt/jwk/key_finder.rb | 54 +++++++++------------ spec/jwt/jwk/key_finder_spec.rb | 83 +++++++++++++++++++++++++++++---- 6 files changed, 119 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a9cc1312..c3eadd015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ **Fixes and enhancements:** -- Enforce optional JWK `alg`, `use`, and `key_ops` metadata when selecting a verification key from a JWKS [#769](https://github.com/jwt/ruby-jwt/pull/769) - [@sj26](https://github.com/sj26). +- Enforce optional JWK `alg`, `use`, and `key_ops` metadata during JWKS selection and verification. `KeyFinder#call` now returns the JWK when `alg` is present to bind verification to that algorithm; `key_for` keeps returning raw keys. Direct JWK verification also enforces `use` and `key_ops` [#769](https://github.com/jwt/ruby-jwt/pull/769) - [@sj26](https://github.com/sj26). - Test that `JWT::Claims::Required` raises `JWT::MissingRequiredClaim` for a non-Hash payload [#768](https://github.com/jwt/ruby-jwt/pull/768) ([@renanmpimentel](https://github.com/renanmpimentel)) - Your contribution here diff --git a/README.md b/README.md index f0514887c..7ee6aa7ce 100644 --- a/README.md +++ b/README.md @@ -671,15 +671,21 @@ algorithms = jwks.map { |key| key[:alg] }.compact.uniq JWT.decode(token, nil, true, algorithms: algorithms, jwks: jwks) ``` -The `jwks` option and `JWT::JWK::KeyFinder` check the selected key's optional metadata before verification: +The `jwks` option and `JWT::JWK::KeyFinder` select a key with compatible optional metadata before verification: - `alg`, when present, must match the token's `alg` header exactly (case-sensitive). - `use`, when present, must be `sig`. - `key_ops`, when present, must contain `verify`. -Omitted metadata does not restrict the key. Incompatible metadata raises `JWT::VerificationKeyError`. +Omitted metadata does not restrict the key. Incompatible candidates are skipped; if no eligible key is found, +the existing loader invalidation is attempted before raising `JWT::SignatureError`. +Direct verification with a JWK also enforces `use` and `key_ops`, raising `JWT::VerificationKeyError` if incompatible. The caller must still supply the allowed algorithms; JWK metadata does not replace that allowlist. -These checks do not apply when an application extracts a raw key with `jwk.verify_key` itself. +`KeyFinder#call` returns the JWK itself when `alg` is present, binding verification to that algorithm even +when the caller allows several algorithms. Without `alg`, it returns the raw verification key as before. +`KeyFinder#key_for(kid, key_field = :kid)` still returns a raw key and has no token algorithm context. +`call` resolves JWKs directly; subclasses that customize token lookup should override `call`, not `key_for`. +Applications extracting a raw key with `key_for` or `jwk.verify_key` must enforce algorithm restrictions themselves. 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. diff --git a/lib/jwt/jwa.rb b/lib/jwt/jwa.rb index 5b3654eb0..b5938df94 100644 --- a/lib/jwt/jwa.rb +++ b/lib/jwt/jwa.rb @@ -64,7 +64,7 @@ def validate_jwk_algorithms!(jwks, algorithms, error_class) return if Array(jwks).all? do |jwk| algorithms.any? do |alg| - jwk.jwa.valid_alg?(alg) + jwk.jwa.valid_alg?(resolve(alg).alg) end end diff --git a/lib/jwt/jwk/key_base.rb b/lib/jwt/jwk/key_base.rb index ac9d9b911..a22258b9a 100644 --- a/lib/jwt/jwk/key_base.rb +++ b/lib/jwt/jwk/key_base.rb @@ -42,7 +42,17 @@ def ==(other) other.is_a?(::JWT::JWK::KeyBase) && self[:kid] == other[:kid] end + def usable_for_verification?(algorithm = nil) + return false if algorithm && parameters.fetch(:alg, algorithm) != algorithm + return false if parameters.fetch(:use, 'sig') != 'sig' + + key_ops = parameters.fetch(:key_ops, ['verify']) + key_ops.is_a?(Array) && key_ops.include?('verify') + end + def verify(**kwargs) + raise JWT::VerificationKeyError, 'JWK metadata does not permit signature verification' unless usable_for_verification? + jwa.verify(**kwargs, verification_key: verify_key) end diff --git a/lib/jwt/jwk/key_finder.rb b/lib/jwt/jwk/key_finder.rb index 7b4c69b72..d1b489b3c 100644 --- a/lib/jwt/jwk/key_finder.rb +++ b/lib/jwt/jwk/key_finder.rb @@ -28,60 +28,52 @@ def initialize(options) # Returns the verification key for the given kid # @param [String] kid the key id # @param [Symbol] key_field the JWK field to match - # @param [String, nil] algorithm the token's algorithm, when available - def key_for(kid, key_field = :kid, algorithm: nil) - raise ::JWT::MalformedTokenError, "Invalid type for #{key_field} header parameter" unless kid.nil? || kid.is_a?(String) - - jwk = resolve_key(kid, key_field) - - raise ::JWT::SignatureError, 'No keys found in jwks' unless @jwks.any? - raise ::JWT::SignatureError, "Could not find public key for kid #{kid}" unless jwk - - validate_key_usage!(jwk, algorithm) - - jwk.verify_key + def key_for(kid, key_field = :kid) + find_jwk(kid, key_field).verify_key end # Returns the key for the given token # @param [JWT::EncodedToken] token the token + # @return [JWT::JWK::KeyBase, Object] the JWK when alg is present, otherwise its raw verification key def call(token) - @key_fields.each do |key_field| - field_value = token.header[key_field.to_s] - - return key_for(field_value, key_field, algorithm: token.header['alg']) if field_value - end + key_field = @key_fields.find { |field| token.header[field.to_s] } - raise ::JWT::SignatureError, 'No key id (kid) or x5t found from token headers' unless @allow_nil_kid + raise ::JWT::SignatureError, 'No key id (kid) or x5t found from token headers' unless key_field || @allow_nil_kid - kid = token.header['kid'] - key_for(kid, algorithm: token.header['alg']) + key_field ||= :kid + jwk = find_jwk(token.header[key_field.to_s], key_field, token.header['alg']) + jwk.parameters.key?(:alg) ? jwk : jwk.verify_key end private - def validate_key_usage!(jwk, algorithm) - raise JWT::VerificationKeyError, 'JWK alg does not match the token algorithm' if algorithm && jwk.parameters.fetch(:alg, algorithm) != algorithm + def find_jwk(kid, key_field, algorithm = nil) + raise ::JWT::MalformedTokenError, "Invalid type for #{key_field} header parameter" unless kid.nil? || kid.is_a?(String) - raise JWT::VerificationKeyError, 'JWK use does not permit signature verification' if jwk.parameters.fetch(:use, 'sig') != 'sig' + jwk = resolve_key(kid, key_field, algorithm) - key_ops = jwk.parameters.fetch(:key_ops, ['verify']) - return if key_ops.is_a?(Array) && key_ops.include?('verify') + raise ::JWT::SignatureError, 'No keys found in jwks' unless @jwks.any? + raise ::JWT::SignatureError, "Could not find public key for kid #{kid}" unless jwk - raise JWT::VerificationKeyError, 'JWK key_ops does not permit signature verification' + jwk end - def resolve_key(kid, key_field) - key_matcher = ->(key) { (kid.nil? && @allow_nil_kid) || key[key_field] == kid } - + def resolve_key(kid, key_field, algorithm) # First try without invalidation to facilitate application caching @jwks ||= JWT::JWK::Set.new(@jwks_loader.call(key_field => kid)) - jwk = @jwks.find { |key| key_matcher.call(key) } + jwk = matching_key(kid, key_field, algorithm) return jwk if jwk # Second try, invalidate for backwards compatibility @jwks = JWT::JWK::Set.new(@jwks_loader.call(invalidate: true, kid_not_found: true, key_field => kid)) - @jwks.find { |key| key_matcher.call(key) } + matching_key(kid, key_field, algorithm) + end + + def matching_key(kid, key_field, algorithm) + @jwks.find do |key| + ((kid.nil? && @allow_nil_kid) || key[key_field] == kid) && key.usable_for_verification?(algorithm) + end end end end diff --git a/spec/jwt/jwk/key_finder_spec.rb b/spec/jwt/jwk/key_finder_spec.rb index 34395065a..efb208e1f 100644 --- a/spec/jwt/jwk/key_finder_spec.rb +++ b/spec/jwt/jwk/key_finder_spec.rb @@ -16,14 +16,14 @@ expect(finder.key_for('thumbprint', :x5t)).to eq(jwk.verify_key) end - it 'checks the algorithm when supplied' do - parameters[:alg] = 'HS512' - expect { finder.key_for(jwk.kid, algorithm: 'HS256') }.to raise_error(JWT::VerificationKeyError) + it 'preserves raw-key lookup with algorithm metadata' do + parameters[:alg] = 'HS256' + expect(finder.key_for(jwk.kid)).to eq(jwk.verify_key) end it 'checks key usage even without an algorithm' do parameters[:use] = 'enc' - expect { finder.key_for(jwk.kid) }.to raise_error(JWT::VerificationKeyError) + expect { finder.key_for(jwk.kid) }.to raise_error(JWT::SignatureError) end end @@ -68,11 +68,11 @@ let(:metadata) { metadata } it 'rejects decoding through the jwks option' do - expect { JWT.decode(jwt, nil, true, algorithms: algorithms, jwks: { keys: [parameters] }) }.to raise_error(JWT::VerificationKeyError) + expect { JWT.decode(jwt, nil, true, algorithms: algorithms, jwks: { keys: [parameters] }) }.to raise_error(JWT::SignatureError) end it 'rejects verification through the token key finder' do - expect { token.verify_signature!(algorithm: algorithms, key_finder: finder) }.to raise_error(JWT::VerificationKeyError) + expect { token.verify_signature!(algorithm: algorithms, key_finder: finder) }.to raise_error(JWT::SignatureError) end end end @@ -85,7 +85,7 @@ let(:token) { JWT::EncodedToken.new(JWT.encode({}, jwk.signing_key, 'HS256')) } it 'still enforces the JWK algorithm' do - expect { finder.call(token) }.to raise_error(JWT::VerificationKeyError) + expect { finder.call(token) }.to raise_error(JWT::SignatureError) end end @@ -95,7 +95,74 @@ let(:token) { JWT::EncodedToken.new(JWT.encode({}, jwk.signing_key, 'HS256', x5t: 'thumbprint')) } it 'still enforces the JWK algorithm' do - expect { finder.call(token) }.to raise_error(JWT::VerificationKeyError) + expect { finder.call(token) }.to raise_error(JWT::SignatureError) + end + end + + context 'when the signature uses a different allowed algorithm from the metadata' do + let(:metadata) { { alg: 'HS512' } } + let(:jwt) do + header = JWT::Base64.url_encode(JSON.generate(alg: 'HS512', kid: jwk.kid)) + payload = JWT::Base64.url_encode(JSON.generate(data: 'example')) + input = [header, payload].join('.') + signature = OpenSSL::HMAC.digest('SHA256', jwk.signing_key, input) + [input, JWT::Base64.url_encode(signature)].join('.') + end + + it 'rejects the signature despite matching JWK and token alg headers' do + token = JWT::EncodedToken.new(jwt) + expect { token.verify_signature!(algorithm: %w[HS256 HS512], key_finder: finder) }.to raise_error(JWT::VerificationError) + end + + it 'also rejects decoding through the jwks option' do + expect { JWT.decode(jwt, nil, true, algorithms: %w[HS256 HS512], jwks: { keys: [parameters] }) }.to raise_error(JWT::VerificationError) + end + end + + context 'when several keys match the identifier' do + let(:metadata) { { alg: 'HS256', use: 'sig', key_ops: ['verify'] } } + let(:jwt) { JWT.encode({ 'data' => 'example' }, jwk.signing_key, 'HS256', kid: jwk.kid) } + let(:finder) { described_class.new(jwks: { keys: [incompatible_key, parameters] }) } + + [{ use: 'enc' }, { key_ops: ['sign'] }, { alg: 'HS512' }].each do |restrictions| + context "with an incompatible first key #{restrictions.inspect}" do + let(:incompatible_key) { JWT::JWK.new('a-different-secret', kid: jwk.kid).export(include_private: true).merge(restrictions) } + + it 'verifies with the compatible candidate' do + expect(JWT::EncodedToken.new(jwt).verify_signature!(algorithm: %w[HS256 HS512], key_finder: finder)).to be_nil + expect(JWT.decode(jwt, nil, true, algorithms: %w[HS256 HS512], jwks: { keys: [incompatible_key, parameters] }).first).to eq('data' => 'example') + end + end + end + + context 'without a kid' do + let(:incompatible_key) { JWT::JWK.new('a-different-secret', use: 'enc').export(include_private: true) } + let(:jwt) { JWT.encode({ 'data' => 'example' }, jwk.signing_key, 'HS256') } + + it 'skips incompatible candidates with allow_nil_kid' do + payload, = JWT.decode(jwt, nil, true, algorithms: ['HS256'], jwks: { keys: [incompatible_key, parameters] }, allow_nil_kid: true) + expect(payload).to eq('data' => 'example') + end + end + end + + context 'when cached metadata does not permit verification' do + let(:metadata) { { use: 'sig' } } + + it 'uses the existing loader invalidation path to find an eligible key' do + loader = ->(options) { { keys: [options[:invalidate] ? parameters : parameters.merge(use: 'enc')] } } + jwt = JWT.encode({ 'data' => 'example' }, jwk.signing_key, 'HS256', kid: jwk.kid) + expect(JWT.decode(jwt, nil, true, algorithms: ['HS256'], jwks: loader).first).to eq('data' => 'example') + end + end + + context 'when a restricted JWK is used directly' do + [{ use: 'enc' }, { key_ops: ['sign'] }].each do |restrictions| + it "rejects #{restrictions.inspect} without a key finder" do + key = JWT::JWK.new(jwk.signing_key, restrictions.merge(alg: 'HS256')) + token = JWT::EncodedToken.new(JWT.encode({}, jwk.signing_key, 'HS256')) + expect { token.verify_signature!(algorithm: ['HS256'], key: key) }.to raise_error(JWT::VerificationKeyError) + end end end end