Skip to content

Commit 98d238b

Browse files
hieuk09anakinj
andauthored
Add support for x5t and x5t#S256 header (#669)
* Add support for x5t and x5t#S256 header * Update documentation * Handle x5t using JWK key finder * Update documentation and add tests * Add `include_` prefix to x5t option for consistency * Do not handle x5c via JWK * Do not include x5t when export JWK from RSA * Utilize key_fields instead of manually defining key matching order * Remove unused method and update README --------- Co-authored-by: Joakim Antman <[email protected]>
1 parent ddea2b1 commit 98d238b

File tree

6 files changed

+59
-15
lines changed

6 files changed

+59
-15
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
**Features:**
88

9+
- Add support for x5t header parameter for X.509 certificate thumbprint verification [#669](https://github.com/jwt/ruby-jwt/pull/669) ([@hieuk09](https://github.com/hieuk09))
910
- Your contribution here
1011

1112
**Fixes and enhancements:**

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -644,13 +644,14 @@ algorithms = jwks.map { |key| key[:alg] }.compact.uniq
644644
JWT.decode(token, nil, true, algorithms: algorithms, jwks: jwks)
645645
```
646646

647-
The `jwks` option can also be given as a lambda that evaluates every time a kid is resolved.
647+
The `jwks` option can also be given as a lambda that evaluates every time a key identifier is resolved.
648648
This can be used to implement caching of remotely fetched JWK Sets.
649649

650-
If the requested `kid` is not found from the given set the loader will be called a second time with the `kid_not_found` option set to `true`.
650+
Key identifiers can be specified using `kid`, `x5t` header parameters.
651+
If the requested identifier is not found from the given set the loader will be called a second time with the `kid_not_found` option set to `true`.
651652
The application can choose to implement some kind of JWK cache invalidation or other mechanism to handle such cases.
652653

653-
Tokens without a specified `kid` are rejected by default.
654+
Tokens without a specified key identifier (`kid` or `x5t`) are rejected by default.
654655
This behaviour may be overwritten by setting the `allow_nil_kid` option for `decode` to `true`.
655656

656657
```ruby

lib/jwt/decode.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,14 @@ def verify_algo
6565

6666
def set_key
6767
@key = find_key(&@keyfinder) if @keyfinder
68-
@key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks], allow_nil_kid: @options[:allow_nil_kid]).key_for(token.header['kid']) if @options[:jwks]
68+
if @options[:jwks]
69+
@key = ::JWT::JWK::KeyFinder.new(
70+
jwks: @options[:jwks],
71+
allow_nil_kid: @options[:allow_nil_kid],
72+
key_fields: @options[:key_fields]
73+
).call(token)
74+
end
75+
6976
return unless (x5c_options = @options[:x5c])
7077

7178
@key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(token.header['x5c'])

lib/jwt/jwk/key_finder.rb

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ class KeyFinder
99
# @param [Hash] options the options to create a KeyFinder with
1010
# @option options [Proc, JWT::JWK::Set] :jwks the jwks or a loader proc
1111
# @option options [Boolean] :allow_nil_kid whether to allow nil kid
12+
# @option options [Array] :key_fields the fields to use for key matching,
13+
# the order of the fields are used to determine
14+
# the priority of the keys.
1215
def initialize(options)
1316
@allow_nil_kid = options[:allow_nil_kid]
1417
jwks_or_loader = options[:jwks]
@@ -18,15 +21,16 @@ def initialize(options)
1821
else
1922
->(_options) { jwks_or_loader }
2023
end
24+
25+
@key_fields = options[:key_fields] || %i[kid]
2126
end
2227

2328
# Returns the verification key for the given kid
2429
# @param [String] kid the key id
25-
def key_for(kid)
26-
raise ::JWT::DecodeError, 'No key id (kid) found from token headers' unless kid || @allow_nil_kid
27-
raise ::JWT::DecodeError, 'Invalid type for kid header parameter' unless kid.nil? || kid.is_a?(String)
30+
def key_for(kid, key_field = :kid)
31+
raise ::JWT::DecodeError, "Invalid type for #{key_field} header parameter" unless kid.nil? || kid.is_a?(String)
2832

29-
jwk = resolve_key(kid)
33+
jwk = resolve_key(kid, key_field)
3034

