From bf095f9eb97baf20130218f351019187a2540502 Mon Sep 17 00:00:00 2001 From: ydah Date: Thu, 29 Jan 2026 22:12:16 +0900 Subject: [PATCH 1/3] Add duplicate claim name detection per RFC 7519 Section 4 Implements duplicate claim name detection as specified in RFC 7519 Section 4, which states: > The Claim Names within a JWT Claims Set MUST be unique; JWT parsers MUST either reject JWTs with duplicate Claim Names or use a JSON parser that returns only the lexically last duplicate member name. This feature allows users to reject JWTs that contain duplicate keys in the header or payload, which is recommended for security-sensitive applications to prevent claim confusion attacks. --- CHANGELOG.md | 2 +- README.md | 29 +++++ lib/jwt/configuration/decode_configuration.rb | 9 +- lib/jwt/decode.rb | 8 +- lib/jwt/encoded_token.rb | 9 +- lib/jwt/error.rb | 4 + lib/jwt/json.rb | 113 ++++++++++++++++- spec/jwt/claims/duplicate_key_spec.rb | 119 ++++++++++++++++++ spec/jwt/json_spec.rb | 50 ++++++++ 9 files changed, 335 insertions(+), 8 deletions(-) create mode 100644 spec/jwt/claims/duplicate_key_spec.rb create mode 100644 spec/jwt/json_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 047b59037..696481813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ **Features:** -- Your contribution here +- Add duplicate claim name detection per RFC 7519 Section 4 [#713](https://github.com/jwt/ruby-jwt/pull/713) ([@ydah](https://github.com/ydah)) **Fixes and enhancements:** diff --git a/README.md b/README.md index 0e8122edc..1851709e0 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,35 @@ encoded_token.verify_signature!(algorithm: 'HS256', key: "secret") encoded_token.payload # => {"pay"=>"load"} ``` +## Duplicate Claim Name Detection + +RFC 7519 Section 4 specifies that claim names within a JWT Claims Set MUST be unique. By default, ruby-jwt follows ECMAScript 5.1 behavior and uses the last value for duplicate keys. You can enable strict duplicate key detection to reject JWTs with duplicate claim names. + +### Rejecting Duplicate Keys + +```ruby +# Reject JWTs with duplicate keys in header or payload +begin + JWT.decode(token, secret, true, algorithm: 'HS256', allow_duplicate_keys: false) +rescue JWT::DuplicateKeyError => e + puts "Duplicate key detected: #{e.message}" +end +``` + +### Global Configuration + +```ruby +# Globally reject duplicate keys +JWT.configure do |config| + config.decode.allow_duplicate_keys = false +end + +# Per-decode override +JWT.decode(token, secret, true, algorithm: 'HS256', allow_duplicate_keys: true) +``` + +This is recommended for security-sensitive applications to prevent attacks that exploit different systems reading different values from the same JWT. + ## Claims JSON Web Token defines some reserved claim names and defines how they should be diff --git a/lib/jwt/configuration/decode_configuration.rb b/lib/jwt/configuration/decode_configuration.rb index 4acfd3ebb..56655d580 100644 --- a/lib/jwt/configuration/decode_configuration.rb +++ b/lib/jwt/configuration/decode_configuration.rb @@ -24,6 +24,8 @@ class DecodeConfiguration # @return [Array] the list of acceptable algorithms. # @!attribute [rw] required_claims # @return [Array] the list of required claims. + # @!attribute [rw] allow_duplicate_keys + # @return [Boolean] whether to allow duplicate keys in JWT header and payload. attr_accessor :verify_expiration, :verify_not_before, @@ -34,7 +36,8 @@ class DecodeConfiguration :verify_sub, :leeway, :algorithms, - :required_claims + :required_claims, + :allow_duplicate_keys # Initializes a new DecodeConfiguration instance with default settings. def initialize @@ -48,6 +51,7 @@ def initialize @leeway = 0 @algorithms = ['HS256'] @required_claims = [] + @allow_duplicate_keys = true end # @api private @@ -62,7 +66,8 @@ def to_h verify_sub: verify_sub, leeway: leeway, algorithms: algorithms, - required_claims: required_claims + required_claims: required_claims, + allow_duplicate_keys: allow_duplicate_keys } end end diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index 9a8a0a60b..b1703bf0b 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -22,7 +22,7 @@ class Decode def initialize(jwt, key, verify, options, &keyfinder) raise JWT::DecodeError, 'Nil JSON web token' unless jwt - @token = EncodedToken.new(jwt) + @token = EncodedToken.new(jwt, allow_duplicate_keys: allow_duplicate_keys?(options)) @key = key @options = options @verify = verify @@ -119,5 +119,11 @@ def none_algorithm? def alg_in_header token.header['alg'] end + + def allow_duplicate_keys?(options) + return options[:allow_duplicate_keys] if options.key?(:allow_duplicate_keys) + + JWT.configuration.decode.allow_duplicate_keys + end end end diff --git a/lib/jwt/encoded_token.rb b/lib/jwt/encoded_token.rb index cbaec1c8d..108bd81d3 100644 --- a/lib/jwt/encoded_token.rb +++ b/lib/jwt/encoded_token.rb @@ -11,7 +11,7 @@ module JWT # encoded_token = JWT::EncodedToken.new(token.jwt) # encoded_token.verify_signature!(algorithm: 'HS256', key: 'secret') # encoded_token.payload # => {'pay' => 'load'} - class EncodedToken + class EncodedToken # rubocop:disable Metrics/ClassLength # @private # Allow access to the unverified payload for claim verification. class ClaimsContext @@ -39,11 +39,14 @@ def payload # Initializes a new EncodedToken instance. # # @param jwt [String] the encoded JWT token. + # @param allow_duplicate_keys [Boolean] whether to allow duplicate keys in header/payload (default: true). # @raise [ArgumentError] if the provided JWT is not a String. - def initialize(jwt) + # @raise [JWT::DuplicateKeyError] if allow_duplicate_keys is false and duplicate keys are found. + def initialize(jwt, allow_duplicate_keys: true) raise ArgumentError, 'Provided JWT must be a String' unless jwt.is_a?(String) @jwt = jwt + @allow_duplicate_keys = allow_duplicate_keys @signature_verified = false @claims_verified = false @@ -224,7 +227,7 @@ def parse_unencoded(segment) end def parse(segment) - JWT::JSON.parse(segment) + JWT::JSON.parse(segment, allow_duplicate_keys: @allow_duplicate_keys) rescue ::JSON::ParserError raise JWT::DecodeError, 'Invalid segment encoding' end diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb index 2a0f8a2ce..a6bbf4a0d 100644 --- a/lib/jwt/error.rb +++ b/lib/jwt/error.rb @@ -51,4 +51,8 @@ class Base64DecodeError < DecodeError; end # The JWKError class is raised when there is an error with the JSON Web Key (JWK). class JWKError < DecodeError; end + + # The DuplicateKeyError class is raised when a JWT contains duplicate keys in the header or payload. + # @see https://datatracker.ietf.org/doc/html/rfc7519#section-4 RFC 7519 Section 4 + class DuplicateKeyError < DecodeError; end end diff --git a/lib/jwt/json.rb b/lib/jwt/json.rb index 90ae45855..8550177f0 100644 --- a/lib/jwt/json.rb +++ b/lib/jwt/json.rb @@ -1,18 +1,129 @@ # frozen_string_literal: true require 'json' +require 'strscan' module JWT + # JSON parsing utilities with duplicate key detection support # @api private class JSON class << self + # Generates a JSON string from the given data + # @param data [Object] the data to serialize + # @return [String] the JSON string def generate(data) ::JSON.generate(data) end - def parse(data) + # Parses a JSON string with optional duplicate key detection + # + # @param data [String] the JSON string to parse + # @param allow_duplicate_keys [Boolean] whether to allow duplicate keys (default: true) + # @return [Hash] the parsed JSON object + # @raise [JWT::DuplicateKeyError] if allow_duplicate_keys is false and duplicate keys are found + # + # @example Default behavior (allows duplicates, uses last value) + # JWT::JSON.parse('{"a":1,"a":2}') # => {"a" => 2} + # + # @example Strict mode (rejects duplicates) + # JWT::JSON.parse('{"a":1,"a":2}', allow_duplicate_keys: false) + # # => raises JWT::DuplicateKeyError + def parse(data, allow_duplicate_keys: true) + DuplicateKeyChecker.check!(data) unless allow_duplicate_keys ::JSON.parse(data) end end + + # @api private + # Checks for duplicate keys in a JSON string using a StringScanner-based tokenizer + # rubocop:disable Style/RedundantRegexpArgument + class DuplicateKeyChecker + def self.check!(json_str) + new(json_str).check! + end + + def initialize(json_str) + @scanner = StringScanner.new(json_str) + @seen_keys_stack = [[]] + @depth = 0 + @in_array_stack = [false] + end + + def check! + scan_tokens until @scanner.eos? + end + + private + + def scan_tokens # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + skip_whitespace + return if @scanner.eos? + + if @scanner.scan(/\{/) + handle_object_start + elsif @scanner.scan(/\}/) + handle_container_end + elsif @scanner.scan(/\[/) + handle_array_start + elsif @scanner.scan(/\]/) + @depth -= 1 + elsif @scanner.scan(/,/) || @scanner.scan(/:/) + # skip comma and colon + elsif @scanner.scan(/"/) + handle_string + elsif @scanner.scan(/-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/) + # skip number + elsif @scanner.scan(/true|false|null/) + # skip literal + else + @scanner.getch + end + end + + def skip_whitespace + @scanner.scan(/\s+/) + end + + def handle_object_start + @depth += 1 + @seen_keys_stack[@depth] = [] + @in_array_stack[@depth] = false + end + + def handle_array_start + @depth += 1 + @seen_keys_stack[@depth] = [] + @in_array_stack[@depth] = true + end + + def handle_container_end + @depth -= 1 + end + + def handle_string + str = scan_string_content + check_if_key(str) + end + + def scan_string_content + str = +'' + str << (@scanner.getch || '') until @scanner.scan(/"/) + str + end + + def check_if_key(str) + return if @in_array_stack[@depth] + + pos = @scanner.pos + skip_whitespace + if @scanner.peek(1) == ':' + raise JWT::DuplicateKeyError, "Duplicate key detected: #{str}" if @seen_keys_stack[@depth].include?(str) + + @seen_keys_stack[@depth] << str + end + @scanner.pos = pos + end + end + # rubocop:enable Style/RedundantRegexpArgument end end diff --git a/spec/jwt/claims/duplicate_key_spec.rb b/spec/jwt/claims/duplicate_key_spec.rb new file mode 100644 index 000000000..c81d7da2c --- /dev/null +++ b/spec/jwt/claims/duplicate_key_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +RSpec.describe 'Duplicate Claim Name Detection' do + let(:secret) { 'test_secret' } + let(:algorithm) { 'HS256' } + + def sign_jwt(signing_input, secret) + signature = OpenSSL::HMAC.digest('SHA256', secret, signing_input) + JWT::Base64.url_encode(signature) + end + + def build_jwt_with_duplicate_payload(duplicate_payload_json) + header = JWT::Base64.url_encode('{"alg":"HS256"}') + payload = JWT::Base64.url_encode(duplicate_payload_json) + signing_input = "#{header}.#{payload}" + signature = sign_jwt(signing_input, secret) + "#{signing_input}.#{signature}" + end + + def build_jwt_with_duplicate_header(duplicate_header_json, payload_json = '{"sub":"user"}') + header = JWT::Base64.url_encode(duplicate_header_json) + payload = JWT::Base64.url_encode(payload_json) + signing_input = "#{header}.#{payload}" + signature = sign_jwt(signing_input, secret) + "#{signing_input}.#{signature}" + end + + describe 'payload with duplicate keys' do + let(:duplicate_payload_jwt) { build_jwt_with_duplicate_payload('{"sub":"user","sub":"admin"}') } + + context 'with default configuration' do + it 'uses the last value (backward compatible)' do + payload, = JWT.decode(duplicate_payload_jwt, secret, true, algorithm: algorithm) + expect(payload['sub']).to eq('admin') + end + end + + context 'with allow_duplicate_keys: true' do + it 'uses the last value' do + payload, = JWT.decode(duplicate_payload_jwt, secret, true, algorithm: algorithm, allow_duplicate_keys: true) + expect(payload['sub']).to eq('admin') + end + end + + context 'with allow_duplicate_keys: false' do + it 'raises DuplicateKeyError' do + expect do + JWT.decode(duplicate_payload_jwt, secret, true, algorithm: algorithm, allow_duplicate_keys: false) + end.to raise_error(JWT::DuplicateKeyError, /Duplicate key detected: sub/) + end + end + end + + describe 'header with duplicate keys' do + let(:duplicate_header_jwt) { build_jwt_with_duplicate_header('{"alg":"HS256","alg":"none"}') } + + context 'with default configuration' do + it 'uses the last value (backward compatible)' do + _, header = JWT.decode(duplicate_header_jwt, nil, false) + expect(header['alg']).to eq('none') + end + end + + context 'with allow_duplicate_keys: false' do + it 'raises DuplicateKeyError for header' do + expect do + JWT.decode(duplicate_header_jwt, nil, false, allow_duplicate_keys: false) + end.to raise_error(JWT::DuplicateKeyError, /Duplicate key detected: alg/) + end + end + end + + describe 'global configuration' do + around do |example| + original = JWT.configuration.decode.allow_duplicate_keys + example.run + JWT.configuration.decode.allow_duplicate_keys = original + end + + let(:duplicate_payload_jwt) { build_jwt_with_duplicate_payload('{"sub":"user","sub":"admin"}') } + + it 'respects global configuration when set to false' do + JWT.configuration.decode.allow_duplicate_keys = false + + expect do + JWT.decode(duplicate_payload_jwt, secret, true, algorithm: algorithm) + end.to raise_error(JWT::DuplicateKeyError) + end + + it 'allows per-decode override of global configuration' do + JWT.configuration.decode.allow_duplicate_keys = false + + payload, = JWT.decode( + duplicate_payload_jwt, + secret, + true, + algorithm: algorithm, + allow_duplicate_keys: true + ) + expect(payload['sub']).to eq('admin') + end + + it 'defaults to allowing duplicate keys' do + expect(JWT.configuration.decode.allow_duplicate_keys).to be(true) + end + end + + describe 'multiple duplicate keys' do + let(:multiple_duplicates_jwt) { build_jwt_with_duplicate_payload('{"a":1,"b":2,"a":3,"b":4}') } + + context 'with allow_duplicate_keys: false' do + it 'raises DuplicateKeyError for the first duplicate found' do + expect do + JWT.decode(multiple_duplicates_jwt, secret, true, algorithm: algorithm, allow_duplicate_keys: false) + end.to raise_error(JWT::DuplicateKeyError, /Duplicate key detected: a/) + end + end + end +end diff --git a/spec/jwt/json_spec.rb b/spec/jwt/json_spec.rb new file mode 100644 index 000000000..8f2b7b36b --- /dev/null +++ b/spec/jwt/json_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +RSpec.describe JWT::JSON do + describe '.generate' do + it 'generates JSON from a hash' do + expect(described_class.generate({ 'a' => 1 })).to eq('{"a":1}') + end + end + + describe '.parse' do + context 'with allow_duplicate_keys: true (default)' do + it 'uses the last value for duplicate keys' do + result = described_class.parse('{"a":1,"a":2}') + expect(result['a']).to eq(2) + end + + it 'parses valid JSON without duplicates' do + result = described_class.parse('{"a":1,"b":2}') + expect(result).to eq({ 'a' => 1, 'b' => 2 }) + end + end + + context 'with allow_duplicate_keys: false' do + it 'raises DuplicateKeyError for duplicate keys' do + expect do + described_class.parse('{"a":1,"a":2}', allow_duplicate_keys: false) + end.to raise_error(JWT::DuplicateKeyError, /Duplicate key detected: a/) + end + + it 'parses valid JSON without duplicates' do + result = described_class.parse('{"a":1,"b":2}', allow_duplicate_keys: false) + expect(result).to eq({ 'a' => 1, 'b' => 2 }) + end + + it 'detects duplicates in nested objects' do + json = '{"outer":{"inner":1,"inner":2}}' + expect do + described_class.parse(json, allow_duplicate_keys: false) + end.to raise_error(JWT::DuplicateKeyError, /Duplicate key detected: inner/) + end + + it 'allows same key in different objects' do + json = '{"obj1":{"a":1},"obj2":{"a":2}}' + result = described_class.parse(json, allow_duplicate_keys: false) + expect(result['obj1']['a']).to eq(1) + expect(result['obj2']['a']).to eq(2) + end + end + end +end From 71b43ea0577e8fb2316930c78f386c7105b1603b Mon Sep 17 00:00:00 2001 From: ydah Date: Fri, 30 Jan 2026 18:49:14 +0900 Subject: [PATCH 2/3] Remove duplicate key detection from JWT.decode API in favor of EncodedToken API --- README.md | 25 ++-- lib/jwt/configuration/decode_configuration.rb | 9 +- lib/jwt/decode.rb | 8 +- lib/jwt/encoded_token.rb | 22 +++- lib/jwt/json.rb | 98 +-------------- ruby-jwt.gemspec | 1 + spec/jwt/claims/duplicate_key_spec.rb | 115 ++++++++---------- spec/jwt/json_spec.rb | 4 +- 8 files changed, 87 insertions(+), 195 deletions(-) diff --git a/README.md b/README.md index 1851709e0..775665f02 100644 --- a/README.md +++ b/README.md @@ -327,31 +327,24 @@ encoded_token.payload # => {"pay"=>"load"} ## Duplicate Claim Name Detection -RFC 7519 Section 4 specifies that claim names within a JWT Claims Set MUST be unique. By default, ruby-jwt follows ECMAScript 5.1 behavior and uses the last value for duplicate keys. You can enable strict duplicate key detection to reject JWTs with duplicate claim names. +RFC 7519 Section 4 specifies that claim names within a JWT Claims Set MUST be unique. By default, ruby-jwt follows ECMAScript 5.1 behavior and uses the last value for duplicate keys. You can enable strict duplicate key detection to reject JWTs with duplicate claim names using the `EncodedToken` API. -### Rejecting Duplicate Keys +### Using EncodedToken API ```ruby -# Reject JWTs with duplicate keys in header or payload +# Enable strict duplicate key detection +token = JWT::EncodedToken.new(jwt_string) +token.raise_on_duplicate_keys! + begin - JWT.decode(token, secret, true, algorithm: 'HS256', allow_duplicate_keys: false) + token.verify_signature!(algorithm: 'HS256', key: secret) + token.verify_claims! + token.payload rescue JWT::DuplicateKeyError => e puts "Duplicate key detected: #{e.message}" end ``` -### Global Configuration - -```ruby -# Globally reject duplicate keys -JWT.configure do |config| - config.decode.allow_duplicate_keys = false -end - -# Per-decode override -JWT.decode(token, secret, true, algorithm: 'HS256', allow_duplicate_keys: true) -``` - This is recommended for security-sensitive applications to prevent attacks that exploit different systems reading different values from the same JWT. ## Claims diff --git a/lib/jwt/configuration/decode_configuration.rb b/lib/jwt/configuration/decode_configuration.rb index 56655d580..4acfd3ebb 100644 --- a/lib/jwt/configuration/decode_configuration.rb +++ b/lib/jwt/configuration/decode_configuration.rb @@ -24,8 +24,6 @@ class DecodeConfiguration # @return [Array] the list of acceptable algorithms. # @!attribute [rw] required_claims # @return [Array] the list of required claims. - # @!attribute [rw] allow_duplicate_keys - # @return [Boolean] whether to allow duplicate keys in JWT header and payload. attr_accessor :verify_expiration, :verify_not_before, @@ -36,8 +34,7 @@ class DecodeConfiguration :verify_sub, :leeway, :algorithms, - :required_claims, - :allow_duplicate_keys + :required_claims # Initializes a new DecodeConfiguration instance with default settings. def initialize @@ -51,7 +48,6 @@ def initialize @leeway = 0 @algorithms = ['HS256'] @required_claims = [] - @allow_duplicate_keys = true end # @api private @@ -66,8 +62,7 @@ def to_h verify_sub: verify_sub, leeway: leeway, algorithms: algorithms, - required_claims: required_claims, - allow_duplicate_keys: allow_duplicate_keys + required_claims: required_claims } end end diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index b1703bf0b..9a8a0a60b 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -22,7 +22,7 @@ class Decode def initialize(jwt, key, verify, options, &keyfinder) raise JWT::DecodeError, 'Nil JSON web token' unless jwt - @token = EncodedToken.new(jwt, allow_duplicate_keys: allow_duplicate_keys?(options)) + @token = EncodedToken.new(jwt) @key = key @options = options @verify = verify @@ -119,11 +119,5 @@ def none_algorithm? def alg_in_header token.header['alg'] end - - def allow_duplicate_keys?(options) - return options[:allow_duplicate_keys] if options.key?(:allow_duplicate_keys) - - JWT.configuration.decode.allow_duplicate_keys - end end end diff --git a/lib/jwt/encoded_token.rb b/lib/jwt/encoded_token.rb index 108bd81d3..de50bd6bb 100644 --- a/lib/jwt/encoded_token.rb +++ b/lib/jwt/encoded_token.rb @@ -39,20 +39,34 @@ def payload # Initializes a new EncodedToken instance. # # @param jwt [String] the encoded JWT token. - # @param allow_duplicate_keys [Boolean] whether to allow duplicate keys in header/payload (default: true). # @raise [ArgumentError] if the provided JWT is not a String. - # @raise [JWT::DuplicateKeyError] if allow_duplicate_keys is false and duplicate keys are found. - def initialize(jwt, allow_duplicate_keys: true) + def initialize(jwt) raise ArgumentError, 'Provided JWT must be a String' unless jwt.is_a?(String) @jwt = jwt - @allow_duplicate_keys = allow_duplicate_keys + @allow_duplicate_keys = true @signature_verified = false @claims_verified = false @encoded_header, @encoded_payload, @encoded_signature = jwt.split('.') end + # Enables strict duplicate key detection for this token. + # When called, the token will raise JWT::DuplicateKeyError if duplicate keys + # are found in the header or payload during parsing. + # + # @example + # token = JWT::EncodedToken.new(jwt_string) + # token.raise_on_duplicate_keys! + # token.header # May raise JWT::DuplicateKeyError + # + # @return [self] + # @raise [JWT::DuplicateKeyError] if duplicate keys are found during subsequent parsing. + def raise_on_duplicate_keys! + @allow_duplicate_keys = false + self + end + # Returns the decoded signature of the JWT token. # # @return [String] the decoded signature. diff --git a/lib/jwt/json.rb b/lib/jwt/json.rb index 8550177f0..20edb11cb 100644 --- a/lib/jwt/json.rb +++ b/lib/jwt/json.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'json' -require 'strscan' module JWT # JSON parsing utilities with duplicate key detection support @@ -29,101 +28,12 @@ def generate(data) # JWT::JSON.parse('{"a":1,"a":2}', allow_duplicate_keys: false) # # => raises JWT::DuplicateKeyError def parse(data, allow_duplicate_keys: true) - DuplicateKeyChecker.check!(data) unless allow_duplicate_keys - ::JSON.parse(data) - end - end - - # @api private - # Checks for duplicate keys in a JSON string using a StringScanner-based tokenizer - # rubocop:disable Style/RedundantRegexpArgument - class DuplicateKeyChecker - def self.check!(json_str) - new(json_str).check! - end - - def initialize(json_str) - @scanner = StringScanner.new(json_str) - @seen_keys_stack = [[]] - @depth = 0 - @in_array_stack = [false] - end - - def check! - scan_tokens until @scanner.eos? - end - - private - - def scan_tokens # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity - skip_whitespace - return if @scanner.eos? - - if @scanner.scan(/\{/) - handle_object_start - elsif @scanner.scan(/\}/) - handle_container_end - elsif @scanner.scan(/\[/) - handle_array_start - elsif @scanner.scan(/\]/) - @depth -= 1 - elsif @scanner.scan(/,/) || @scanner.scan(/:/) - # skip comma and colon - elsif @scanner.scan(/"/) - handle_string - elsif @scanner.scan(/-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/) - # skip number - elsif @scanner.scan(/true|false|null/) - # skip literal - else - @scanner.getch - end - end - - def skip_whitespace - @scanner.scan(/\s+/) - end - - def handle_object_start - @depth += 1 - @seen_keys_stack[@depth] = [] - @in_array_stack[@depth] = false - end - - def handle_array_start - @depth += 1 - @seen_keys_stack[@depth] = [] - @in_array_stack[@depth] = true - end - - def handle_container_end - @depth -= 1 - end - - def handle_string - str = scan_string_content - check_if_key(str) - end - - def scan_string_content - str = +'' - str << (@scanner.getch || '') until @scanner.scan(/"/) - str - end - - def check_if_key(str) - return if @in_array_stack[@depth] - - pos = @scanner.pos - skip_whitespace - if @scanner.peek(1) == ':' - raise JWT::DuplicateKeyError, "Duplicate key detected: #{str}" if @seen_keys_stack[@depth].include?(str) + ::JSON.parse(data, allow_duplicate_key: allow_duplicate_keys) + rescue ::JSON::ParserError => e + raise JWT::DuplicateKeyError, e.message if e.message.include?('duplicate key') - @seen_keys_stack[@depth] << str - end - @scanner.pos = pos + raise end end - # rubocop:enable Style/RedundantRegexpArgument end end diff --git a/ruby-jwt.gemspec b/ruby-jwt.gemspec index 1c469c462..6555a8389 100644 --- a/ruby-jwt.gemspec +++ b/ruby-jwt.gemspec @@ -32,6 +32,7 @@ Gem::Specification.new do |spec| spec.require_paths = %w[lib] spec.add_dependency 'base64' + spec.add_dependency 'json', '>= 2.13.0' spec.add_development_dependency 'appraisal' spec.add_development_dependency 'bundler' diff --git a/spec/jwt/claims/duplicate_key_spec.rb b/spec/jwt/claims/duplicate_key_spec.rb index c81d7da2c..03ccbf489 100644 --- a/spec/jwt/claims/duplicate_key_spec.rb +++ b/spec/jwt/claims/duplicate_key_spec.rb @@ -25,94 +25,79 @@ def build_jwt_with_duplicate_header(duplicate_header_json, payload_json = '{"sub "#{signing_input}.#{signature}" end - describe 'payload with duplicate keys' do - let(:duplicate_payload_jwt) { build_jwt_with_duplicate_payload('{"sub":"user","sub":"admin"}') } - - context 'with default configuration' do - it 'uses the last value (backward compatible)' do - payload, = JWT.decode(duplicate_payload_jwt, secret, true, algorithm: algorithm) - expect(payload['sub']).to eq('admin') - end - end - - context 'with allow_duplicate_keys: true' do - it 'uses the last value' do - payload, = JWT.decode(duplicate_payload_jwt, secret, true, algorithm: algorithm, allow_duplicate_keys: true) - expect(payload['sub']).to eq('admin') + describe 'using EncodedToken API' do + describe 'payload with duplicate keys' do + let(:duplicate_payload_jwt) { build_jwt_with_duplicate_payload('{"sub":"user","sub":"admin"}') } + + context 'with default behavior' do + it 'uses the last value (allows duplicates)' do + token = JWT::EncodedToken.new(duplicate_payload_jwt) + expect(token.unverified_payload['sub']).to eq('admin') + end end - end - context 'with allow_duplicate_keys: false' do - it 'raises DuplicateKeyError' do - expect do - JWT.decode(duplicate_payload_jwt, secret, true, algorithm: algorithm, allow_duplicate_keys: false) - end.to raise_error(JWT::DuplicateKeyError, /Duplicate key detected: sub/) + context 'with raise_on_duplicate_keys!' do + it 'raises DuplicateKeyError' do + token = JWT::EncodedToken.new(duplicate_payload_jwt) + token.raise_on_duplicate_keys! + expect do + token.unverified_payload + end.to raise_error(JWT::DuplicateKeyError, /duplicate key/) + end end end - end - describe 'header with duplicate keys' do - let(:duplicate_header_jwt) { build_jwt_with_duplicate_header('{"alg":"HS256","alg":"none"}') } + describe 'header with duplicate keys' do + let(:duplicate_header_jwt) { build_jwt_with_duplicate_header('{"alg":"HS256","alg":"none"}') } - context 'with default configuration' do - it 'uses the last value (backward compatible)' do - _, header = JWT.decode(duplicate_header_jwt, nil, false) - expect(header['alg']).to eq('none') + context 'with default behavior' do + it 'uses the last value (allows duplicates)' do + token = JWT::EncodedToken.new(duplicate_header_jwt) + expect(token.header['alg']).to eq('none') + end end - end - context 'with allow_duplicate_keys: false' do - it 'raises DuplicateKeyError for header' do - expect do - JWT.decode(duplicate_header_jwt, nil, false, allow_duplicate_keys: false) - end.to raise_error(JWT::DuplicateKeyError, /Duplicate key detected: alg/) + context 'with raise_on_duplicate_keys!' do + it 'raises DuplicateKeyError for header' do + token = JWT::EncodedToken.new(duplicate_header_jwt) + token.raise_on_duplicate_keys! + expect do + token.header + end.to raise_error(JWT::DuplicateKeyError, /duplicate key/) + end end end - end - - describe 'global configuration' do - around do |example| - original = JWT.configuration.decode.allow_duplicate_keys - example.run - JWT.configuration.decode.allow_duplicate_keys = original - end - let(:duplicate_payload_jwt) { build_jwt_with_duplicate_payload('{"sub":"user","sub":"admin"}') } + describe 'chaining' do + let(:valid_jwt) { build_jwt_with_duplicate_payload('{"sub":"user"}') } - it 'respects global configuration when set to false' do - JWT.configuration.decode.allow_duplicate_keys = false - - expect do - JWT.decode(duplicate_payload_jwt, secret, true, algorithm: algorithm) - end.to raise_error(JWT::DuplicateKeyError) + it 'returns self for method chaining' do + token = JWT::EncodedToken.new(valid_jwt) + expect(token.raise_on_duplicate_keys!).to eq(token) + end end - it 'allows per-decode override of global configuration' do - JWT.configuration.decode.allow_duplicate_keys = false - - payload, = JWT.decode( - duplicate_payload_jwt, - secret, - true, - algorithm: algorithm, - allow_duplicate_keys: true - ) - expect(payload['sub']).to eq('admin') - end + describe 'valid tokens' do + let(:valid_jwt) { build_jwt_with_duplicate_payload('{"sub":"user","name":"John"}') } - it 'defaults to allowing duplicate keys' do - expect(JWT.configuration.decode.allow_duplicate_keys).to be(true) + it 'parses valid JSON without duplicates' do + token = JWT::EncodedToken.new(valid_jwt) + token.raise_on_duplicate_keys! + expect(token.unverified_payload).to eq({ 'sub' => 'user', 'name' => 'John' }) + end end end describe 'multiple duplicate keys' do let(:multiple_duplicates_jwt) { build_jwt_with_duplicate_payload('{"a":1,"b":2,"a":3,"b":4}') } - context 'with allow_duplicate_keys: false' do + context 'with raise_on_duplicate_keys!' do it 'raises DuplicateKeyError for the first duplicate found' do + token = JWT::EncodedToken.new(multiple_duplicates_jwt) + token.raise_on_duplicate_keys! expect do - JWT.decode(multiple_duplicates_jwt, secret, true, algorithm: algorithm, allow_duplicate_keys: false) - end.to raise_error(JWT::DuplicateKeyError, /Duplicate key detected: a/) + token.unverified_payload + end.to raise_error(JWT::DuplicateKeyError, /duplicate key/) end end end diff --git a/spec/jwt/json_spec.rb b/spec/jwt/json_spec.rb index 8f2b7b36b..a2d81534e 100644 --- a/spec/jwt/json_spec.rb +++ b/spec/jwt/json_spec.rb @@ -24,7 +24,7 @@ it 'raises DuplicateKeyError for duplicate keys' do expect do described_class.parse('{"a":1,"a":2}', allow_duplicate_keys: false) - end.to raise_error(JWT::DuplicateKeyError, /Duplicate key detected: a/) + end.to raise_error(JWT::DuplicateKeyError, /duplicate key/) end it 'parses valid JSON without duplicates' do @@ -36,7 +36,7 @@ json = '{"outer":{"inner":1,"inner":2}}' expect do described_class.parse(json, allow_duplicate_keys: false) - end.to raise_error(JWT::DuplicateKeyError, /Duplicate key detected: inner/) + end.to raise_error(JWT::DuplicateKeyError, /duplicate key/) end it 'allows same key in different objects' do From 11450ac096ab20a4b660c9f5769bb35e35b20c4a Mon Sep 17 00:00:00 2001 From: ydah Date: Fri, 30 Jan 2026 18:59:04 +0900 Subject: [PATCH 3/3] Update Ruby version requirements and modify CI configurations --- .github/workflows/test.yml | 6 ------ .rubocop.yml | 2 +- CHANGELOG.md | 4 ++++ lib/jwt/jwa/ecdsa.rb | 2 +- ruby-jwt.gemspec | 2 +- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ee4eb119..bf8f52f75 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,8 +33,6 @@ jobs: os: - ubuntu-latest ruby: - - "2.5" - - "2.6" - "2.7" - "3.0" - "3.1" @@ -46,10 +44,6 @@ jobs: - gemfiles/standalone.gemfile experimental: [false] include: - - os: ubuntu-latest - ruby: "2.5" - gemfile: gemfiles/openssl.gemfile - experimental: false - os: ubuntu-latest ruby: "truffleruby-head" gemfile: "gemfiles/standalone.gemfile" diff --git a/.rubocop.yml b/.rubocop.yml index 8093a01d4..a8ec975c0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,5 @@ AllCops: - TargetRubyVersion: 2.5 + TargetRubyVersion: 2.7 NewCops: enable SuggestExtensions: false Exclude: diff --git a/CHANGELOG.md b/CHANGELOG.md index 696481813..9369f4b72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ [Full Changelog](https://github.com/jwt/ruby-jwt/compare/v3.1.2...v3.1.3) +**Breaking changes:** +- Drop support for Ruby 2.6 and older [#713](https://github.com/jwt/ruby-jwt/pull/713) ([@ydah](https://github.com/ydah)) +- Bump minimum json gem version to 2.6 [#713](https://github.com/jwt/ruby-jwt/pull/713) ([@ydah](https://github.com/ydah)) + **Features:** - Add duplicate claim name detection per RFC 7519 Section 4 [#713](https://github.com/jwt/ruby-jwt/pull/713) ([@ydah](https://github.com/ydah)) diff --git a/lib/jwt/jwa/ecdsa.rb b/lib/jwt/jwa/ecdsa.rb index 9840621f7..33d6159bf 100644 --- a/lib/jwt/jwa/ecdsa.rb +++ b/lib/jwt/jwa/ecdsa.rb @@ -98,7 +98,7 @@ def curve_by_name(name) def raw_to_asn1(signature, private_key) byte_size = (private_key.group.degree + 7) / 8 sig_bytes = signature[0..(byte_size - 1)] - sig_char = signature[byte_size..-1] || '' + sig_char = signature[byte_size..] || '' OpenSSL::ASN1::Sequence.new([sig_bytes, sig_char].map { |int| OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(int, 2)) }).to_der end diff --git a/ruby-jwt.gemspec b/ruby-jwt.gemspec index 6555a8389..f755b3d9c 100644 --- a/ruby-jwt.gemspec +++ b/ruby-jwt.gemspec @@ -15,7 +15,7 @@ Gem::Specification.new do |spec| spec.description = 'A pure ruby implementation of the RFC 7519 OAuth JSON Web Token (JWT) standard.' spec.homepage = 'https://github.com/jwt/ruby-jwt' spec.license = 'MIT' - spec.required_ruby_version = '>= 2.5' + spec.required_ruby_version = '>= 2.7' spec.metadata = { 'bug_tracker_uri' => 'https://github.com/jwt/ruby-jwt/issues', 'changelog_uri' => "https://github.com/jwt/ruby-jwt/blob/v#{JWT.gem_version}/CHANGELOG.md",