From deb6d43b117330a974882728be20a375849a3938 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Fri, 27 Mar 2026 21:25:32 +0200 Subject: [PATCH 01/20] Revamp error hierarchy --- lib/jwt/claims.rb | 2 +- lib/jwt/claims/verifier.rb | 2 +- lib/jwt/decode.rb | 12 ++-- lib/jwt/encoded_token.rb | 14 ++--- lib/jwt/error.rb | 63 +++++++++++++-------- lib/jwt/jwa/signing_algorithm.rb | 2 +- lib/jwt/jwk/key_finder.rb | 8 +-- lib/jwt/token.rb | 2 +- spec/jwt/error_spec.rb | 97 ++++++++++++++++++++++++++++++++ 9 files changed, 157 insertions(+), 45 deletions(-) create mode 100644 spec/jwt/error_spec.rb diff --git a/lib/jwt/claims.rb b/lib/jwt/claims.rb index 45ed547b1..eb2ce14fb 100644 --- a/lib/jwt/claims.rb +++ b/lib/jwt/claims.rb @@ -41,7 +41,7 @@ class << self # @param payload [Hash] the JWT payload. # @param options [Array] the options for verifying the claims. # @return [void] - # @raise [JWT::DecodeError] if any claim is invalid. + # @raise [JWT::ClaimValidationError] if any claim is invalid. def verify_payload!(payload, *options) Verifier.verify!(VerificationContext.new(payload: payload), *options) end diff --git a/lib/jwt/claims/verifier.rb b/lib/jwt/claims/verifier.rb index 81ce8a23d..2785cbb75 100644 --- a/lib/jwt/claims/verifier.rb +++ b/lib/jwt/claims/verifier.rb @@ -33,7 +33,7 @@ def errors(context, *options) errors = [] iterate_verifiers(*options) do |verifier, verifier_options| verify_one!(context, verifier, verifier_options) - rescue ::JWT::DecodeError => e + rescue ::JWT::ClaimValidationError => e errors << Error.new(message: e.message) end errors diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index e6b8e74dd..50a34fce4 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -18,9 +18,9 @@ class Decode # @param verify [Boolean] whether to verify the token's signature. # @param options [Hash] additional options for decoding and verification. # @param keyfinder [Proc] an optional key finder block to dynamically find the key for verification. - # @raise [JWT::DecodeError] if decoding or verification fails. + # @raise [JWT::Error] if decoding or verification fails. def initialize(jwt, key, verify, options, &keyfinder) - raise JWT::DecodeError, 'Nil JSON web token' unless jwt + raise JWT::MalformedTokenError, 'Nil JSON web token' unless jwt @token = EncodedToken.new(jwt) @key = key @@ -51,14 +51,14 @@ def decode_segments def verify_signature return if none_algorithm? - raise JWT::DecodeError, 'No verification key available' unless @key + raise JWT::SignatureError, 'No verification key available' unless @key token.verify_signature!(algorithm: allowed_and_valid_algorithms, key: @key) end def verify_algo raise JWT::IncorrectAlgorithm, 'An algorithm must be specified' if allowed_algorithms.empty? - raise JWT::DecodeError, 'Token header not a JSON object' unless valid_token_header? + raise JWT::MalformedTokenError, 'Token header not a JSON object' unless valid_token_header? raise JWT::IncorrectAlgorithm, 'Token is missing alg header' unless alg_in_header raise JWT::IncorrectAlgorithm, 'Expected a different algorithm' if allowed_and_valid_algorithms.empty? end @@ -100,7 +100,7 @@ def find_key(&keyfinder) # key can be of type [string, nil, OpenSSL::PKey, Array] return key if key && !Array(key).empty? - raise JWT::DecodeError, 'No verification key available' + raise JWT::SignatureError, 'No verification key available' end def validate_segment_count! @@ -109,7 +109,7 @@ def validate_segment_count! return if !@verify && segment_count == 2 # If no verifying required, the signature is not needed return if segment_count == 2 && none_algorithm? - raise JWT::DecodeError, 'Not enough or too many segments' + raise JWT::MalformedTokenError, 'Not enough or too many segments' end def none_algorithm? diff --git a/lib/jwt/encoded_token.rb b/lib/jwt/encoded_token.rb index 214981df6..e6fb37f41 100644 --- a/lib/jwt/encoded_token.rb +++ b/lib/jwt/encoded_token.rb @@ -63,10 +63,10 @@ def header # Returns the payload of the JWT token. Access requires the signature and claims to have been verified. # # @return [Hash] the payload. - # @raise [JWT::DecodeError] if the signature has not been verified. + # @raise [JWT::Error] if the signature has not been verified. def payload - raise JWT::DecodeError, 'Verify the token signature before accessing the payload' unless @signature_verified - raise JWT::DecodeError, 'Verify the token claims before accessing the payload' unless @claims_verified + raise JWT::Error, 'Verify the token signature before accessing the payload' unless @signature_verified + raise JWT::Error, 'Verify the token claims before accessing the payload' unless @claims_verified decoded_payload end @@ -98,7 +98,7 @@ def signing_input # @param signature [Hash] the parameters for signature verification (see {#verify_signature!}). # @param claims [Array, Hash] the claims to verify (see {#verify_claims!}). # @return [nil] - # @raise [JWT::DecodeError] if the signature or claim verification fails. + # @raise [JWT::Error] if the signature or claim verification fails. def verify!(signature:, claims: nil) verify_signature!(**signature) claims.is_a?(Array) ? verify_claims!(*claims) : verify_claims!(claims) @@ -152,7 +152,7 @@ def valid_signature?(algorithm: nil, key: nil, key_finder: nil) # Verifies the claims of the token. # @param options [Array, Hash] the claims to verify. By default, it checks the 'exp' claim. - # @raise [JWT::DecodeError] if the claims are invalid. + # @raise [JWT::ClaimValidationError] if the claims are invalid. def verify_claims!(*options) Claims::Verifier.verify!(ClaimsContext.new(self), *claims_options(options)).tap do @claims_verified = true @@ -187,7 +187,7 @@ def claims_options(options) end def decode_payload - raise JWT::DecodeError, 'Encoded payload is empty' if encoded_payload == '' + raise JWT::MalformedTokenError, 'Encoded payload is empty' if encoded_payload == '' if unencoded_payload? verify_claims!(crit: ['b64']) @@ -212,7 +212,7 @@ def parse_unencoded(segment) def parse(segment) JWT::JSON.parse(segment) rescue ::JSON::ParserError - raise JWT::DecodeError, 'Invalid segment encoding' + raise JWT::MalformedTokenError, 'Invalid segment encoding' end def decoded_payload diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb index ea4c9d71c..bcc49ddbf 100644 --- a/lib/jwt/error.rb +++ b/lib/jwt/error.rb @@ -1,57 +1,72 @@ # frozen_string_literal: true module JWT + # The base error class for all JWT errors. + class Error < StandardError; end + # The EncodeError class is raised when there is an error encoding a JWT. - class EncodeError < StandardError; end + class EncodeError < Error; end - # The DecodeError class is raised when there is an error decoding a JWT. - class DecodeError < StandardError; end + # The TokenError class is the base class for all errors related to token processing. + class TokenError < Error; end - # The VerificationError class is raised when there is an error verifying a JWT. - class VerificationError < DecodeError; end + # The MalformedTokenError class is raised when the token is structurally invalid. + class MalformedTokenError < TokenError; end - # The ExpiredSignature class is raised when the JWT signature has expired. - class ExpiredSignature < DecodeError; end + # The Base64DecodeError class is raised when there is an error decoding a Base64-encoded string. + class Base64DecodeError < MalformedTokenError; end - # The IncorrectAlgorithm class is raised when the JWT algorithm is incorrect. - class IncorrectAlgorithm < DecodeError; end + # The SignatureError class is the base class for signature and algorithm related errors. + class SignatureError < TokenError; end - # The ImmatureSignature class is raised when the JWT signature is immature. - class ImmatureSignature < DecodeError; end + # The VerificationError class is raised when there is an error verifying a JWT signature. + class VerificationError < SignatureError; end - # The InvalidIssuerError class is raised when the JWT issuer is invalid. - class InvalidIssuerError < DecodeError; end + # The IncorrectAlgorithm class is raised when the JWT algorithm is incorrect. + class IncorrectAlgorithm < SignatureError; end # The UnsupportedEcdsaCurve class is raised when the ECDSA curve is unsupported. class UnsupportedEcdsaCurve < IncorrectAlgorithm; end + # The ClaimValidationError class is the base class for all claim validation errors. + class ClaimValidationError < TokenError; end + + # The ExpiredSignature class is raised when the JWT token has expired. + class ExpiredSignature < ClaimValidationError; end + + # The ImmatureSignature class is raised when the JWT token is not yet valid (nbf). + class ImmatureSignature < ClaimValidationError; end + + # The InvalidIssuerError class is raised when the JWT issuer is invalid. + class InvalidIssuerError < ClaimValidationError; end + # The InvalidIatError class is raised when the JWT issued at (iat) claim is invalid. - class InvalidIatError < DecodeError; end + class InvalidIatError < ClaimValidationError; end # The InvalidAudError class is raised when the JWT audience (aud) claim is invalid. - class InvalidAudError < DecodeError; end + class InvalidAudError < ClaimValidationError; end # The InvalidSubError class is raised when the JWT subject (sub) claim is invalid. - class InvalidSubError < DecodeError; end + class InvalidSubError < ClaimValidationError; end # The InvalidCritError class is raised when the JWT crit header is invalid. - class InvalidCritError < DecodeError; end + class InvalidCritError < ClaimValidationError; end # The InvalidJtiError class is raised when the JWT ID (jti) claim is invalid. - class InvalidJtiError < DecodeError; end + class InvalidJtiError < ClaimValidationError; end # The InvalidPayload class is raised when the JWT payload is invalid. - class InvalidPayload < DecodeError; end + class InvalidPayload < ClaimValidationError; end # The MissingRequiredClaim class is raised when a required claim is missing from the JWT. - class MissingRequiredClaim < DecodeError; end - - # The Base64DecodeError class is raised when there is an error decoding a Base64-encoded string. - class Base64DecodeError < DecodeError; end + class MissingRequiredClaim < ClaimValidationError; end # The JWKError class is raised when there is an error with the JSON Web Key (JWK). - class JWKError < DecodeError; end + class JWKError < Error; end # Raised when a JWK uses a key type (kty) that this library does not support. class UnsupportedKeyType < JWKError; end + + # Backwards compatibility alias + DecodeError = Error end diff --git a/lib/jwt/jwa/signing_algorithm.rb b/lib/jwt/jwa/signing_algorithm.rb index b4590a8b0..b9fac2053 100644 --- a/lib/jwt/jwa/signing_algorithm.rb +++ b/lib/jwt/jwa/signing_algorithm.rb @@ -35,7 +35,7 @@ def verify(*) end def raise_verify_error!(message) - raise(DecodeError.new(message).tap { |e| e.set_backtrace(caller(1)) }) + raise(VerificationError.new(message).tap { |e| e.set_backtrace(caller(1)) }) end def raise_sign_error!(message) diff --git a/lib/jwt/jwk/key_finder.rb b/lib/jwt/jwk/key_finder.rb index c7387841e..f69236d0c 100644 --- a/lib/jwt/jwk/key_finder.rb +++ b/lib/jwt/jwk/key_finder.rb @@ -28,12 +28,12 @@ def initialize(options) # Returns the verification key for the given kid # @param [String] kid the key id def key_for(kid, key_field = :kid) - raise ::JWT::DecodeError, "Invalid type for #{key_field} header parameter" unless kid.nil? || kid.is_a?(String) + raise ::JWT::SignatureError, "Invalid type for #{key_field} header parameter" unless kid.nil? || kid.is_a?(String) 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 + raise ::JWT::SignatureError, 'No keys found in jwks' unless @jwks.any? + raise ::JWT::SignatureError, "Could not find public key for kid #{kid}" unless jwk jwk.verify_key end @@ -47,7 +47,7 @@ def call(token) return key_for(field_value, key_field) if field_value end - raise ::JWT::DecodeError, 'No key id (kid) or x5t found from token headers' unless @allow_nil_kid + raise ::JWT::SignatureError, 'No key id (kid) or x5t found from token headers' unless @allow_nil_kid kid = token.header['kid'] key_for(kid) diff --git a/lib/jwt/token.rb b/lib/jwt/token.rb index 0c643886f..f6cd7a4df 100644 --- a/lib/jwt/token.rb +++ b/lib/jwt/token.rb @@ -104,7 +104,7 @@ def sign!(key:, algorithm:) # Verifies the claims of the token. # @param options [Array, Hash] the claims to verify. - # @raise [JWT::DecodeError] if the claims are invalid. + # @raise [JWT::ClaimValidationError] if the claims are invalid. def verify_claims!(*options) Claims::Verifier.verify!(self, *options) end diff --git a/spec/jwt/error_spec.rb b/spec/jwt/error_spec.rb new file mode 100644 index 000000000..414ac67e4 --- /dev/null +++ b/spec/jwt/error_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +RSpec.describe 'JWT error hierarchy' do + context 'base classes' do + it 'JWT::Error inherits from StandardError' do + expect(JWT::Error).to be < StandardError + end + + it 'JWT::EncodeError inherits from JWT::Error' do + expect(JWT::EncodeError).to be < JWT::Error + end + + it 'JWT::TokenError inherits from JWT::Error' do + expect(JWT::TokenError).to be < JWT::Error + end + end + + context 'backwards compatibility' do + it 'JWT::DecodeError is an alias for JWT::Error' do + expect(JWT::DecodeError).to eq(JWT::Error) + end + end + + context 'malformed token errors' do + it 'JWT::MalformedTokenError inherits from JWT::TokenError' do + expect(JWT::MalformedTokenError).to be < JWT::TokenError + end + + it 'JWT::Base64DecodeError inherits from JWT::MalformedTokenError' do + expect(JWT::Base64DecodeError).to be < JWT::MalformedTokenError + end + end + + context 'signature errors' do + it 'JWT::SignatureError inherits from JWT::TokenError' do + expect(JWT::SignatureError).to be < JWT::TokenError + end + + it 'JWT::VerificationError inherits from JWT::SignatureError' do + expect(JWT::VerificationError).to be < JWT::SignatureError + end + + it 'JWT::IncorrectAlgorithm inherits from JWT::SignatureError' do + expect(JWT::IncorrectAlgorithm).to be < JWT::SignatureError + end + + it 'JWT::UnsupportedEcdsaCurve inherits from JWT::IncorrectAlgorithm' do + expect(JWT::UnsupportedEcdsaCurve).to be < JWT::IncorrectAlgorithm + end + end + + context 'claim validation errors' do + it 'JWT::ClaimValidationError inherits from JWT::TokenError' do + expect(JWT::ClaimValidationError).to be < JWT::TokenError + end + + %i[ + ExpiredSignature + ImmatureSignature + InvalidIssuerError + InvalidIatError + InvalidAudError + InvalidSubError + InvalidCritError + InvalidJtiError + InvalidPayload + MissingRequiredClaim + ].each do |error_class| + it "JWT::#{error_class} inherits from JWT::ClaimValidationError" do + expect(JWT.const_get(error_class)).to be < JWT::ClaimValidationError + end + end + end + + context 'JWK errors' do + it 'JWT::JWKError inherits from JWT::Error' do + expect(JWT::JWKError).to be < JWT::Error + end + end + + context 'error groups do not overlap' do + it 'claim validation errors are not signature errors' do + expect(JWT::ClaimValidationError).not_to be <= JWT::SignatureError + expect(JWT::SignatureError).not_to be <= JWT::ClaimValidationError + end + + it 'claim validation errors are not malformed token errors' do + expect(JWT::ClaimValidationError).not_to be <= JWT::MalformedTokenError + expect(JWT::MalformedTokenError).not_to be <= JWT::ClaimValidationError + end + + it 'signature errors are not malformed token errors' do + expect(JWT::SignatureError).not_to be <= JWT::MalformedTokenError + expect(JWT::MalformedTokenError).not_to be <= JWT::SignatureError + end + end +end From de6d3fdcaa7fcbd6b06181239f03cae143b6ea4f Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sat, 28 Mar 2026 10:07:57 +0200 Subject: [PATCH 02/20] More specific exception assertions and specific error types --- lib/jwt/encoded_token.rb | 6 +-- lib/jwt/jwk/key_finder.rb | 2 +- spec/integration/readme_examples_spec.rb | 2 +- spec/jwt/encoded_token_spec.rb | 24 +++++----- spec/jwt/jwa/ecdsa_spec.rb | 12 ++--- spec/jwt/jwa/hmac_spec.rb | 14 +++--- spec/jwt/jwk/decode_with_jwk_spec.rb | 18 ++++---- spec/jwt/jwk/ec_spec.rb | 4 +- spec/jwt/jwk/rsa_spec.rb | 4 +- spec/jwt/jwt_spec.rb | 58 ++++++++++++------------ spec/jwt/token_spec.rb | 2 +- 11 files changed, 73 insertions(+), 73 deletions(-) diff --git a/lib/jwt/encoded_token.rb b/lib/jwt/encoded_token.rb index e6fb37f41..7c6bd9cd4 100644 --- a/lib/jwt/encoded_token.rb +++ b/lib/jwt/encoded_token.rb @@ -63,10 +63,10 @@ def header # Returns the payload of the JWT token. Access requires the signature and claims to have been verified. # # @return [Hash] the payload. - # @raise [JWT::Error] if the signature has not been verified. + # @raise [JWT::TokenError] if the signature has not been verified. def payload - raise JWT::Error, 'Verify the token signature before accessing the payload' unless @signature_verified - raise JWT::Error, 'Verify the token claims before accessing the payload' unless @claims_verified + raise JWT::TokenError, 'Verify the token signature before accessing the payload' unless @signature_verified + raise JWT::TokenError, 'Verify the token claims before accessing the payload' unless @claims_verified decoded_payload end diff --git a/lib/jwt/jwk/key_finder.rb b/lib/jwt/jwk/key_finder.rb index f69236d0c..6fd18bb8e 100644 --- a/lib/jwt/jwk/key_finder.rb +++ b/lib/jwt/jwk/key_finder.rb @@ -28,7 +28,7 @@ def initialize(options) # Returns the verification key for the given kid # @param [String] kid the key id def key_for(kid, key_field = :kid) - raise ::JWT::SignatureError, "Invalid type for #{key_field} header parameter" unless kid.nil? || kid.is_a?(String) + raise ::JWT::MalformedTokenError, "Invalid type for #{key_field} header parameter" unless kid.nil? || kid.is_a?(String) jwk = resolve_key(kid, key_field) diff --git a/spec/integration/readme_examples_spec.rb b/spec/integration/readme_examples_spec.rb index af499d3c8..91502c065 100644 --- a/spec/integration/readme_examples_spec.rb +++ b/spec/integration/readme_examples_spec.rb @@ -322,7 +322,7 @@ jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), 'yet-another-new-kid') headers = { kid: jwk.kid } token = JWT.encode(payload, jwk.signing_key, 'RS512', headers) - expect { JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwk_loader }) }.to raise_error(JWT::DecodeError, 'Could not find public key for kid yet-another-new-kid') + expect { JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwk_loader }) }.to raise_error(JWT::SignatureError, 'Could not find public key for kid yet-another-new-kid') end it 'works as expected' do diff --git a/spec/jwt/encoded_token_spec.rb b/spec/jwt/encoded_token_spec.rb index ce38a231c..ec11a6ee5 100644 --- a/spec/jwt/encoded_token_spec.rb +++ b/spec/jwt/encoded_token_spec.rb @@ -26,7 +26,7 @@ context 'when payload is not provided' do it 'raises decode error' do - expect { token.unverified_payload }.to raise_error(JWT::DecodeError, 'Encoded payload is empty') + expect { token.unverified_payload }.to raise_error(JWT::MalformedTokenError, 'Encoded payload is empty') end end end @@ -45,7 +45,7 @@ let(:encoded_token) { '' } it 'raises decode error' do - expect { token.unverified_payload }.to raise_error(JWT::DecodeError, 'Invalid segment encoding') + expect { token.unverified_payload }.to raise_error(JWT::MalformedTokenError, 'Invalid segment encoding') end end end @@ -81,7 +81,7 @@ before { token.verify_signature!(algorithm: 'HS256', key: 'secret') } it 'raises an error' do - expect { token.payload }.to raise_error(JWT::DecodeError, 'Verify the token claims before accessing the payload') + expect { token.payload }.to raise_error(JWT::TokenError, 'Verify the token claims before accessing the payload') end end @@ -89,13 +89,13 @@ before { token.valid_signature?(algorithm: 'HS256', key: 'wrong') } it 'raises an error' do - expect { token.payload }.to raise_error(JWT::DecodeError, 'Verify the token signature before accessing the payload') + expect { token.payload }.to raise_error(JWT::TokenError, 'Verify the token signature before accessing the payload') end end context 'when token is not verified' do it 'raises an error' do - expect { token.payload }.to raise_error(JWT::DecodeError, 'Verify the token signature before accessing the payload') + expect { token.payload }.to raise_error(JWT::TokenError, 'Verify the token signature before accessing the payload') end end end @@ -107,7 +107,7 @@ let(:encoded_token) { '' } it 'raises decode error' do - expect { token.header }.to raise_error(JWT::DecodeError, 'Invalid segment encoding') + expect { token.header }.to raise_error(JWT::MalformedTokenError, 'Invalid segment encoding') end end end @@ -308,7 +308,7 @@ end context 'when payload is not provided' do it 'raises decode error' do - expect { token.verify_claims!(:exp, :nbf) }.to raise_error(JWT::DecodeError, 'Encoded payload is empty') + expect { token.verify_claims!(:exp, :nbf) }.to raise_error(JWT::MalformedTokenError, 'Encoded payload is empty') end end end @@ -451,16 +451,16 @@ expect(token.unverified_payload).to eq({ 'pay' => 'load' }) expect(token.header).to eq({ 'alg' => 'HS256' }) - expect { token.payload }.to raise_error(JWT::DecodeError, 'Verify the token signature before accessing the payload') + expect { token.payload }.to raise_error(JWT::TokenError, 'Verify the token signature before accessing the payload') expect(token.valid_signature?(algorithm: 'HS256', key: 'invalid_signing_key')).to be(false) - expect { token.payload }.to raise_error(JWT::DecodeError, 'Verify the token signature before accessing the payload') + expect { token.payload }.to raise_error(JWT::TokenError, 'Verify the token signature before accessing the payload') expect(token.valid_signature?(algorithm: 'HS256', key: 'secret_signing_key')).to be(true) - expect { token.payload }.to raise_error(JWT::DecodeError, 'Verify the token claims before accessing the payload') + expect { token.payload }.to raise_error(JWT::TokenError, 'Verify the token claims before accessing the payload') expect(token.valid_claims?(iss: 'issuer')).to be(false) - expect { token.payload }.to raise_error(JWT::DecodeError, 'Verify the token claims before accessing the payload') + expect { token.payload }.to raise_error(JWT::TokenError, 'Verify the token claims before accessing the payload') expect(token.valid_claims?).to be(true) expect(token.payload).to eq({ 'pay' => 'load' }) @@ -468,7 +468,7 @@ token = described_class.new(encoded_token) expect(token.valid?(signature: { algorithm: 'HS256', key: 'invalid_signing_key' })).to be(false) - expect { token.payload }.to raise_error(JWT::DecodeError, 'Verify the token signature before accessing the payload') + expect { token.payload }.to raise_error(JWT::TokenError, 'Verify the token signature before accessing the payload') expect(token.valid?(signature: { algorithm: 'HS256', key: 'secret_signing_key' })).to be(true) expect(token.payload).to eq({ 'pay' => 'load' }) diff --git a/spec/jwt/jwa/ecdsa_spec.rb b/spec/jwt/jwa/ecdsa_spec.rb index 4a14d6de7..2d56d858a 100644 --- a/spec/jwt/jwa/ecdsa_spec.rb +++ b/spec/jwt/jwa/ecdsa_spec.rb @@ -58,10 +58,10 @@ end context 'when the verification key is not an OpenSSL::PKey::EC instance' do - it 'raises a JWT::DecodeError' do + it 'raises a JWT::VerificationError' do expect do instance.verify(data: data, signature: '', verification_key: 'not_a_key') - end.to raise_error(JWT::DecodeError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance') + end.to raise_error(JWT::VerificationError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance') end end @@ -82,7 +82,7 @@ end context 'when the signing key is a public key' do - it 'raises a JWT::DecodeError' do + it 'raises a JWT::VerificationError' do public_key = test_pkey('ec256-public.pem') expect do instance.sign(data: data, signing_key: public_key) @@ -91,7 +91,7 @@ end context 'when the signing key is not an OpenSSL::PKey::EC instance' do - it 'raises a JWT::DecodeError' do + it 'raises a JWT::VerificationError' do expect do instance.sign(data: data, signing_key: 'not_a_key') end.to raise_error(JWT::EncodeError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance') @@ -99,11 +99,11 @@ end context 'when the signing key is invalid' do - it 'raises a JWT::DecodeError' do + it 'raises a JWT::UnsupportedEcdsaCurve' do invalid_key = OpenSSL::PKey::EC.generate('sect571r1') expect do instance.sign(data: data, signing_key: invalid_key) - end.to raise_error(JWT::DecodeError, "The ECDSA curve 'sect571r1' is not supported") + end.to raise_error(JWT::UnsupportedEcdsaCurve, "The ECDSA curve 'sect571r1' is not supported") end end end diff --git a/spec/jwt/jwa/hmac_spec.rb b/spec/jwt/jwa/hmac_spec.rb index d394c6d6a..0a355187e 100644 --- a/spec/jwt/jwa/hmac_spec.rb +++ b/spec/jwt/jwa/hmac_spec.rb @@ -17,8 +17,8 @@ context 'when nil hmac_secret is passed' do let(:hmac_secret) { nil } - it 'raises JWT::DecodeError' do - expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key expected to be a String') + it 'raises JWT::VerificationError' do + expect { subject }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') end it 'does not call OpenSSL::HMAC.digest' do @@ -30,8 +30,8 @@ context 'when blank hmac_secret is passed' do let(:hmac_secret) { '' } - it 'raises JWT::DecodeError' do - expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key cannot be empty') + it 'raises JWT::VerificationError' do + expect { subject }.to raise_error(JWT::VerificationError, 'HMAC key cannot be empty') end it 'does not call OpenSSL::HMAC.digest' do @@ -85,7 +85,7 @@ let(:hmac_secret) { 'short' } it 'raises error' do - expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key must be at least 32 bytes for HS256 algorithm') + expect { subject }.to raise_error(JWT::VerificationError, 'HMAC key must be at least 32 bytes for HS256 algorithm') end end @@ -119,7 +119,7 @@ let(:hmac_secret) { 123 } it 'raises error' do - expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key expected to be a String') + expect { subject }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') end end @@ -159,7 +159,7 @@ let(:hmac_secret) { 'short' } it 'raises error' do - expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key must be at least 32 bytes for HS256 algorithm') + expect { subject }.to raise_error(JWT::VerificationError, 'HMAC key must be at least 32 bytes for HS256 algorithm') end end diff --git a/spec/jwt/jwk/decode_with_jwk_spec.rb b/spec/jwt/jwk/decode_with_jwk_spec.rb index 314f47bca..f65f9394c 100644 --- a/spec/jwt/jwk/decode_with_jwk_spec.rb +++ b/spec/jwt/jwk/decode_with_jwk_spec.rb @@ -34,7 +34,7 @@ end it 'raises an exception' do expect { described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks }) }.to raise_error( - JWT::DecodeError, /Could not find public key for kid .*/ + JWT::SignatureError, /Could not find public key for kid .*/ ) end end @@ -63,7 +63,7 @@ let(:public_jwks) { { keys: [] } } it 'raises an exception' do expect { described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks }) }.to raise_error( - JWT::DecodeError, /No keys found in jwks/ + JWT::SignatureError, /No keys found in jwks/ ) end end @@ -72,7 +72,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) or x5t found from token headers' + JWT::SignatureError, 'No key id (kid) or x5t found from token headers' ) end end @@ -116,7 +116,7 @@ let(:token_headers) { { kid: 5 } } it 'raises an exception' do expect { described_class.decode(signed_token, nil, true, { algorithms: ['RS512'], jwks: public_jwks }) }.to raise_error( - JWT::DecodeError, 'Invalid type for kid header parameter' + JWT::MalformedTokenError, 'Invalid type for kid header parameter' ) end end @@ -131,16 +131,16 @@ context 'when RSA key is pointed to as HMAC secret' do let(:signed_token) { described_class.encode({ 'foo' => 'bar' }, 'is not really relevant in the scenario', 'HS256', { kid: rsa_jwk.kid }) } - it 'raises JWT::DecodeError' do - expect { described_class.decode(signed_token, nil, true, algorithms: ['HS256'], jwks: jwks) }.to raise_error(JWT::DecodeError, 'HMAC key expected to be a String') + it 'raises JWT::SignatureError' do + expect { described_class.decode(signed_token, nil, true, algorithms: ['HS256'], jwks: jwks) }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') end end context 'when EC key is pointed to as HMAC secret' do let(:signed_token) { described_class.encode({ 'foo' => 'bar' }, 'is not really relevant in the scenario', 'HS256', { kid: ec_jwk_secp384r1.kid }) } - it 'raises JWT::DecodeError' do - expect { described_class.decode(signed_token, nil, true, algorithms: ['HS256'], jwks: jwks) }.to raise_error(JWT::DecodeError, 'HMAC key expected to be a String') + it 'raises JWT::SignatureError' do + expect { described_class.decode(signed_token, nil, true, algorithms: ['HS256'], jwks: jwks) }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') end end @@ -169,7 +169,7 @@ it 'fails in some way' do expect { described_class.decode(signed_token, nil, true, algorithms: ['ES384'], jwks: jwks) }.to( - raise_error(JWT::DecodeError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance') + raise_error(JWT::VerificationError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance') ) end end diff --git a/spec/jwt/jwk/ec_spec.rb b/spec/jwt/jwk/ec_spec.rb index 86349ef87..0f893ce2c 100644 --- a/spec/jwt/jwk/ec_spec.rb +++ b/spec/jwt/jwk/ec_spec.rb @@ -156,8 +156,8 @@ context 'when the jwk has HS256 as the alg parameter' do let(:rsa) { described_class.new(ec_key, alg: 'HS256') } - it 'raises JWT::DecodeError' do - expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::DecodeError, 'HMAC key expected to be a String') + it 'raises JWT::VerificationError' do + expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') end end end diff --git a/spec/jwt/jwk/rsa_spec.rb b/spec/jwt/jwk/rsa_spec.rb index 321dafee1..7980edb0f 100644 --- a/spec/jwt/jwk/rsa_spec.rb +++ b/spec/jwt/jwk/rsa_spec.rb @@ -133,8 +133,8 @@ context 'when the jwk has HS256 as the alg parameter' do let(:rsa) { described_class.new(rsa_key, alg: 'HS256') } - it 'raises JWT::DecodeError' do - expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::DecodeError, 'HMAC key expected to be a String') + it 'raises JWT::VerificationError' do + expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') end end end diff --git a/spec/jwt/jwt_spec.rb b/spec/jwt/jwt_spec.rb index 0c66bc94d..bd1cb060d 100644 --- a/spec/jwt/jwt_spec.rb +++ b/spec/jwt/jwt_spec.rb @@ -83,7 +83,7 @@ it 'should fail to decode the token' do expect do JWT.decode encoded_token, nil, true - end.to raise_error JWT::DecodeError + end.to raise_error JWT::IncorrectAlgorithm end end end @@ -105,13 +105,13 @@ expect(jwt_payload).to eq payload end - it 'wrong secret should raise JWT::DecodeError' do + it 'wrong secret should raise JWT::VerificationError' do expect do JWT.decode data[alg], 'wrong_secret', true, algorithm: alg end.to raise_error JWT::VerificationError end - it 'wrong secret and verify = false should not raise JWT::DecodeError' do + it 'wrong secret and verify = false should not raise an error' do expect do JWT.decode data[alg], 'wrong_secret', false end.not_to raise_error @@ -141,15 +141,15 @@ expect(jwt_payload).to eq payload end - it 'wrong key should raise JWT::DecodeError' do + it 'wrong key should raise JWT::VerificationError' do key = test_pkey('rsa-2048-wrong-public.pem') expect do JWT.decode data[alg], key, true, algorithm: alg - end.to raise_error JWT::DecodeError + end.to raise_error JWT::VerificationError end - it 'wrong key and verify = false should not raise JWT::DecodeError' do + it 'wrong key and verify = false should not raise an error' do key = test_pkey('rsa-2048-wrong-public.pem') expect do @@ -185,13 +185,13 @@ expect(jwt_payload).to eq payload end - it 'wrong key should raise JWT::DecodeError' do + it 'wrong key should raise JWT::SignatureError' do expect do JWT.decode data[alg], wrong_key - end.to raise_error JWT::DecodeError + end.to raise_error JWT::SignatureError end - it 'wrong key and verify = false should not raise JWT::DecodeError' do + it 'wrong key and verify = false should not raise an error' do expect do JWT.decode data[alg], wrong_key, false end.not_to raise_error @@ -234,13 +234,13 @@ expect(jwt_payload).to eq payload end - it 'wrong key should raise JWT::DecodeError' do + it 'wrong key should raise JWT::SignatureError' do expect do JWT.decode data[alg], wrong_key - end.to raise_error JWT::DecodeError + end.to raise_error JWT::SignatureError end - it 'wrong key and verify = false should not raise JWT::DecodeError' do + it 'wrong key and verify = false should not raise an error' do expect do JWT.decode data[alg], wrong_key, false end.not_to raise_error @@ -249,7 +249,7 @@ end context 'Invalid' do - it 'algorithm should raise DecodeError' do + it 'invalid algorithm should raise EncodeError' do expect do JWT.encode payload, 'secret', 'HS255' end.to raise_error JWT::EncodeError @@ -257,7 +257,7 @@ it 'raises "No verification key available" error' do token = JWT.encode({}, 'foo') - expect { JWT.decode(token, nil, true) }.to raise_error(JWT::DecodeError, 'No verification key available') + expect { JWT.decode(token, nil, true) }.to raise_error(JWT::SignatureError, 'No verification key available') end it 'ECDSA curve_name should raise JWT::IncorrectAlgorithm' do @@ -424,7 +424,7 @@ JWT.decode(token, nil, true, algorithm: 'HS256') do nil end - end.to raise_error JWT::DecodeError, 'No verification key available' + end.to raise_error JWT::SignatureError, 'No verification key available' end it 'should raise JWT::IncorrectAlgorithm when algorithms array does not contain algorithm' do @@ -466,18 +466,18 @@ end context 'invalid header format' do - it 'should raise JWT::DecodeError' do + it 'should raise JWT::MalformedTokenError' do expect do JWT.decode data[:invalid_header_token] - end.to raise_error JWT::DecodeError + end.to raise_error JWT::MalformedTokenError end end context 'invalid 2-segment header format' do - it 'should raise JWT::DecodeError' do + it 'should raise JWT::MalformedTokenError' do expect do JWT.decode data[:invalid_2_segment_header_token] - end.to raise_error JWT::DecodeError, 'Not enough or too many segments' + end.to raise_error JWT::MalformedTokenError, 'Not enough or too many segments' end end @@ -485,7 +485,7 @@ it 'should raise JWT::IncorrectAlgorithm' do expect do JWT.decode data[:empty_token_2_segment] - end.to raise_error JWT::DecodeError + end.to raise_error JWT::IncorrectAlgorithm end end end @@ -539,21 +539,21 @@ end context 'a token with no segments' do - it 'raises JWT::DecodeError' do - expect { JWT.decode('ThisIsNotAValidJWTToken', nil, true) }.to raise_error(JWT::DecodeError, 'Not enough or too many segments') + it 'raises JWT::MalformedTokenError' do + expect { JWT.decode('ThisIsNotAValidJWTToken', nil, true) }.to raise_error(JWT::MalformedTokenError, 'Not enough or too many segments') end end context 'a token with not enough segments' do - it 'raises JWT::DecodeError' do + it 'raises JWT::MalformedTokenError' do token = JWT.encode('ThisIsNotAValidJWTToken', 'secret').split('.').slice(1, 2).join - expect { JWT.decode(token, nil, true) }.to raise_error(JWT::DecodeError, 'Not enough or too many segments') + expect { JWT.decode(token, nil, true) }.to raise_error(JWT::MalformedTokenError, 'Not enough or too many segments') end end context 'a token with not too many segments' do - it 'raises JWT::DecodeError' do - expect { JWT.decode('ThisIsNotAValidJWTToken.second.third.signature', nil, true) }.to raise_error(JWT::DecodeError, 'Not enough or too many segments') + it 'raises JWT::MalformedTokenError' do + expect { JWT.decode('ThisIsNotAValidJWTToken.second.third.signature', nil, true) }.to raise_error(JWT::MalformedTokenError, 'Not enough or too many segments') end end @@ -731,8 +731,8 @@ JWT.configuration.strict_base64_decoding = true end - it 'raises JWT::DecodeError' do - expect { JWT.decode(token, 'secret', true, algorithm: 'HS256') }.to raise_error(JWT::DecodeError, 'Invalid base64 encoding') + it 'raises JWT::Base64DecodeError' do + expect { JWT.decode(token, 'secret', true, algorithm: 'HS256') }.to raise_error(JWT::Base64DecodeError, 'Invalid base64 encoding') end end @@ -929,7 +929,7 @@ def verify(*) end it 'raises error on decoding' do - expect { JWT.decode(expected_token, 'secret', true, algorithm: custom_algorithm.new) }.to raise_error(JWT::DecodeError, /missing the verify method/) + expect { JWT.decode(expected_token, 'secret', true, algorithm: custom_algorithm.new) }.to raise_error(JWT::VerificationError, /missing the verify method/) end end end diff --git a/spec/jwt/token_spec.rb b/spec/jwt/token_spec.rb index 3f4438419..c55b1ee92 100644 --- a/spec/jwt/token_spec.rb +++ b/spec/jwt/token_spec.rb @@ -42,7 +42,7 @@ context 'with mismatching algorithm provided in sign call' do it 'signs the token' do - expect { token.sign!(algorithm: %w[RS384 RS512], key: jwk) }.to raise_error(JWT::DecodeError, 'Provided JWKs do not support one of the specified algorithms: RS384, RS512') + expect { token.sign!(algorithm: %w[RS384 RS512], key: jwk) }.to raise_error(JWT::Error, 'Provided JWKs do not support one of the specified algorithms: RS384, RS512') end end end From e2dac628e2d515f5056ffdcb214609b088fc6644 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sat, 28 Mar 2026 10:10:42 +0200 Subject: [PATCH 03/20] Deprecation comment --- lib/jwt/error.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb index bcc49ddbf..29e784f57 100644 --- a/lib/jwt/error.rb +++ b/lib/jwt/error.rb @@ -67,6 +67,6 @@ class JWKError < Error; end # Raised when a JWK uses a key type (kty) that this library does not support. class UnsupportedKeyType < JWKError; end - # Backwards compatibility alias + # @deprecated Use {JWT::Error}, {JWT::TokenError}, or a more specific error class instead. DecodeError = Error end From 4a0c99193fecd97bd9141c0edfea4bffffff5727 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sat, 28 Mar 2026 10:11:56 +0200 Subject: [PATCH 04/20] Changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5c1d897..5032149f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ **Features:** +- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, and `JWT::ClaimValidationError` grouping classes [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) - Your contribution here **Fixes and enhancements:** From 5743c0e3dbcb68f54022d5c8f724d2be337a6d90 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sat, 28 Mar 2026 10:26:22 +0200 Subject: [PATCH 05/20] React on copilot comments --- CHANGELOG.md | 2 +- lib/jwt/jwa.rb | 2 +- spec/integration/readme_examples_spec.rb | 8 ++++---- spec/jwt/jwk/decode_with_jwk_spec.rb | 4 ++-- spec/jwt/jwt_spec.rb | 6 ++++++ spec/jwt/token_spec.rb | 2 +- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5032149f3..28cf5270c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ **Features:** -- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, and `JWT::ClaimValidationError` grouping classes [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) +- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, and `JWT::ClaimValidationError` grouping classes. `JWT::DecodeError` is now a deprecated alias for `JWT::Error` [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) - Your contribution here **Fixes and enhancements:** diff --git a/lib/jwt/jwa.rb b/lib/jwt/jwa.rb index e5c051988..78cac30df 100644 --- a/lib/jwt/jwa.rb +++ b/lib/jwt/jwa.rb @@ -37,7 +37,7 @@ def resolve_and_sort(algorithms:, preferred_algorithm:) # @api private def create_signer(algorithm:, key:) if key.is_a?(JWK::KeyBase) - validate_jwk_algorithms!(key, algorithm, DecodeError) + validate_jwk_algorithms!(key, algorithm, EncodeError) return key end diff --git a/spec/integration/readme_examples_spec.rb b/spec/integration/readme_examples_spec.rb index 91502c065..a354f1996 100644 --- a/spec/integration/readme_examples_spec.rb +++ b/spec/integration/readme_examples_spec.rb @@ -304,8 +304,8 @@ JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwk_loader }) rescue JWT::JWKError # Handle problems with the provided JWKs - rescue JWT::DecodeError - # Handle other decode related issues e.g. no kid in header, no matching public key found etc. + rescue JWT::TokenError + # Handle other token related issues e.g. no kid in header, no matching public key found etc. end ## This is not in the example but verifies that the cache is invalidated after 5 minutes @@ -355,8 +355,8 @@ JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwks_loader }) rescue JWT::JWKError # Handle problems with the provided JWKs - rescue JWT::DecodeError - # Handle other decode related issues e.g. no kid in header, no matching public key found etc. + rescue JWT::TokenError + # Handle other token related issues e.g. no kid in header, no matching public key found etc. end end end diff --git a/spec/jwt/jwk/decode_with_jwk_spec.rb b/spec/jwt/jwk/decode_with_jwk_spec.rb index f65f9394c..129e05ce4 100644 --- a/spec/jwt/jwk/decode_with_jwk_spec.rb +++ b/spec/jwt/jwk/decode_with_jwk_spec.rb @@ -131,7 +131,7 @@ context 'when RSA key is pointed to as HMAC secret' do let(:signed_token) { described_class.encode({ 'foo' => 'bar' }, 'is not really relevant in the scenario', 'HS256', { kid: rsa_jwk.kid }) } - it 'raises JWT::SignatureError' do + it 'raises JWT::VerificationError' do expect { described_class.decode(signed_token, nil, true, algorithms: ['HS256'], jwks: jwks) }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') end end @@ -139,7 +139,7 @@ context 'when EC key is pointed to as HMAC secret' do let(:signed_token) { described_class.encode({ 'foo' => 'bar' }, 'is not really relevant in the scenario', 'HS256', { kid: ec_jwk_secp384r1.kid }) } - it 'raises JWT::SignatureError' do + it 'raises JWT::VerificationError' do expect { described_class.decode(signed_token, nil, true, algorithms: ['HS256'], jwks: jwks) }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') end end diff --git a/spec/jwt/jwt_spec.rb b/spec/jwt/jwt_spec.rb index bd1cb060d..754a02b45 100644 --- a/spec/jwt/jwt_spec.rb +++ b/spec/jwt/jwt_spec.rb @@ -538,6 +538,12 @@ end end + context 'when nil is passed as the token' do + it 'raises JWT::MalformedTokenError' do + expect { JWT.decode(nil, nil, true) }.to raise_error(JWT::MalformedTokenError, 'Nil JSON web token') + end + end + context 'a token with no segments' do it 'raises JWT::MalformedTokenError' do expect { JWT.decode('ThisIsNotAValidJWTToken', nil, true) }.to raise_error(JWT::MalformedTokenError, 'Not enough or too many segments') diff --git a/spec/jwt/token_spec.rb b/spec/jwt/token_spec.rb index c55b1ee92..93dec3a74 100644 --- a/spec/jwt/token_spec.rb +++ b/spec/jwt/token_spec.rb @@ -42,7 +42,7 @@ context 'with mismatching algorithm provided in sign call' do it 'signs the token' do - expect { token.sign!(algorithm: %w[RS384 RS512], key: jwk) }.to raise_error(JWT::Error, 'Provided JWKs do not support one of the specified algorithms: RS384, RS512') + expect { token.sign!(algorithm: %w[RS384 RS512], key: jwk) }.to raise_error(JWT::EncodeError, 'Provided JWKs do not support one of the specified algorithms: RS384, RS512') end end end From 07491ef20d865bf15b211bceb5e9f026672754bb Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sat, 28 Mar 2026 10:36:52 +0200 Subject: [PATCH 06/20] Make specs test what they claim they are testing --- spec/jwt/jwt_spec.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/spec/jwt/jwt_spec.rb b/spec/jwt/jwt_spec.rb index 754a02b45..35d5a9d70 100644 --- a/spec/jwt/jwt_spec.rb +++ b/spec/jwt/jwt_spec.rb @@ -169,7 +169,7 @@ data[alg] = JWT.encode(payload, data["#{alg}_private"], alg) end - let(:wrong_key) { test_pkey('ec256-wrong-public.pem') } + let(:wrong_key) { OpenSSL::PKey::EC.generate(data["#{alg}_private"].group.curve_name) } it 'should generate a valid token' do jwt_payload, header = JWT.decode data[alg], data["#{alg}_public"], true, algorithm: alg @@ -185,10 +185,10 @@ expect(jwt_payload).to eq payload end - it 'wrong key should raise JWT::SignatureError' do + it 'wrong key should raise JWT::VerificationError' do expect do - JWT.decode data[alg], wrong_key - end.to raise_error JWT::SignatureError + JWT.decode data[alg], wrong_key, true, algorithm: alg + end.to raise_error JWT::VerificationError end it 'wrong key and verify = false should not raise an error' do @@ -234,10 +234,10 @@ expect(jwt_payload).to eq payload end - it 'wrong key should raise JWT::SignatureError' do + it 'wrong key should raise JWT::VerificationError' do expect do - JWT.decode data[alg], wrong_key - end.to raise_error JWT::SignatureError + JWT.decode data[alg], wrong_key, true, algorithm: alg + end.to raise_error JWT::VerificationError end it 'wrong key and verify = false should not raise an error' do From 0f9aee77875a0594269744678ce7b949edf17511 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sat, 30 May 2026 09:39:51 +0300 Subject: [PATCH 07/20] Sharpen examples and changelog --- CHANGELOG.md | 2 +- README.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28cf5270c..50a3cb2b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ **Features:** -- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, and `JWT::ClaimValidationError` grouping classes. `JWT::DecodeError` is now a deprecated alias for `JWT::Error` [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) +- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, and `JWT::ClaimValidationError` grouping classes. `JWT::DecodeError` is now a deprecated alias for `JWT::Error`; because of this, `rescue JWT::DecodeError` now also catches `JWT::EncodeError` and `JWT::JWKError` [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) - Your contribution here **Fixes and enhancements:** diff --git a/README.md b/README.md index 0e8122edc..63a6476a2 100644 --- a/README.md +++ b/README.md @@ -632,7 +632,7 @@ end begin JWT.decode(token, nil, true, { x5c: { root_certificates: root_certificates, crls: crls } }) -rescue JWT::DecodeError +rescue JWT::TokenError # Handle error, e.g. x5c header certificate revoked or expired end ``` @@ -697,8 +697,8 @@ begin JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwks_loader }) rescue JWT::JWKError # Handle problems with the provided JWKs -rescue JWT::DecodeError - # Handle other decode related issues e.g. no kid in header, no matching public key found etc. +rescue JWT::TokenError + # Handle other token related issues e.g. no kid in header, no matching public key found etc. end ``` From be105221b627165cf58c17d0aefdda1156675d65 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Wed, 2 Sep 2026 17:42:06 +0300 Subject: [PATCH 08/20] Assert the specific error class in HMAC specs --- spec/jwt/jwa/hmac_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/jwt/jwa/hmac_spec.rb b/spec/jwt/jwa/hmac_spec.rb index 0a355187e..222fd410b 100644 --- a/spec/jwt/jwa/hmac_spec.rb +++ b/spec/jwt/jwa/hmac_spec.rb @@ -23,7 +23,7 @@ it 'does not call OpenSSL::HMAC.digest' do expect(OpenSSL::HMAC).not_to receive(:digest) - expect { subject }.to raise_error(JWT::DecodeError) + expect { subject }.to raise_error(JWT::VerificationError) end end @@ -36,7 +36,7 @@ it 'does not call OpenSSL::HMAC.digest' do expect(OpenSSL::HMAC).not_to receive(:digest) - expect { subject }.to raise_error(JWT::DecodeError) + expect { subject }.to raise_error(JWT::VerificationError) end end @@ -130,7 +130,7 @@ it 'raises error and does not call OpenSSL::HMAC.digest' do expect(OpenSSL::HMAC).not_to receive(:digest) - expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key expected to be a String') + expect { subject }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') end end @@ -140,7 +140,7 @@ it 'raises error and does not call OpenSSL::HMAC.digest' do expect(OpenSSL::HMAC).not_to receive(:digest) - expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key cannot be empty') + expect { subject }.to raise_error(JWT::VerificationError, 'HMAC key cannot be empty') end end From 7a8a8672eb567ca569ccfab162f068fb1d3eef21 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Wed, 2 Sep 2026 17:42:06 +0300 Subject: [PATCH 09/20] Remove EC fixture no longer used by any spec --- spec/fixtures/keys/ec256-wrong-public.pem | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 spec/fixtures/keys/ec256-wrong-public.pem diff --git a/spec/fixtures/keys/ec256-wrong-public.pem b/spec/fixtures/keys/ec256-wrong-public.pem deleted file mode 100644 index 511a0def9..000000000 --- a/spec/fixtures/keys/ec256-wrong-public.pem +++ /dev/null @@ -1,4 +0,0 @@ ------BEGIN PUBLIC KEY----- -MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEPmuXZT3jpJnEMVPOW6RMsmxeGLOCE1PN -6fwvUwOsxv7YnyoQ5/bpo64n+Jp4slSl1aUNoCBF2oz9bS0iyBo3jg== ------END PUBLIC KEY----- From a69465513c21896bed83cf25808dc5f30716b9e1 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Wed, 2 Sep 2026 17:42:06 +0300 Subject: [PATCH 10/20] Correct changelog note on what rescue JWT::DecodeError now catches --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a3cb2b0..f8e75723c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ **Features:** -- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, and `JWT::ClaimValidationError` grouping classes. `JWT::DecodeError` is now a deprecated alias for `JWT::Error`; because of this, `rescue JWT::DecodeError` now also catches `JWT::EncodeError` and `JWT::JWKError` [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) +- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, and `JWT::ClaimValidationError` grouping classes. `JWT::DecodeError` is now a deprecated alias for `JWT::Error`; because of this, `rescue JWT::DecodeError` now also catches `JWT::EncodeError` [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) - Your contribution here **Fixes and enhancements:** From 50bb298231d3a78f652d7df168d44b6f9679c47b Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Thu, 3 Sep 2026 15:58:37 +0300 Subject: [PATCH 11/20] Raise EncodeError for every signing failure Signing with an ECDSA key on a mismatched or unsupported curve raised IncorrectAlgorithm or UnsupportedEcdsaCurve, and an invalid HMAC key raised through the verify helper. Under the new hierarchy those are SignatureError < TokenError, so `JWT.encode` could fail with an error that is not an EncodeError, unlike its other signing failures and the Token#sign! contract. The ECDSA sign path now resolves the curve through a signing-side helper that raises EncodeError, and the HMAC key validation yields its message so sign and verify each raise their own error class. Verify paths are unchanged. Specs: rename the ECDSA sign examples that claimed VerificationError while asserting EncodeError, cover the curve mismatch on sign, and split the curve_name spec into an encode and a decode case. The JWK "ES384 key pointed to as ES512 key" spec was loading the P-384 fixture for both keys and only passed because encoding failed first; it now uses the P-521 fixture and exercises verification. --- CHANGELOG.md | 2 +- lib/jwt/jwa/ecdsa.rb | 13 +++++++++---- lib/jwt/jwa/hmac.rb | 20 ++++++++------------ spec/jwt/jwa/ecdsa_spec.rb | 19 ++++++++++++++----- spec/jwt/jwa/hmac_spec.rb | 14 +++++++------- spec/jwt/jwk/decode_with_jwk_spec.rb | 8 ++++---- spec/jwt/jwt_spec.rb | 11 +++++++---- 7 files changed, 50 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e75723c..e0461b364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ **Features:** -- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, and `JWT::ClaimValidationError` grouping classes. `JWT::DecodeError` is now a deprecated alias for `JWT::Error`; because of this, `rescue JWT::DecodeError` now also catches `JWT::EncodeError` [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) +- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, and `JWT::ClaimValidationError` grouping classes. `JWT::DecodeError` is now a deprecated alias for `JWT::Error`; because of this, `rescue JWT::DecodeError` now also catches `JWT::EncodeError`. Signing failures now consistently raise `JWT::EncodeError`: an ECDSA signing key with a mismatched or unsupported curve and an invalid HMAC signing key previously surfaced as `JWT::IncorrectAlgorithm`, `JWT::UnsupportedEcdsaCurve` or `JWT::DecodeError` from `JWT.encode` [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) - Your contribution here **Fixes and enhancements:** diff --git a/lib/jwt/jwa/ecdsa.rb b/lib/jwt/jwa/ecdsa.rb index 9840621f7..761dcb774 100644 --- a/lib/jwt/jwa/ecdsa.rb +++ b/lib/jwt/jwa/ecdsa.rb @@ -15,10 +15,8 @@ def sign(data:, signing_key:) raise_sign_error!("The given key is a #{signing_key.class}. It has to be an OpenSSL::PKey::EC instance") unless signing_key.is_a?(::OpenSSL::PKey::EC) raise_sign_error!('The given key is not a private key') unless signing_key.private? - curve_definition = curve_by_name(signing_key.group.curve_name) - key_algorithm = curve_definition[:algorithm] - - raise IncorrectAlgorithm, "payload algorithm is #{alg} but #{key_algorithm} signing key was provided" if alg != key_algorithm + key_algorithm = signing_key_algorithm(signing_key) + raise_sign_error!("payload algorithm is #{alg} but #{key_algorithm} signing key was provided") if alg != key_algorithm asn1_to_raw(signing_key.dsa_sign_asn1(OpenSSL::Digest.new(digest).digest(data)), signing_key) end @@ -95,6 +93,13 @@ def curve_by_name(name) self.class.curve_by_name(name) end + # Signing-side counterpart of {.curve_by_name}. An unsupported curve on the + # signing key is an encoding problem, so it raises a JWT::EncodeError. + def signing_key_algorithm(signing_key) + curve_name = signing_key.group.curve_name + NAMED_CURVES.fetch(curve_name) { raise_sign_error!("The ECDSA curve '#{curve_name}' is not supported") }[:algorithm] + end + def raw_to_asn1(signature, private_key) byte_size = (private_key.group.degree + 7) / 8 sig_bytes = signature[0..(byte_size - 1)] diff --git a/lib/jwt/jwa/hmac.rb b/lib/jwt/jwa/hmac.rb index 0e52851ea..75f2ebe67 100644 --- a/lib/jwt/jwa/hmac.rb +++ b/lib/jwt/jwa/hmac.rb @@ -21,15 +21,13 @@ def initialize(alg, digest) end def sign(data:, signing_key:) - ensure_valid_key!(signing_key) - validate_key_length!(signing_key) + validate_key!(signing_key) { |message| raise_sign_error!(message) } OpenSSL::HMAC.digest(digest.new, signing_key, data) end def verify(data:, signature:, verification_key:) - ensure_valid_key!(verification_key) - validate_key_length!(verification_key) + validate_key!(verification_key) { |message| raise_verify_error!(message) } SecurityUtils.secure_compare(signature, OpenSSL::HMAC.digest(digest.new, verification_key, data)) end @@ -42,18 +40,16 @@ def verify(data:, signature:, verification_key:) attr_reader :digest - def ensure_valid_key!(key) - raise_verify_error!('HMAC key expected to be a String') unless key.is_a?(String) - raise_verify_error!('HMAC key cannot be empty') if key.empty? - end + # Yields a message for the first problem found with the key. The caller + # raises it, so signing and verification failures keep their own error class. + def validate_key!(key) + yield 'HMAC key expected to be a String' unless key.is_a?(String) + yield 'HMAC key cannot be empty' if key.empty? - def validate_key_length!(key) return unless JWT.configuration.decode.enforce_hmac_key_length min_length = MIN_KEY_LENGTHS[alg] - return if key.bytesize >= min_length - - raise_verify_error!("HMAC key must be at least #{min_length} bytes for #{alg} algorithm") + yield "HMAC key must be at least #{min_length} bytes for #{alg} algorithm" if key.bytesize < min_length end # Copy of https://github.com/rails/rails/blob/v7.0.3.1/activesupport/lib/active_support/security_utils.rb diff --git a/spec/jwt/jwa/ecdsa_spec.rb b/spec/jwt/jwa/ecdsa_spec.rb index 2d56d858a..0cd4df20a 100644 --- a/spec/jwt/jwa/ecdsa_spec.rb +++ b/spec/jwt/jwa/ecdsa_spec.rb @@ -82,7 +82,7 @@ end context 'when the signing key is a public key' do - it 'raises a JWT::VerificationError' do + it 'raises a JWT::EncodeError' do public_key = test_pkey('ec256-public.pem') expect do instance.sign(data: data, signing_key: public_key) @@ -91,19 +91,28 @@ end context 'when the signing key is not an OpenSSL::PKey::EC instance' do - it 'raises a JWT::VerificationError' do + it 'raises a JWT::EncodeError' do expect do instance.sign(data: data, signing_key: 'not_a_key') end.to raise_error(JWT::EncodeError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance') end end - context 'when the signing key is invalid' do - it 'raises a JWT::UnsupportedEcdsaCurve' do + context 'when the signing key uses an unsupported curve' do + it 'raises a JWT::EncodeError' do invalid_key = OpenSSL::PKey::EC.generate('sect571r1') expect do instance.sign(data: data, signing_key: invalid_key) - end.to raise_error(JWT::UnsupportedEcdsaCurve, "The ECDSA curve 'sect571r1' is not supported") + end.to raise_error(JWT::EncodeError, "The ECDSA curve 'sect571r1' is not supported") + end + end + + context 'when the signing key is for another curve' do + it 'raises a JWT::EncodeError' do + other_curve_key = OpenSSL::PKey::EC.generate('secp384r1') + expect do + instance.sign(data: data, signing_key: other_curve_key) + end.to raise_error(JWT::EncodeError, 'payload algorithm is ES256 but ES384 signing key was provided') end end end diff --git a/spec/jwt/jwa/hmac_spec.rb b/spec/jwt/jwa/hmac_spec.rb index 222fd410b..92640653c 100644 --- a/spec/jwt/jwa/hmac_spec.rb +++ b/spec/jwt/jwa/hmac_spec.rb @@ -17,26 +17,26 @@ context 'when nil hmac_secret is passed' do let(:hmac_secret) { nil } - it 'raises JWT::VerificationError' do - expect { subject }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') + it 'raises JWT::EncodeError' do + expect { subject }.to raise_error(JWT::EncodeError, 'HMAC key expected to be a String') end it 'does not call OpenSSL::HMAC.digest' do expect(OpenSSL::HMAC).not_to receive(:digest) - expect { subject }.to raise_error(JWT::VerificationError) + expect { subject }.to raise_error(JWT::EncodeError) end end context 'when blank hmac_secret is passed' do let(:hmac_secret) { '' } - it 'raises JWT::VerificationError' do - expect { subject }.to raise_error(JWT::VerificationError, 'HMAC key cannot be empty') + it 'raises JWT::EncodeError' do + expect { subject }.to raise_error(JWT::EncodeError, 'HMAC key cannot be empty') end it 'does not call OpenSSL::HMAC.digest' do expect(OpenSSL::HMAC).not_to receive(:digest) - expect { subject }.to raise_error(JWT::VerificationError) + expect { subject }.to raise_error(JWT::EncodeError) end end @@ -85,7 +85,7 @@ let(:hmac_secret) { 'short' } it 'raises error' do - expect { subject }.to raise_error(JWT::VerificationError, 'HMAC key must be at least 32 bytes for HS256 algorithm') + expect { subject }.to raise_error(JWT::EncodeError, 'HMAC key must be at least 32 bytes for HS256 algorithm') end end diff --git a/spec/jwt/jwk/decode_with_jwk_spec.rb b/spec/jwt/jwk/decode_with_jwk_spec.rb index 129e05ce4..03d8f2e6e 100644 --- a/spec/jwt/jwk/decode_with_jwk_spec.rb +++ b/spec/jwt/jwk/decode_with_jwk_spec.rb @@ -125,7 +125,7 @@ let(:hmac_jwk) { JWT::JWK.new('secret') } let(:rsa_jwk) { JWT::JWK.new(test_pkey('rsa-2048-private.pem')) } let(:ec_jwk_secp384r1) { JWT::JWK.new(test_pkey('ec384-private.pem')) } - let(:ec_jwk_secp521r1) { JWT::JWK.new(test_pkey('ec384-private.pem')) } + let(:ec_jwk_secp521r1) { JWT::JWK.new(test_pkey('ec512-private.pem')) } let(:jwks) { { keys: [hmac_jwk.export(include_private: true), rsa_jwk.export, ec_jwk_secp384r1.export, ec_jwk_secp521r1.export] } } context 'when RSA key is pointed to as HMAC secret' do @@ -175,11 +175,11 @@ end context 'when ES384 key is pointed to as ES512 key' do - let(:signed_token) { described_class.encode({ 'foo' => 'bar' }, ec_jwk_secp384r1.signing_key, 'ES512', { kid: ec_jwk_secp521r1.kid }) } + let(:signed_token) { described_class.encode({ 'foo' => 'bar' }, ec_jwk_secp384r1.signing_key, 'ES384', { kid: ec_jwk_secp521r1.kid }) } it 'fails in some way' do - expect { described_class.decode(signed_token, nil, true, algorithms: ['ES512'], jwks: jwks) }.to( - raise_error(JWT::IncorrectAlgorithm, 'payload algorithm is ES512 but ES384 signing key was provided') + expect { described_class.decode(signed_token, nil, true, algorithms: ['ES384'], jwks: jwks) }.to( + raise_error(JWT::IncorrectAlgorithm, 'payload algorithm is ES384 but ES512 verification key was provided') ) end end diff --git a/spec/jwt/jwt_spec.rb b/spec/jwt/jwt_spec.rb index 35d5a9d70..55d6df684 100644 --- a/spec/jwt/jwt_spec.rb +++ b/spec/jwt/jwt_spec.rb @@ -260,18 +260,21 @@ expect { JWT.decode(token, nil, true) }.to raise_error(JWT::SignatureError, 'No verification key available') end - it 'ECDSA curve_name should raise JWT::IncorrectAlgorithm' do + it 'ECDSA curve_name mismatch should raise JWT::EncodeError when encoding' do key = OpenSSL::PKey::EC.generate('secp256k1') expect do JWT.encode payload, key, 'ES256' - end.to raise_error JWT::IncorrectAlgorithm + end.to raise_error JWT::EncodeError, 'payload algorithm is ES256 but ES256K signing key was provided' + end + it 'ECDSA curve_name mismatch should raise JWT::IncorrectAlgorithm when decoding' do + key = OpenSSL::PKey::EC.generate('secp256k1') token = JWT.encode payload, data['ES256_private'], 'ES256' expect do - JWT.decode token, key - end.to raise_error JWT::IncorrectAlgorithm + JWT.decode token, key, true, algorithm: 'ES256' + end.to raise_error JWT::IncorrectAlgorithm, 'payload algorithm is ES256 but ES256K verification key was provided' end end From ab0db891c45d2d0094f6290ad96be7b280d3af98 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Thu, 3 Sep 2026 20:40:14 +0300 Subject: [PATCH 12/20] Keep the claim predicates from raising on a malformed payload Claims::Verifier.errors used to rescue the catch-all DecodeError, so a payload segment that could not be decoded showed up as a claim error and EncodedToken#valid_claims?, #claim_errors and #valid? returned false or a list. Narrowing the rescue to ClaimValidationError let the MalformedTokenError raised while decoding the payload escape, turning those predicates into raisers. Rescue MalformedTokenError alongside ClaimValidationError so the predicate API keeps its contract, and cover the detached-and-missing payload and non-JSON payload cases, including #valid? with a signature that verifies. --- lib/jwt/claims/verifier.rb | 4 +++- spec/jwt/encoded_token_spec.rb | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/jwt/claims/verifier.rb b/lib/jwt/claims/verifier.rb index 2785cbb75..fa2f95bc0 100644 --- a/lib/jwt/claims/verifier.rb +++ b/lib/jwt/claims/verifier.rb @@ -33,7 +33,9 @@ def errors(context, *options) errors = [] iterate_verifiers(*options) do |verifier, verifier_options| verify_one!(context, verifier, verifier_options) - rescue ::JWT::ClaimValidationError => e + rescue ::JWT::ClaimValidationError, ::JWT::MalformedTokenError => e + # A payload that cannot be decoded has no valid claims either, so the + # predicate API reports it instead of raising. errors << Error.new(message: e.message) end errors diff --git a/spec/jwt/encoded_token_spec.rb b/spec/jwt/encoded_token_spec.rb index ec11a6ee5..122b6d54d 100644 --- a/spec/jwt/encoded_token_spec.rb +++ b/spec/jwt/encoded_token_spec.rb @@ -417,6 +417,32 @@ end end end + + context 'when payload is detached and not provided' do + let(:encoded_token) { detached_payload_token.jwt } + + it 'returns false instead of raising' do + expect(token.valid_claims?(:exp)).to be(false) + end + end + + context 'when payload is not valid JSON' do + let(:encoded_token) do + header_segment = Base64.urlsafe_encode64('{"alg":"HS256"}', padding: false) + payload_segment = Base64.urlsafe_encode64('not json', padding: false) + signature = Base64.urlsafe_encode64(OpenSSL::HMAC.digest('SHA256', 'secret', "#{header_segment}.#{payload_segment}"), padding: false) + [header_segment, payload_segment, signature].join('.') + end + + it 'returns false instead of raising' do + expect(token.valid_claims?(:exp)).to be(false) + end + + it 'makes #valid? return false even though the signature is valid' do + expect(token.valid_signature?(algorithm: 'HS256', key: 'secret')).to be(true) + expect(token.valid?(signature: { algorithm: 'HS256', key: 'secret' })).to be(false) + end + end end describe '#claim_errors' do @@ -435,6 +461,14 @@ end end end + + context 'when payload is detached and not provided' do + let(:encoded_token) { detached_payload_token.jwt } + + it 'reports the decoding problem instead of raising' do + expect(token.claim_errors(:exp).map(&:message)).to eq(['Encoded payload is empty']) + end + end end describe 'integration use-cases' do From f33ed78ffdb631bea18e6aa1c3048c0871431933 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sat, 5 Sep 2026 20:01:01 +0300 Subject: [PATCH 13/20] Keep JWT::DecodeError as a real class and split verification key errors Making DecodeError an alias for Error changed JWT::DecodeError.name to "JWT::Error", which silently breaks error-tracking groupings and log filters keyed on the class name, and widened rescue JWT::DecodeError to also catch JWT::EncodeError. Keeping it as a deprecated class between Error and TokenError preserves both, and leaves the only intended behaviour change: signing failures raise JWT::EncodeError. raise_verify_error! is only ever used for a key or algorithm that cannot be used, never for a signature that does not match, so it now raises a separate JWT::VerificationKeyError. On main those cases were plain DecodeError, so rescue JWT::VerificationError keeps meaning exactly what it means today rather than widening to cover key problems. The backwards compatibility spec now discovers the error classes instead of listing them, so a class added later is covered without touching it. --- CHANGELOG.md | 2 +- lib/jwt/error.rb | 21 +++++++++---- lib/jwt/jwa/signing_algorithm.rb | 2 +- spec/jwt/error_spec.rb | 45 ++++++++++++++++++++++++---- spec/jwt/jwa/ecdsa_spec.rb | 4 +-- spec/jwt/jwa/hmac_spec.rb | 8 ++--- spec/jwt/jwk/decode_with_jwk_spec.rb | 10 +++---- spec/jwt/jwk/ec_spec.rb | 4 +-- spec/jwt/jwk/rsa_spec.rb | 4 +-- spec/jwt/jwt_spec.rb | 2 +- 10 files changed, 72 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d3c0eb9..885d70e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ **Features:** - Allow a leeway to be given for the `iat` claim verification [#747](https://github.com/jwt/ruby-jwt/pull/747) - ([@denis1011101](https://github.com/denis1011101)) -- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, and `JWT::ClaimValidationError` grouping classes. `JWT::DecodeError` is now a deprecated alias for `JWT::Error`; because of this, `rescue JWT::DecodeError` now also catches `JWT::EncodeError`. Signing failures now consistently raise `JWT::EncodeError`: an ECDSA signing key with a mismatched or unsupported curve and an invalid HMAC signing key previously surfaced as `JWT::IncorrectAlgorithm`, `JWT::UnsupportedEcdsaCurve` or `JWT::DecodeError` from `JWT.encode` [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) +- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, `JWT::VerificationKeyError` and `JWT::ClaimValidationError` grouping classes. `JWT::DecodeError` is deprecated but keeps its meaning: every error class except `JWT::EncodeError` still inherits from it. An unusable verification key now raises `JWT::VerificationKeyError` instead of `JWT::DecodeError`, keeping `JWT::VerificationError` for signatures that do not match. Signing failures now consistently raise `JWT::EncodeError`: an ECDSA signing key with a mismatched or unsupported curve previously surfaced as `JWT::IncorrectAlgorithm` or `JWT::UnsupportedEcdsaCurve` from `JWT.encode`. Code wrapping `JWT.encode` in `rescue JWT::DecodeError` should rescue `JWT::Error` or `JWT::EncodeError` instead [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) - Your contribution here **Fixes and enhancements:** diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb index 29e784f57..69fb179dd 100644 --- a/lib/jwt/error.rb +++ b/lib/jwt/error.rb @@ -7,8 +7,15 @@ class Error < StandardError; end # The EncodeError class is raised when there is an error encoding a JWT. class EncodeError < Error; end + # The historical grouping of every error that is not an encoding error. Every + # error class below is a descendant, so `rescue JWT::DecodeError` keeps its + # original meaning. + # + # @deprecated Use {JWT::Error}, {JWT::TokenError} or a more specific error class instead. + class DecodeError < Error; end + # The TokenError class is the base class for all errors related to token processing. - class TokenError < Error; end + class TokenError < DecodeError; end # The MalformedTokenError class is raised when the token is structurally invalid. class MalformedTokenError < TokenError; end @@ -19,9 +26,14 @@ class Base64DecodeError < MalformedTokenError; end # The SignatureError class is the base class for signature and algorithm related errors. class SignatureError < TokenError; end - # The VerificationError class is raised when there is an error verifying a JWT signature. + # The VerificationError class is raised when the signature of a token does not + # match the one calculated from the signing input. class VerificationError < SignatureError; end + # The VerificationKeyError class is raised when the key or algorithm given for + # verification cannot be used, as opposed to a signature that does not match. + class VerificationKeyError < SignatureError; end + # The IncorrectAlgorithm class is raised when the JWT algorithm is incorrect. class IncorrectAlgorithm < SignatureError; end @@ -62,11 +74,8 @@ class InvalidPayload < ClaimValidationError; end class MissingRequiredClaim < ClaimValidationError; end # The JWKError class is raised when there is an error with the JSON Web Key (JWK). - class JWKError < Error; end + class JWKError < DecodeError; end # Raised when a JWK uses a key type (kty) that this library does not support. class UnsupportedKeyType < JWKError; end - - # @deprecated Use {JWT::Error}, {JWT::TokenError}, or a more specific error class instead. - DecodeError = Error end diff --git a/lib/jwt/jwa/signing_algorithm.rb b/lib/jwt/jwa/signing_algorithm.rb index b9fac2053..08251fe6d 100644 --- a/lib/jwt/jwa/signing_algorithm.rb +++ b/lib/jwt/jwa/signing_algorithm.rb @@ -35,7 +35,7 @@ def verify(*) end def raise_verify_error!(message) - raise(VerificationError.new(message).tap { |e| e.set_backtrace(caller(1)) }) + raise(VerificationKeyError.new(message).tap { |e| e.set_backtrace(caller(1)) }) end def raise_sign_error!(message) diff --git a/spec/jwt/error_spec.rb b/spec/jwt/error_spec.rb index 414ac67e4..7405e35ab 100644 --- a/spec/jwt/error_spec.rb +++ b/spec/jwt/error_spec.rb @@ -1,5 +1,11 @@ # frozen_string_literal: true +# Every JWT error class, discovered rather than listed, so a class added later +# is covered by the backwards compatibility contract without touching this spec. +JWT_ERROR_CLASSES = JWT.constants.map { |name| JWT.const_get(name) } + .select { |const| const.is_a?(Class) && const <= StandardError } + .freeze + RSpec.describe 'JWT error hierarchy' do context 'base classes' do it 'JWT::Error inherits from StandardError' do @@ -10,14 +16,32 @@ expect(JWT::EncodeError).to be < JWT::Error end - it 'JWT::TokenError inherits from JWT::Error' do - expect(JWT::TokenError).to be < JWT::Error + it 'JWT::TokenError inherits from JWT::DecodeError' do + expect(JWT::TokenError).to be < JWT::DecodeError end end context 'backwards compatibility' do - it 'JWT::DecodeError is an alias for JWT::Error' do - expect(JWT::DecodeError).to eq(JWT::Error) + # The contract `rescue JWT::DecodeError` has always had: every error the + # library raises is caught by it, with the sole exception of JWT::EncodeError. + it 'discovers the error classes it asserts on' do + expect(JWT_ERROR_CLASSES).to include(JWT::Error, JWT::TokenError, JWT::JWKError) + end + + JWT_ERROR_CLASSES.each do |error_class| + next if error_class == JWT::Error || error_class <= JWT::EncodeError + + it "JWT::DecodeError catches #{error_class}" do + expect(error_class).to be <= JWT::DecodeError + end + end + + it 'JWT::DecodeError does not catch JWT::EncodeError' do + expect(JWT::EncodeError).not_to be <= JWT::DecodeError + end + + it 'JWT::DecodeError inherits from JWT::Error' do + expect(JWT::DecodeError).to be < JWT::Error end end @@ -40,6 +64,15 @@ expect(JWT::VerificationError).to be < JWT::SignatureError end + it 'JWT::VerificationKeyError inherits from JWT::SignatureError' do + expect(JWT::VerificationKeyError).to be < JWT::SignatureError + end + + it 'JWT::VerificationKeyError is not a JWT::VerificationError' do + expect(JWT::VerificationKeyError).not_to be <= JWT::VerificationError + expect(JWT::VerificationError).not_to be <= JWT::VerificationKeyError + end + it 'JWT::IncorrectAlgorithm inherits from JWT::SignatureError' do expect(JWT::IncorrectAlgorithm).to be < JWT::SignatureError end @@ -73,8 +106,8 @@ end context 'JWK errors' do - it 'JWT::JWKError inherits from JWT::Error' do - expect(JWT::JWKError).to be < JWT::Error + it 'JWT::JWKError inherits from JWT::DecodeError' do + expect(JWT::JWKError).to be < JWT::DecodeError end end diff --git a/spec/jwt/jwa/ecdsa_spec.rb b/spec/jwt/jwa/ecdsa_spec.rb index 0cd4df20a..5d1e9497f 100644 --- a/spec/jwt/jwa/ecdsa_spec.rb +++ b/spec/jwt/jwa/ecdsa_spec.rb @@ -58,10 +58,10 @@ end context 'when the verification key is not an OpenSSL::PKey::EC instance' do - it 'raises a JWT::VerificationError' do + it 'raises a JWT::VerificationKeyError' do expect do instance.verify(data: data, signature: '', verification_key: 'not_a_key') - end.to raise_error(JWT::VerificationError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance') + end.to raise_error(JWT::VerificationKeyError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance') end end diff --git a/spec/jwt/jwa/hmac_spec.rb b/spec/jwt/jwa/hmac_spec.rb index 92640653c..0ea4e42b7 100644 --- a/spec/jwt/jwa/hmac_spec.rb +++ b/spec/jwt/jwa/hmac_spec.rb @@ -119,7 +119,7 @@ let(:hmac_secret) { 123 } it 'raises error' do - expect { subject }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') + expect { subject }.to raise_error(JWT::VerificationKeyError, 'HMAC key expected to be a String') end end @@ -130,7 +130,7 @@ it 'raises error and does not call OpenSSL::HMAC.digest' do expect(OpenSSL::HMAC).not_to receive(:digest) - expect { subject }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') + expect { subject }.to raise_error(JWT::VerificationKeyError, 'HMAC key expected to be a String') end end @@ -140,7 +140,7 @@ it 'raises error and does not call OpenSSL::HMAC.digest' do expect(OpenSSL::HMAC).not_to receive(:digest) - expect { subject }.to raise_error(JWT::VerificationError, 'HMAC key cannot be empty') + expect { subject }.to raise_error(JWT::VerificationKeyError, 'HMAC key cannot be empty') end end @@ -159,7 +159,7 @@ let(:hmac_secret) { 'short' } it 'raises error' do - expect { subject }.to raise_error(JWT::VerificationError, 'HMAC key must be at least 32 bytes for HS256 algorithm') + expect { subject }.to raise_error(JWT::VerificationKeyError, 'HMAC key must be at least 32 bytes for HS256 algorithm') end end diff --git a/spec/jwt/jwk/decode_with_jwk_spec.rb b/spec/jwt/jwk/decode_with_jwk_spec.rb index 03d8f2e6e..7fac70beb 100644 --- a/spec/jwt/jwk/decode_with_jwk_spec.rb +++ b/spec/jwt/jwk/decode_with_jwk_spec.rb @@ -131,16 +131,16 @@ context 'when RSA key is pointed to as HMAC secret' do let(:signed_token) { described_class.encode({ 'foo' => 'bar' }, 'is not really relevant in the scenario', 'HS256', { kid: rsa_jwk.kid }) } - it 'raises JWT::VerificationError' do - expect { described_class.decode(signed_token, nil, true, algorithms: ['HS256'], jwks: jwks) }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') + it 'raises JWT::VerificationKeyError' do + expect { described_class.decode(signed_token, nil, true, algorithms: ['HS256'], jwks: jwks) }.to raise_error(JWT::VerificationKeyError, 'HMAC key expected to be a String') end end context 'when EC key is pointed to as HMAC secret' do let(:signed_token) { described_class.encode({ 'foo' => 'bar' }, 'is not really relevant in the scenario', 'HS256', { kid: ec_jwk_secp384r1.kid }) } - it 'raises JWT::VerificationError' do - expect { described_class.decode(signed_token, nil, true, algorithms: ['HS256'], jwks: jwks) }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') + it 'raises JWT::VerificationKeyError' do + expect { described_class.decode(signed_token, nil, true, algorithms: ['HS256'], jwks: jwks) }.to raise_error(JWT::VerificationKeyError, 'HMAC key expected to be a String') end end @@ -169,7 +169,7 @@ it 'fails in some way' do expect { described_class.decode(signed_token, nil, true, algorithms: ['ES384'], jwks: jwks) }.to( - raise_error(JWT::VerificationError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance') + raise_error(JWT::VerificationKeyError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance') ) end end diff --git a/spec/jwt/jwk/ec_spec.rb b/spec/jwt/jwk/ec_spec.rb index 0f893ce2c..4b516c012 100644 --- a/spec/jwt/jwk/ec_spec.rb +++ b/spec/jwt/jwk/ec_spec.rb @@ -156,8 +156,8 @@ context 'when the jwk has HS256 as the alg parameter' do let(:rsa) { described_class.new(ec_key, alg: 'HS256') } - it 'raises JWT::VerificationError' do - expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') + it 'raises JWT::VerificationKeyError' do + expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::VerificationKeyError, 'HMAC key expected to be a String') end end end diff --git a/spec/jwt/jwk/rsa_spec.rb b/spec/jwt/jwk/rsa_spec.rb index 7980edb0f..620100303 100644 --- a/spec/jwt/jwk/rsa_spec.rb +++ b/spec/jwt/jwk/rsa_spec.rb @@ -133,8 +133,8 @@ context 'when the jwk has HS256 as the alg parameter' do let(:rsa) { described_class.new(rsa_key, alg: 'HS256') } - it 'raises JWT::VerificationError' do - expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::VerificationError, 'HMAC key expected to be a String') + it 'raises JWT::VerificationKeyError' do + expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::VerificationKeyError, 'HMAC key expected to be a String') end end end diff --git a/spec/jwt/jwt_spec.rb b/spec/jwt/jwt_spec.rb index cb7267f3b..ba115833e 100644 --- a/spec/jwt/jwt_spec.rb +++ b/spec/jwt/jwt_spec.rb @@ -946,7 +946,7 @@ def verify(*) end it 'raises error on decoding' do - expect { JWT.decode(expected_token, 'secret', true, algorithm: custom_algorithm.new) }.to raise_error(JWT::VerificationError, /missing the verify method/) + expect { JWT.decode(expected_token, 'secret', true, algorithm: custom_algorithm.new) }.to raise_error(JWT::VerificationKeyError, /missing the verify method/) end end end From 1959028b1f91429818cb58cdcc080cf08b6dff68 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sat, 5 Sep 2026 20:05:44 +0300 Subject: [PATCH 14/20] Keep the error class lookup on one line Layout/MultilineMethodCallIndentation disagreed about which call in the chain to align with, so drop the chain. Layout/LineLength is disabled in this project. --- spec/jwt/error_spec.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/spec/jwt/error_spec.rb b/spec/jwt/error_spec.rb index 7405e35ab..ad1e7d99d 100644 --- a/spec/jwt/error_spec.rb +++ b/spec/jwt/error_spec.rb @@ -2,9 +2,7 @@ # Every JWT error class, discovered rather than listed, so a class added later # is covered by the backwards compatibility contract without touching this spec. -JWT_ERROR_CLASSES = JWT.constants.map { |name| JWT.const_get(name) } - .select { |const| const.is_a?(Class) && const <= StandardError } - .freeze +JWT_ERROR_CLASSES = JWT.constants.map { |name| JWT.const_get(name) }.select { |const| const.is_a?(Class) && const <= StandardError }.freeze RSpec.describe 'JWT error hierarchy' do context 'base classes' do From a297d0dec75df329c9224366e6745af87075c110 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sat, 5 Sep 2026 20:17:02 +0300 Subject: [PATCH 15/20] Make every unusable verification key a VerificationKeyError VerificationError was never limited to signature mismatches: JWA.create_verifiers raises it for a JWK that does not support the requested algorithm, and JWA::Unsupported#verify for an algorithm we cannot verify with. A sibling VerificationKeyError therefore left two paths contradicting its own contract, and moving them onto a sibling would have stopped 'rescue JWT::VerificationError' catching them. Making VerificationKeyError a subclass of VerificationError instead lets both move without narrowing anything: every rescue that worked before still works, and callers that want to tell an unusable key from a signature that does not match now can. While here, give RS* and PS* verification the key type guard their signing counterparts already have. Pointing an HMAC secret at an RSA algorithm used to raise a bare NoMethodError out of JWT.decode, which no JWT rescue caught, and the EC equivalent right next to it in the same spec file already raised a JWT error. --- CHANGELOG.md | 2 +- lib/jwt/error.rb | 2 +- lib/jwt/jwa.rb | 2 +- lib/jwt/jwa/ps.rb | 2 ++ lib/jwt/jwa/rsa.rb | 2 ++ lib/jwt/jwa/unsupported.rb | 2 +- spec/jwt/encoded_token_spec.rb | 4 ++-- spec/jwt/error_spec.rb | 11 ++++------- spec/jwt/jwa/unsupported_spec.rb | 2 +- spec/jwt/jwk/decode_with_jwk_spec.rb | 4 ++-- spec/jwt/jwk/ec_spec.rb | 4 ++-- spec/jwt/jwk/rsa_spec.rb | 4 ++-- 12 files changed, 21 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 885d70e65..16bb8f4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ **Features:** - Allow a leeway to be given for the `iat` claim verification [#747](https://github.com/jwt/ruby-jwt/pull/747) - ([@denis1011101](https://github.com/denis1011101)) -- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, `JWT::VerificationKeyError` and `JWT::ClaimValidationError` grouping classes. `JWT::DecodeError` is deprecated but keeps its meaning: every error class except `JWT::EncodeError` still inherits from it. An unusable verification key now raises `JWT::VerificationKeyError` instead of `JWT::DecodeError`, keeping `JWT::VerificationError` for signatures that do not match. Signing failures now consistently raise `JWT::EncodeError`: an ECDSA signing key with a mismatched or unsupported curve previously surfaced as `JWT::IncorrectAlgorithm` or `JWT::UnsupportedEcdsaCurve` from `JWT.encode`. Code wrapping `JWT.encode` in `rescue JWT::DecodeError` should rescue `JWT::Error` or `JWT::EncodeError` instead [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) +- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, `JWT::VerificationKeyError` and `JWT::ClaimValidationError` grouping classes. `JWT::DecodeError` is deprecated but keeps its meaning: every error class except `JWT::EncodeError` still inherits from it. An unusable verification key now raises `JWT::VerificationKeyError`, a subclass of `JWT::VerificationError`, so existing rescues keep working while callers can distinguish an unusable key from a signature that does not match. `RS*` and `PS*` verification now reject a key of the wrong type instead of letting a `NoMethodError` escape. Signing failures now consistently raise `JWT::EncodeError`: an ECDSA signing key with a mismatched or unsupported curve previously surfaced as `JWT::IncorrectAlgorithm` or `JWT::UnsupportedEcdsaCurve` from `JWT.encode`. Code wrapping `JWT.encode` in `rescue JWT::DecodeError` should rescue `JWT::Error` or `JWT::EncodeError` instead [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) - Your contribution here **Fixes and enhancements:** diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb index 69fb179dd..abdeb2e1f 100644 --- a/lib/jwt/error.rb +++ b/lib/jwt/error.rb @@ -32,7 +32,7 @@ class VerificationError < SignatureError; end # The VerificationKeyError class is raised when the key or algorithm given for # verification cannot be used, as opposed to a signature that does not match. - class VerificationKeyError < SignatureError; end + class VerificationKeyError < VerificationError; end # The IncorrectAlgorithm class is raised when the JWT algorithm is incorrect. class IncorrectAlgorithm < SignatureError; end diff --git a/lib/jwt/jwa.rb b/lib/jwt/jwa.rb index 78cac30df..5b3654eb0 100644 --- a/lib/jwt/jwa.rb +++ b/lib/jwt/jwa.rb @@ -49,7 +49,7 @@ def create_signer(algorithm:, key:) def create_verifiers(algorithms:, keys:, preferred_algorithm:) jwks, other_keys = keys.partition { |key| key.is_a?(JWK::KeyBase) } - validate_jwk_algorithms!(jwks, algorithms, VerificationError) + validate_jwk_algorithms!(jwks, algorithms, VerificationKeyError) jwks + resolve_and_sort(algorithms: algorithms, preferred_algorithm: preferred_algorithm) diff --git a/lib/jwt/jwa/ps.rb b/lib/jwt/jwa/ps.rb index 85ef615a5..34710f1a3 100644 --- a/lib/jwt/jwa/ps.rb +++ b/lib/jwt/jwa/ps.rb @@ -19,6 +19,8 @@ def sign(data:, signing_key:) end def verify(data:, signature:, verification_key:) + raise_verify_error!("The given key is a #{verification_key.class}. It has to be an OpenSSL::PKey::RSA instance") unless verification_key.is_a?(::OpenSSL::PKey::RSA) + verification_key.verify_pss(digest_algorithm, signature, data, salt_length: :auto, mgf1_hash: digest_algorithm) rescue OpenSSL::PKey::PKeyError raise JWT::VerificationError, 'Signature verification raised' diff --git a/lib/jwt/jwa/rsa.rb b/lib/jwt/jwa/rsa.rb index d25b57646..fb8848cd4 100644 --- a/lib/jwt/jwa/rsa.rb +++ b/lib/jwt/jwa/rsa.rb @@ -19,6 +19,8 @@ def sign(data:, signing_key:) end def verify(data:, signature:, verification_key:) + raise_verify_error!("The given key is a #{verification_key.class}. It has to be an OpenSSL::PKey::RSA instance") unless verification_key.is_a?(::OpenSSL::PKey::RSA) + verification_key.verify(OpenSSL::Digest.new(digest), signature, data) rescue OpenSSL::PKey::PKeyError raise JWT::VerificationError, 'Signature verification raised' diff --git a/lib/jwt/jwa/unsupported.rb b/lib/jwt/jwa/unsupported.rb index beb4be1f4..6c67c496a 100644 --- a/lib/jwt/jwa/unsupported.rb +++ b/lib/jwt/jwa/unsupported.rb @@ -12,7 +12,7 @@ def sign(*) end def verify(*) - raise JWT::VerificationError, 'Algorithm not supported' + raise_verify_error!('Algorithm not supported') end end end diff --git a/spec/jwt/encoded_token_spec.rb b/spec/jwt/encoded_token_spec.rb index 122b6d54d..afc6c6d0a 100644 --- a/spec/jwt/encoded_token_spec.rb +++ b/spec/jwt/encoded_token_spec.rb @@ -245,8 +245,8 @@ end context 'with algorithms not supported by key provided' do - it 'raises JWT::VerificationError' do - expect { token.verify_signature!(algorithm: %w[RS384 RS512], key: jwk) }.to raise_error(JWT::VerificationError, 'Provided JWKs do not support one of the specified algorithms: RS384, RS512') + it 'raises JWT::VerificationKeyError' do + expect { token.verify_signature!(algorithm: %w[RS384 RS512], key: jwk) }.to raise_error(JWT::VerificationKeyError, 'Provided JWKs do not support one of the specified algorithms: RS384, RS512') end end end diff --git a/spec/jwt/error_spec.rb b/spec/jwt/error_spec.rb index ad1e7d99d..d60b3eafe 100644 --- a/spec/jwt/error_spec.rb +++ b/spec/jwt/error_spec.rb @@ -62,13 +62,10 @@ expect(JWT::VerificationError).to be < JWT::SignatureError end - it 'JWT::VerificationKeyError inherits from JWT::SignatureError' do - expect(JWT::VerificationKeyError).to be < JWT::SignatureError - end - - it 'JWT::VerificationKeyError is not a JWT::VerificationError' do - expect(JWT::VerificationKeyError).not_to be <= JWT::VerificationError - expect(JWT::VerificationError).not_to be <= JWT::VerificationKeyError + # A key that cannot be used is a kind of verification failure, so rescuing + # JWT::VerificationError keeps catching everything it caught before. + it 'JWT::VerificationKeyError inherits from JWT::VerificationError' do + expect(JWT::VerificationKeyError).to be < JWT::VerificationError end it 'JWT::IncorrectAlgorithm inherits from JWT::SignatureError' do diff --git a/spec/jwt/jwa/unsupported_spec.rb b/spec/jwt/jwa/unsupported_spec.rb index ba904096b..d8144bdd0 100644 --- a/spec/jwt/jwa/unsupported_spec.rb +++ b/spec/jwt/jwa/unsupported_spec.rb @@ -9,7 +9,7 @@ describe '.verify' do it 'raises an error for unsupported algorithm' do - expect { described_class.verify('data', 'signature', 'key') }.to raise_error(JWT::VerificationError, 'Algorithm not supported') + expect { described_class.verify('data', 'signature', 'key') }.to raise_error(JWT::VerificationKeyError, 'Algorithm not supported') end end end diff --git a/spec/jwt/jwk/decode_with_jwk_spec.rb b/spec/jwt/jwk/decode_with_jwk_spec.rb index 7fac70beb..b8f342a74 100644 --- a/spec/jwt/jwk/decode_with_jwk_spec.rb +++ b/spec/jwt/jwk/decode_with_jwk_spec.rb @@ -149,7 +149,7 @@ it 'fails in some way' do expect { described_class.decode(signed_token, nil, true, algorithms: [algorithm], jwks: jwks) }.to( - raise_error(JWT::VerificationError, 'Signature verification raised') + raise_error(JWT::VerificationKeyError, 'The given key is a OpenSSL::PKey::EC. It has to be an OpenSSL::PKey::RSA instance') ) end end @@ -159,7 +159,7 @@ it 'fails in some way' do expect { described_class.decode(signed_token, nil, true, algorithms: [algorithm], jwks: jwks) }.to( - raise_error(NoMethodError, /undefined method .*verify/) + raise_error(JWT::VerificationKeyError, 'The given key is a String. It has to be an OpenSSL::PKey::RSA instance') ) end end diff --git a/spec/jwt/jwk/ec_spec.rb b/spec/jwt/jwk/ec_spec.rb index 4b516c012..77bb77f4f 100644 --- a/spec/jwt/jwk/ec_spec.rb +++ b/spec/jwt/jwk/ec_spec.rb @@ -142,8 +142,8 @@ context 'when the jwk has an invalid alg header' do let(:rsa) { described_class.new(ec_key, alg: 'INVALID') } - it 'raises JWT::VerificationError' do - expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::VerificationError, 'Algorithm not supported') + it 'raises JWT::VerificationKeyError' do + expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::VerificationKeyError, 'Algorithm not supported') end end diff --git a/spec/jwt/jwk/rsa_spec.rb b/spec/jwt/jwk/rsa_spec.rb index 620100303..a96902223 100644 --- a/spec/jwt/jwk/rsa_spec.rb +++ b/spec/jwt/jwk/rsa_spec.rb @@ -119,8 +119,8 @@ context 'when the jwk has an invalid alg header' do let(:rsa) { described_class.new(rsa_key, alg: 'INVALID') } - it 'raises JWT::VerificationError' do - expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::VerificationError, 'Algorithm not supported') + it 'raises JWT::VerificationKeyError' do + expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::VerificationKeyError, 'Algorithm not supported') end end From 4cd6c0320a46345d9af69d7a39bd5a20b9d41574 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sat, 5 Sep 2026 20:25:17 +0300 Subject: [PATCH 16/20] Cover the verification key guards and document decoding failures The RS*/PS* key type guards only had integration coverage through decode_with_jwk_spec, and PS none at all, so a regression would have restored the escaping NoMethodError unnoticed. Both now have a unit example alongside their signing counterparts. EncodedToken#verify_claims! decodes the payload before validating anything, so it can raise JWT::MalformedTokenError as well as the documented JWT::ClaimValidationError. --- lib/jwt/encoded_token.rb | 1 + spec/jwt/jwa/ps_spec.rb | 8 ++++++++ spec/jwt/jwa/rsa_spec.rb | 8 ++++++++ 3 files changed, 17 insertions(+) diff --git a/lib/jwt/encoded_token.rb b/lib/jwt/encoded_token.rb index 7c6bd9cd4..c499e16ab 100644 --- a/lib/jwt/encoded_token.rb +++ b/lib/jwt/encoded_token.rb @@ -153,6 +153,7 @@ def valid_signature?(algorithm: nil, key: nil, key_finder: nil) # Verifies the claims of the token. # @param options [Array, Hash] the claims to verify. By default, it checks the 'exp' claim. # @raise [JWT::ClaimValidationError] if the claims are invalid. + # @raise [JWT::MalformedTokenError] if the payload cannot be decoded, which happens before any claim is validated. def verify_claims!(*options) Claims::Verifier.verify!(ClaimsContext.new(self), *claims_options(options)).tap do @claims_verified = true diff --git a/spec/jwt/jwa/ps_spec.rb b/spec/jwt/jwa/ps_spec.rb index 2aa2983b0..4cfd614a9 100644 --- a/spec/jwt/jwa/ps_spec.rb +++ b/spec/jwt/jwa/ps_spec.rb @@ -83,6 +83,14 @@ end end + context 'when the verification key is not an OpenSSL::PKey::RSA instance' do + it 'raises a JWT::VerificationKeyError' do + expect do + ps256_instance.verify(data: data, signature: ps256_signature, verification_key: 'not_a_key') + end.to raise_error(JWT::VerificationKeyError, 'The given key is a String. It has to be an OpenSSL::PKey::RSA instance') + end + end + context 'when verification results in a OpenSSL::PKey::PKeyError error' do it 'raises a JWT::VerificationError' do allow(rsa_key).to receive(:verify_pss).and_raise(OpenSSL::PKey::PKeyError.new('Error')) diff --git a/spec/jwt/jwa/rsa_spec.rb b/spec/jwt/jwa/rsa_spec.rb index 4f39cc20a..fa9458435 100644 --- a/spec/jwt/jwa/rsa_spec.rb +++ b/spec/jwt/jwa/rsa_spec.rb @@ -59,5 +59,13 @@ expect(rsa_instance.verify(data: data, signature: 'invalid_signature', verification_key: OpenSSL::PKey::RSA.generate(2048))).to be(false) end end + + context 'when the verification key is not an OpenSSL::PKey::RSA instance' do + it 'raises a JWT::VerificationKeyError' do + expect do + rsa_instance.verify(data: data, signature: signature, verification_key: 'not_a_key') + end.to raise_error(JWT::VerificationKeyError, 'The given key is a String. It has to be an OpenSSL::PKey::RSA instance') + end + end end end From cd808387d18112137aaca73ec9796986b4241e74 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sat, 5 Sep 2026 20:29:26 +0300 Subject: [PATCH 17/20] Target the next release at 3.3.0 Two narrow rescues stop working: rescue JWT::IncorrectAlgorithm and rescue JWT::UnsupportedEcdsaCurve around JWT.encode now see JWT::EncodeError instead. That is more than a patch release. Also qualify JWT::Claims::Error at its only use. It resolves through lexical scope today, but JWT::Error now exists one level up, so a bare Error under JWT::Claims is a trap for whoever writes the next file there. --- CHANGELOG.md | 4 ++-- lib/jwt/claims/verifier.rb | 2 +- lib/jwt/version.rb | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16bb8f4c7..ca4021690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # Changelog -## [v3.2.1](https://github.com/jwt/ruby-jwt/tree/v3.2.1) (NEXT) +## [v3.3.0](https://github.com/jwt/ruby-jwt/tree/v3.3.0) (NEXT) -[Full Changelog](https://github.com/jwt/ruby-jwt/compare/v3.2.0...v3.2.1) +[Full Changelog](https://github.com/jwt/ruby-jwt/compare/v3.2.0...v3.3.0) **Features:** diff --git a/lib/jwt/claims/verifier.rb b/lib/jwt/claims/verifier.rb index 0c6152e36..6ab5f12b3 100644 --- a/lib/jwt/claims/verifier.rb +++ b/lib/jwt/claims/verifier.rb @@ -36,7 +36,7 @@ def errors(context, *options) rescue ::JWT::ClaimValidationError, ::JWT::MalformedTokenError => e # A payload that cannot be decoded has no valid claims either, so the # predicate API reports it instead of raising. - errors << Error.new(message: e.message) + errors << JWT::Claims::Error.new(message: e.message) end errors end diff --git a/lib/jwt/version.rb b/lib/jwt/version.rb index 23deb16e8..2b13a63a8 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 = 2 - TINY = 1 + MINOR = 3 + TINY = 0 PRE = nil STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.') From 241070f9ecfcc9680c40a739b74768613e443546 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sat, 5 Sep 2026 20:41:34 +0300 Subject: [PATCH 18/20] Reject a public RS*/PS* signing key with a JWT error The changelog promises that every signing failure raises JWT::EncodeError, but JWA::Rsa#sign and JWA::Ps#sign passed a public key straight to OpenSSL, which raised ArgumentError('private key is needed') out of JWT.encode. JWA::Ecdsa#sign already guards this; both now do the same. --- CHANGELOG.md | 2 +- lib/jwt/jwa/ps.rb | 1 + lib/jwt/jwa/rsa.rb | 1 + spec/jwt/jwa/ps_spec.rb | 8 ++++++++ spec/jwt/jwa/rsa_spec.rb | 8 ++++++++ 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca4021690..fa74bce2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ **Features:** - Allow a leeway to be given for the `iat` claim verification [#747](https://github.com/jwt/ruby-jwt/pull/747) - ([@denis1011101](https://github.com/denis1011101)) -- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, `JWT::VerificationKeyError` and `JWT::ClaimValidationError` grouping classes. `JWT::DecodeError` is deprecated but keeps its meaning: every error class except `JWT::EncodeError` still inherits from it. An unusable verification key now raises `JWT::VerificationKeyError`, a subclass of `JWT::VerificationError`, so existing rescues keep working while callers can distinguish an unusable key from a signature that does not match. `RS*` and `PS*` verification now reject a key of the wrong type instead of letting a `NoMethodError` escape. Signing failures now consistently raise `JWT::EncodeError`: an ECDSA signing key with a mismatched or unsupported curve previously surfaced as `JWT::IncorrectAlgorithm` or `JWT::UnsupportedEcdsaCurve` from `JWT.encode`. Code wrapping `JWT.encode` in `rescue JWT::DecodeError` should rescue `JWT::Error` or `JWT::EncodeError` instead [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) +- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, `JWT::VerificationKeyError` and `JWT::ClaimValidationError` grouping classes. `JWT::DecodeError` is deprecated but keeps its meaning: every error class except `JWT::EncodeError` still inherits from it. An unusable verification key now raises `JWT::VerificationKeyError`, a subclass of `JWT::VerificationError`, so existing rescues keep working while callers can distinguish an unusable key from a signature that does not match. `RS*` and `PS*` verification now reject a key of the wrong type instead of letting a `NoMethodError` escape. Signing failures now consistently raise `JWT::EncodeError`: an ECDSA signing key with a mismatched or unsupported curve previously surfaced as `JWT::IncorrectAlgorithm` or `JWT::UnsupportedEcdsaCurve`, and an `RS*` or `PS*` public signing key as an `ArgumentError`, from `JWT.encode`. Code wrapping `JWT.encode` in `rescue JWT::DecodeError` should rescue `JWT::Error` or `JWT::EncodeError` instead [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) - Your contribution here **Fixes and enhancements:** diff --git a/lib/jwt/jwa/ps.rb b/lib/jwt/jwa/ps.rb index 34710f1a3..b6d89cc95 100644 --- a/lib/jwt/jwa/ps.rb +++ b/lib/jwt/jwa/ps.rb @@ -13,6 +13,7 @@ def initialize(alg) def sign(data:, signing_key:) raise_sign_error!("The given key is a #{signing_key.class}. It has to be an OpenSSL::PKey::RSA instance.") unless signing_key.is_a?(::OpenSSL::PKey::RSA) + raise_sign_error!('The given key is not a private key') unless signing_key.private? raise_sign_error!('The key length must be greater than or equal to 2048 bits') if signing_key.n.num_bits < 2048 signing_key.sign_pss(digest_algorithm, data, salt_length: :digest, mgf1_hash: digest_algorithm) diff --git a/lib/jwt/jwa/rsa.rb b/lib/jwt/jwa/rsa.rb index fb8848cd4..9d6bbc6b4 100644 --- a/lib/jwt/jwa/rsa.rb +++ b/lib/jwt/jwa/rsa.rb @@ -13,6 +13,7 @@ def initialize(alg) def sign(data:, signing_key:) raise_sign_error!("The given key is a #{signing_key.class}. It has to be an OpenSSL::PKey::RSA instance") unless signing_key.is_a?(OpenSSL::PKey::RSA) + raise_sign_error!('The given key is not a private key') unless signing_key.private? raise_sign_error!('The key length must be greater than or equal to 2048 bits') if signing_key.n.num_bits < 2048 signing_key.sign(OpenSSL::Digest.new(digest), data) diff --git a/spec/jwt/jwa/ps_spec.rb b/spec/jwt/jwa/ps_spec.rb index 4cfd614a9..51b5d46e0 100644 --- a/spec/jwt/jwa/ps_spec.rb +++ b/spec/jwt/jwa/ps_spec.rb @@ -47,6 +47,14 @@ end end + context 'with a public key' do + it 'raises an error' do + expect do + ps256_instance.sign(data: data, signing_key: OpenSSL::PKey::RSA.new(rsa_key.public_to_pem)) + end.to raise_error(JWT::EncodeError, 'The given key is not a private key') + end + end + context 'with a key length less than 2048 bits' do let(:rsa_key) { OpenSSL::PKey::RSA.generate(1536) } diff --git a/spec/jwt/jwa/rsa_spec.rb b/spec/jwt/jwa/rsa_spec.rb index fa9458435..3768bb1fe 100644 --- a/spec/jwt/jwa/rsa_spec.rb +++ b/spec/jwt/jwa/rsa_spec.rb @@ -37,6 +37,14 @@ end.to raise_error(JWT::EncodeError, /The given key is a String. It has to be an OpenSSL::PKey::RSA instance/) end end + + context 'with a public key' do + it 'raises an error' do + expect do + rsa_instance.sign(data: data, signing_key: OpenSSL::PKey::RSA.new(rsa_key.public_to_pem)) + end.to raise_error(JWT::EncodeError, 'The given key is not a private key') + end + end end describe '#verify' do From 957dc7379504b943fdf946ba128041b9d0db17bd Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sat, 5 Sep 2026 20:44:08 +0300 Subject: [PATCH 19/20] Use the RSA public key fixture in the new sign specs public_to_pem needs a newer openssl gem than the Ruby 2.5 through 2.7 builds ship, and this gem supports Ruby >= 2.5. Read the public key from the fixture instead, matching how the ECDSA spec covers the same case. --- spec/jwt/jwa/ps_spec.rb | 2 +- spec/jwt/jwa/rsa_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/jwt/jwa/ps_spec.rb b/spec/jwt/jwa/ps_spec.rb index 51b5d46e0..f70953c11 100644 --- a/spec/jwt/jwa/ps_spec.rb +++ b/spec/jwt/jwa/ps_spec.rb @@ -50,7 +50,7 @@ context 'with a public key' do it 'raises an error' do expect do - ps256_instance.sign(data: data, signing_key: OpenSSL::PKey::RSA.new(rsa_key.public_to_pem)) + ps256_instance.sign(data: data, signing_key: test_pkey('rsa-2048-public.pem')) end.to raise_error(JWT::EncodeError, 'The given key is not a private key') end end diff --git a/spec/jwt/jwa/rsa_spec.rb b/spec/jwt/jwa/rsa_spec.rb index 3768bb1fe..216a1f0f7 100644 --- a/spec/jwt/jwa/rsa_spec.rb +++ b/spec/jwt/jwa/rsa_spec.rb @@ -41,7 +41,7 @@ context 'with a public key' do it 'raises an error' do expect do - rsa_instance.sign(data: data, signing_key: OpenSSL::PKey::RSA.new(rsa_key.public_to_pem)) + rsa_instance.sign(data: data, signing_key: test_pkey('rsa-2048-public.pem')) end.to raise_error(JWT::EncodeError, 'The given key is not a private key') end end From 318c6492788233a024d1d6c3f01f23e5e91be135 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sat, 5 Sep 2026 21:07:47 +0300 Subject: [PATCH 20/20] Move the error hierarchy migration notes to UPGRADING.md The changelog entry had grown to five sentences and roughly 980 characters, against a 226 character longest entry anywhere else in the file. The claim verification revamp set the precedent: a one line changelog entry, with the migration detail in UPGRADING.md. The new section states the compatibility position plainly. Decoding is unaffected, so the common case needs no work. Signing failures moving to JWT::EncodeError is the part that breaks, and it gets a table of every affected case. --- CHANGELOG.md | 2 +- UPGRADING.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa74bce2c..6d6ba0431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ **Features:** - Allow a leeway to be given for the `iat` claim verification [#747](https://github.com/jwt/ruby-jwt/pull/747) - ([@denis1011101](https://github.com/denis1011101)) -- Revamp error hierarchy: introduce `JWT::Error`, `JWT::TokenError`, `JWT::MalformedTokenError`, `JWT::SignatureError`, `JWT::VerificationKeyError` and `JWT::ClaimValidationError` grouping classes. `JWT::DecodeError` is deprecated but keeps its meaning: every error class except `JWT::EncodeError` still inherits from it. An unusable verification key now raises `JWT::VerificationKeyError`, a subclass of `JWT::VerificationError`, so existing rescues keep working while callers can distinguish an unusable key from a signature that does not match. `RS*` and `PS*` verification now reject a key of the wrong type instead of letting a `NoMethodError` escape. Signing failures now consistently raise `JWT::EncodeError`: an ECDSA signing key with a mismatched or unsupported curve previously surfaced as `JWT::IncorrectAlgorithm` or `JWT::UnsupportedEcdsaCurve`, and an `RS*` or `PS*` public signing key as an `ArgumentError`, from `JWT.encode`. Code wrapping `JWT.encode` in `rescue JWT::DecodeError` should rescue `JWT::Error` or `JWT::EncodeError` instead [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) +- Revamp the error hierarchy under a new `JWT::Error` base class; signing failures now consistently raise `JWT::EncodeError`, see [UPGRADING.md](UPGRADING.md) [#722](https://github.com/jwt/ruby-jwt/pull/722) ([@anakinj](https://github.com/anakinj)) - Your contribution here **Fixes and enhancements:** diff --git a/UPGRADING.md b/UPGRADING.md index 10c95d1ea..8fa88ce8d 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,3 +1,33 @@ +# Upgrading ruby-jwt to >= 3.3.0 + +## Error hierarchy revamp + +The [error classes were reorganised](https://github.com/jwt/ruby-jwt/pull/722) under a new `JWT::Error` base class, so failures can be rescued by category instead of one class at a time: + +- `JWT::Error` is the base class for everything the gem raises. +- `JWT::TokenError` covers every failure in processing a token, and splits into `JWT::MalformedTokenError` (the token is structurally invalid), `JWT::SignatureError` (signature and algorithm problems) and `JWT::ClaimValidationError` (a claim did not verify). +- `JWT::VerificationKeyError`, a subclass of `JWT::VerificationError`, says the key or algorithm given for verification cannot be used, as opposed to a signature that does not match. + +### Decoding is unaffected + +`JWT::DecodeError` is deprecated in favour of the classes above, but it keeps its meaning: every error class except `JWT::EncodeError` still inherits from it. A `rescue JWT::DecodeError` around `JWT.decode` catches everything it caught before, and the specific classes it has always raised, such as `JWT::ExpiredSignature`, are unchanged. There is nothing to do on the decoding side. + +Verification also became more predictable: `RS*` and `PS*` now reject a key of the wrong type with a `JWT::VerificationKeyError` instead of letting a `NoMethodError` escape. + +### Signing failures now raise `JWT::EncodeError` + +This is the part that can break. Signing failures used to surface as decode errors, and are now consistently `JWT::EncodeError`, which is deliberately not a `JWT::DecodeError`: + +| Signing with | Used to raise | Now raises | +| --- | --- | --- | +| a `nil`, empty or too short HMAC key | `JWT::DecodeError` | `JWT::EncodeError` | +| an ECDSA key whose curve does not match the algorithm | `JWT::IncorrectAlgorithm` | `JWT::EncodeError` | +| an ECDSA key on an unsupported curve | `JWT::UnsupportedEcdsaCurve` | `JWT::EncodeError` | +| a JWK whose `alg` does not match the algorithm | `JWT::DecodeError` | `JWT::EncodeError` | +| an `RS*` or `PS*` public key | `ArgumentError` | `JWT::EncodeError` | + +If you wrap `JWT.encode` in `rescue JWT::DecodeError`, `rescue JWT::IncorrectAlgorithm` or `rescue JWT::UnsupportedEcdsaCurve`, rescue `JWT::EncodeError` or `JWT::Error` instead. + # Upgrading ruby-jwt to >= 3.0.0 ## Removal of the indirect [RbNaCl](https://github.com/RubyCrypto/rbnacl) dependency