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: 0 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,6 @@ jobs:
os:
- ubuntu-latest
ruby:
- "2.5"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

MEMO: We added json gem >= 2.13.0 to the dependencies, but this version requires Ruby 2.7+. Since ruby-jwt has required_ruby_version = ‘>= 2.5’, dependency resolution fails during bundle install in a Ruby 2.5 environment.

- "2.6"
- "2.7"
- "3.0"
- "3.1"
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
AllCops:
TargetRubyVersion: 2.5
TargetRubyVersion: 2.7
NewCops: enable
SuggestExtensions: false
Exclude:
Expand Down
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
21 changes: 19 additions & 2 deletions lib/jwt/encoded_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,12 +44,29 @@ 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

@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.
Expand Down Expand Up @@ -224,7 +241,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
Expand Down
4 changes: 4 additions & 0 deletions lib/jwt/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 23 additions & 2 deletions lib/jwt/json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,36 @@
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

def parse(data)
::JSON.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)
::JSON.parse(data, allow_duplicate_key: allow_duplicate_keys)
rescue ::JSON::ParserError => e
raise JWT::DuplicateKeyError, e.message if e.message.include?('duplicate key')

raise
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/jwt/jwa/ecdsa.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion ruby-jwt.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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'
Expand Down
104 changes: 104 additions & 0 deletions spec/jwt/claims/duplicate_key_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# 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' 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

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' 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

describe 'chaining' 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' 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' 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
end
end
end
50 changes: 50 additions & 0 deletions spec/jwt/json_spec.rb
Original file line number Diff line number Diff line change
@@ -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/)
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
end
end
Loading