Skip to content

Add support for x5t and x5t#S256 header #669

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Take a look at the [upgrade guide](UPGRADING.md) for more details.
- JWT::EncodedToken#verify! method that bundles signature and claim validation [#647](https://github.com/jwt/ruby-jwt/pull/647) ([@anakinj](https://github.com/anakinj))
- Do not override the alg header if already given [#659](https://github.com/jwt/ruby-jwt/pull/659) ([@anakinj](https://github.com/anakinj))
- Make `JWK::KeyFinder` compatible with `JWT::EncodedToken` [#663](https://github.com/jwt/ruby-jwt/pull/663) ([@anakinj](https://github.com/anakinj))
- 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))
- Your contribution here

**Fixes and enhancements:**
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -674,13 +674,14 @@ algorithms = jwks.map { |key| key[:alg] }.compact.uniq
JWT.decode(token, nil, true, algorithms: algorithms, jwks: jwks)
```

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

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`.
Key identifiers can be specified using `kid`, `x5t` or `x5c` header parameters.
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`.
The application can choose to implement some kind of JWK cache invalidation or other mechanism to handle such cases.

Tokens without a specified `kid` are rejected by default.
Tokens without a specified key identifier (`kid`, `x5t` or `x5c`) are rejected by default.
This behaviour may be overwritten by setting the `allow_nil_kid` option for `decode` to `true`.

```ruby
Expand Down
6 changes: 6 additions & 0 deletions lib/jwt/base64.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ def url_encode(str)
::Base64.urlsafe_encode64(str, padding: false)
end

# Encode a string with Base64 complying with RFC 4648 (padded).
# @api private
def strict_encode(str)
::Base64.strict_encode64(str)
end

# Decode a string with URL-safe Base64 complying with RFC 4648.
# @api private
def url_decode(str)
Expand Down
3 changes: 2 additions & 1 deletion lib/jwt/decode.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ def verify_algo

def set_key
@key = find_key(&@keyfinder) if @keyfinder
@key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks], allow_nil_kid: @options[:allow_nil_kid]).key_for(token.header['kid']) if @options[:jwks]
@key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks], allow_nil_kid: @options[:allow_nil_kid]).call(token) if @options[:jwks]

return unless (x5c_options = @options[:x5c])

@key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(token.header['x5c'])
Expand Down
31 changes: 22 additions & 9 deletions lib/jwt/jwk/key_finder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@ def initialize(options)

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

jwk = resolve_key(kid)
jwk = resolve_key(kid, key_field)

raise ::JWT::DecodeError, 'No keys found in jwks' unless @jwks.any?
raise ::JWT::DecodeError, "Could not find public key for kid #{kid}" unless jwk
Expand All @@ -37,22 +36,36 @@ def key_for(kid)
# Returns the key for the given token
# @param [JWT::EncodedToken] token the token
def call(token)
key_for(token.header['kid'])
kid = token.header['kid']
x5t = token.header['x5t']
x5c = token.header['x5c']

if kid
key_for(kid, :kid)
elsif x5t
key_for(x5t, :x5t)
elsif x5c
key_for(x5c, :x5c)
elsif @allow_nil_kid
key_for(kid)
else
raise ::JWT::DecodeError, 'No key id (kid) or x5t found from token headers'
end
end

private

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

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

return jwk if jwk

# Second try, invalidate for backwards compatibility
@jwks = JWT::JWK::Set.new(@jwks_loader.call(invalidate: true, kid_not_found: true, kid: kid))
@jwks = JWT::JWK::Set.new(@jwks_loader.call(invalidate: true, kid_not_found: true, key_field => kid))
@jwks.find { |key| key_matcher.call(key) }
end
end
Expand Down
6 changes: 5 additions & 1 deletion lib/jwt/jwk/rsa.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ def verify_key
def export(options = {})
exported = parameters.clone
exported.reject! { |k, _| RSA_PRIVATE_KEY_ELEMENTS.include? k } unless private? && options[:include_private] == true

exported[:x5c] = Base64.strict_encode(rsa_key.to_der) if options[:x5c]
exported[:x5t] = Base64.url_encode(OpenSSL::Digest::SHA1.new(rsa_key.to_der).digest) if options[:x5t]

exported
end

Expand All @@ -67,7 +71,7 @@ def key_digest
def []=(key, value)
raise ArgumentError, 'cannot overwrite cryptographic key attributes' if RSA_KEY_ELEMENTS.include?(key.to_sym)

super(key, value)
super
end

private
Expand Down
23 changes: 21 additions & 2 deletions spec/jwt/jwk/decode_with_jwk_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
describe '.decode for JWK usecase' do
let(:keypair) { test_pkey('rsa-2048-private.pem') }
let(:jwk) { JWT::JWK.new(keypair) }
let(:public_jwks) { { keys: [jwk.export, { kid: 'not_the_correct_one', kty: 'oct', k: 'secret' }] } }
let(:valid_key) { jwk.export }
let(:public_jwks) { { keys: [valid_key, { kid: 'not_the_correct_one', kty: 'oct', k: 'secret' }] } }
let(:token_payload) { { 'data' => 'something' } }
let(:token_headers) { { kid: jwk.kid } }
let(:algorithm) { 'RS512' }
Expand Down Expand Up @@ -38,6 +39,24 @@
end
end

context 'and x5t is in the set' do
let(:valid_key) { jwk.export(x5t: true) }
let(:token_headers) { { x5t: Base64.urlsafe_encode64(OpenSSL::Digest::SHA1.new(keypair.to_der).digest, padding: false) } }
it 'is able to decode the token' do
payload, _header = described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks })
expect(payload).to eq(token_payload)
end
end

context 'and x5c is in the set' do
let(:valid_key) { jwk.export(x5c: true) }
let(:token_headers) { { x5c: Base64.strict_encode64(keypair.to_der) } }
it 'is able to decode the token' do
payload, _header = described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks })
expect(payload).to eq(token_payload)
end
end

context 'no keys are found in the set' do
let(:public_jwks) { { keys: [] } }
it 'raises an exception' do
Expand All @@ -51,7 +70,7 @@
let(:token_headers) { {} }
it 'raises an exception' do
expect { described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks }) }.to raise_error(
JWT::DecodeError, 'No key id (kid) found from token headers'
JWT::DecodeError, 'No key id (kid) or x5t found from token headers'
)
end
end
Expand Down
20 changes: 20 additions & 0 deletions spec/jwt/jwk/rsa_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@
expect(subject).to include(:kty, :n, :e, :kid, :d, :p, :q, :dp, :dq, :qi)
end
end

context 'when x5c option is requested' do
subject { described_class.new(keypair).export(x5c: true) }
let(:keypair) { rsa_key }
it 'returns a hash with x5c certificate chain' do
expect(subject).to be_a Hash
expect(subject).to include(:kty, :n, :e, :kid, :x5c)
expect(subject[:x5c]).to be_a String
end
end

context 'when x5t option is requested' do
subject { described_class.new(keypair).export(x5t: true) }
let(:keypair) { rsa_key }
it 'returns a hash with x5t thumbprint' do
expect(subject).to be_a Hash
expect(subject).to include(:kty, :n, :e, :kid, :x5t)
expect(subject[:x5t]).to be_a String
end
end
end

describe '.kid' do
Expand Down
Loading