diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ef12b47..caf9b331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Fix rejection of unknown algorithms from JWKs for RFC compliance and pquip [#728](https://github.com/jwt/ruby-jwt/pull/728) - Fix the `Style/DirectiveScope` RuboCop offense failing the build [#752](https://github.com/jwt/ruby-jwt/pull/752) +- Fix `JWT::JWK::Set` sharing its key collection with the set it was copied from [#751](https://github.com/jwt/ruby-jwt/pull/751) ## [v3.2.0](https://github.com/jwt/ruby-jwt/tree/v3.2.0) (2026-05-13) diff --git a/lib/jwt/jwk/set.rb b/lib/jwt/jwk/set.rb index 2a11aee5..c1dff7b5 100644 --- a/lib/jwt/jwk/set.rb +++ b/lib/jwt/jwk/set.rb @@ -17,7 +17,7 @@ def initialize(jwks = nil, options = {}) # rubocop:disable Metrics/CyclomaticCom @keys = case jwks when JWT::JWK::Set # Simple duplication - jwks.keys + jwks.keys.dup when JWT::JWK::KeyBase # Singleton [jwks] when Hash @@ -34,6 +34,13 @@ def initialize(jwks = nil, options = {}) # rubocop:disable Metrics/CyclomaticCom end end + # Ensures a duplicated set owns its key collection. The keys themselves are + # intentionally shared; only the collection is copied. + def initialize_copy(other) + super + @keys = @keys.dup + end + def export(options = {}) { keys: @keys.map { |k| k.export(options) } } end diff --git a/spec/jwt/jwk/set_spec.rb b/spec/jwt/jwk/set_spec.rb index 4678db08..9c0adf11 100644 --- a/spec/jwt/jwk/set_spec.rb +++ b/spec/jwt/jwk/set_spec.rb @@ -36,6 +36,67 @@ end end + context 'when created from an existing JWT::JWK::Set' do + let(:jwk) { JWT::JWK.new({ kty: 'oct', k: Base64.strict_encode64('testkey') }) } + let(:other) { JWT::JWK.new({ kty: 'oct', k: Base64.strict_encode64('otherkey') }) } + let(:original) { described_class.new([jwk]) } + let(:copy) { described_class.new(original) } + + it 'does not share the key collection with the original' do + expect(copy.keys).not_to be(original.keys) + end + + it 'keeps the original intact when keys are added to the copy' do + copy.add(other) + expect(original.keys).to eql([jwk]) + end + + it 'keeps the original intact when keys are removed from the copy' do + copy.delete(jwk) + expect(original.keys).to eql([jwk]) + end + + it 'keeps the original intact when the copy is filtered' do + copy.select! { false } + expect(original.keys).to eql([jwk]) + end + + it 'shares the key objects with the original' do + expect(copy.keys.first).to be(original.keys.first) + end + end + + context 'when duplicated' do + let(:jwk) { JWT::JWK.new({ kty: 'oct', k: Base64.strict_encode64('testkey') }) } + let(:other) { JWT::JWK.new({ kty: 'oct', k: Base64.strict_encode64('otherkey') }) } + let(:original) { described_class.new([jwk]) } + + it 'does not share the key collection with the original' do + expect(original.dup.keys).not_to be(original.keys) + end + + it 'keeps the original intact when keys are added to the duplicate' do + original.dup << other + expect(original.keys).to eql([jwk]) + end + + it 'keeps the original intact when the duplicate is filtered' do + original.dup.reject! { true } + expect(original.keys).to eql([jwk]) + end + end + + context 'when a union is built from it' do + let(:jwk) { JWT::JWK.new({ kty: 'oct', k: Base64.strict_encode64('testkey') }) } + let(:other) { JWT::JWK.new({ kty: 'oct', k: Base64.strict_encode64('otherkey') }) } + let(:original) { described_class.new([jwk]) } + + it 'keeps the original intact' do + original.union([other]) + expect(original.keys).to eql([jwk]) + end + end + it 'ignores keys with unsupported kty values (RFC 7517 ยง5), required for hybrid PQC' do jwks = { keys: [