diff --git a/CHANGELOG.md b/CHANGELOG.md index 43cea9ee..c7fa7df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ **Fixes and enhancements:** +- Refactor claim validators into their own classes [#605](https://github.com/jwt/ruby-jwt/pull/605) ([@anakinj](https://github.com/anakinj), [@MatteoPierro](https://github.com/MatteoPierro)) - Your contribution here ## [v2.8.2](https://github.com/jwt/ruby-jwt/tree/v2.8.2) (2024-06-18) diff --git a/lib/jwt.rb b/lib/jwt.rb index 235d3a9f..f7f28d47 100644 --- a/lib/jwt.rb +++ b/lib/jwt.rb @@ -9,6 +9,7 @@ require 'jwt/encode' require 'jwt/error' require 'jwt/jwk' +require 'jwt/claims' # JSON Web Token implementation # diff --git a/lib/jwt/claims.rb b/lib/jwt/claims.rb new file mode 100644 index 00000000..55fda678 --- /dev/null +++ b/lib/jwt/claims.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative 'claims/audience' +require_relative 'claims/expiration' +require_relative 'claims/issued_at' +require_relative 'claims/issuer' +require_relative 'claims/jwt_id' +require_relative 'claims/not_before' +require_relative 'claims/numeric' +require_relative 'claims/required' +require_relative 'claims/subject' + +module JWT + 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) { Claims::Issuer.new(issuers: options[:iss]) }, + verify_iat: ->(*) { Claims::IssuedAt.new }, + verify_jti: ->(options) { Claims::JwtId.new(validator: options[:verify_jti]) }, + verify_aud: ->(options) { 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 + + class << self + def verify!(payload, options) + VERIFIERS.each do |key, verifier_builder| + next unless options[key] + + verifier_builder&.call(options)&.verify!(context: VerificationContext.new(payload: payload)) + end + end + end + end +end diff --git a/lib/jwt/claims/audience.rb b/lib/jwt/claims/audience.rb new file mode 100644 index 00000000..5ca512c7 --- /dev/null +++ b/lib/jwt/claims/audience.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module JWT + module Claims + class Audience + def initialize(expected_audience:) + @expected_audience = expected_audience + end + + def verify!(context:, **_args) + aud = context.payload['aud'] + raise JWT::InvalidAudError, "Invalid audience. Expected #{expected_audience}, received #{aud || ''}" if ([*aud] & [*expected_audience]).empty? + end + + private + + attr_reader :expected_audience + end + end +end diff --git a/lib/jwt/claims/expiration.rb b/lib/jwt/claims/expiration.rb new file mode 100644 index 00000000..071885ad --- /dev/null +++ b/lib/jwt/claims/expiration.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module JWT + module Claims + class Expiration + def initialize(leeway:) + @leeway = leeway || 0 + end + + def verify!(context:, **_args) + return unless context.payload.is_a?(Hash) + return unless context.payload.key?('exp') + + raise JWT::ExpiredSignature, 'Signature has expired' if context.payload['exp'].to_i <= (Time.now.to_i - leeway) + end + + private + + attr_reader :leeway + end + end +end diff --git a/lib/jwt/claims/issued_at.rb b/lib/jwt/claims/issued_at.rb new file mode 100644 index 00000000..6aaf5108 --- /dev/null +++ b/lib/jwt/claims/issued_at.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module JWT + module Claims + class IssuedAt + def verify!(context:, **_args) + return unless context.payload.is_a?(Hash) + return unless context.payload.key?('iat') + + iat = context.payload['iat'] + raise(JWT::InvalidIatError, 'Invalid iat') if !iat.is_a?(::Numeric) || iat.to_f > Time.now.to_f + end + end + end +end diff --git a/lib/jwt/claims/issuer.rb b/lib/jwt/claims/issuer.rb new file mode 100644 index 00000000..f973e7f0 --- /dev/null +++ b/lib/jwt/claims/issuer.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module JWT + module Claims + class Issuer + def initialize(issuers:) + @issuers = Array(issuers).map { |item| item.is_a?(Symbol) ? item.to_s : item } + end + + def verify!(context:, **_args) + case (iss = context.payload['iss']) + when *issuers + nil + else + raise JWT::InvalidIssuerError, "Invalid issuer. Expected #{issuers}, received #{iss || ''}" + end + end + + private + + attr_reader :issuers + end + end +end diff --git a/lib/jwt/claims/jwt_id.rb b/lib/jwt/claims/jwt_id.rb new file mode 100644 index 00000000..9dc5b0d0 --- /dev/null +++ b/lib/jwt/claims/jwt_id.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module JWT + module Claims + class JwtId + def initialize(validator:) + @validator = validator + end + + def verify!(context:, **_args) + jti = context.payload['jti'] + if validator.respond_to?(:call) + verified = validator.arity == 2 ? validator.call(jti, context.payload) : validator.call(jti) + raise(JWT::InvalidJtiError, 'Invalid jti') unless verified + elsif jti.to_s.strip.empty? + raise(JWT::InvalidJtiError, 'Missing jti') + end + end + + private + + attr_reader :validator + end + end +end diff --git a/lib/jwt/claims/not_before.rb b/lib/jwt/claims/not_before.rb new file mode 100644 index 00000000..e53f6de3 --- /dev/null +++ b/lib/jwt/claims/not_before.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module JWT + module Claims + class NotBefore + def initialize(leeway:) + @leeway = leeway || 0 + end + + def verify!(context:, **_args) + return unless context.payload.is_a?(Hash) + return unless context.payload.key?('nbf') + + raise JWT::ImmatureSignature, 'Signature nbf has not been reached' if context.payload['nbf'].to_i > (Time.now.to_i + leeway) + end + + private + + attr_reader :leeway + end + end +end diff --git a/lib/jwt/claims/numeric.rb b/lib/jwt/claims/numeric.rb new file mode 100644 index 00000000..c537b8f3 --- /dev/null +++ b/lib/jwt/claims/numeric.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module JWT + module Claims + class Numeric + def self.verify!(payload:, **_args) + return unless payload.is_a?(Hash) + + new(payload).verify! + end + + NUMERIC_CLAIMS = %i[ + exp + iat + nbf + ].freeze + + def initialize(payload) + @payload = payload.transform_keys(&:to_sym) + end + + def verify! + validate_numeric_claims + + true + end + + private + + def validate_numeric_claims + NUMERIC_CLAIMS.each do |claim| + validate_is_numeric(claim) if @payload.key?(claim) + end + end + + def validate_is_numeric(claim) + return if @payload[claim].is_a?(::Numeric) + + raise InvalidPayload, "#{claim} claim must be a Numeric value but it is a #{@payload[claim].class}" + end + end + end +end diff --git a/lib/jwt/claims/required.rb b/lib/jwt/claims/required.rb new file mode 100644 index 00000000..a360821e --- /dev/null +++ b/lib/jwt/claims/required.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module JWT + module Claims + class Required + def initialize(required_claims:) + @required_claims = required_claims + end + + def verify!(context:, **_args) + required_claims.each do |required_claim| + next if context.payload.is_a?(Hash) && context.payload.key?(required_claim) + + raise JWT::MissingRequiredClaim, "Missing required claim #{required_claim}" + end + end + + private + + attr_reader :required_claims + end + end +end diff --git a/lib/jwt/claims/subject.rb b/lib/jwt/claims/subject.rb new file mode 100644 index 00000000..dd1df517 --- /dev/null +++ b/lib/jwt/claims/subject.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module JWT + module Claims + class Subject + def initialize(expected_subject:) + @expected_subject = expected_subject.to_s + end + + def verify!(context:, **_args) + sub = context.payload['sub'] + raise(JWT::InvalidSubError, "Invalid subject. Expected #{expected_subject}, received #{sub || ''}") unless sub.to_s == expected_subject + end + + private + + attr_reader :expected_subject + end + end +end diff --git a/lib/jwt/claims_validator.rb b/lib/jwt/claims_validator.rb deleted file mode 100644 index f7f6a5e4..00000000 --- a/lib/jwt/claims_validator.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require_relative 'error' - -module JWT - class ClaimsValidator - NUMERIC_CLAIMS = %i[ - exp - iat - nbf - ].freeze - - def initialize(payload) - @payload = payload.transform_keys(&:to_sym) - end - - def validate! - validate_numeric_claims - - true - end - - private - - def validate_numeric_claims - NUMERIC_CLAIMS.each do |claim| - validate_is_numeric(claim) if @payload.key?(claim) - end - end - - def validate_is_numeric(claim) - return if @payload[claim].is_a?(Numeric) - - raise InvalidPayload, "#{claim} claim must be a Numeric value but it is a #{@payload[claim].class}" - end - end -end diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index aa73e8ab..d3196ae4 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true require 'json' - -require 'jwt/verify' require 'jwt/x5c_key_finder' # JWT::Decode module @@ -113,8 +111,7 @@ def find_key(&keyfinder) end def verify_claims - Verify.verify_claims(payload, @options) - Verify.verify_required_claims(payload, @options) + Claims.verify!(payload, @options) end def validate_segment_count! diff --git a/lib/jwt/encode.rb b/lib/jwt/encode.rb index 8527834e..3859c4cb 100644 --- a/lib/jwt/encode.rb +++ b/lib/jwt/encode.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require_relative 'jwa' -require_relative 'claims_validator' # JWT::Encode module module JWT @@ -55,7 +54,7 @@ def signature def validate_claims! return unless @payload.is_a?(Hash) - ClaimsValidator.new(@payload).validate! + Claims::Numeric.new(@payload).verify! end def encode_signature diff --git a/lib/jwt/verify.rb b/lib/jwt/verify.rb deleted file mode 100644 index 4bc7635f..00000000 --- a/lib/jwt/verify.rb +++ /dev/null @@ -1,117 +0,0 @@ -# frozen_string_literal: true - -require 'jwt/error' - -module JWT - # JWT verify methods - class Verify - DEFAULTS = { - leeway: 0 - }.freeze - - class << self - %w[verify_aud verify_expiration verify_iat verify_iss verify_jti verify_not_before verify_sub verify_required_claims].each do |method_name| - define_method method_name do |payload, options| - new(payload, options).send(method_name) - end - end - - def verify_claims(payload, options) - options.each do |key, val| - next unless key.to_s =~ /verify/ - - Verify.send(key, payload, options) if val - end - end - end - - def initialize(payload, options) - @payload = payload - @options = DEFAULTS.merge(options) - end - - def verify_aud - return unless (options_aud = @options[:aud]) - - aud = @payload['aud'] - raise JWT::InvalidAudError, "Invalid audience. Expected #{options_aud}, received #{aud || ''}" if ([*aud] & [*options_aud]).empty? - end - - def verify_expiration - return unless contains_key?(@payload, 'exp') - raise JWT::ExpiredSignature, 'Signature has expired' if @payload['exp'].to_i <= (Time.now.to_i - exp_leeway) - end - - def verify_iat - return unless contains_key?(@payload, 'iat') - - iat = @payload['iat'] - raise JWT::InvalidIatError, 'Invalid iat' if !iat.is_a?(Numeric) || iat.to_f > Time.now.to_f - end - - def verify_iss - return unless (options_iss = @options[:iss]) - - iss = @payload['iss'] - - options_iss = Array(options_iss).map { |item| item.is_a?(Symbol) ? item.to_s : item } - - case iss - when *options_iss - nil - else - raise JWT::InvalidIssuerError, "Invalid issuer. Expected #{options_iss}, received #{iss || ''}" - end - end - - def verify_jti - options_verify_jti = @options[:verify_jti] - jti = @payload['jti'] - - if options_verify_jti.respond_to?(:call) - verified = options_verify_jti.arity == 2 ? options_verify_jti.call(jti, @payload) : options_verify_jti.call(jti) - raise JWT::InvalidJtiError, 'Invalid jti' unless verified - elsif jti.to_s.strip.empty? - raise JWT::InvalidJtiError, 'Missing jti' - end - end - - def verify_not_before - return unless contains_key?(@payload, 'nbf') - raise JWT::ImmatureSignature, 'Signature nbf has not been reached' if @payload['nbf'].to_i > (Time.now.to_i + nbf_leeway) - end - - def verify_sub - return unless (options_sub = @options[:sub]) - - sub = @payload['sub'] - raise JWT::InvalidSubError, "Invalid subject. Expected #{options_sub}, received #{sub || ''}" unless sub.to_s == options_sub.to_s - end - - def verify_required_claims - return unless (options_required_claims = @options[:required_claims]) - - options_required_claims.each do |required_claim| - raise JWT::MissingRequiredClaim, "Missing required claim #{required_claim}" unless contains_key?(@payload, required_claim) - end - end - - private - - def global_leeway - @options[:leeway] - end - - def exp_leeway - @options[:exp_leeway] || global_leeway - end - - def nbf_leeway - @options[:nbf_leeway] || global_leeway - end - - def contains_key?(payload, key) - payload.respond_to?(:key?) && payload.key?(key) - end - end -end diff --git a/spec/integration/readme_examples_spec.rb b/spec/integration/readme_examples_spec.rb index 725f9d73..98d96ee9 100644 --- a/spec/integration/readme_examples_spec.rb +++ b/spec/integration/readme_examples_spec.rb @@ -239,7 +239,15 @@ token = JWT.encode sub_payload, hmac_secret, 'HS256' expect do - JWT.decode token, hmac_secret, true, 'sub' => sub, :verify_sub => true, :algorithm => 'HS256' + JWT.decode token, hmac_secret, true, { sub: sub, verify_sub: true, algorithm: 'HS256' } + end.not_to raise_error + + expect do + JWT.decode token, hmac_secret, true, { sub: 'sub', verify_sub: true, algorithm: 'HS256' } + end.to raise_error(JWT::InvalidSubError) + + expect do + JWT.decode token, hmac_secret, true, { 'sub' => 'sub', verify_sub: true, algorithm: 'HS256' } end.not_to raise_error end diff --git a/spec/jwt/claims/audience_spec.rb b/spec/jwt/claims/audience_spec.rb new file mode 100644 index 00000000..96f2326b --- /dev/null +++ b/spec/jwt/claims/audience_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +RSpec.describe JWT::Claims::Audience do + let(:payload) { { 'nbf' => (Time.now.to_i + 5) } } + + describe '#verify!' do + let(:scalar_aud) { 'ruby-jwt-aud' } + let(:array_aud) { %w[ruby-jwt-aud test-aud ruby-ruby-ruby] } + + subject(:verify!) { described_class.new(expected_audience: expected_audience).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) } + + context 'when the singular audience does not match' do + let(:expected_audience) { 'no-match' } + let(:payload) { { 'aud' => scalar_aud } } + + it 'raises JWT::InvalidAudError' do + expect do + subject + end.to raise_error JWT::InvalidAudError + end + end + + context 'when the payload has an array and none match the supplied value' do + let(:expected_audience) { 'no-match' } + let(:payload) { { 'aud' => array_aud } } + + it 'raises JWT::InvalidAudError' do + expect do + subject + end.to raise_error JWT::InvalidAudError + end + end + + context 'when single audience is required' do + let(:expected_audience) { scalar_aud } + let(:payload) { { 'aud' => scalar_aud } } + + it 'passes validation' do + subject + end + end + + context 'when any value in payload matches a single expected' do + let(:expected_audience) { array_aud.first } + let(:payload) { { 'aud' => array_aud } } + + it 'passes validation' do + subject + end + end + + context 'when an array with any value matching the one in the options' do + let(:expected_audience) { array_aud.first } + let(:payload) { { 'aud' => array_aud } } + + it 'passes validation' do + subject + end + end + + context 'when an array with any value matching all in the options' do + let(:expected_audience) { array_aud } + let(:payload) { { 'aud' => array_aud } } + + it 'passes validation' do + subject + end + end + + context 'when a singular audience payload matching any value in the options array' do + let(:expected_audience) { array_aud } + let(:payload) { { 'aud' => scalar_aud } } + + it 'passes validation' do + subject + end + end + end +end diff --git a/spec/jwt/claims/expiration_spec.rb b/spec/jwt/claims/expiration_spec.rb new file mode 100644 index 00000000..f8638ad9 --- /dev/null +++ b/spec/jwt/claims/expiration_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +RSpec.describe JWT::Claims::Expiration do + let(:payload) { { 'exp' => (Time.now.to_i + 5) } } + let(:leeway) { 0 } + + subject(:verify!) { described_class.new(leeway: leeway).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) } + + context 'when token is expired' do + let(:payload) { { 'exp' => (Time.now.to_i - 5) } } + + it 'must raise JWT::ExpiredSignature when the token has expired' do + expect { verify! }.to(raise_error(JWT::ExpiredSignature)) + end + end + + context 'when token is expired but some leeway is defined' do + let(:payload) { { 'exp' => (Time.now.to_i - 5) } } + let(:leeway) { 10 } + + it 'passes validation' do + verify! + end + end + + context 'when token exp is set to current time' do + let(:payload) { { 'exp' => Time.now.to_i } } + + it 'fails validation' do + expect { verify! }.to(raise_error(JWT::ExpiredSignature)) + end + end + + context 'when token is not a Hash' do + let(:payload) { 'beautyexperts_nbf_iat' } + it 'passes validation' do + verify! + end + end +end diff --git a/spec/jwt/claims/issued_at_spec.rb b/spec/jwt/claims/issued_at_spec.rb new file mode 100644 index 00000000..c34e5e85 --- /dev/null +++ b/spec/jwt/claims/issued_at_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +RSpec.describe JWT::Claims::IssuedAt do + let(:payload) { { 'iat' => Time.now.to_f } } + + subject(:verify!) { described_class.new.verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) } + + context 'when iat is now' do + it 'passes validation' do + verify! + end + end + + context 'when iat is now as a integer' do + let(:payload) { { 'iat' => Time.now.to_i } } + + it 'passes validation' do + verify! + end + end + context 'when iat is not a number' do + let(:payload) { { 'iat' => 'not_a_number' } } + + it 'fails validation' do + expect { verify! }.to raise_error(JWT::InvalidIatError) + end + end + + context 'when iat is in the future' do + let(:payload) { { 'iat' => Time.now.to_f + 120.0 } } + + it 'fails validation' do + expect { verify! }.to raise_error(JWT::InvalidIatError) + end + end + + context 'when payload is a string containing iat' do + let(:payload) { 'beautyexperts_nbf_iat' } + + it 'passes validation' do + verify! + end + end +end diff --git a/spec/jwt/claims/issuer_spec.rb b/spec/jwt/claims/issuer_spec.rb new file mode 100644 index 00000000..33d9470a --- /dev/null +++ b/spec/jwt/claims/issuer_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +RSpec.describe JWT::Claims::Issuer do + let(:issuer) { 'ruby-jwt-gem' } + let(:payload) { { 'iss' => issuer } } + let(:expected_issuers) { 'ruby-jwt-gem' } + + subject(:verify!) { described_class.new(issuers: expected_issuers).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) } + + context 'when expected issuer is a string that matches the payload' do + it 'passes validation' do + verify! + end + end + + context 'when expected issuer is a string that does not match the payload' do + let(:issuer) { 'mismatched-issuer' } + it 'raises JWT::InvalidIssuerError' do + expect { verify! }.to raise_error(JWT::InvalidIssuerError, 'Invalid issuer. Expected ["ruby-jwt-gem"], received mismatched-issuer') + end + end + + context 'when payload does not contain any issuer' do + let(:payload) { {} } + it 'raises JWT::InvalidIssuerError' do + expect { verify! }.to raise_error(JWT::InvalidIssuerError, 'Invalid issuer. Expected ["ruby-jwt-gem"], received ') + end + end + + context 'when expected issuer is an array that matches the payload' do + let(:expected_issuers) { ['first', issuer, 'third'] } + it 'passes validation' do + verify! + end + end + + context 'when expected issuer is an array that does not match the payload' do + let(:expected_issuers) { %w[first second] } + it 'raises JWT::InvalidIssuerError' do + expect { verify! }.to raise_error(JWT::InvalidIssuerError, 'Invalid issuer. Expected ["first", "second"], received ruby-jwt-gem') + end + end + + context 'when expected issuer is an array and payload does not have any issuer' do + let(:payload) { {} } + let(:expected_issuers) { %w[first second] } + it 'raises JWT::InvalidIssuerError' do + expect { verify! }.to raise_error(JWT::InvalidIssuerError, 'Invalid issuer. Expected ["first", "second"], received ') + end + end + + context 'when issuer is given as a RegExp' do + let(:issuer) { 'ruby-jwt-gem' } + let(:expected_issuers) { /\A(first|#{issuer}|third)\z/ } + it 'passes validation' do + verify! + end + end + + context 'when issuer is given as a RegExp and does not match the payload' do + let(:issuer) { 'mismatched-issuer' } + let(:expected_issuers) { /\A(first|second)\z/ } + it 'raises JWT::InvalidIssuerError' do + expect { verify! }.to raise_error(JWT::InvalidIssuerError, 'Invalid issuer. Expected [/\A(first|second)\z/], received mismatched-issuer') + end + end + + context 'when issuer is given as a RegExp and payload does not have any issuer' do + let(:payload) { {} } + let(:expected_issuers) { /\A(first|second)\z/ } + it 'raises JWT::InvalidIssuerError' do + expect { verify! }.to raise_error(JWT::InvalidIssuerError, 'Invalid issuer. Expected [/\A(first|second)\z/], received ') + end + end + + context 'when issuer is given as a Proc' do + let(:issuer) { 'ruby-jwt-gem' } + let(:expected_issuers) { ->(iss) { iss.start_with?('ruby') } } + it 'passes validation' do + verify! + end + end + + context 'when issuer is given as a Proc and does not match the payload' do + let(:issuer) { 'mismatched-issuer' } + let(:expected_issuers) { ->(iss) { iss.start_with?('ruby') } } + it 'raises JWT::InvalidIssuerError' do + expect { verify! }.to raise_error(JWT::InvalidIssuerError, /received mismatched-issuer/) + end + end + + context 'when issuer is given as a Proc and payload does not have any issuer' do + let(:payload) { {} } + let(:expected_issuers) { ->(iss) { iss&.start_with?('ruby') } } + it 'raises JWT::InvalidIssuerError' do + expect { verify! }.to raise_error(JWT::InvalidIssuerError, /received /) + end + end + + context 'when issuer is given as a Method instance' do + def issuer_start_with_ruby?(issuer) + issuer&.start_with?('ruby') + end + + let(:issuer) { 'ruby-jwt-gem' } + let(:expected_issuers) { method(:issuer_start_with_ruby?) } + + it 'passes validation' do + verify! + end + end +end diff --git a/spec/jwt/claims/jwt_id_spec.rb b/spec/jwt/claims/jwt_id_spec.rb new file mode 100644 index 00000000..89db8a7c --- /dev/null +++ b/spec/jwt/claims/jwt_id_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +RSpec.describe JWT::Claims::JwtId do + let(:jti) { 'some-random-uuid-or-whatever' } + let(:payload) { { 'jti' => jti } } + let(:validator) { nil } + + subject(:verify!) { described_class.new(validator: validator).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) } + context 'when payload contains a jti' do + it 'passes validation' do + verify! + end + end + + context 'when payload is missing a jti' do + let(:payload) { {} } + it 'raises JWT::InvalidJtiError' do + expect { verify! }.to raise_error(JWT::InvalidJtiError, 'Missing jti') + end + end + + context 'when payload contains a jti that is an empty string' do + let(:jti) { '' } + it 'raises JWT::InvalidJtiError' do + expect { verify! }.to raise_error(JWT::InvalidJtiError, 'Missing jti') + end + end + + context 'when payload contains a jti that is a blank string' do + let(:jti) { ' ' } + it 'raises JWT::InvalidJtiError' do + expect { verify! }.to raise_error(JWT::InvalidJtiError, 'Missing jti') + end + end + + context 'when jti validator is a proc returning false' do + let(:validator) { ->(_jti) { false } } + it 'raises JWT::InvalidJtiError' do + expect { verify! }.to raise_error(JWT::InvalidJtiError, 'Invalid jti') + end + end + + context 'when jti validator is a proc returning true' do + let(:validator) { ->(_jti) { true } } + it 'passes validation' do + verify! + end + end + + context 'when jti validator has 2 args' do + let(:validator) { ->(_jti, _pl) { true } } + it 'passes validation' do + verify! + end + end + + context 'when jti validator has 2 args' do + it 'the second arg is the payload' do + described_class.new(validator: ->(_jti, pl) { expect(pl).to eq(payload) }).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) + end + end +end diff --git a/spec/jwt/claims/not_before_spec.rb b/spec/jwt/claims/not_before_spec.rb new file mode 100644 index 00000000..1f8b4930 --- /dev/null +++ b/spec/jwt/claims/not_before_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.describe JWT::Claims::NotBefore do + let(:payload) { { 'nbf' => (Time.now.to_i + 5) } } + + describe '#verify!' do + context 'when nbf is in the future' do + it 'raises JWT::ImmatureSignature' do + expect { described_class.new(leeway: 0).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) }.to raise_error JWT::ImmatureSignature + end + end + + context 'when nbf is in the past' do + let(:payload) { { 'nbf' => (Time.now.to_i - 5) } } + + it 'does not raise error' do + expect { described_class.new(leeway: 0).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) }.not_to raise_error + end + end + + context 'when leeway is given' do + it 'does not raise error' do + expect { described_class.new(leeway: 10).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) }.not_to raise_error + end + end + end +end diff --git a/spec/jwt/claims_validator_spec.rb b/spec/jwt/claims/numeric_spec.rb similarity index 94% rename from spec/jwt/claims_validator_spec.rb rename to spec/jwt/claims/numeric_spec.rb index be24bc89..6cef1251 100644 --- a/spec/jwt/claims_validator_spec.rb +++ b/spec/jwt/claims/numeric_spec.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -RSpec.describe JWT::ClaimsValidator do +RSpec.describe JWT::Claims::Numeric do let(:validator) { described_class.new(claims) } - describe '#validate!' do - subject { validator.validate! } + describe '#verify!' do + subject { validator.verify! } shared_examples_for 'a NumericDate claim' do |claim| context "when #{claim} payload is an integer" do diff --git a/spec/jwt/claims/required_spec.rb b/spec/jwt/claims/required_spec.rb new file mode 100644 index 00000000..97033460 --- /dev/null +++ b/spec/jwt/claims/required_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec.describe JWT::Claims::Required do + let(:payload) { { 'data' => 'value' } } + + subject(:verify!) { described_class.new(required_claims: required_claims).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) } + + context 'when payload is missing the required claim' do + let(:required_claims) { ['exp'] } + it 'raises JWT::MissingRequiredClaim' do + expect { verify! }.to raise_error JWT::MissingRequiredClaim, 'Missing required claim exp' + end + end + + context 'when payload has the required claims' do + let(:payload) { { 'exp' => 'exp', 'custom_claim' => true } } + let(:required_claims) { %w[exp custom_claim] } + it 'passes validation' do + verify! + end + end +end diff --git a/spec/jwt/jwt_spec.rb b/spec/jwt/jwt_spec.rb index cf7da054..ddd68ecd 100644 --- a/spec/jwt/jwt_spec.rb +++ b/spec/jwt/jwt_spec.rb @@ -105,25 +105,6 @@ end end - context 'payload validation' do - it 'validates the payload with the ClaimsValidator if the payload is a hash' do - validator = double - expect(JWT::ClaimsValidator).to receive(:new) { validator } - expect(validator).to receive(:validate!) { true } - - payload = {} - JWT.encode payload, 'secret', 'HS256' - end - - it 'does not validate the payload if it is not present' do - validator = double - expect(JWT::ClaimsValidator).not_to receive(:new) { validator } - - payload = nil - JWT.encode payload, 'secret', 'HS256' - end - end - algorithms = %w[HS256 HS384 HS512] algorithms << 'HS512256' if JWT.rbnacl? @@ -553,7 +534,7 @@ let(:iss) { 'ruby-jwt-gem' } let(:invalid_token) { JWT.encode payload, data[:secret] } - let :token do + let(:token) do iss_payload = payload.merge(iss: iss) JWT.encode iss_payload, data[:secret] end @@ -563,6 +544,16 @@ end.not_to raise_error end end + + context 'claim verification order' do + let(:token) { JWT.encode({ nbf: Time.now.to_i + 100 }, 'secret') } + + context 'when two claims are invalid' do + it 'depends on the order of the parameters what error is raised' do + expect { JWT.decode(token, 'secret', true, { verify_jti: true, verify_not_before: true }) }.to raise_error(JWT::ImmatureSignature, 'Signature nbf has not been reached') + end + end + end end context 'a token with no segments' do diff --git a/spec/jwt/verify_spec.rb b/spec/jwt/verify_spec.rb deleted file mode 100644 index cb259c05..00000000 --- a/spec/jwt/verify_spec.rb +++ /dev/null @@ -1,336 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe JWT::Verify do - let(:base_payload) { { 'user_id' => 'some@user.tld' } } - let(:string_payload) { 'beautyexperts_nbf_iat' } - let(:options) { { leeway: 0 } } - - context '.verify_aud(payload, options)' do - let(:scalar_aud) { 'ruby-jwt-aud' } - let(:array_aud) { %w[ruby-jwt-aud test-aud ruby-ruby-ruby] } - let(:scalar_payload) { base_payload.merge('aud' => scalar_aud) } - let(:array_payload) { base_payload.merge('aud' => array_aud) } - - it 'must raise JWT::InvalidAudError when the singular audience does not match' do - expect do - described_class.verify_aud(scalar_payload, options.merge(aud: 'no-match')) - end.to raise_error JWT::InvalidAudError - end - - it 'must raise JWT::InvalidAudError when the payload has an array and none match the supplied value' do - expect do - described_class.verify_aud(array_payload, options.merge(aud: 'no-match')) - end.to raise_error JWT::InvalidAudError - end - - it 'must allow a matching singular audience to pass' do - described_class.verify_aud(scalar_payload, options.merge(aud: scalar_aud)) - end - - it 'must allow an array with any value matching the one in the options' do - described_class.verify_aud(array_payload, options.merge(aud: array_aud.first)) - end - - it 'must allow an array with any value matching any value in the options array' do - described_class.verify_aud(array_payload, options.merge(aud: array_aud)) - end - - it 'must allow a singular audience payload matching any value in the options array' do - described_class.verify_aud(scalar_payload, options.merge(aud: array_aud)) - end - end - - context '.verify_expiration(payload, options)' do - let(:payload) { base_payload.merge('exp' => (Time.now.to_i - 5)) } - - it 'must raise JWT::ExpiredSignature when the token has expired' do - expect do - described_class.verify_expiration(payload, options) - end.to raise_error JWT::ExpiredSignature - end - - it 'must allow some leeway in the expiration when global leeway is configured' do - described_class.verify_expiration(payload, options.merge(leeway: 10)) - end - - it 'must allow some leeway in the expiration when exp_leeway is configured' do - described_class.verify_expiration(payload, options.merge(exp_leeway: 10)) - end - - it 'must be expired if the exp claim equals the current time' do - payload['exp'] = Time.now.to_i - - expect do - described_class.verify_expiration(payload, options) - end.to raise_error JWT::ExpiredSignature - end - - it 'must not consider string containing exp as expired' do - expect(described_class.verify_expiration(string_payload, options)).to eq(nil) - end - - context 'when leeway is not specified' do - let(:options) { {} } - - it 'used a default leeway of 0' do - expect do - described_class.verify_expiration(payload, options) - end.to raise_error JWT::ExpiredSignature - end - end - end - - context '.verify_iat(payload, options)' do - let(:iat) { Time.now.to_f } - let(:payload) { base_payload.merge('iat' => iat) } - - it 'must allow a valid iat' do - described_class.verify_iat(payload, options) - end - - it 'must ignore configured leeway' do - expect { described_class.verify_iat(payload.merge('iat' => (iat + 60)), options.merge(leeway: 70)) } - .to raise_error(JWT::InvalidIatError) - end - - it 'must properly handle integer times' do - described_class.verify_iat(payload.merge('iat' => Time.now.to_i), options) - end - - it 'must raise JWT::InvalidIatError when the iat value is not Numeric' do - expect do - described_class.verify_iat(payload.merge('iat' => 'not a number'), options) - end.to raise_error JWT::InvalidIatError - end - - it 'must raise JWT::InvalidIatError when the iat value is in the future' do - expect do - described_class.verify_iat(payload.merge('iat' => (iat + 120)), options) - end.to raise_error JWT::InvalidIatError - end - - it 'must not validate if the payload is a string containing iat' do - expect(described_class.verify_iat(string_payload, options)).to eq(nil) - end - end - - context '.verify_iss(payload, options)' do - let(:iss) { 'ruby-jwt-gem' } - let(:payload) { base_payload.merge('iss' => iss) } - - let(:invalid_token) { JWT.encode base_payload, payload[:secret] } - - context 'when iss is a String' do - it 'must raise JWT::InvalidIssuerError when the configured issuer does not match the payload issuer' do - expect do - described_class.verify_iss(payload, options.merge(iss: 'mismatched-issuer')) - end.to raise_error JWT::InvalidIssuerError - end - - it 'must raise JWT::InvalidIssuerError when the payload does not include an issuer' do - expect do - described_class.verify_iss(base_payload, options.merge(iss: iss)) - end.to raise_error(JWT::InvalidIssuerError, /received /) - end - - it 'must allow a matching issuer to pass' do - described_class.verify_iss(payload, options.merge(iss: iss)) - end - end - context 'when iss is an Array' do - it 'must raise JWT::InvalidIssuerError when no matching issuers in array' do - expect do - described_class.verify_iss(payload, options.merge(iss: %w[first second])) - end.to raise_error JWT::InvalidIssuerError - end - - it 'must raise JWT::InvalidIssuerError when the payload does not include an issuer' do - expect do - described_class.verify_iss(base_payload, options.merge(iss: %w[first second])) - end.to raise_error(JWT::InvalidIssuerError, /received /) - end - - it 'must allow an array with matching issuer to pass' do - described_class.verify_iss(payload, options.merge(iss: ['first', iss, 'third'])) - end - end - context 'when iss is a RegExp' do - it 'must raise JWT::InvalidIssuerError when the regular expression does not match' do - expect do - described_class.verify_iss(payload, options.merge(iss: /\A(first|second)\z/)) - end.to raise_error JWT::InvalidIssuerError - end - - it 'must raise JWT::InvalidIssuerError when the payload does not include an issuer' do - expect do - described_class.verify_iss(base_payload, options.merge(iss: /\A(first|second)\z/)) - end.to raise_error(JWT::InvalidIssuerError, /received /) - end - - it 'must allow a regular expression matching the issuer to pass' do - described_class.verify_iss(payload, options.merge(iss: /\A(first|#{iss}|third)\z/)) - end - end - context 'when iss is a Proc' do - it 'must raise JWT::InvalidIssuerError when the proc returns false' do - expect do - described_class.verify_iss(payload, options.merge(iss: ->(iss) { iss && iss.start_with?('first') })) - end.to raise_error JWT::InvalidIssuerError - end - - it 'must raise JWT::InvalidIssuerError when the payload does not include an issuer' do - expect do - described_class.verify_iss(base_payload, options.merge(iss: ->(iss) { iss && iss.start_with?('first') })) - end.to raise_error(JWT::InvalidIssuerError, /received /) - end - - it 'must allow a proc that returns true to pass' do - described_class.verify_iss(payload, options.merge(iss: ->(iss) { iss && iss.start_with?('ruby') })) - end - end - context 'when iss is a Method instance' do - def issuer_start_with_first?(issuer) - issuer&.start_with?('first') - end - - def issuer_start_with_ruby?(issuer) - issuer&.start_with?('ruby') - end - - it 'must raise JWT::InvalidIssuerError when the method returns false' do - expect do - described_class.verify_iss(payload, options.merge(iss: method(:issuer_start_with_first?))) - end.to raise_error JWT::InvalidIssuerError - end - - it 'must raise JWT::InvalidIssuerError when the payload does not include an issuer' do - expect do - described_class.verify_iss(base_payload, options.merge(iss: method(:issuer_start_with_first?))) - end.to raise_error(JWT::InvalidIssuerError, /received /) - end - - it 'must allow a method that returns true to pass' do - described_class.verify_iss(payload, options.merge(iss: method(:issuer_start_with_ruby?))) - end - end - end - - context '.verify_jti(payload, options)' do - let(:payload) { base_payload.merge('jti' => 'some-random-uuid-or-whatever') } - - it 'must allow any jti when the verfy_jti key in the options is truthy but not a proc' do - described_class.verify_jti(payload, options.merge(verify_jti: true)) - end - - it 'must raise JWT::InvalidJtiError when the jti is missing' do - expect do - described_class.verify_jti(base_payload, options) - end.to raise_error JWT::InvalidJtiError, /missing/i - end - - it 'must raise JWT::InvalidJtiError when the jti is an empty string' do - expect do - described_class.verify_jti(base_payload.merge('jti' => ' '), options) - end.to raise_error JWT::InvalidJtiError, /missing/i - end - - it 'must raise JWT::InvalidJtiError when verify_jti proc returns false' do - expect do - described_class.verify_jti(payload, options.merge(verify_jti: ->(_jti) { false })) - end.to raise_error JWT::InvalidJtiError, /invalid/i - end - - it 'true proc should not raise JWT::InvalidJtiError' do - described_class.verify_jti(payload, options.merge(verify_jti: ->(_jti) { true })) - end - - it 'it should not throw arguement error with 2 args' do - expect do - described_class.verify_jti(payload, options.merge(verify_jti: lambda { |_jti, _pl| - true - })) - end.to_not raise_error - end - it 'should have payload as second param in proc' do - described_class.verify_jti(payload, options.merge(verify_jti: lambda { |_jti, pl| - expect(pl).to eq(payload) - })) - end - end - - context '.verify_not_before(payload, options)' do - let(:payload) { base_payload.merge('nbf' => (Time.now.to_i + 5)) } - - it 'must raise JWT::ImmatureSignature when the nbf in the payload is in the future' do - expect do - described_class.verify_not_before(payload, options) - end.to raise_error JWT::ImmatureSignature - end - - it 'must allow some leeway in the token age when global leeway is configured' do - described_class.verify_not_before(payload, options.merge(leeway: 10)) - end - - it 'must allow some leeway in the token age when nbf_leeway is configured' do - described_class.verify_not_before(payload, options.merge(nbf_leeway: 10)) - end - - it 'must not validate if the payload is a string containing iat' do - expect(described_class.verify_not_before(string_payload, options)).to eq(nil) - end - end - - context '.verify_sub(payload, options)' do - let(:sub) { 'ruby jwt subject' } - - it 'must raise JWT::InvalidSubError when the subjects do not match' do - expect do - described_class.verify_sub(base_payload.merge('sub' => 'not-a-match'), options.merge(sub: sub)) - end.to raise_error JWT::InvalidSubError - end - - it 'must allow a matching sub' do - described_class.verify_sub(base_payload.merge('sub' => sub), options.merge(sub: sub)) - end - end - - context '.verify_claims' do - let(:fail_verifications_options) { { iss: 'mismatched-issuer', aud: 'no-match', sub: 'some subject' } } - let(:fail_verifications_payload) do - { - 'exp' => (Time.now.to_i - 50), - 'jti' => ' ', - 'iss' => 'some-issuer', - 'nbf' => (Time.now.to_i + 50), - 'iat' => 'not a number', - 'sub' => 'not-a-match' - } - end - - %w[verify_aud verify_expiration verify_iat verify_iss verify_jti verify_not_before verify_sub].each do |method| - let(:payload) { base_payload.merge(fail_verifications_payload) } - it "must skip verification when #{method} option is set to false" do - described_class.verify_claims(payload, options.merge(method => false)) - end - - it "must raise error when #{method} option is set to true" do - expect do - described_class.verify_claims(payload, options.merge(method => true).merge(fail_verifications_options)) - end.to raise_error JWT::DecodeError - end - end - end - - context '.verify_required_claims(payload, options)' do - it 'must raise JWT::MissingRequiredClaim if a required claim is absent' do - expect do - described_class.verify_required_claims(base_payload, options.merge(required_claims: ['exp'])) - end.to raise_error JWT::MissingRequiredClaim - end - - it 'must verify the claims if all required claims are present' do - payload = base_payload.merge('exp' => (Time.now.to_i + 5), 'custom_claim' => true) - described_class.verify_required_claims(payload, options.merge(required_claims: %w[exp custom_claim])) - end - end -end