diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a0d71fa..5471896a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,13 @@ [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:** -- 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 0e8122ed..775665f0 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,28 @@ 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 using the `EncodedToken` API. + +### Using EncodedToken API + +```ruby +# Enable strict duplicate key detection +token = JWT::EncodedToken.new(jwt_string) +token.raise_on_duplicate_keys! + +begin + token.verify_signature!(algorithm: 'HS256', key: secret) + token.verify_claims! + token.payload +rescue JWT::DuplicateKeyError => e + puts "Duplicate key detected: #{e.message}" +end +``` + +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/encoded_token.rb b/lib/jwt/encoded_token.rb index cbaec1c8..e0b8f1bc 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 # @@ -12,30 +16,25 @@ module JWT # encoded_token.verify_signature!(algorithm: 'HS256', key: 'secret') # encoded_token.payload # => {'pay' => 'load'} 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 - 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. @@ -44,60 +43,61 @@ def initialize(jwt) raise ArgumentError, 'Provided JWT must be a String' unless jwt.is_a?(String) @jwt = jwt + @allow_duplicate_keys = true @signature_verified = false - @claims_verified = false - + @claims_verified = false @encoded_header, @encoded_payload, @encoded_signature = jwt.split('.') end - # Returns the decoded signature of the JWT token. + # 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. + # @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 + @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('.') @@ -121,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. @@ -151,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 @@ -195,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) - 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 00000000..80d3ff53 --- /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 00000000..bcbac4df --- /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 00000000..a92e2644 --- /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 diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb index 2a0f8a2c..9d0da4a8 100644 --- a/lib/jwt/error.rb +++ b/lib/jwt/error.rb @@ -51,4 +51,11 @@ 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 + + # 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 90ae4585..99188187 100644 --- a/lib/jwt/json.rb +++ b/lib/jwt/json.rb @@ -10,8 +10,21 @@ def generate(data) ::JSON.generate(data) end - def parse(data) - ::JSON.parse(data) + def parse(data, allow_duplicate_keys: true) + options = {} + options[:allow_duplicate_key] = false if !allow_duplicate_keys && supports_duplicate_key_detection? + + ::JSON.parse(data, options) + rescue ::JSON::ParserError => e + raise JWT::DuplicateKeyError, e.message if e.message.include?('duplicate key') + + raise + end + + 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 diff --git a/spec/jwt/claims/duplicate_key_spec.rb b/spec/jwt/claims/duplicate_key_spec.rb new file mode 100644 index 00000000..c8f5efd5 --- /dev/null +++ b/spec/jwt/claims/duplicate_key_spec.rb @@ -0,0 +1,125 @@ +# 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 '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 + + context 'with raise_on_duplicate_keys!' 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 + + describe 'header with duplicate keys' do + let(:duplicate_header_jwt) { build_jwt_with_duplicate_header('{"alg":"HS256","alg":"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 + + context 'with raise_on_duplicate_keys!' 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', 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 + token = JWT::EncodedToken.new(valid_jwt) + expect(token.raise_on_duplicate_keys!).to eq(token) + end + end + + 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 + 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 raise_on_duplicate_keys!' 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 new file mode 100644 index 00000000..84800d71 --- /dev/null +++ b/spec/jwt/json_spec.rb @@ -0,0 +1,59 @@ +# 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 + 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 '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 '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 + + 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 +end