3135
raise ::JWT::DecodeError, 'No keys found in jwks' unless @jwks.any?
3236
raise ::JWT::DecodeError, "Could not find public key for kid #{kid}" unless jwk
@@ -37,22 +41,31 @@ def key_for(kid)
3741
# Returns the key for the given token
3842
# @param [JWT::EncodedToken] token the token
3943
def call(token)
40-
key_for(token.header['kid'])
44+
@key_fields.each do |key_field|
45+
field_value = token.header[key_field.to_s]
46+
47+
return key_for(field_value, key_field) if field_value
48+
end
49+
50+
raise ::JWT::DecodeError, 'No key id (kid) or x5t found from token headers' unless @allow_nil_kid
51+
52+
kid = token.header['kid']
53+
key_for(kid)
4154
end
4255

4356
private
4457

45-
def resolve_key(kid)
46-
key_matcher = ->(key) { (kid.nil? && @allow_nil_kid) || key[:kid] == kid }
58+
def resolve_key(kid, key_field)
59+
key_matcher = ->(key) { (kid.nil? && @allow_nil_kid) || key[key_field] == kid }
4760

4861
# First try without invalidation to facilitate application caching
49-
@jwks ||= JWT::JWK::Set.new(@jwks_loader.call(kid: kid))
62+
@jwks ||= JWT::JWK::Set.new(@jwks_loader.call(key_field => kid))
5063
jwk = @jwks.find { |key| key_matcher.call(key) }
5164

5265
return jwk if jwk
5366

5467
# Second try, invalidate for backwards compatibility
55-
@jwks = JWT::JWK::Set.new(@jwks_loader.call(invalidate: true, kid_not_found: true, kid: kid))
68+
@jwks = JWT::JWK::Set.new(@jwks_loader.call(invalidate: true, kid_not_found: true, key_field => kid))
5669
@jwks.find { |key| key_matcher.call(key) }
5770
end
5871
end

lib/jwt/jwk/rsa.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def verify_key
5151
def export(options = {})
5252
exported = parameters.clone
5353
exported.reject! { |k, _| RSA_PRIVATE_KEY_ELEMENTS.include? k } unless private? && options[:include_private] == true
54+
5455
exported
5556
end
5657

spec/jwt/jwk/decode_with_jwk_spec.rb

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
describe '.decode for JWK usecase' do
55
let(:keypair) { test_pkey('rsa-2048-private.pem') }
66
let(:jwk) { JWT::JWK.new(keypair) }
7-
let(:public_jwks) { { keys: [jwk.export, { kid: 'not_the_correct_one', kty: 'oct', k: 'secret' }] } }
7+
let(:valid_key) { jwk.export }
8+
let(:public_jwks) { { keys: [valid_key, { kid: 'not_the_correct_one', kty: 'oct', k: 'secret' }] } }
89
let(:token_payload) { { 'data' => 'something' } }
910
let(:token_headers) { { kid: jwk.kid } }
1011
let(:algorithm) { 'RS512' }
@@ -38,6 +39,26 @@
3839
end
3940
end
4041

42+
context 'and x5t is in the set' do
43+
let(:x5t) { Base64.urlsafe_encode64(OpenSSL::Digest::SHA1.new(keypair.to_der).digest, padding: false) }
44+
let(:valid_key) { jwk.export.merge({ x5t: x5t }) }
45+
let(:token_headers) { { x5t: x5t } }
46+
it 'is able to decode the token' do
47+
payload, _header = described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks, key_fields: [:x5t] })
48+
expect(payload).to eq(token_payload)
49+
end
50+
end
51+
52+
context 'and both kid and x5t is in the set' do
53+
let(:x5t) { Base64.urlsafe_encode64(OpenSSL::Digest::SHA1.new(keypair.to_der).digest, padding: false) }
54+
let(:valid_key) { jwk.export.merge({ x5t: x5t }) }
55+
let(:token_headers) { { x5t: x5t, kid: 'NOT_A_MATCH' } }
56+
it 'is able to decode the token based on the priority of the key defined in key_fields' do
57+
payload, _header = described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks, key_fields: %i[x5t kid] })
58+
expect(payload).to eq(token_payload)
59+
end
60+
end
61+
4162
context 'no keys are found in the set' do
4263
let(:public_jwks) { { keys: [] } }
4364
it 'raises an exception' do
@@ -51,7 +72,7 @@
5172
let(:token_headers) { {} }
5273
it 'raises an exception' do
5374
expect { described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks }) }.to raise_error(
54-
JWT::DecodeError, 'No key id (kid) found from token headers'
75+
JWT::DecodeError, 'No key id (kid) or x5t found from token headers'
5576
)
5677
end
5778
end

0 commit comments

Comments
 (0)