diff --git a/CHANGELOG.md b/CHANGELOG.md index 06abefad..c0f1ab07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [v2.8.3](https://github.com/jwt/ruby-jwt/tree/v2.8.3) (NEXT) + +[Full Changelog](https://github.com/jwt/ruby-jwt/compare/v2.8.2...main) + +**Features:** + +- Your contribution here + +**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)) +- Allow extending available algorithms [#607](https://github.com/jwt/ruby-jwt/pull/607) ([@anakinj](https://github.com/anakinj)) +- Your contribution here + ## [v2.8.2](https://github.com/jwt/ruby-jwt/tree/v2.8.2) (2024-06-18) [Full Changelog](https://github.com/jwt/ruby-jwt/compare/v2.8.1...v2.8.2) diff --git a/README.md b/README.md index 41beb8f1..fa8f49d9 100644 --- a/README.md +++ b/README.md @@ -223,18 +223,22 @@ puts decoded_token ### **Custom algorithms** -An object implementing custom signing or verification behaviour can be passed in the `algorithm` option when encoding and decoding. The given object needs to implement the method `valid_alg?` and `verify` and/or `alg` and `sign`, depending if object is used for encoding or decoding. +When encoding or decoding a token, you can pass in a custom object through the `algorithm` option to handle signing or verification. This custom object must include or extend the `JWT::JWA::SigningAlgorithm` module and implement certain methods: + +- For decoding/verifying: The object must implement the methods `alg` and `verify`. +- For encoding/signing: The object must implement the methods `alg` and `sign`. + +For customization options check the details from `JWT::JWA::SigningAlgorithm`. + ```ruby module CustomHS512Algorithm + extend JWT::JWA::SigningAlgorithm + def self.alg 'HS512' end - def self.valid_alg?(alg_to_validate) - alg_to_validate == alg - end - def self.sign(data:, signing_key:) OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha512'), data, signing_key) end 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..a8de603d 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 @@ -92,7 +90,7 @@ def allowed_algorithms end def resolve_allowed_algorithms - algs = given_algorithms.map { |alg| JWA.create(alg) } + algs = given_algorithms.map { |alg| JWA.resolve(alg) } sort_by_alg_header(algs) end @@ -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..973f5b2f 100644 --- a/lib/jwt/encode.rb +++ b/lib/jwt/encode.rb @@ -1,20 +1,16 @@ # frozen_string_literal: true require_relative 'jwa' -require_relative 'claims_validator' # JWT::Encode module module JWT # Encoding logic for JWT class Encode - ALG_KEY = 'alg' - def initialize(options) @payload = options[:payload] @key = options[:key] - @algorithm = JWA.create(options[:algorithm]) + @algorithm = JWA.resolve(options[:algorithm]) @headers = options[:headers].transform_keys(&:to_s) - @headers[ALG_KEY] = @algorithm.alg end def segments @@ -41,7 +37,7 @@ def encoded_header_and_payload end def encode_header - encode_data(@headers) + encode_data(@headers.merge(@algorithm.header(signing_key: @key))) end def encode_payload @@ -55,7 +51,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/jwa.rb b/lib/jwt/jwa.rb index 6e88f514..8d515d26 100644 --- a/lib/jwt/jwa.rb +++ b/lib/jwt/jwa.rb @@ -8,54 +8,35 @@ raise if defined?(RbNaCl) end -require_relative 'jwa/hmac' -require_relative 'jwa/eddsa' +require_relative 'jwa/signing_algorithm' + require_relative 'jwa/ecdsa' -require_relative 'jwa/rsa' -require_relative 'jwa/ps' +require_relative 'jwa/eddsa' +require_relative 'jwa/hmac' require_relative 'jwa/none' +require_relative 'jwa/ps' +require_relative 'jwa/rsa' require_relative 'jwa/unsupported' require_relative 'jwa/wrapper' +if JWT.rbnacl_6_or_greater? + require_relative 'jwa/hmac_rbnacl' +elsif JWT.rbnacl? + require_relative 'jwa/hmac_rbnacl_fixed' +end + module JWT module JWA - ALGOS = [Hmac, Ecdsa, Rsa, Eddsa, Ps, None, Unsupported].tap do |l| - if ::JWT.rbnacl_6_or_greater? - require_relative 'jwa/hmac_rbnacl' - l << Algos::HmacRbNaCl - elsif ::JWT.rbnacl? - require_relative 'jwa/hmac_rbnacl_fixed' - l << Algos::HmacRbNaClFixed - end - end.freeze - class << self - def find(algorithm) - indexed[algorithm&.downcase] - end - - def create(algorithm) - return algorithm if JWA.implementation?(algorithm) - - Wrapper.new(*find(algorithm)) - end - - def implementation?(algorithm) - (algorithm.respond_to?(:valid_alg?) && algorithm.respond_to?(:verify)) || - (algorithm.respond_to?(:alg) && algorithm.respond_to?(:sign)) - end + def resolve(algorithm) + return find(algorithm) if algorithm.is_a?(String) || algorithm.is_a?(Symbol) - private - - def indexed - @indexed ||= begin - fallback = [nil, Unsupported] - ALGOS.each_with_object(Hash.new(fallback)) do |cls, hash| - cls.const_get(:SUPPORTED).each do |alg| - hash[alg.downcase] = [alg, cls] - end - end + unless algorithm.is_a?(SigningAlgorithm) + Deprecations.warning('Custom algorithms are required to include JWT::JWA::SigningAlgorithm') + return Wrapper.new(algorithm) end + + algorithm end end end diff --git a/lib/jwt/jwa/ecdsa.rb b/lib/jwt/jwa/ecdsa.rb index 8d9313c1..abc7246e 100644 --- a/lib/jwt/jwa/ecdsa.rb +++ b/lib/jwt/jwa/ecdsa.rb @@ -2,8 +2,35 @@ module JWT module JWA - module Ecdsa - module_function + class Ecdsa + include JWT::JWA::SigningAlgorithm + + def initialize(alg, digest) + @alg = alg + @digest = OpenSSL::Digest.new(digest) + end + + def sign(data:, signing_key:) + curve_definition = curve_by_name(signing_key.group.curve_name) + key_algorithm = curve_definition[:algorithm] + if alg != key_algorithm + raise IncorrectAlgorithm, "payload algorithm is #{alg} but #{key_algorithm} signing key was provided" + end + + asn1_to_raw(signing_key.dsa_sign_asn1(digest.digest(data)), signing_key) + end + + def verify(data:, signature:, verification_key:) + curve_definition = curve_by_name(verification_key.group.curve_name) + key_algorithm = curve_definition[:algorithm] + if alg != key_algorithm + raise IncorrectAlgorithm, "payload algorithm is #{alg} but #{key_algorithm} verification key was provided" + end + + verification_key.dsa_verify_asn1(digest.digest(data), raw_to_asn1(signature, verification_key)) + rescue OpenSSL::PKey::PKeyError + raise JWT::VerificationError, 'Signature verification raised' + end NAMED_CURVES = { 'prime256v1' => { @@ -28,36 +55,22 @@ module Ecdsa } }.freeze - SUPPORTED = NAMED_CURVES.map { |_, c| c[:algorithm] }.uniq.freeze + NAMED_CURVES.each_value do |v| + register_algorithm(new(v[:algorithm], v[:digest])) + end - def sign(algorithm, msg, key) - curve_definition = curve_by_name(key.group.curve_name) - key_algorithm = curve_definition[:algorithm] - if algorithm != key_algorithm - raise IncorrectAlgorithm, "payload algorithm is #{algorithm} but #{key_algorithm} signing key was provided" + def self.curve_by_name(name) + NAMED_CURVES.fetch(name) do + raise UnsupportedEcdsaCurve, "The ECDSA curve '#{name}' is not supported" end - - digest = OpenSSL::Digest.new(curve_definition[:digest]) - asn1_to_raw(key.dsa_sign_asn1(digest.digest(msg)), key) end - def verify(algorithm, public_key, signing_input, signature) - curve_definition = curve_by_name(public_key.group.curve_name) - key_algorithm = curve_definition[:algorithm] - if algorithm != key_algorithm - raise IncorrectAlgorithm, "payload algorithm is #{algorithm} but #{key_algorithm} verification key was provided" - end + private - digest = OpenSSL::Digest.new(curve_definition[:digest]) - public_key.dsa_verify_asn1(digest.digest(signing_input), raw_to_asn1(signature, public_key)) - rescue OpenSSL::PKey::PKeyError - raise JWT::VerificationError, 'Signature verification raised' - end + attr_reader :digest def curve_by_name(name) - NAMED_CURVES.fetch(name) do - raise UnsupportedEcdsaCurve, "The ECDSA curve '#{name}' is not supported" - end + self.class.curve_by_name(name) end def raw_to_asn1(signature, private_key) diff --git a/lib/jwt/jwa/eddsa.rb b/lib/jwt/jwa/eddsa.rb index eef27f1b..3db3e3e6 100644 --- a/lib/jwt/jwa/eddsa.rb +++ b/lib/jwt/jwa/eddsa.rb @@ -2,41 +2,33 @@ module JWT module JWA - module Eddsa - SUPPORTED = %w[ED25519 EdDSA].freeze - SUPPORTED_DOWNCASED = SUPPORTED.map(&:downcase).freeze + class Eddsa + include JWT::JWA::SigningAlgorithm - class << self - def sign(algorithm, msg, key) - unless key.is_a?(RbNaCl::Signatures::Ed25519::SigningKey) - raise EncodeError, "Key given is a #{key.class} but has to be an RbNaCl::Signatures::Ed25519::SigningKey" - end - - validate_algorithm!(algorithm) + def initialize(alg) + @alg = alg + end - key.sign(msg) + def sign(data:, signing_key:) + unless signing_key.is_a?(RbNaCl::Signatures::Ed25519::SigningKey) + raise_encode_error!("Key given is a #{signing_key.class} but has to be an RbNaCl::Signatures::Ed25519::SigningKey") end - def verify(algorithm, public_key, signing_input, signature) - unless public_key.is_a?(RbNaCl::Signatures::Ed25519::VerifyKey) - raise DecodeError, "key given is a #{public_key.class} but has to be a RbNaCl::Signatures::Ed25519::VerifyKey" - end - - validate_algorithm!(algorithm) + signing_key.sign(data) + end - public_key.verify(signature, signing_input) - rescue RbNaCl::CryptoError - false + def verify(data:, signature:, verification_key:) + unless verification_key.is_a?(RbNaCl::Signatures::Ed25519::VerifyKey) + raise_decode_error!("key given is a #{verification_key.class} but has to be a RbNaCl::Signatures::Ed25519::VerifyKey") end - private - - def validate_algorithm!(algorithm) - return if SUPPORTED_DOWNCASED.include?(algorithm.downcase) - - raise IncorrectAlgorithm, "Algorithm #{algorithm} not supported. Supported algoritms are #{SUPPORTED.join(', ')}" - end + verification_key.verify(signature, data) + rescue RbNaCl::CryptoError + false end + + register_algorithm(new('ED25519')) + register_algorithm(new('EdDSA')) end end end diff --git a/lib/jwt/jwa/hmac.rb b/lib/jwt/jwa/hmac.rb index da73ff64..1dac71b3 100644 --- a/lib/jwt/jwa/hmac.rb +++ b/lib/jwt/jwa/hmac.rb @@ -2,35 +2,40 @@ module JWT module JWA - module Hmac - module_function + class Hmac + include JWT::JWA::SigningAlgorithm - MAPPING = { - 'HS256' => OpenSSL::Digest::SHA256, - 'HS384' => OpenSSL::Digest::SHA384, - 'HS512' => OpenSSL::Digest::SHA512 - }.freeze - - SUPPORTED = MAPPING.keys + def initialize(alg, digest) + @alg = alg + @digest = digest + end - def sign(algorithm, msg, key) - key ||= '' + def sign(data:, signing_key:) + signing_key ||= '' - raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String) + raise_verify_error!('HMAC key expected to be a String') unless signing_key.is_a?(String) - OpenSSL::HMAC.digest(MAPPING[algorithm].new, key, msg) + OpenSSL::HMAC.digest(digest.new, signing_key, data) rescue OpenSSL::HMACError => e - if key == '' && e.message == 'EVP_PKEY_new_mac_key: malloc failure' - raise JWT::DecodeError, 'OpenSSL 3.0 does not support nil or empty hmac_secret' + if signing_key == '' && e.message == 'EVP_PKEY_new_mac_key: malloc failure' + raise_verify_error!('OpenSSL 3.0 does not support nil or empty hmac_secret') end raise e end - def verify(algorithm, key, signing_input, signature) - SecurityUtils.secure_compare(signature, sign(algorithm, signing_input, key)) + def verify(data:, signature:, verification_key:) + SecurityUtils.secure_compare(signature, sign(data: data, signing_key: verification_key)) end + register_algorithm(new('HS256', OpenSSL::Digest::SHA256)) + register_algorithm(new('HS384', OpenSSL::Digest::SHA384)) + register_algorithm(new('HS512', OpenSSL::Digest::SHA512)) + + private + + attr_reader :digest + # Copy of https://github.com/rails/rails/blob/v7.0.3.1/activesupport/lib/active_support/security_utils.rb # rubocop:disable Naming/MethodParameterName, Style/StringLiterals, Style/NumericPredicate module SecurityUtils diff --git a/lib/jwt/jwa/hmac_rbnacl.rb b/lib/jwt/jwa/hmac_rbnacl.rb index 67c1ac41..15870828 100644 --- a/lib/jwt/jwa/hmac_rbnacl.rb +++ b/lib/jwt/jwa/hmac_rbnacl.rb @@ -1,49 +1,44 @@ # frozen_string_literal: true module JWT - module Algos - module HmacRbNaCl - MAPPING = { 'HS512256' => ::RbNaCl::HMAC::SHA512256 }.freeze - SUPPORTED = MAPPING.keys - class << self - def sign(algorithm, msg, key) - Deprecations.warning("The use of the algorithm #{algorithm} is deprecated and will be removed in the next major version of ruby-jwt") - if (hmac = resolve_algorithm(algorithm)) - hmac.auth(key_for_rbnacl(hmac, key).encode('binary'), msg.encode('binary')) - else - Hmac.sign(algorithm, msg, key) - end - end - - def verify(algorithm, key, signing_input, signature) - Deprecations.warning("The use of the algorithm #{algorithm} is deprecated and will be removed in the next major version of ruby-jwt") - if (hmac = resolve_algorithm(algorithm)) - hmac.verify(key_for_rbnacl(hmac, key).encode('binary'), signature.encode('binary'), signing_input.encode('binary')) - else - Hmac.verify(algorithm, key, signing_input, signature) - end - rescue ::RbNaCl::BadAuthenticatorError, ::RbNaCl::LengthError - false - end - - private - - def key_for_rbnacl(hmac, key) - key ||= '' - raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String) - - return padded_empty_key(hmac.key_bytes) if key == '' - - key - end - - def resolve_algorithm(algorithm) - MAPPING.fetch(algorithm) - end - - def padded_empty_key(length) - Array.new(length, 0x0).pack('C*').encode('binary') - end + module JWA + class HmacRbNaCl + include JWT::JWA::SigningAlgorithm + + def initialize(alg, hmac) + @alg = alg + @hmac = hmac + end + + def sign(data:, signing_key:) + Deprecations.warning("The use of the algorithm #{alg} is deprecated and will be removed in the next major version of ruby-jwt") + hmac.auth(key_for_rbnacl(hmac, signing_key).encode('binary'), data.encode('binary')) + end + + def verify(data:, signature:, verification_key:) + Deprecations.warning("The use of the algorithm #{alg} is deprecated and will be removed in the next major version of ruby-jwt") + hmac.verify(key_for_rbnacl(hmac, verification_key).encode('binary'), signature.encode('binary'), data.encode('binary')) + rescue ::RbNaCl::BadAuthenticatorError, ::RbNaCl::LengthError + false + end + + register_algorithm(new('HS512256', ::RbNaCl::HMAC::SHA512256)) + + private + + attr_reader :hmac + + def key_for_rbnacl(hmac, key) + key ||= '' + raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String) + + return padded_empty_key(hmac.key_bytes) if key == '' + + key + end + + def padded_empty_key(length) + Array.new(length, 0x0).pack('C*').encode('binary') end end end diff --git a/lib/jwt/jwa/hmac_rbnacl_fixed.rb b/lib/jwt/jwa/hmac_rbnacl_fixed.rb index cece2d52..909cc810 100644 --- a/lib/jwt/jwa/hmac_rbnacl_fixed.rb +++ b/lib/jwt/jwa/hmac_rbnacl_fixed.rb @@ -1,45 +1,41 @@ # frozen_string_literal: true module JWT - module Algos - module HmacRbNaClFixed - MAPPING = { 'HS512256' => ::RbNaCl::HMAC::SHA512256 }.freeze - SUPPORTED = MAPPING.keys - - class << self - def sign(algorithm, msg, key) - key ||= '' - Deprecations.warning("The use of the algorithm #{algorithm} is deprecated and will be removed in the next major version of ruby-jwt") - raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String) - - if (hmac = resolve_algorithm(algorithm)) && key.bytesize <= hmac.key_bytes - hmac.auth(padded_key_bytes(key, hmac.key_bytes), msg.encode('binary')) - else - Hmac.sign(algorithm, msg, key) - end - end - - def verify(algorithm, key, signing_input, signature) - key ||= '' - Deprecations.warning("The use of the algorithm #{algorithm} is deprecated and will be removed in the next major version of ruby-jwt") - raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String) - - if (hmac = resolve_algorithm(algorithm)) && key.bytesize <= hmac.key_bytes - hmac.verify(padded_key_bytes(key, hmac.key_bytes), signature.encode('binary'), signing_input.encode('binary')) - else - Hmac.verify(algorithm, key, signing_input, signature) - end - rescue ::RbNaCl::BadAuthenticatorError, ::RbNaCl::LengthError - false - end - - def resolve_algorithm(algorithm) - MAPPING.fetch(algorithm) - end - - def padded_key_bytes(key, bytesize) - key.bytes.fill(0, key.bytesize...bytesize).pack('C*') - end + module JWA + class HmacRbNaClFixed + include JWT::JWA::SigningAlgorithm + + def initialize(alg, hmac) + @alg = alg + @hmac = hmac + end + + def sign(data:, signing_key:) + signing_key ||= '' + Deprecations.warning("The use of the algorithm #{alg} is deprecated and will be removed in the next major version of ruby-jwt") + raise JWT::DecodeError, 'HMAC key expected to be a String' unless signing_key.is_a?(String) + + hmac.auth(padded_key_bytes(signing_key, hmac.key_bytes), data.encode('binary')) + end + + def verify(data:, signature:, verification_key:) + verification_key ||= '' + Deprecations.warning("The use of the algorithm #{alg} is deprecated and will be removed in the next major version of ruby-jwt") + raise JWT::DecodeError, 'HMAC key expected to be a String' unless verification_key.is_a?(String) + + hmac.verify(padded_key_bytes(verification_key, hmac.key_bytes), signature.encode('binary'), data.encode('binary')) + rescue ::RbNaCl::BadAuthenticatorError, ::RbNaCl::LengthError + false + end + + register_algorithm(new('HS512256', ::RbNaCl::HMAC::SHA512256)) + + private + + attr_reader :hmac + + def padded_key_bytes(key, bytesize) + key.bytes.fill(0, key.bytesize...bytesize).pack('C*') end end end diff --git a/lib/jwt/jwa/none.rb b/lib/jwt/jwa/none.rb index d6fd7e62..ec2db20c 100644 --- a/lib/jwt/jwa/none.rb +++ b/lib/jwt/jwa/none.rb @@ -2,10 +2,12 @@ module JWT module JWA - module None - module_function + class None + include JWT::JWA::SigningAlgorithm - SUPPORTED = %w[none].freeze + def initialize + @alg = 'none' + end def sign(*) '' @@ -14,6 +16,8 @@ def sign(*) def verify(*) true end + + register_algorithm(new) end end end diff --git a/lib/jwt/jwa/ps.rb b/lib/jwt/jwa/ps.rb index f7488bd0..ed27f792 100644 --- a/lib/jwt/jwa/ps.rb +++ b/lib/jwt/jwa/ps.rb @@ -2,29 +2,35 @@ module JWT module JWA - module Ps - # RSASSA-PSS signing algorithms + class Ps + include JWT::JWA::SigningAlgorithm - module_function - - SUPPORTED = %w[PS256 PS384 PS512].freeze + def initialize(alg) + @alg = alg + @digest_algorithm = alg.sub('PS', 'sha') + end - def sign(algorithm, msg, key) - unless key.is_a?(::OpenSSL::PKey::RSA) - raise EncodeError, "The given key is a #{key_class}. It has to be an OpenSSL::PKey::RSA instance." + def sign(data:, signing_key:) + unless signing_key.is_a?(::OpenSSL::PKey::RSA) + raise_sign_error!("The given key is a #{signing_key.class}. It has to be an OpenSSL::PKey::RSA instance.") end - translated_algorithm = algorithm.sub('PS', 'sha') - - key.sign_pss(translated_algorithm, msg, salt_length: :digest, mgf1_hash: translated_algorithm) + signing_key.sign_pss(digest_algorithm, data, salt_length: :digest, mgf1_hash: digest_algorithm) end - def verify(algorithm, public_key, signing_input, signature) - translated_algorithm = algorithm.sub('PS', 'sha') - public_key.verify_pss(translated_algorithm, signature, signing_input, salt_length: :auto, mgf1_hash: translated_algorithm) + def verify(data:, signature:, verification_key:) + 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' end + + register_algorithm(new('PS256')) + register_algorithm(new('PS384')) + register_algorithm(new('PS512')) + + private + + attr_reader :digest_algorithm end end end diff --git a/lib/jwt/jwa/rsa.rb b/lib/jwt/jwa/rsa.rb index f9970bda..79ca52a0 100644 --- a/lib/jwt/jwa/rsa.rb +++ b/lib/jwt/jwa/rsa.rb @@ -2,24 +2,35 @@ module JWT module JWA - module Rsa - module_function + class Rsa + include JWT::JWA::SigningAlgorithm - SUPPORTED = %w[RS256 RS384 RS512].freeze + def initialize(alg) + @alg = alg + @digest = OpenSSL::Digest.new(alg.sub('RS', 'SHA')) + end - def sign(algorithm, msg, key) - unless key.is_a?(OpenSSL::PKey::RSA) - raise EncodeError, "The given key is a #{key.class}. It has to be an OpenSSL::PKey::RSA instance" + def sign(data:, signing_key:) + unless signing_key.is_a?(OpenSSL::PKey::RSA) + raise_sign_error!("The given key is a #{signing_key.class}. It has to be an OpenSSL::PKey::RSA instance") end - key.sign(OpenSSL::Digest.new(algorithm.sub('RS', 'sha')), msg) + signing_key.sign(digest, data) end - def verify(algorithm, public_key, signing_input, signature) - public_key.verify(OpenSSL::Digest.new(algorithm.sub('RS', 'sha')), signature, signing_input) + def verify(data:, signature:, verification_key:) + verification_key.verify(digest, signature, data) rescue OpenSSL::PKey::PKeyError raise JWT::VerificationError, 'Signature verification raised' end + + register_algorithm(new('RS256')) + register_algorithm(new('RS384')) + register_algorithm(new('RS512')) + + private + + attr_reader :digest end end end diff --git a/lib/jwt/jwa/signing_algorithm.rb b/lib/jwt/jwa/signing_algorithm.rb new file mode 100644 index 00000000..d00f1c01 --- /dev/null +++ b/lib/jwt/jwa/signing_algorithm.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module JWT + module JWA + module SigningAlgorithm + module ClassMethods + def register_algorithm(algo) + ::JWT::JWA.register_algorithm(algo) + end + end + + def self.included(klass) + klass.extend(ClassMethods) + end + + attr_reader :alg + + def valid_alg?(alg_to_check) + alg&.casecmp(alg_to_check)&.zero? == true + end + + def header(*) + { 'alg' => alg } + end + + def sign(*) + raise_sign_error!('Algorithm implementation is missing the sign method') + end + + def verify(*) + raise_verify_error!('Algorithm implementation is missing the verify method') + end + + def raise_verify_error!(message) + raise(DecodeError.new(message).tap { |e| e.set_backtrace(caller(1)) }) + end + + def raise_sign_error!(message) + raise(EncodeError.new(message).tap { |e| e.set_backtrace(caller(1)) }) + end + end + + class << self + def register_algorithm(algo) + algorithms[algo.alg.to_s.downcase] = algo + end + + def find(algo) + algorithms.fetch(algo.to_s.downcase, Unsupported) + end + + private + + def algorithms + @algorithms ||= {} + end + end + end +end diff --git a/lib/jwt/jwa/unsupported.rb b/lib/jwt/jwa/unsupported.rb index 681a6a75..5d596101 100644 --- a/lib/jwt/jwa/unsupported.rb +++ b/lib/jwt/jwa/unsupported.rb @@ -3,16 +3,16 @@ module JWT module JWA module Unsupported - module_function + class << self + include JWT::JWA::SigningAlgorithm - SUPPORTED = [].freeze + def sign(*) + raise_sign_error!('Unsupported signing method') + end - def sign(*) - raise NotImplementedError, 'Unsupported signing method' - end - - def verify(*) - raise JWT::VerificationError, 'Algorithm not supported' + def verify(*) + raise JWT::VerificationError, 'Algorithm not supported' + end end end end diff --git a/lib/jwt/jwa/wrapper.rb b/lib/jwt/jwa/wrapper.rb index e53713e1..46ff0e2f 100644 --- a/lib/jwt/jwa/wrapper.rb +++ b/lib/jwt/jwa/wrapper.rb @@ -3,23 +3,40 @@ module JWT module JWA class Wrapper - attr_reader :alg, :cls + include SigningAlgorithm - def initialize(alg, cls) - @alg = alg - @cls = cls + def initialize(algorithm) + @algorithm = algorithm + end + + def alg + return @algorithm.alg if @algorithm.respond_to?(:alg) + + super end def valid_alg?(alg_to_check) - alg&.casecmp(alg_to_check)&.zero? == true + return @algorithm.valid_alg?(alg_to_check) if @algorithm.respond_to?(:valid_alg?) + + super end - def sign(data:, signing_key:) - cls.sign(alg, data, signing_key) + def header(*args, **kwargs) + return @algorithm.header(*args, **kwargs) if @algorithm.respond_to?(:header) + + super end - def verify(data:, signature:, verification_key:) - cls.verify(alg, verification_key, data, signature) + def sign(*args, **kwargs) + return @algorithm.sign(*args, **kwargs) if @algorithm.respond_to?(:sign) + + super + end + + def verify(*args, **kwargs) + return @algorithm.verify(*args, **kwargs) if @algorithm.respond_to?(:verify) + + super end end end 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/lib/jwt/version.rb b/lib/jwt/version.rb index 1daf6c29..5621eddd 100644 --- a/lib/jwt/version.rb +++ b/lib/jwt/version.rb @@ -13,7 +13,7 @@ module VERSION # minor version MINOR = 8 # tiny version - TINY = 2 + TINY = 3 # alpha, beta, etc. tag PRE = nil diff --git a/spec/integration/readme_examples_spec.rb b/spec/integration/readme_examples_spec.rb index 725f9d73..ff488a96 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 @@ -443,14 +451,12 @@ context 'custom algorithm example' do it 'allows a module to be used as algorithm on encode and decode' do custom_hs512_alg = Module.new do + extend JWT::JWA::SigningAlgorithm + def self.alg 'HS512' end - def self.valid_alg?(alg_to_validate) - alg_to_validate == alg - end - def self.sign(data:, signing_key:) OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha512'), data, signing_key) end @@ -461,7 +467,8 @@ def self.verify(data:, signature:, verification_key:) end token = JWT.encode({ 'pay' => 'load' }, 'secret', custom_hs512_alg) - _payload, _header = JWT.decode(token, 'secret', true, algorithm: custom_hs512_alg) + _payload, header = JWT.decode(token, 'secret', true, algorithm: custom_hs512_alg) + expect(header).to include('alg' => 'HS512') end end 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/jwa/hmac_rbnacl_fixed_spec.rb b/spec/jwt/jwa/hmac_rbnacl_fixed_spec.rb new file mode 100644 index 00000000..e095cf15 --- /dev/null +++ b/spec/jwt/jwa/hmac_rbnacl_fixed_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +RSpec.describe 'JWT::JWA::HmacRbNaClFixed' do + subject(:instance) { JWT::JWA::HmacRbNaClFixed.new('HS512256', RbNaCl::HMAC::SHA512256) } + let(:data) { 'test' } + + before do + skip('Requires the rbnacl gem') unless JWT.rbnacl? && !JWT.rbnacl_6_or_greater? + end + + describe '#sign' do + subject(:sign) { instance.sign(data: data, signing_key: signing_key) } + + let(:signing_key) { '*' * (RbNaCl::HMAC::SHA512256.key_bytes - 1) } + + it { is_expected.not_to be_empty } + + context 'when signing_key key is larger than hmac key bytes' do + let(:signing_key) { '*' * (RbNaCl::HMAC::SHA512256.key_bytes + 1) } + + it 'raises length error' do + expect { sign }.to raise_error(RbNaCl::LengthError, a_string_including('key was 33 bytes (Expected 32)')) + end + end + end + + describe '#verify' do + subject(:verify) { instance.verify(data: data, signature: signature, verification_key: verification_key) } + + let(:signature) { instance.sign(data: data, signing_key: signing_key) } + + let(:verification_key) { '*' * (RbNaCl::HMAC::SHA512256.key_bytes - 1) } + let(:signing_key) { verification_key } + + it { is_expected.to be(true) } + + context 'when verification_key key is larger than hmac key bytes' do + let(:verification_key) { '*' * (RbNaCl::HMAC::SHA512256.key_bytes + 1) } + let(:signature) { 'a_signature' } + + it { is_expected.to be(false) } + end + end +end diff --git a/spec/jwt/jwa/hmac_spec.rb b/spec/jwt/jwa/hmac_spec.rb index 84bc6734..736f1e56 100644 --- a/spec/jwt/jwa/hmac_spec.rb +++ b/spec/jwt/jwa/hmac_spec.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true RSpec.describe JWT::JWA::Hmac do - describe '.sign' do - subject { described_class.sign('HS256', 'test', hmac_secret) } + let(:instance) { described_class.new('HS256', OpenSSL::Digest::SHA256) } + + describe '#sign' do + subject { instance.sign(data: 'test', signing_key: hmac_secret) } # Address OpenSSL 3.0 errors with empty hmac_secret - https://github.com/jwt/ruby-jwt/issues/526 context 'when nil hmac_secret is passed' do diff --git a/spec/jwt/jwt_spec.rb b/spec/jwt/jwt_spec.rb index cf7da054..d7324495 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? @@ -333,10 +314,10 @@ end context 'Invalid' do - it 'algorithm should raise NotImplementedError' do + it 'algorithm should raise DecodeError' do expect do JWT.encode payload, 'secret', 'HS255' - end.to raise_error NotImplementedError + end.to raise_error JWT::EncodeError end it 'raises "No verification key available" error' do @@ -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 @@ -660,7 +651,7 @@ it 'raises error for invalid algorithm' do expect do JWT.encode(payload, '', 'xyz') - end.to raise_error(NotImplementedError) + end.to raise_error(JWT::EncodeError) end end @@ -860,11 +851,11 @@ context 'when algorithm is a custom class' do let(:custom_algorithm) do Class.new do - attr_reader :alg + include JWT::JWA::SigningAlgorithm def initialize(signature: 'custom_signature', alg: 'custom') @signature = signature - @alg = alg + @alg = alg end def sign(*) @@ -874,10 +865,6 @@ def sign(*) def verify(data:, signature:, verification_key:) # rubocop:disable Lint/UnusedMethodArgument signature == @signature end - - def valid_alg?(alg) - alg == self.alg - end end end @@ -898,6 +885,50 @@ def valid_alg?(alg) end end + context 'when class has custom header method' do + before do + custom_algorithm.class_eval do + def header(*) + { 'alg' => alg, 'foo' => 'bar' } + end + end + end + + it 'uses the provided header' do + expect(JWT.decode(token, 'secret', true, algorithm: custom_algorithm.new)).to eq([payload, { 'alg' => 'custom', 'foo' => 'bar' }]) + end + end + + context 'when class is not utilizing the ::JWT::JWA::SigningAlgorithm module' do + let(:custom_algorithm) do + Class.new do + attr_reader :alg + + def initialize(signature: 'custom_signature', alg: 'custom') + @signature = signature + @alg = alg + end + + def header(*) + { 'alg' => @alg, 'foo' => 'bar' } + end + + def sign(*) + @signature + end + + def verify(*) + true + end + end + end + + it 'emits a deprecation warning' do + expect { token }.to output("[DEPRECATION WARNING] Custom algorithms are required to include JWT::JWA::SigningAlgorithm\n").to_stderr + expect(JWT.decode(token, 'secret', true, algorithm: custom_algorithm.new)).to eq([payload, { 'alg' => 'custom', 'foo' => 'bar' }]) + end + end + context 'when alg is not matching' do it 'fails the validation process' do expect { JWT.decode(token, 'secret', true, algorithms: custom_algorithm.new(alg: 'not_a_match')) }.to raise_error(JWT::IncorrectAlgorithm, 'Expected a different algorithm') @@ -917,9 +948,8 @@ def valid_alg?(alg) end end - # This behaviour should be somehow nicer it 'raises an error on encoding' do - expect { token }.to raise_error(NoMethodError) + expect { token }.to raise_error(JWT::EncodeError, /missing the sign method/) end it 'allows decoding' do @@ -938,9 +968,8 @@ def valid_alg?(alg) expect(token).to eq(expected_token) end - # This behaviour should be somehow nicer it 'raises error on decoding' do - expect { JWT.decode(expected_token, 'secret', true, algorithm: custom_algorithm.new) }.to raise_error(NoMethodError) + expect { JWT.decode(expected_token, 'secret', true, algorithm: custom_algorithm.new) }.to raise_error(JWT::DecodeError, /missing the verify method/) end end end 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