diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f8cff0b..ff5cbf07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:** diff --git a/README.md b/README.md index cbd7c066..264cb56b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/jwt/base64.rb b/lib/jwt/base64.rb index fdf1bf95..3683c14e 100644 --- a/lib/jwt/base64.rb +++ b/lib/jwt/base64.rb @@ -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) diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index 5928f401..fe4e0b72 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -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']) diff --git a/lib/jwt/jwk/key_finder.rb b/lib/jwt/jwk/key_finder.rb index 80a2e7fe..2e4dd7be 100644 --- a/lib/jwt/jwk/key_finder.rb +++ b/lib/jwt/jwk/key_finder.rb @@ -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 @@ -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 diff --git a/lib/jwt/jwk/rsa.rb b/lib/jwt/jwk/rsa.rb index df0eeab2..78792c10 100644 --- a/lib/jwt/jwk/rsa.rb +++ b/lib/jwt/jwk/rsa.rb @@ -51,6 +51,9 @@ 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[:x5t] = Base64.url_encode(OpenSSL::Digest::SHA1.new(rsa_key.to_der).digest) if options[:include_x5t] + exported end @@ -67,7 +70,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 diff --git a/spec/jwt/jwk/decode_with_jwk_spec.rb b/spec/jwt/jwk/decode_with_jwk_spec.rb index dfbde197..5d20d3f2 100644 --- a/spec/jwt/jwk/decode_with_jwk_spec.rb +++ b/spec/jwt/jwk/decode_with_jwk_spec.rb @@ -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' } @@ -38,6 +39,15 @@ end end + context 'and x5t is in the set' do + let(:valid_key) { jwk.export(include_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 'no keys are found in the set' do let(:public_jwks) { { keys: [] } } it 'raises an exception' do @@ -51,7 +61,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 diff --git a/spec/jwt/jwk/rsa_spec.rb b/spec/jwt/jwk/rsa_spec.rb index 7c574e00..9c0259ae 100644 --- a/spec/jwt/jwk/rsa_spec.rb +++ b/spec/jwt/jwk/rsa_spec.rb @@ -67,6 +67,16 @@ expect(subject).to include(:kty, :n, :e, :kid, :d, :p, :q, :dp, :dq, :qi) end end + + context 'when x5t option is requested' do + subject { described_class.new(keypair).export(include_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