diff --git a/CHANGELOG.md b/CHANGELOG.md index 00168932..d2e16d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # Changelog -## [v3.0.1](https://github.com/jwt/ruby-jwt/tree/v3.0.1) (NEXT) +## [v3.1.0](https://github.com/jwt/ruby-jwt/tree/v3.1.0) (NEXT) -[Full Changelog](https://github.com/jwt/ruby-jwt/compare/v3.0.0...main) +[Full Changelog](https://github.com/jwt/ruby-jwt/compare/v3.0.0...v3.1.0) **Features:** @@ -10,6 +10,7 @@ - Raise an error if the ECDSA signing or verification key is not an instance of `OpenSSL::PKey::EC` [#688](https://github.com/jwt/ruby-jwt/pull/688) ([@anakinj](https://github.com/anakinj)) - Allow `OpenSSL::PKey::EC::Point` to be used as the verification key in ECDSA [#689](https://github.com/jwt/ruby-jwt/pull/689) ([@anakinj](https://github.com/anakinj)) - Require claims to have been verified before accessing the `JWT::EncodedToken#payload` [#690](https://github.com/jwt/ruby-jwt/pull/690) ([@anakinj](https://github.com/anakinj)) +- Support signing and verifying tokens using a JWK [#692](https://github.com/jwt/ruby-jwt/pull/692) ([@anakinj](https://github.com/anakinj)) - Your contribution here **Fixes and enhancements:** diff --git a/README.md b/README.md index d928b8b2..8b41157d 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ See [CHANGELOG.md](CHANGELOG.md) for a complete set of changes and [upgrade guid ## Sponsors -|Logo|Message| -|----|-------| -||If you want to quickly add secure token-based authentication to Ruby projects, feel free to check Auth0's Ruby SDK and free plan at [auth0.com/developers](https://auth0.com/developers?utm_source=GHsponsor&utm_medium=GHsponsor&utm_campaign=rubyjwt&utm_content=auth)| +| Logo | Message | +| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +|  | If you want to quickly add secure token-based authentication to Ruby projects, feel free to check Auth0's Ruby SDK and free plan at [auth0.com/developers](https://auth0.com/developers?utm_source=GHsponsor&utm_medium=GHsponsor&utm_campaign=rubyjwt&utm_content=auth) | ## Installing @@ -251,6 +251,26 @@ encoded_token.payload # => { 'exp'=>1234, 'jti'=>'1234", 'sub'=>'my-subject' } encoded_token.header # {'kid'=>'hmac', 'alg'=>'HS256'} ``` +A JWK can be used to sign and verify the token if it's possible to derive the signing algorithm from the key. + +```ruby +jwk_json = '{ + "kty": "oct", + "k": "c2VjcmV0", + "alg": "HS256", + "kid": "hmac" +}' + +jwk = JWT::JWK.import(JSON.parse(jwk_json)) + +token = JWT::Token.new(payload: payload, header: header) + +token.sign!(key: jwk) + +encoded_token = JWT::EncodedToken.new(token.jwt) +encoded_token.verify!(signature: { key: jwk}) +``` + #### Using a keyfinder A keyfinder can be used to verify a signature. A keyfinder is an object responding to the `#call` method. The method expects to receive one argument, which is the token to be verified. diff --git a/lib/jwt/encoded_token.rb b/lib/jwt/encoded_token.rb index 2ce88475..3e94edf1 100644 --- a/lib/jwt/encoded_token.rb +++ b/lib/jwt/encoded_token.rb @@ -138,12 +138,8 @@ def valid?(signature:, claims: nil) # @return [nil] # @raise [JWT::VerificationError] if the signature verification fails. # @raise [ArgumentError] if neither key nor key_finder is provided, or if both are provided. - def verify_signature!(algorithm:, key: nil, key_finder: nil) - raise ArgumentError, 'Provide either key or key_finder, not both or neither' if key.nil? == key_finder.nil? - - key ||= key_finder.call(self) - - return if valid_signature?(algorithm: algorithm, key: key) + def verify_signature!(algorithm: nil, key: nil, key_finder: nil) + return if valid_signature?(algorithm: algorithm, key: key, key_finder: key_finder) raise JWT::VerificationError, 'Signature verification failed' end @@ -151,15 +147,20 @@ def verify_signature!(algorithm:, key: nil, key_finder: nil) # Checks if the signature of the JWT token is valid. # # @param algorithm [String, Array<String>, Object, Array<Object>] the algorithm(s) to use for verification. - # @param key [String, Array<String>] the key(s) to use for verification. + # @param key [String, Array<String>, JWT::JWK::KeyBase, Array<JWT::JWK::KeyBase>] the key(s) to use for verification. + # @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:, key:) - valid = Array(JWA.resolve_and_sort(algorithms: algorithm, preferred_algorithm: header['alg'])).any? do |algo| - Array(key).any? do |one_key| - algo.verify(data: signing_input, signature: signature, verification_key: one_key) - end - end + 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) + end valid.tap { |verified| @signature_verified = verified } end diff --git a/lib/jwt/jwa.rb b/lib/jwt/jwa.rb index 92df7dd5..d5b0de6a 100644 --- a/lib/jwt/jwa.rb +++ b/lib/jwt/jwa.rb @@ -13,11 +13,43 @@ module JWT # The JWA module contains all supported algorithms. module JWA + # @api private + class VerifierContext + def initialize(jwa:, keys:) + @jwa = jwa + @keys = Array(keys) + end + + def verify(*args, **kwargs) + @keys.any? do |key| + @jwa.verify(*args, **kwargs, verification_key: key) + end + end + end + + # @api private + class SignerContext + def initialize(jwa:, key:) + @jwa = jwa + @key = key + end + + def sign(*args, **kwargs) + @jwa.sign(*args, **kwargs, signing_key: @key) + end + + def jwa_header + @jwa.header + end + end + class << self # @api private def resolve(algorithm) return find(algorithm) if algorithm.is_a?(String) || algorithm.is_a?(Symbol) + raise ArgumentError, 'Algorithm must be provided' if algorithm.nil? + raise ArgumentError, 'Custom algorithms are required to include JWT::JWA::SigningAlgorithm' unless algorithm.is_a?(SigningAlgorithm) algorithm @@ -25,8 +57,25 @@ def resolve(algorithm) # @api private def resolve_and_sort(algorithms:, preferred_algorithm:) - algs = Array(algorithms).map { |alg| JWA.resolve(alg) } - algs.partition { |alg| alg.valid_alg?(preferred_algorithm) }.flatten + Array(algorithms).map { |alg| JWA.resolve(alg) } + .partition { |alg| alg.valid_alg?(preferred_algorithm) } + .flatten + end + + # @api private + def create_signer(algorithm:, key:) + return key if key.is_a?(JWK::KeyBase) + + SignerContext.new(jwa: resolve(algorithm), key: key) + end + + # @api private + def create_verifiers(algorithms:, keys:, preferred_algorithm:) + jwks, other_keys = keys.partition { |key| key.is_a?(JWK::KeyBase) } + + jwks + resolve_and_sort(algorithms: algorithms, + preferred_algorithm: preferred_algorithm) + .map { |jwa| VerifierContext.new(jwa: jwa, keys: other_keys) } end end end diff --git a/lib/jwt/jwa/ecdsa.rb b/lib/jwt/jwa/ecdsa.rb index 60ddf6e3..12458c3b 100644 --- a/lib/jwt/jwa/ecdsa.rb +++ b/lib/jwt/jwa/ecdsa.rb @@ -64,6 +64,7 @@ def verify(data:, signature:, verification_key:) register_algorithm(new(v[:algorithm], v[:digest])) end + # @api private def self.curve_by_name(name) NAMED_CURVES.fetch(name) do raise UnsupportedEcdsaCurve, "The ECDSA curve '#{name}' is not supported" diff --git a/lib/jwt/jwk/ec.rb b/lib/jwt/jwk/ec.rb index 68bdad01..4f442b8c 100644 --- a/lib/jwt/jwk/ec.rb +++ b/lib/jwt/jwk/ec.rb @@ -73,6 +73,13 @@ def []=(key, value) private + def jwa + return super if self[:alg] + + curve_name = self.class.to_openssl_curve(self[:crv]) + JWA.resolve(JWA::Ecdsa.curve_by_name(curve_name)[:algorithm]) + end + def ec_key @ec_key ||= create_ec_key(self[:crv], self[:x], self[:y], self[:d]) end diff --git a/lib/jwt/jwk/key_base.rb b/lib/jwt/jwk/key_base.rb index 4962d112..84c46f8f 100644 --- a/lib/jwt/jwk/key_base.rb +++ b/lib/jwt/jwk/key_base.rb @@ -42,6 +42,19 @@ def ==(other) other.is_a?(::JWT::JWK::KeyBase) && self[:kid] == other[:kid] end + def verify(**kwargs) + jwa.verify(**kwargs, verification_key: verify_key) + end + + def sign(**kwargs) + jwa.sign(**kwargs, signing_key: signing_key) + end + + # @api private + def jwa_header + jwa.header + end + alias eql? == def <=>(other) @@ -52,6 +65,12 @@ def <=>(other) private + def jwa + raise JWT::JWKError, 'Could not resolve the JWA, the "alg" parameter is missing' unless self[:alg] + + JWA.resolve(self[:alg]) + end + attr_reader :parameters end end diff --git a/lib/jwt/token.rb b/lib/jwt/token.rb index 7bf5a104..8552e531 100644 --- a/lib/jwt/token.rb +++ b/lib/jwt/token.rb @@ -87,16 +87,16 @@ def detach_payload! # Signs the JWT token. # + # @param key [String, JWT::JWK::KeyBase] the key to use for signing. # @param algorithm [String, Object] the algorithm to use for signing. - # @param key [String] the key to use for signing. # @return [void] # @raise [JWT::EncodeError] if the token is already signed or other problems when signing - def sign!(algorithm:, key:) + def sign!(key:, algorithm: nil) raise ::JWT::EncodeError, 'Token already signed' if @signature - JWA.resolve(algorithm).tap do |algo| - header.merge!(algo.header) { |_key, old, _new| old } - @signature = algo.sign(data: signing_input, signing_key: key) + JWA.create_signer(algorithm: algorithm, key: key).tap do |signer| + header.merge!(signer.jwa_header) { |_key, old, _new| old } + @signature = signer.sign(data: signing_input) end nil diff --git a/lib/jwt/version.rb b/lib/jwt/version.rb index 290267ef..ffe3375d 100644 --- a/lib/jwt/version.rb +++ b/lib/jwt/version.rb @@ -15,8 +15,8 @@ def self.gem_version # Version constants module VERSION MAJOR = 3 - MINOR = 0 - TINY = 1 + MINOR = 1 + TINY = 0 PRE = nil STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.') diff --git a/spec/integration/readme_examples_spec.rb b/spec/integration/readme_examples_spec.rb index 3c8ecc80..a1fd7c33 100644 --- a/spec/integration/readme_examples_spec.rb +++ b/spec/integration/readme_examples_spec.rb @@ -456,4 +456,26 @@ def self.verify(data:, signature:, verification_key:) expect(header).to include('alg' => 'HS512') end end + + context 'JWK to verify a signature' do + it 'allows to verify a signature with a JWK' do + payload = { exp: Time.now.to_i + 60, jti: '1234', sub: 'my-subject' } + header = { kid: 'hmac' } + + jwk_json = '{ + "kty": "oct", + "k": "c2VjcmV0", + "alg": "HS256", + "kid": "hmac" + }' + + jwk = JWT::JWK.import(JSON.parse(jwk_json)) + + token = JWT::Token.new(payload: payload, header: header) + token.sign!(key: jwk) + + encoded_token = JWT::EncodedToken.new(token.jwt) + expect { encoded_token.verify!(signature: { key: jwk }) }.not_to raise_error + end + end end diff --git a/spec/jwt/encoded_token_spec.rb b/spec/jwt/encoded_token_spec.rb index 8d61f833..e239c785 100644 --- a/spec/jwt/encoded_token_spec.rb +++ b/spec/jwt/encoded_token_spec.rb @@ -139,6 +139,12 @@ end end + context 'when algorithm is not given' do + it 'raises an error' do + expect { token.verify_signature!(key: 'secret') }.to raise_error(JWT::VerificationError, 'No algorithm provided') + end + end + context 'when header has invalid alg value' do let(:header) { { 'alg' => 'HS123' } } @@ -211,6 +217,19 @@ expect(token.verify_signature!(algorithm: 'RS256', key_finder: key_finder)).to eq(nil) end end + + context 'when JWK is given as a key' do + let(:jwk) { JWT::JWK.new(test_pkey('rsa-2048-private.pem'), alg: 'RS256') } + let(:encoded_token) do + JWT::Token.new(payload: payload) + .tap { |t| t.sign!(algorithm: 'RS256', key: jwk.signing_key) } + .jwt + end + + it 'uses the JWK for verification' do + expect(token.verify_signature!(key: jwk)).to eq(nil) + end + end end describe '#verify_claims!' do diff --git a/spec/jwt/jwk/ec_spec.rb b/spec/jwt/jwk/ec_spec.rb index 147c9591..88ef9b76 100644 --- a/spec/jwt/jwk/ec_spec.rb +++ b/spec/jwt/jwk/ec_spec.rb @@ -110,6 +110,37 @@ end end + describe '#verify' do + let(:data) { 'data_to_sign' } + let(:signature) { jwk.sign(data: data) } + + context 'when jwk is missing the alg parameter' do + let(:jwk) { described_class.new(ec_key) } + + context 'when the signature is valid' do + it 'returns true' do + expect(jwk.verify(data: data, signature: signature)).to be(true) + end + end + end + + context 'when jwk has alg parameter' do + let(:jwk) { described_class.new(ec_key, alg: 'ES384') } + + context 'when the signature is valid' do + it 'returns true' do + expect(jwk.verify(data: data, signature: signature)).to be(true) + end + end + + context 'when the signature is invalid' do + it 'returns false' do + expect(jwk.verify(data: data, signature: 'invalid')).to be(false) + end + end + end + end + describe '.to_openssl_curve' do context 'when a valid curve name is given' do it 'returns the corresponding OpenSSL curve name' do diff --git a/spec/jwt/jwk/rsa_spec.rb b/spec/jwt/jwk/rsa_spec.rb index 7c574e00..78457454 100644 --- a/spec/jwt/jwk/rsa_spec.rb +++ b/spec/jwt/jwk/rsa_spec.rb @@ -93,6 +93,38 @@ end end + describe '#verify' do + let(:rsa) { described_class.new(rsa_key, alg: 'RS256') } + let(:data) { 'data_to_sign' } + let(:signature) { rsa.sign(data: data) } + + context 'when the signature is valid' do + it 'returns true' do + expect(rsa.verify(data: data, signature: signature)).to be(true) + end + end + + context 'when the signature is invalid' do + it 'returns false' do + expect(rsa.verify(data: data, signature: 'invalid_signature')).to be(false) + end + end + + context 'when the jwk is missing the alg header' do + let(:rsa) { described_class.new(rsa_key) } + it 'raises JWT::JWKError' do + expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::JWKError, 'Could not resolve the JWA, the "alg" parameter is missing') + end + end + + context 'when the jwk has an invalid alg header' do + let(:rsa) { described_class.new(rsa_key, alg: 'INVALID') } + it 'raises JWT::JWKError' do + expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::VerificationError, 'Algorithm not supported') + end + end + end + describe '.common_parameters' do context 'when a common parameters hash is given' do it 'imports the common parameter' do diff --git a/spec/jwt/token_spec.rb b/spec/jwt/token_spec.rb index 63d8efe3..e622dd7f 100644 --- a/spec/jwt/token_spec.rb +++ b/spec/jwt/token_spec.rb @@ -22,6 +22,22 @@ expect { token.sign!(algorithm: 'HS256', key: 'secret') }.to raise_error(JWT::EncodeError) end end + + context 'when JWK is given as key' do + let(:jwk) { JWT::JWK::RSA.new(OpenSSL::PKey::RSA.new(2048), alg: 'RS256') } + + it 'signs the token' do + token.sign!(key: jwk) + + expect(JWT::EncodedToken.new(token.jwt).valid_signature?(algorithm: 'RS256', key: jwk.verify_key)).to be(true) + end + end + + context 'when string key is given but not algorithm' do + it 'raises an error' do + expect { token.sign!(key: 'secret') }.to raise_error(ArgumentError, 'Algorithm must be provided') + end + end end describe '#jwt' do