Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
141 changes: 54 additions & 87 deletions lib/jwt/encoded_token.rb
Original file line number Diff line number Diff line change
@@ -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
#
Expand All @@ -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.
Expand All @@ -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
Copy link
Member

@anakinj anakinj Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we here detect if the JSON gem is supporting the feature and raise if we are not compatible? Would allow us to support older Ruby versions still.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds good! I modified it that way. WDYT?

@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('.')
Expand All @@ -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<Symbol>, 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.
Expand All @@ -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<Symbol>, 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
Expand All @@ -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
21 changes: 21 additions & 0 deletions lib/jwt/encoded_token/claims_context.rb
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions lib/jwt/encoded_token/segment_parser.rb
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions lib/jwt/encoded_token/signature_verifier.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions lib/jwt/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 15 additions & 2 deletions lib/jwt/json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading