From 9d094e3c9a6de65d81dbadfb4bddd4f3378a8bd4 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Wed, 2 Oct 2024 20:41:53 +0300 Subject: [PATCH] Official claim validation --- CHANGELOG.md | 1 + lib/jwt/claims.rb | 78 +++++++++++++++++----- lib/jwt/claims/decode_verifier.rb | 40 +++++++++++ lib/jwt/claims/numeric.rb | 40 +++++++---- lib/jwt/claims/verifier.rb | 62 +++++++++++++++++ lib/jwt/claims_validator.rb | 2 +- lib/jwt/decode.rb | 2 +- lib/jwt/encode.rb | 2 +- spec/jwt/claims/numeric_spec.rb | 106 ++++++++++++++++++++---------- spec/jwt/claims_spec.rb | 76 +++++++++++++++++++++ 10 files changed, 340 insertions(+), 69 deletions(-) create mode 100644 lib/jwt/claims/decode_verifier.rb create mode 100644 lib/jwt/claims/verifier.rb create mode 100644 spec/jwt/claims_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f0edc56..51861e18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ **Features:** +- Standalone claim verification interface [#626](https://github.com/jwt/ruby-jwt/pull/626) ([@anakinj](https://github.com/anakinj)) - Your contribution here **Fixes and enhancements:** diff --git a/lib/jwt/claims.rb b/lib/jwt/claims.rb index 199af0a6..f4e00b53 100644 --- a/lib/jwt/claims.rb +++ b/lib/jwt/claims.rb @@ -9,30 +9,74 @@ require_relative 'claims/numeric' require_relative 'claims/required' require_relative 'claims/subject' +require_relative 'claims/decode_verifier' +require_relative 'claims/verifier' module JWT + # JWT Claim verifications + # https://datatracker.ietf.org/doc/html/rfc7519#section-4 + # + # Verification is supported for the following claims: + # exp + # nbf + # iss + # iat + # jti + # aud + # sub + # required + # numeric + # module Claims - VerificationContext = Struct.new(:payload, keyword_init: true) - - VERIFIERS = { - verify_expiration: ->(options) { Claims::Expiration.new(leeway: options[:exp_leeway] || options[:leeway]) }, - verify_not_before: ->(options) { Claims::NotBefore.new(leeway: options[:nbf_leeway] || options[:leeway]) }, - verify_iss: ->(options) { options[:iss] && Claims::Issuer.new(issuers: options[:iss]) }, - verify_iat: ->(*) { Claims::IssuedAt.new }, - verify_jti: ->(options) { Claims::JwtId.new(validator: options[:verify_jti]) }, - verify_aud: ->(options) { options[:aud] && Claims::Audience.new(expected_audience: options[:aud]) }, - verify_sub: ->(options) { options[:sub] && Claims::Subject.new(expected_subject: options[:sub]) }, - required_claims: ->(options) { Claims::Required.new(required_claims: options[:required_claims]) } - }.freeze + # Represents a claim verification error + Error = Struct.new(:message, keyword_init: true) class << self + # @deprecated Use {verify_payload!} instead. Will be removed in the next major version of ruby-jwt. def verify!(payload, options) - VERIFIERS.each do |key, verifier_builder| - next unless options[key] || options[key.to_s] + Deprecations.warning('Calling ::JWT::Claims::verify! will be removed in the next major version of ruby-jwt') + DecodeVerifier.verify!(payload, options) + end + + # Checks if the claims in the JWT payload are valid. + # @example + # + # ::JWT::Claims.verify_payload!({"exp" => Time.now.to_i + 10}, :exp) + # ::JWT::Claims.verify_payload!({"exp" => Time.now.to_i - 10}, exp: { leeway: 11}) + # + # @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. + def verify_payload!(payload, *options) + verify_token!(VerificationContext.new(payload: payload), *options) + end + + # Checks if the claims in the JWT payload are valid. + # + # @param payload [Hash] the JWT payload. + # @param options [Array] the options for verifying the claims. + # @return [Boolean] true if the claims are valid, false otherwise + def valid_payload?(payload, *options) + payload_errors(payload, *options).empty? + end + + # Returns the errors in the claims of the JWT token. + # + # @param options [Array] the options for verifying the claims. + # @return [Array] the errors in the claims of the JWT + def payload_errors(payload, *options) + token_errors(VerificationContext.new(payload: payload), *options) + end + + private + + def verify_token!(token, *options) + Verifier.verify!(token, *options) + end - verifier_builder&.call(options)&.verify!(context: VerificationContext.new(payload: payload)) - end - nil + def token_errors(token, *options) + Verifier.errors(token, *options) end end end diff --git a/lib/jwt/claims/decode_verifier.rb b/lib/jwt/claims/decode_verifier.rb new file mode 100644 index 00000000..2548f4d3 --- /dev/null +++ b/lib/jwt/claims/decode_verifier.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module JWT + module Claims + # Context class to contain the data passed to individual claim validators + # + # @private + VerificationContext = Struct.new(:payload, keyword_init: true) + + # Verifiers to support the ::JWT.decode method + # + # @private + module DecodeVerifier + VERIFIERS = { + verify_expiration: ->(options) { Claims::Expiration.new(leeway: options[:exp_leeway] || options[:leeway]) }, + verify_not_before: ->(options) { Claims::NotBefore.new(leeway: options[:nbf_leeway] || options[:leeway]) }, + verify_iss: ->(options) { options[:iss] && Claims::Issuer.new(issuers: options[:iss]) }, + verify_iat: ->(*) { Claims::IssuedAt.new }, + verify_jti: ->(options) { Claims::JwtId.new(validator: options[:verify_jti]) }, + verify_aud: ->(options) { options[:aud] && Claims::Audience.new(expected_audience: options[:aud]) }, + verify_sub: ->(options) { options[:sub] && Claims::Subject.new(expected_subject: options[:sub]) }, + required_claims: ->(options) { Claims::Required.new(required_claims: options[:required_claims]) } + }.freeze + + private_constant(:VERIFIERS) + + class << self + # @private + def verify!(payload, options) + VERIFIERS.each do |key, verifier_builder| + next unless options[key] || options[key.to_s] + + verifier_builder&.call(options)&.verify!(context: VerificationContext.new(payload: payload)) + end + nil + end + end + end + end +end diff --git a/lib/jwt/claims/numeric.rb b/lib/jwt/claims/numeric.rb index c537b8f3..5df2c942 100644 --- a/lib/jwt/claims/numeric.rb +++ b/lib/jwt/claims/numeric.rb @@ -3,10 +3,14 @@ module JWT module Claims class Numeric - def self.verify!(payload:, **_args) - return unless payload.is_a?(Hash) + class Compat + def initialize(payload) + @payload = payload + end - new(payload).verify! + def verify! + JWT::Claims.verify_payload!(@payload, :numeric) + end end NUMERIC_CLAIMS = %i[ @@ -15,28 +19,38 @@ def self.verify!(payload:, **_args) nbf ].freeze - def initialize(payload) - @payload = payload.transform_keys(&:to_sym) + def self.new(*args) + return super if args.empty? + + Deprecations.warning('Calling ::JWT::Claims::Numeric.new with the payload will be removed in the next major version of ruby-jwt') + Compat.new(*args) end - def verify! - validate_numeric_claims + def verify!(context:) + validate_numeric_claims(context.payload) + end - true + def self.verify!(payload:, **_args) + Deprecations.warning('Calling ::JWT::Claims::Numeric.verify! with the payload will be removed in the next major version of ruby-jwt') + JWT::Claims.verify_payload!(payload, :numeric) end private - def validate_numeric_claims + def validate_numeric_claims(payload) NUMERIC_CLAIMS.each do |claim| - validate_is_numeric(claim) if @payload.key?(claim) + validate_is_numeric(payload, claim) end end - def validate_is_numeric(claim) - return if @payload[claim].is_a?(::Numeric) + def validate_is_numeric(payload, claim) + return unless payload.is_a?(Hash) + return unless payload.key?(claim) || + payload.key?(claim.to_s) + + return if payload[claim].is_a?(::Numeric) || payload[claim.to_s].is_a?(::Numeric) - raise InvalidPayload, "#{claim} claim must be a Numeric value but it is a #{@payload[claim].class}" + raise InvalidPayload, "#{claim} claim must be a Numeric value but it is a #{(payload[claim] || payload[claim.to_s]).class}" end end end diff --git a/lib/jwt/claims/verifier.rb b/lib/jwt/claims/verifier.rb new file mode 100644 index 00000000..608c3fd4 --- /dev/null +++ b/lib/jwt/claims/verifier.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module JWT + module Claims + # @private + module Verifier + VERIFIERS = { + exp: ->(options) { Claims::Expiration.new(leeway: options.dig(:exp, :leeway)) }, + nbf: ->(options) { Claims::NotBefore.new(leeway: options.dig(:nbf, :leeway)) }, + iss: ->(options) { Claims::Issuer.new(issuers: options[:iss]) }, + iat: ->(*) { Claims::IssuedAt.new }, + jti: ->(options) { Claims::JwtId.new(validator: options[:jti]) }, + aud: ->(options) { Claims::Audience.new(expected_audience: options[:aud]) }, + sub: ->(options) { Claims::Subject.new(expected_subject: options[:sub]) }, + + required: ->(options) { Claims::Required.new(required_claims: options[:required]) }, + numeric: ->(*) { Claims::Numeric.new } + }.freeze + + private_constant(:VERIFIERS) + + class << self + # @private + def verify!(context, *options) + iterate_verifiers(*options) do |verifier, verifier_options| + verify_one!(context, verifier, verifier_options) + end + nil + end + + # @private + def errors(context, *options) + errors = [] + iterate_verifiers(*options) do |verifier, verifier_options| + verify_one!(context, verifier, verifier_options) + rescue ::JWT::DecodeError => e + errors << Error.new(message: e.message) + end + errors + end + + # @private + def iterate_verifiers(*options) + options.each do |element| + if element.is_a?(Hash) + element.each_key { |key| yield(key, element) } + else + yield(element, {}) + end + end + end + + private + + def verify_one!(context, verifier, options) + verifier_builder = VERIFIERS.fetch(verifier) { raise ArgumentError, "#{verifier} not a valid claim verifier" } + verifier_builder.call(options || {}).verify!(context: context) + end + end + end + end +end diff --git a/lib/jwt/claims_validator.rb b/lib/jwt/claims_validator.rb index 72540ebd..05bf0abd 100644 --- a/lib/jwt/claims_validator.rb +++ b/lib/jwt/claims_validator.rb @@ -10,7 +10,7 @@ def initialize(payload) end def validate! - Claims::Numeric.verify!(payload: @payload) + Claims.verify_payload!(@payload, :numeric) end end end diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index e51a9582..e3d81d86 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -112,7 +112,7 @@ def find_key(&keyfinder) end def verify_claims - Claims.verify!(payload, @options) + Claims::DecodeVerifier.verify!(payload, @options) end def validate_segment_count! diff --git a/lib/jwt/encode.rb b/lib/jwt/encode.rb index 973f5b2f..e6829050 100644 --- a/lib/jwt/encode.rb +++ b/lib/jwt/encode.rb @@ -51,7 +51,7 @@ def signature def validate_claims! return unless @payload.is_a?(Hash) - Claims::Numeric.new(@payload).verify! + Claims.verify_payload!(@payload, :numeric) end def encode_signature diff --git a/spec/jwt/claims/numeric_spec.rb b/spec/jwt/claims/numeric_spec.rb index 6cef1251..d6a9e53b 100644 --- a/spec/jwt/claims/numeric_spec.rb +++ b/spec/jwt/claims/numeric_spec.rb @@ -1,68 +1,83 @@ # frozen_string_literal: true RSpec.describe JWT::Claims::Numeric do - let(:validator) { described_class.new(claims) } + shared_examples_for 'a NumericDate claim' do |claim| + context "when #{claim} payload is an integer" do + let(:claims) { { claim => 12_345 } } - describe '#verify!' do - subject { validator.verify! } + it 'does not raise error' do + expect { subject }.not_to raise_error + end - shared_examples_for 'a NumericDate claim' do |claim| - context "when #{claim} payload is an integer" do - let(:claims) { { claim => 12_345 } } + context 'and key is a string' do + let(:claims) { { claim.to_s => 43.32 } } it 'does not raise error' do expect { subject }.not_to raise_error end + end + end - context 'and key is a string' do - let(:claims) { { claim.to_s => 43.32 } } + context "when #{claim} payload is a float" do + let(:claims) { { claim => 43.32 } } - it 'does not raise error' do - expect { subject }.not_to raise_error - end - end + it 'does not raise error' do + expect { subject }.not_to raise_error end + end - context "when #{claim} payload is a float" do - let(:claims) { { claim => 43.32 } } + context "when #{claim} payload is a string" do + let(:claims) { { claim => '1' } } - it 'does not raise error' do - expect { subject }.not_to raise_error - end + it 'raises error' do + expect { subject }.to raise_error JWT::InvalidPayload end - context "when #{claim} payload is a string" do - let(:claims) { { claim => '1' } } + context 'and key is a string' do + let(:claims) { { claim.to_s => '1' } } it 'raises error' do expect { subject }.to raise_error JWT::InvalidPayload end + end + end - context 'and key is a string' do - let(:claims) { { claim.to_s => '1' } } + context "when #{claim} payload is a Time object" do + let(:claims) { { claim => Time.now } } - it 'raises error' do - expect { subject }.to raise_error JWT::InvalidPayload - end - end + it 'raises error' do + expect { subject }.to raise_error JWT::InvalidPayload end + end - context "when #{claim} payload is a Time object" do - let(:claims) { { claim => Time.now } } + context "when #{claim} payload is a string" do + let(:claims) { { claim => '1' } } - it 'raises error' do - expect { subject }.to raise_error JWT::InvalidPayload - end + it 'raises error' do + expect { subject }.to raise_error JWT::InvalidPayload end + end + end - context "when #{claim} payload is a string" do - let(:claims) { { claim => '1' } } + let(:validator) { described_class.new } - it 'raises error' do - expect { subject }.to raise_error JWT::InvalidPayload - end - end + describe '#verify!' do + subject { validator.verify!(context: JWT::Claims::VerificationContext.new(payload: claims)) } + context 'exp claim' do + it_should_behave_like 'a NumericDate claim', :exp + end + + context 'iat claim' do + it_should_behave_like 'a NumericDate claim', :iat + end + + context 'nbf claim' do + it_should_behave_like 'a NumericDate claim', :nbf end + end + + describe 'use via ::JWT::Claims.verify_payload!' do + subject { JWT::Claims.verify_payload!(claims, :numeric) } context 'exp claim' do it_should_behave_like 'a NumericDate claim', :exp @@ -76,4 +91,23 @@ it_should_behave_like 'a NumericDate claim', :nbf end end + + context 'Legacy use' do + let(:validator) { described_class.new(claims) } + describe '#verify!' do + subject { validator.verify! } + + context 'exp claim' do + it_should_behave_like 'a NumericDate claim', :exp + end + + context 'iat claim' do + it_should_behave_like 'a NumericDate claim', :iat + end + + context 'nbf claim' do + it_should_behave_like 'a NumericDate claim', :nbf + end + end + end end diff --git a/spec/jwt/claims_spec.rb b/spec/jwt/claims_spec.rb new file mode 100644 index 00000000..0f62ee2a --- /dev/null +++ b/spec/jwt/claims_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +RSpec.describe JWT::Claims do + let(:payload) { { 'pay' => 'load' } } + describe '.verify_payload!' do + context 'when required_claims is passed' do + it 'raises error' do + expect { described_class.verify_payload!(payload, required: ['exp']) }.to raise_error(JWT::MissingRequiredClaim, 'Missing required claim exp') + end + end + + context 'exp claim' do + let(:payload) { { 'exp' => Time.now.to_i - 10, 'pay' => 'load' } } + + it 'verifies the exp' do + described_class.verify_payload!(payload, required: ['exp']) + expect { described_class.verify_payload!(payload, exp: {}) }.to raise_error(JWT::ExpiredSignature, 'Signature has expired') + described_class.verify_payload!(payload, exp: { leeway: 1000 }) + end + + context 'when claims given as symbol' do + it 'validates the claim' do + expect { described_class.verify_payload!(payload, :exp) }.to raise_error(JWT::ExpiredSignature, 'Signature has expired') + end + end + + context 'when claims given as a list of symbols' do + it 'validates the claim' do + expect { described_class.verify_payload!(payload, :exp, :nbf) }.to raise_error(JWT::ExpiredSignature, 'Signature has expired') + end + end + + context 'when claims given as a list of symbols and hashes' do + it 'validates the claim' do + expect { described_class.verify_payload!(payload, { exp: { leeway: 1000 }, nbf: {} }, :exp, :nbf) }.to raise_error(JWT::ExpiredSignature, 'Signature has expired') + end + end + end + end + + describe '.valid_payload?' do + context 'exp claim' do + let(:payload) { { 'exp' => Time.now.to_i - 10, 'pay' => 'load' } } + + context 'when claim is valid' do + it 'returns true' do + expect(described_class.valid_payload?(payload, exp: { leeway: 1000 })).to be(true) + end + end + + context 'when claim is invalid' do + it 'returns false' do + expect(described_class.valid_payload?(payload, :exp)).to be(false) + end + end + end + end + + describe '.payload_errors' do + context 'exp claim' do + let(:payload) { { 'exp' => Time.now.to_i - 10, 'pay' => 'load' } } + + context 'when claim is valid' do + it 'returns empty array' do + expect(described_class.payload_errors(payload, exp: { leeway: 1000 })).to be_empty + end + end + + context 'when claim is invalid' do + it 'returns array with error objects' do + expect(described_class.payload_errors(payload, :exp).map(&:message)).to eq(['Signature has expired']) + end + end + end + end +end