From fe7a3a371ae7854c9845e424be04e38924e9c051 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sun, 29 Sep 2024 00:35:48 +0300 Subject: [PATCH] Encapsulate token in a object --- lib/jwt.rb | 1 + lib/jwt/claims.rb | 6 +-- lib/jwt/decode.rb | 68 ++++++------------------------ lib/jwt/encode.rb | 8 ++-- lib/jwt/token.rb | 49 +++++++++++++++++++++ spec/jwt/claims/audience_spec.rb | 2 +- spec/jwt/claims/expiration_spec.rb | 2 +- spec/jwt/claims/issued_at_spec.rb | 2 +- spec/jwt/claims/issuer_spec.rb | 2 +- spec/jwt/claims/jwt_id_spec.rb | 4 +- spec/jwt/claims/not_before_spec.rb | 6 +-- spec/jwt/claims/required_spec.rb | 2 +- spec/spec_helper.rb | 1 + spec/spec_support/token.rb | 3 ++ 14 files changed, 83 insertions(+), 73 deletions(-) create mode 100644 lib/jwt/token.rb create mode 100644 spec/spec_support/token.rb diff --git a/lib/jwt.rb b/lib/jwt.rb index f7f28d47..6fc14e4a 100644 --- a/lib/jwt.rb +++ b/lib/jwt.rb @@ -10,6 +10,7 @@ require 'jwt/error' require 'jwt/jwk' require 'jwt/claims' +require 'jwt/token' # JSON Web Token implementation # diff --git a/lib/jwt/claims.rb b/lib/jwt/claims.rb index 9bb71488..09ebf72b 100644 --- a/lib/jwt/claims.rb +++ b/lib/jwt/claims.rb @@ -12,8 +12,6 @@ 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]) }, @@ -26,11 +24,11 @@ module Claims }.freeze class << self - def verify!(payload, options) + def verify!(token, options) VERIFIERS.each do |key, verifier_builder| next unless options[key] - verifier_builder&.call(options)&.verify!(context: VerificationContext.new(payload: payload)) + verifier_builder&.call(options)&.verify!(context: token) end end end diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index a8de603d..475c1d9f 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -10,41 +10,35 @@ class Decode def initialize(jwt, key, verify, options, &keyfinder) raise JWT::DecodeError, 'Nil JSON web token' unless jwt - @jwt = jwt + @token = Token.new(jwt) @key = key @options = options - @segments = jwt.split('.') @verify = verify - @signature = '' @keyfinder = keyfinder end def decode_segments validate_segment_count! if @verify - decode_signature verify_algo set_key verify_signature - verify_claims + Claims.verify!(token, @options) end - raise JWT::DecodeError, 'Not enough or too many segments' unless header && payload - [payload, header] + [token.payload, token.header] end private - def verify_signature - return unless @key || @verify + attr_reader :token + def verify_signature return if none_algorithm? raise JWT::DecodeError, 'No verification key available' unless @key - return if Array(@key).any? { |key| verify_signature_for?(key) } - - raise JWT::VerificationError, 'Signature verification failed' + token.verify!(algorithms: allowed_and_valid_algorithms, verification_keys: @key) end def verify_algo @@ -55,16 +49,10 @@ def verify_algo def set_key @key = find_key(&@keyfinder) if @keyfinder - @key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks], allow_nil_kid: @options[:allow_nil_kid]).key_for(header['kid']) if @options[:jwks] + @key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks], allow_nil_kid: @options[:allow_nil_kid]).key_for(token.header['kid']) if @options[:jwks] return unless (x5c_options = @options[:x5c]) - @key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(header['x5c']) - end - - def verify_signature_for?(key) - allowed_and_valid_algorithms.any? do |alg| - alg.verify(data: signing_input, signature: @signature, verification_key: key) - end + @key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(token.header['x5c']) end def allowed_and_valid_algorithms @@ -103,57 +91,27 @@ def sort_by_alg_header(algs) end def find_key(&keyfinder) - key = (keyfinder.arity == 2 ? yield(header, payload) : yield(header)) + key = (keyfinder.arity == 2 ? yield(token.header, token.payload) : yield(token.header)) # key can be of type [string, nil, OpenSSL::PKey, Array] return key if key && !Array(key).empty? raise JWT::DecodeError, 'No verification key available' end - def verify_claims - Claims.verify!(payload, @options) - end - def validate_segment_count! - return if segment_length == 3 - return if !@verify && segment_length == 2 # If no verifying required, the signature is not needed - return if segment_length == 2 && none_algorithm? + return if token.segment_count == 3 + return if !@verify && token.segment_count == 2 # If no verifying required, the signature is not needed + return if token.segment_count == 2 && none_algorithm? raise JWT::DecodeError, 'Not enough or too many segments' end - def segment_length - @segments.count - end - def none_algorithm? alg_in_header == 'none' end - def decode_signature - @signature = ::JWT::Base64.url_decode(@segments[2] || '') - end - def alg_in_header - header['alg'] - end - - def header - @header ||= parse_and_decode @segments[0] - end - - def payload - @payload ||= parse_and_decode @segments[1] - end - - def signing_input - @segments.first(2).join('.') - end - - def parse_and_decode(segment) - JWT::JSON.parse(::JWT::Base64.url_decode(segment)) - rescue ::JSON::ParserError - raise JWT::DecodeError, 'Invalid segment encoding' + token.header['alg'] end end end diff --git a/lib/jwt/encode.rb b/lib/jwt/encode.rb index 973f5b2f..f01e1967 100644 --- a/lib/jwt/encode.rb +++ b/lib/jwt/encode.rb @@ -7,10 +7,10 @@ module JWT # Encoding logic for JWT class Encode def initialize(options) - @payload = options[:payload] - @key = options[:key] - @algorithm = JWA.resolve(options[:algorithm]) - @headers = options[:headers].transform_keys(&:to_s) + @payload = options[:payload] + @key = options[:key] + @algorithm = JWA.resolve(options[:algorithm]) + @headers = options[:headers].transform_keys(&:to_s) end def segments diff --git a/lib/jwt/token.rb b/lib/jwt/token.rb new file mode 100644 index 00000000..772997b5 --- /dev/null +++ b/lib/jwt/token.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module JWT + class Token + attr_reader :segments, :jwt + + def initialize(jwt) + raise ArgumentError 'jwt is nil' if jwt.nil? + + @segments = jwt.split('.') + end + + def segment_count + @segments.length + end + + def signature + @signature ||= ::JWT::Base64.url_decode(segments[2] || '') + end + + def header + @header ||= parse_and_decode(segments[0]) + end + + def payload + @payload ||= parse_and_decode(segments[1]) + end + + def signing_input + segments.first(2).join('.') + end + + def verify!(algorithms:, verification_keys:) + return if Array(algorithms).any? do |algorithm| + Array(verification_keys).any? do |verification_key| + algorithm.verify(data: signing_input, signature: signature, verification_key: verification_key) + end + end + + raise JWT::VerificationError, 'Signature verification failed' + end + + def parse_and_decode(segment) + JWT::JSON.parse(::JWT::Base64.url_decode(segment)) + rescue ::JSON::ParserError + raise JWT::DecodeError, 'Invalid segment encoding' + end + end +end diff --git a/spec/jwt/claims/audience_spec.rb b/spec/jwt/claims/audience_spec.rb index 96f2326b..a7406ef0 100644 --- a/spec/jwt/claims/audience_spec.rb +++ b/spec/jwt/claims/audience_spec.rb @@ -7,7 +7,7 @@ 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)) } + subject(:verify!) { described_class.new(expected_audience: expected_audience).verify!(context: SpecSupport::Token.new(payload: payload)) } context 'when the singular audience does not match' do let(:expected_audience) { 'no-match' } diff --git a/spec/jwt/claims/expiration_spec.rb b/spec/jwt/claims/expiration_spec.rb index f8638ad9..bbd01876 100644 --- a/spec/jwt/claims/expiration_spec.rb +++ b/spec/jwt/claims/expiration_spec.rb @@ -4,7 +4,7 @@ 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)) } + subject(:verify!) { described_class.new(leeway: leeway).verify!(context: SpecSupport::Token.new(payload: payload)) } context 'when token is expired' do let(:payload) { { 'exp' => (Time.now.to_i - 5) } } diff --git a/spec/jwt/claims/issued_at_spec.rb b/spec/jwt/claims/issued_at_spec.rb index c34e5e85..e5d3f822 100644 --- a/spec/jwt/claims/issued_at_spec.rb +++ b/spec/jwt/claims/issued_at_spec.rb @@ -3,7 +3,7 @@ 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)) } + subject(:verify!) { described_class.new.verify!(context: SpecSupport::Token.new(payload: payload)) } context 'when iat is now' do it 'passes validation' do diff --git a/spec/jwt/claims/issuer_spec.rb b/spec/jwt/claims/issuer_spec.rb index 33d9470a..a97e80bc 100644 --- a/spec/jwt/claims/issuer_spec.rb +++ b/spec/jwt/claims/issuer_spec.rb @@ -5,7 +5,7 @@ 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)) } + subject(:verify!) { described_class.new(issuers: expected_issuers).verify!(context: SpecSupport::Token.new(payload: payload)) } context 'when expected issuer is a string that matches the payload' do it 'passes validation' do diff --git a/spec/jwt/claims/jwt_id_spec.rb b/spec/jwt/claims/jwt_id_spec.rb index 89db8a7c..81a8a44d 100644 --- a/spec/jwt/claims/jwt_id_spec.rb +++ b/spec/jwt/claims/jwt_id_spec.rb @@ -5,7 +5,7 @@ let(:payload) { { 'jti' => jti } } let(:validator) { nil } - subject(:verify!) { described_class.new(validator: validator).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) } + subject(:verify!) { described_class.new(validator: validator).verify!(context: SpecSupport::Token.new(payload: payload)) } context 'when payload contains a jti' do it 'passes validation' do verify! @@ -56,7 +56,7 @@ 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)) + described_class.new(validator: ->(_jti, pl) { expect(pl).to eq(payload) }).verify!(context: SpecSupport::Token.new(payload: payload)) end end end diff --git a/spec/jwt/claims/not_before_spec.rb b/spec/jwt/claims/not_before_spec.rb index 1f8b4930..29677401 100644 --- a/spec/jwt/claims/not_before_spec.rb +++ b/spec/jwt/claims/not_before_spec.rb @@ -6,7 +6,7 @@ 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 + expect { described_class.new(leeway: 0).verify!(context: SpecSupport::Token.new(payload: payload)) }.to raise_error JWT::ImmatureSignature end end @@ -14,13 +14,13 @@ 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 + expect { described_class.new(leeway: 0).verify!(context: SpecSupport::Token.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 + expect { described_class.new(leeway: 10).verify!(context: SpecSupport::Token.new(payload: payload)) }.not_to raise_error end end end diff --git a/spec/jwt/claims/required_spec.rb b/spec/jwt/claims/required_spec.rb index 97033460..e2c5d7a4 100644 --- a/spec/jwt/claims/required_spec.rb +++ b/spec/jwt/claims/required_spec.rb @@ -3,7 +3,7 @@ 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)) } + subject(:verify!) { described_class.new(required_claims: required_claims).verify!(context: SpecSupport::Token.new(payload: payload)) } context 'when payload is missing the required claim' do let(:required_claims) { ['exp'] } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e6333c19..c1992e73 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,6 +5,7 @@ require 'jwt' require_relative 'spec_support/test_keys' +require_relative 'spec_support/token' puts "OpenSSL::VERSION: #{OpenSSL::VERSION}" puts "OpenSSL::OPENSSL_VERSION: #{OpenSSL::OPENSSL_VERSION}" diff --git a/spec/spec_support/token.rb b/spec/spec_support/token.rb new file mode 100644 index 00000000..5dfaae51 --- /dev/null +++ b/spec/spec_support/token.rb @@ -0,0 +1,3 @@ +module SpecSupport + Token = Struct.new(:payload, keyword_init: true) +end