From 1a575483ca2053708142416caf3b896f6a366a61 Mon Sep 17 00:00:00 2001 From: ydah Date: Thu, 29 Jan 2026 22:12:16 +0900 Subject: [PATCH 01/10] 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 2a0d71faa..5db82e507 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 798b92ec44b797eb92078497f11bf6365ee9e3db Mon Sep 17 00:00:00 2001 From: ydah Date: Fri, 30 Jan 2026 18:49:14 +0900 Subject: [PATCH 02/10] 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 bb1f74c0d00a2766fe41f4c498b62ccfb89e46e4 Mon Sep 17 00:00:00 2001 From: ydah Date: Fri, 30 Jan 2026 18:59:04 +0900 Subject: [PATCH 03/10] 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 5db82e507..5471896a6 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", From 53286981c1fd0b48ba9978da68756f4e7ae469be Mon Sep 17 00:00:00 2001 From: ydah Date: Wed, 4 Feb 2026 23:23:39 +0900 Subject: [PATCH 04/10] Lower minimum Ruby version to 2.5 and make JSON duplicate key detection a runtime feature check instead of a hard dependency on json >= 2.13.0 --- .github/workflows/test.yml | 2 ++ .rubocop.yml | 2 +- lib/jwt/json.rb | 16 +++++++++++++++- ruby-jwt.gemspec | 4 ++-- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bf8f52f75..ce4a97fba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,8 @@ jobs: os: - ubuntu-latest ruby: + - "2.5" + - "2.6" - "2.7" - "3.0" - "3.1" diff --git a/.rubocop.yml b/.rubocop.yml index a8ec975c0..8093a01d4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,5 @@ AllCops: - TargetRubyVersion: 2.7 + TargetRubyVersion: 2.5 NewCops: enable SuggestExtensions: false Exclude: diff --git a/lib/jwt/json.rb b/lib/jwt/json.rb index 20edb11cb..a7ccf24d8 100644 --- a/lib/jwt/json.rb +++ b/lib/jwt/json.rb @@ -28,12 +28,26 @@ def generate(data) # JWT::JSON.parse('{"a":1,"a":2}', allow_duplicate_keys: false) # # => raises JWT::DuplicateKeyError def parse(data, allow_duplicate_keys: true) - ::JSON.parse(data, allow_duplicate_key: allow_duplicate_keys) + return ::JSON.parse(data) if allow_duplicate_keys + + if supports_duplicate_key_detection? + ::JSON.parse(data, allow_duplicate_key: false) + else + ::JSON.parse(data) + end rescue ::JSON::ParserError => e raise JWT::DuplicateKeyError, e.message if e.message.include?('duplicate key') raise end + + private + + def supports_duplicate_key_detection? + return @supports_duplicate_key_detection if defined?(@supports_duplicate_key_detection) + + @supports_duplicate_key_detection = Gem::Version.new(::JSON::VERSION) >= Gem::Version.new('2.13.0') + end end end end diff --git a/ruby-jwt.gemspec b/ruby-jwt.gemspec index f755b3d9c..1250505b0 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.7' + spec.required_ruby_version = '>= 2.5' 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", @@ -32,7 +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_dependency 'json' spec.add_development_dependency 'appraisal' spec.add_development_dependency 'bundler' From 9e357f2f1af0c1816a710288bba906c030fef90a Mon Sep 17 00:00:00 2001 From: ydah Date: Wed, 4 Feb 2026 23:25:20 +0900 Subject: [PATCH 05/10] Remove verbose YARD documentation comments from JWT::JSON class --- lib/jwt/json.rb | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/lib/jwt/json.rb b/lib/jwt/json.rb index a7ccf24d8..f7877a190 100644 --- a/lib/jwt/json.rb +++ b/lib/jwt/json.rb @@ -3,30 +3,13 @@ require 'json' 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 - # 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) return ::JSON.parse(data) if allow_duplicate_keys From 730ebdc70ba381ea0d2b68071ca589d339231043 Mon Sep 17 00:00:00 2001 From: ydah Date: Wed, 4 Feb 2026 23:26:50 +0900 Subject: [PATCH 06/10] Simplify JSON parse logic by consolidating duplicate branching into a single call with conditional options --- lib/jwt/json.rb | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/jwt/json.rb b/lib/jwt/json.rb index f7877a190..dc186204e 100644 --- a/lib/jwt/json.rb +++ b/lib/jwt/json.rb @@ -11,13 +11,10 @@ def generate(data) end def parse(data, allow_duplicate_keys: true) - return ::JSON.parse(data) if allow_duplicate_keys + options = {} + options[:allow_duplicate_key] = false if !allow_duplicate_keys && supports_duplicate_key_detection? - if supports_duplicate_key_detection? - ::JSON.parse(data, allow_duplicate_key: false) - else - ::JSON.parse(data) - end + ::JSON.parse(data, options) rescue ::JSON::ParserError => e raise JWT::DuplicateKeyError, e.message if e.message.include?('duplicate key') From 22db63a24e0081e6db92eca16a36f9e0a48076a4 Mon Sep 17 00:00:00 2001 From: ydah Date: Thu, 5 Feb 2026 07:16:54 +0900 Subject: [PATCH 07/10] Add UnsupportedError for unsupported features and update raise_on_duplicate_keys! method --- lib/jwt/encoded_token.rb | 3 +++ lib/jwt/error.rb | 3 +++ lib/jwt/json.rb | 2 -- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/jwt/encoded_token.rb b/lib/jwt/encoded_token.rb index de50bd6bb..95103ba09 100644 --- a/lib/jwt/encoded_token.rb +++ b/lib/jwt/encoded_token.rb @@ -62,7 +62,10 @@ def initialize(jwt) # # @return [self] # @raise [JWT::DuplicateKeyError] if duplicate keys are found during subsequent parsing. + # @raise [JWT::UnsupportedError] if the JSON gem version does not support duplicate key detection. def raise_on_duplicate_keys! + raise JWT::UnsupportedError, 'Duplicate key detection requires JSON gem >= 2.13.0' unless JSON.supports_duplicate_key_detection? + @allow_duplicate_keys = false self end diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb index a6bbf4a0d..9d0da4a8a 100644 --- a/lib/jwt/error.rb +++ b/lib/jwt/error.rb @@ -55,4 +55,7 @@ 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 + + # The UnsupportedError class is raised when a feature is not supported by the current environment. + class UnsupportedError < StandardError; end end diff --git a/lib/jwt/json.rb b/lib/jwt/json.rb index dc186204e..99188187e 100644 --- a/lib/jwt/json.rb +++ b/lib/jwt/json.rb @@ -21,8 +21,6 @@ def parse(data, allow_duplicate_keys: true) raise end - private - def supports_duplicate_key_detection? return @supports_duplicate_key_detection if defined?(@supports_duplicate_key_detection) From f3365e60193cb953e72e9b491b2553f67bb2ead1 Mon Sep 17 00:00:00 2001 From: ydah Date: Thu, 5 Feb 2026 07:26:00 +0900 Subject: [PATCH 08/10] Extract EncodedToken internals into ClaimsContext, SegmentParser, and SignatureVerifier classes to reduce class complexity --- lib/jwt/encoded_token.rb | 123 ++++++-------------- lib/jwt/encoded_token/claims_context.rb | 21 ++++ lib/jwt/encoded_token/segment_parser.rb | 27 +++++ lib/jwt/encoded_token/signature_verifier.rb | 24 ++++ 4 files changed, 107 insertions(+), 88 deletions(-) create mode 100644 lib/jwt/encoded_token/claims_context.rb create mode 100644 lib/jwt/encoded_token/segment_parser.rb create mode 100644 lib/jwt/encoded_token/signature_verifier.rb diff --git a/lib/jwt/encoded_token.rb b/lib/jwt/encoded_token.rb index 95103ba09..e0b8f1bc4 100644 --- a/lib/jwt/encoded_token.rb +++ b/lib/jwt/encoded_token.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true +require_relative 'encoded_token/claims_context' +require_relative 'encoded_token/segment_parser' +require_relative 'encoded_token/signature_verifier' + module JWT # Represents an encoded JWT token # @@ -11,31 +15,26 @@ module JWT # encoded_token = JWT::EncodedToken.new(token.jwt) # encoded_token.verify_signature!(algorithm: 'HS256', key: 'secret') # encoded_token.payload # => {'pay' => 'load'} - class EncodedToken # rubocop:disable Metrics/ClassLength - # @private - # Allow access to the unverified payload for claim verification. - class ClaimsContext - extend Forwardable - - def_delegators :@token, :header, :unverified_payload - - def initialize(token) - @token = token - end - - def payload - unverified_payload - end - end - + class EncodedToken DEFAULT_CLAIMS = [:exp].freeze - private_constant(:DEFAULT_CLAIMS) # Returns the original token provided to the class. # @return [String] The JWT token. attr_reader :jwt + # Returns the encoded signature of the JWT token. + # @return [String] the encoded signature. + attr_reader :encoded_signature + + # Returns the encoded header of the JWT token. + # @return [String] the encoded header. + attr_reader :encoded_header + + # Sets or returns the encoded payload of the JWT token. + # @return [String] the encoded payload. + attr_accessor :encoded_payload + # Initializes a new EncodedToken instance. # # @param jwt [String] the encoded JWT token. @@ -46,8 +45,7 @@ def initialize(jwt) @jwt = jwt @allow_duplicate_keys = true @signature_verified = false - @claims_verified = false - + @claims_verified = false @encoded_header, @encoded_payload, @encoded_signature = jwt.split('.') end @@ -67,57 +65,39 @@ def raise_on_duplicate_keys! raise JWT::UnsupportedError, 'Duplicate key detection requires JSON gem >= 2.13.0' unless JSON.supports_duplicate_key_detection? @allow_duplicate_keys = false + @parser = nil self end # Returns the decoded signature of the JWT token. - # # @return [String] the decoded signature. def signature @signature ||= ::JWT::Base64.url_decode(encoded_signature || '') end - # Returns the encoded signature of the JWT token. - # - # @return [String] the encoded signature. - attr_reader :encoded_signature - # Returns the decoded header of the JWT token. - # # @return [Hash] the header. def header - @header ||= parse_and_decode(@encoded_header) + @header ||= parser.parse_and_decode(@encoded_header) end - # Returns the encoded header of the JWT token. - # - # @return [String] the encoded header. - attr_reader :encoded_header - # Returns the payload of the JWT token. Access requires the signature and claims to have been verified. - # # @return [Hash] the payload. - # @raise [JWT::DecodeError] if the signature has not been verified. + # @raise [JWT::DecodeError] if the signature or claims have not been verified. def payload raise JWT::DecodeError, 'Verify the token signature before accessing the payload' unless @signature_verified raise JWT::DecodeError, 'Verify the token claims before accessing the payload' unless @claims_verified - decoded_payload + unverified_payload end # Returns the payload of the JWT token without requiring the signature to have been verified. # @return [Hash] the payload. def unverified_payload - decoded_payload + @unverified_payload ||= decode_payload end - # Sets or returns the encoded payload of the JWT token. - # - # @return [String] the encoded payload. - attr_accessor :encoded_payload - # Returns the signing input of the JWT token. - # # @return [String] the signing input. def signing_input [encoded_header, encoded_payload].join('.') @@ -141,13 +121,12 @@ def verify!(signature:, claims: nil) # Verifies the token signature and claims. # By default it verifies the 'exp' claim. - + # # @param signature [Hash] the parameters for signature verification (see {#verify_signature!}). # @param claims [Array, Hash] the claims to verify (see {#verify_claims!}). # @return [Boolean] true if the signature and claims are valid, false otherwise. def valid?(signature:, claims: nil) - valid_signature?(**signature) && - (claims.is_a?(Array) ? valid_claims?(*claims) : valid_claims?(claims)) + valid_signature?(**signature) && (claims.is_a?(Array) ? valid_claims?(*claims) : valid_claims?(claims)) end # Verifies the signature of the JWT token. @@ -171,26 +150,17 @@ def verify_signature!(algorithm:, key: nil, key_finder: nil) # @param key_finder [#call] an object responding to `call` to find the key for verification. # @return [Boolean] true if the signature is valid, false otherwise. def valid_signature?(algorithm: nil, key: nil, key_finder: nil) - raise ArgumentError, 'Provide either key or key_finder, not both or neither' if key.nil? == key_finder.nil? - - keys = Array(key || key_finder.call(self)) - verifiers = JWA.create_verifiers(algorithms: algorithm, keys: keys, preferred_algorithm: header['alg']) - - raise JWT::VerificationError, 'No algorithm provided' if verifiers.empty? - - valid = verifiers.any? do |jwa| - jwa.verify(data: signing_input, signature: signature) + SignatureVerifier.new(self).verify(algorithm: algorithm, key: key, key_finder: key_finder).tap do |valid| + @signature_verified = valid end - valid.tap { |verified| @signature_verified = verified } end # Verifies the claims of the token. # @param options [Array, Hash] the claims to verify. By default, it checks the 'exp' claim. + # @return [nil] # @raise [JWT::DecodeError] if the claims are invalid. def verify_claims!(*options) - Claims::Verifier.verify!(ClaimsContext.new(self), *claims_options(options)).tap do - @claims_verified = true - end + Claims::Verifier.verify!(ClaimsContext.new(self), *claims_options(options)).tap { @claims_verified = true } rescue StandardError @claims_verified = false raise @@ -215,42 +185,19 @@ def valid_claims?(*options) private def claims_options(options) - return DEFAULT_CLAIMS if options.first.nil? + options.first.nil? ? DEFAULT_CLAIMS : options + end - options + def parser + @parser ||= SegmentParser.new(allow_duplicate_keys: @allow_duplicate_keys) end def decode_payload raise JWT::DecodeError, 'Encoded payload is empty' if encoded_payload == '' - if unencoded_payload? - verify_claims!(crit: ['b64']) - return parse_unencoded(encoded_payload) - end - - parse_and_decode(encoded_payload) - end - - def unencoded_payload? - header['b64'] == false - end - - def parse_and_decode(segment) - parse(::JWT::Base64.url_decode(segment || '')) - end - - def parse_unencoded(segment) - parse(segment) - end - - def parse(segment) - JWT::JSON.parse(segment, allow_duplicate_keys: @allow_duplicate_keys) - rescue ::JSON::ParserError - raise JWT::DecodeError, 'Invalid segment encoding' - end + return parser.parse_unencoded(encoded_payload).tap { verify_claims!(crit: ['b64']) } if header['b64'] == false - def decoded_payload - @decoded_payload ||= decode_payload + parser.parse_and_decode(encoded_payload) end end end diff --git a/lib/jwt/encoded_token/claims_context.rb b/lib/jwt/encoded_token/claims_context.rb new file mode 100644 index 000000000..80d3ff534 --- /dev/null +++ b/lib/jwt/encoded_token/claims_context.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module JWT + class EncodedToken + # @private + # Allow access to the unverified payload for claim verification. + class ClaimsContext + extend Forwardable + + def_delegators :@token, :header, :unverified_payload + + def initialize(token) + @token = token + end + + def payload + unverified_payload + end + end + end +end diff --git a/lib/jwt/encoded_token/segment_parser.rb b/lib/jwt/encoded_token/segment_parser.rb new file mode 100644 index 000000000..bcbac4df5 --- /dev/null +++ b/lib/jwt/encoded_token/segment_parser.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module JWT + class EncodedToken + # @private + # Handles segment parsing and duplicate key detection. + class SegmentParser + def initialize(allow_duplicate_keys:) + @allow_duplicate_keys = allow_duplicate_keys + end + + def parse_and_decode(segment) + parse(::JWT::Base64.url_decode(segment || '')) + end + + def parse_unencoded(segment) + parse(segment) + end + + def parse(segment) + JWT::JSON.parse(segment, allow_duplicate_keys: @allow_duplicate_keys) + rescue ::JSON::ParserError + raise JWT::DecodeError, 'Invalid segment encoding' + end + end + end +end diff --git a/lib/jwt/encoded_token/signature_verifier.rb b/lib/jwt/encoded_token/signature_verifier.rb new file mode 100644 index 000000000..a92e26440 --- /dev/null +++ b/lib/jwt/encoded_token/signature_verifier.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module JWT + class EncodedToken + # @private + # Handles signature verification logic. + class SignatureVerifier + def initialize(token) + @token = token + end + + def verify(algorithm:, key: nil, key_finder: nil) + raise ArgumentError, 'Provide either key or key_finder, not both or neither' if key.nil? == key_finder.nil? + + keys = Array(key || key_finder.call(@token)) + verifiers = JWA.create_verifiers(algorithms: algorithm, keys: keys, preferred_algorithm: @token.header['alg']) + + raise JWT::VerificationError, 'No algorithm provided' if verifiers.empty? + + verifiers.any? { |jwa| jwa.verify(data: @token.signing_input, signature: @token.signature) } + end + end + end +end From befe5a8463111d83e890d99cc311b58f404bfe08 Mon Sep 17 00:00:00 2001 From: ydah Date: Thu, 5 Feb 2026 07:26:12 +0900 Subject: [PATCH 09/10] Revert drop 2.5, 2.6 support --- .github/workflows/test.yml | 4 ++++ lib/jwt/jwa/ecdsa.rb | 2 +- ruby-jwt.gemspec | 1 - 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce4a97fba..2ee4eb119 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,6 +46,10 @@ 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/lib/jwt/jwa/ecdsa.rb b/lib/jwt/jwa/ecdsa.rb index 33d6159bf..9840621f7 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..] || '' + sig_char = signature[byte_size..-1] || '' 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 1250505b0..1c469c462 100644 --- a/ruby-jwt.gemspec +++ b/ruby-jwt.gemspec @@ -32,7 +32,6 @@ Gem::Specification.new do |spec| spec.require_paths = %w[lib] spec.add_dependency 'base64' - spec.add_dependency 'json' spec.add_development_dependency 'appraisal' spec.add_development_dependency 'bundler' From ad53ab77e5cdf3b7214530642c77d0f9b10dc973 Mon Sep 17 00:00:00 2001 From: ydah Date: Thu, 5 Feb 2026 07:32:19 +0900 Subject: [PATCH 10/10] Add conditional guards for duplicate key detection specs based on JSON gem version support --- spec/jwt/claims/duplicate_key_spec.rb | 31 +++++++++++++++--- spec/jwt/json_spec.rb | 47 ++++++++++++++++----------- 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/spec/jwt/claims/duplicate_key_spec.rb b/spec/jwt/claims/duplicate_key_spec.rb index 03ccbf489..c8f5efd5d 100644 --- a/spec/jwt/claims/duplicate_key_spec.rb +++ b/spec/jwt/claims/duplicate_key_spec.rb @@ -37,13 +37,20 @@ def build_jwt_with_duplicate_header(duplicate_header_json, payload_json = '{"sub end context 'with raise_on_duplicate_keys!' do - it 'raises DuplicateKeyError' do + it 'raises DuplicateKeyError', if: JWT::JSON.supports_duplicate_key_detection? 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 + + it 'raises UnsupportedError', unless: JWT::JSON.supports_duplicate_key_detection? do + token = JWT::EncodedToken.new(duplicate_payload_jwt) + expect do + token.raise_on_duplicate_keys! + end.to raise_error(JWT::UnsupportedError, /JSON gem >= 2\.13\.0/) + end end end @@ -58,17 +65,24 @@ def build_jwt_with_duplicate_header(duplicate_header_json, payload_json = '{"sub end context 'with raise_on_duplicate_keys!' do - it 'raises DuplicateKeyError for header' do + it 'raises DuplicateKeyError for header', if: JWT::JSON.supports_duplicate_key_detection? 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 + + it 'raises UnsupportedError', unless: JWT::JSON.supports_duplicate_key_detection? do + token = JWT::EncodedToken.new(duplicate_header_jwt) + expect do + token.raise_on_duplicate_keys! + end.to raise_error(JWT::UnsupportedError, /JSON gem >= 2\.13\.0/) + end end end - describe 'chaining' do + describe 'chaining', if: JWT::JSON.supports_duplicate_key_detection? do let(:valid_jwt) { build_jwt_with_duplicate_payload('{"sub":"user"}') } it 'returns self for method chaining' do @@ -77,7 +91,7 @@ def build_jwt_with_duplicate_header(duplicate_header_json, payload_json = '{"sub end end - describe 'valid tokens' do + describe 'valid tokens', if: JWT::JSON.supports_duplicate_key_detection? do let(:valid_jwt) { build_jwt_with_duplicate_payload('{"sub":"user","name":"John"}') } it 'parses valid JSON without duplicates' do @@ -92,13 +106,20 @@ def build_jwt_with_duplicate_header(duplicate_header_json, payload_json = '{"sub let(:multiple_duplicates_jwt) { build_jwt_with_duplicate_payload('{"a":1,"b":2,"a":3,"b":4}') } context 'with raise_on_duplicate_keys!' do - it 'raises DuplicateKeyError for the first duplicate found' do + it 'raises DuplicateKeyError for the first duplicate found', if: JWT::JSON.supports_duplicate_key_detection? do token = JWT::EncodedToken.new(multiple_duplicates_jwt) token.raise_on_duplicate_keys! expect do token.unverified_payload end.to raise_error(JWT::DuplicateKeyError, /duplicate key/) end + + it 'raises UnsupportedError', unless: JWT::JSON.supports_duplicate_key_detection? do + token = JWT::EncodedToken.new(multiple_duplicates_jwt) + expect do + token.raise_on_duplicate_keys! + end.to raise_error(JWT::UnsupportedError, /JSON gem >= 2\.13\.0/) + end end end end diff --git a/spec/jwt/json_spec.rb b/spec/jwt/json_spec.rb index a2d81534e..84800d711 100644 --- a/spec/jwt/json_spec.rb +++ b/spec/jwt/json_spec.rb @@ -21,29 +21,38 @@ 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/) - end + context 'when JSON gem supports duplicate key detection', if: JWT::JSON.supports_duplicate_key_detection? 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/) + 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 '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/) + 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/) + 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 - 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) + context 'when JSON gem does not support duplicate key detection', unless: JWT::JSON.supports_duplicate_key_detection? do + it 'silently allows duplicate keys (uses last value)' do + result = described_class.parse('{"a":1,"a":2}', allow_duplicate_keys: false) + expect(result['a']).to eq(2) + end end end end