diff --git a/.reek.yml b/.reek.yml new file mode 100644 index 00000000..680696fc --- /dev/null +++ b/.reek.yml @@ -0,0 +1,7 @@ +detectors: + IrresponsibleModule: + enabled: false + MissingSafeMethod: + enabled: false + NilCheck: + enabled: false diff --git a/.rubocop.yml b/.rubocop.yml index 6c4769fa..436b680e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -37,7 +37,7 @@ Metrics/AbcSize: Max: 25 Metrics/ClassLength: - Max: 103 + Max: 140 Metrics/ModuleLength: Max: 100 diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index c1442e11..9d92df65 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -83,13 +83,6 @@ Style/FrozenStringLiteralComment: - 'lib/jwt/security_utils.rb' - 'ruby-jwt.gemspec' -# Offense count: 2 -# Cop supports --auto-correct. -Style/HashTransformKeys: - Exclude: - - 'lib/jwt/claims_validator.rb' - - 'lib/jwt/encode.rb' - # Offense count: 1 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, Autocorrect. diff --git a/README.md b/README.md index 625cfcec..0906d0e7 100644 --- a/README.md +++ b/README.md @@ -584,6 +584,26 @@ jwk_hash = jwk.export jwk_hash_with_private_key = jwk.export(include_private: true) ``` +## DSL for creating JWT encoders and decoders + +As an alternative to `::JWT.decode` and `::JWT.encode` there is a possibility to use a DSL to define the behaviour for the token handling. This approach allows you to define custom algorithms, encoders and logic for you JWT tokens. + +A few examples use-cases be found from the [specs](spec/dsl/examples) + +```ruby + require 'jwt' + + module AppToken + include ::JWT + algorithm 'HS256' + key { 'secret' } + end + + encoded_token = AppToken.encode!(data: 'data', exp: Time.now.to_i+3600) + + payload, headers = AppToken.decode!(encoded_token) +``` + # Development and Tests We depend on [Bundler](http://rubygems.org/gems/bundler) for defining gemspec and performing releases to rubygems.org, which can be done with diff --git a/lib/jwt.rb b/lib/jwt.rb index 834b5a87..aaa089a4 100644 --- a/lib/jwt.rb +++ b/lib/jwt.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require 'base64' +require 'jwt/dsl' +require 'jwt/decode_token' require 'jwt/json' require 'jwt/decode' require 'jwt/default_options' @@ -13,7 +15,9 @@ # Should be up to date with the latest spec: # https://tools.ietf.org/html/rfc7519 module JWT - include JWT::DefaultOptions + def self.included(cls) + cls.include(::JWT::DSL) + end module_function @@ -25,6 +29,6 @@ def encode(payload, key, algorithm = 'HS256', header_fields = {}) end def decode(jwt, key = nil, verify = true, options = {}, &keyfinder) - Decode.new(jwt, key, verify, DEFAULT_OPTIONS.merge(options), &keyfinder).decode_segments + Decode.new(jwt, DefaultOptions::DECODE_DEFAULT_OPTIONS.merge(key: key, verify: verify).merge(options), &keyfinder).decode_segments end end diff --git a/lib/jwt/algos/none.rb b/lib/jwt/algos/none.rb index 17d15f14..a1d13703 100644 --- a/lib/jwt/algos/none.rb +++ b/lib/jwt/algos/none.rb @@ -5,7 +5,9 @@ module None SUPPORTED = %w[none].freeze - def sign(*); end + def sign(*) + '' + end def verify(*) true diff --git a/lib/jwt/claims_validator.rb b/lib/jwt/claims_validator.rb index 27030c99..42c075b0 100644 --- a/lib/jwt/claims_validator.rb +++ b/lib/jwt/claims_validator.rb @@ -9,7 +9,7 @@ class ClaimsValidator ].freeze def initialize(payload) - @payload = payload.each_with_object({}) { |(k, v), h| h[k.to_sym] = v } + @payload = payload.transform_keys(&:to_sym) end def validate! diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index 5a288bfc..d33e8ea4 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -1,143 +1,84 @@ # frozen_string_literal: true -require 'json' +require_relative 'decode_methods' -require 'jwt/signature' -require 'jwt/verify' -require 'jwt/x5c_key_finder' -# JWT::Decode module module JWT - # Decoding logic for JWT + # Backwards compatible Decoding logic for the JWT Gem. Used by the ::JWT.decode method class Decode - def initialize(jwt, key, verify, options, &keyfinder) - raise(JWT::DecodeError, 'Nil JSON web token') unless jwt - @jwt = jwt - @key = key + include DecodeMethods + + def initialize(token, options, &keyfinder) + raise JWT::DecodeError, 'Nil JSON web token' unless token + @token = token @options = options - @segments = jwt.split('.') - @verify = verify - @signature = '' @keyfinder = keyfinder end def decode_segments validate_segment_count! - if @verify - decode_crypto - verify_algo - set_key - verify_signature - verify_claims + if verify? + verify_algo! + verify_signature! + verify_claims!(options) end - raise(JWT::DecodeError, 'Not enough or too many segments') unless header && payload + raise JWT::DecodeError, 'Not enough or too many segments' unless header && payload [payload, header] end private - def verify_signature - return unless @key || @verify + attr_reader :options, :token, :keyfinder + def verify_signature! return if none_algorithm? - raise JWT::DecodeError, 'No verification key available' unless @key + keys = Array(key) - return if Array(@key).any? { |key| verify_signature_for?(key) } + raise JWT::DecodeError, 'No verification key available' if keys.empty? - raise(JWT::VerificationError, 'Signature verification failed') - end + return if keys.any? { |single_key| verify_signature_for?(algorithm_in_header, single_key) } - def verify_algo - raise(JWT::IncorrectAlgorithm, 'An algorithm must be specified') if allowed_algorithms.empty? - raise(JWT::IncorrectAlgorithm, 'Token is missing alg header') unless algorithm - raise(JWT::IncorrectAlgorithm, 'Expected a different algorithm') unless options_includes_algo_in_header? + raise JWT::VerificationError, 'Signature verification failed' end - def set_key - @key = find_key(&@keyfinder) if @keyfinder - @key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks]).key_for(header['kid']) if @options[:jwks] - if (x5c_options = @options[:x5c]) - @key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(header['x5c']) - end + def verify_algo! + raise JWT::IncorrectAlgorithm, 'An algorithm must be specified' if allowed_algorithms.empty? + raise JWT::IncorrectAlgorithm, 'Token is missing alg header' unless algorithm_in_header + raise JWT::IncorrectAlgorithm, 'Expected a different algorithm' unless options_includes_algo_in_header? end - def verify_signature_for?(key) - Signature.verify(algorithm, key, signing_input, @signature) + def key + @key ||= use_keyfinder || resolve_key end def options_includes_algo_in_header? - allowed_algorithms.any? { |alg| alg.casecmp(algorithm).zero? } + allowed_algorithms.any? { |alg| alg.casecmp(algorithm_in_header).zero? } end def allowed_algorithms - # Order is very important - first check for string keys, next for symbols - algos = if @options.key?('algorithm') - @options['algorithm'] - elsif @options.key?(:algorithm) - @options[:algorithm] - elsif @options.key?('algorithms') - @options['algorithms'] - elsif @options.key?(:algorithms) - @options[:algorithms] - else - [] - end - Array(algos) + Array(algorithm_from_options) end - def find_key(&keyfinder) - key = (keyfinder.arity == 2 ? yield(header, payload) : yield(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 - Verify.verify_claims(payload, @options) - Verify.verify_required_claims(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? - - raise(JWT::DecodeError, 'Not enough or too many segments') + def algorithm_from_options + # Order is very important - first check for string keys, next for symbols + if options.key?('algorithm') + options['algorithm'] + elsif options.key?(:algorithm) + options[:algorithm] + elsif options.key?('algorithms') + options['algorithms'] + elsif options.key?(:algorithms) + options[:algorithms] + end end - def segment_length - @segments.count + # key can be of type [string, nil, OpenSSL::PKey, Array] + def use_keyfinder + keyfinder&.call(header, payload) end def none_algorithm? - algorithm.casecmp('none').zero? - end - - def decode_crypto - @signature = Base64.urlsafe_decode64(@segments[2] || '') - end - - def algorithm - 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(Base64.urlsafe_decode64(segment)) - rescue ::JSON::ParserError, ArgumentError - raise JWT::DecodeError, 'Invalid segment encoding' + algorithm_in_header.casecmp('none').zero? end end end diff --git a/lib/jwt/decode_methods.rb b/lib/jwt/decode_methods.rb new file mode 100644 index 00000000..0a0b95fd --- /dev/null +++ b/lib/jwt/decode_methods.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'jwt/signature' +require 'jwt/verify' +require 'jwt/x5c_key_finder' + +module JWT + # Shared methods and behaviours used by ::JWT::DecodeToken and ::JWT::Decode + module DecodeMethods + def verify? + options[:verify] != false + end + + def segments + @segments ||= token.split('.') + end + + def signature + @signature ||= Base64.urlsafe_decode64(segments[2] || '') + end + + def header + @header ||= decode_header(segments[0]) + end + + def payload + @payload ||= decode_payload(segments[1]) + end + + def algorithm_in_header + header['alg'] + end + + def signing_input + segments.first(2).join('.') + end + + def validate_segment_count! + segment_count = segments.size + + return if segment_count == 3 + return if segment_count == 2 && (!verify? || header['alg'] == 'none') + + raise JWT::DecodeError, 'Not enough or too many segments' + end + + def verify_signature_for?(algorithm, key) + if algorithm.is_a?(String) + raise JWT::DecodeError, 'No verification key available' unless key + + Array(key).any? { |single_key| Signature.verify(algorithm, single_key, signing_input, signature) } + else + algorithm.verify(signing_input, signature, key: key, header: header, payload: payload) + end + end + + def resolve_key + if (jwks = options[:jwks]) + ::JWT::JWK::KeyFinder.new(jwks: jwks).key_for(header['kid']) + elsif (x5c_options = options[:x5c]) + ::JWT::X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(header['x5c']) + elsif (key = options[:key]).respond_to?(:call) + key.call(header) + else + key + end + end + + def verify_claims!(claim_options) + Verify.verify_claims(payload, claim_options) + Verify.verify_required_claims(payload, claim_options) + end + + def decode_header(raw_header) + decode_segment_default(raw_header) + end + + def decode_payload(raw_segment) + if (decode_proc = options[:decode_payload_proc]) + return decode_proc.call(raw_segment, header, signature) + end + + decode_segment_default(raw_segment) + end + + def decode_segment_default(raw_segment) + json_parse(Base64.urlsafe_decode64(raw_segment)) + rescue ArgumentError + raise JWT::DecodeError, 'Invalid segment encoding' + end + + def json_parse(decoded_segment) + JWT::JSON.parse(decoded_segment) + rescue ::JSON::ParserError + raise JWT::DecodeError, 'Invalid segment encoding' + end + end +end diff --git a/lib/jwt/decode_token.rb b/lib/jwt/decode_token.rb new file mode 100644 index 00000000..83ec64b8 --- /dev/null +++ b/lib/jwt/decode_token.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require_relative 'decode_methods' + +module JWT + # Decode logic to support the ::JWT::Extensions::Decode functionality + class DecodeToken + include DecodeMethods + + def initialize(token, options = {}) + raise JWT::DecodeError, 'Provided token is not a String object' unless token.is_a?(String) + + @token = token + @options = options + end + + def decoded_segments + validate_segment_count! + + if verify? + verify_alg_header! + verify_signature! + verify_claims!(options) + end + + [payload, header] + end + + private + + attr_reader :token, :options + + def algorithms + @algorithms ||= Array(options[:algorithms]) + end + + def key + @key ||= resolve_key + end + + def verify_alg_header! + return unless valid_algorithms.empty? + + raise JWT::IncorrectAlgorithm, 'Expected a different algorithm' + end + + def valid_algorithms + @valid_algorithms ||= algorithms.select do |algorithm| + if algorithm.is_a?(String) + algorithm == algorithm_in_header + else + algorithm.valid_alg?(algorithm_in_header) + end + end + end + + def verify_signature! + return if valid_algorithms.any? { |algorithm| verify_signature_for?(algorithm, key) } + + raise JWT::VerificationError, 'Signature verification failed' + end + end +end diff --git a/lib/jwt/default_options.rb b/lib/jwt/default_options.rb index fc02c70f..d33c00d9 100644 --- a/lib/jwt/default_options.rb +++ b/lib/jwt/default_options.rb @@ -1,6 +1,12 @@ +# frozen_string_literal: true + module JWT module DefaultOptions - DEFAULT_OPTIONS = { + LEEWAY_DEFAULT = 0 + ALGORITHMS_DEFAULT = ['HS256'].freeze + + VERIFY_CLAIMS_DEFAULTS = { + leeway: LEEWAY_DEFAULT, verify_expiration: true, verify_not_before: true, verify_iss: false, @@ -8,9 +14,12 @@ module DefaultOptions verify_jti: false, verify_aud: false, verify_sub: false, - leeway: 0, - algorithms: ['HS256'], required_claims: [] }.freeze + + DECODE_DEFAULT_OPTIONS = { + verify: true, + algorithms: ALGORITHMS_DEFAULT + }.merge(VERIFY_CLAIMS_DEFAULTS).freeze end end diff --git a/lib/jwt/dsl.rb b/lib/jwt/dsl.rb new file mode 100644 index 00000000..c594983c --- /dev/null +++ b/lib/jwt/dsl.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative 'dsl/keys' +require_relative 'dsl/decode' +require_relative 'dsl/encode' + +module JWT + module DSL + def self.included(cls) + cls.extend(JWT::DSL::Keys) + cls.extend(JWT::DSL::Decode) + cls.extend(JWT::DSL::Encode) + end + end +end diff --git a/lib/jwt/dsl/decode.rb b/lib/jwt/dsl/decode.rb new file mode 100644 index 00000000..4daba00e --- /dev/null +++ b/lib/jwt/dsl/decode.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module JWT + module DSL + module Decode + def decode_payload(&block) + @decode_payload = block if block_given? + @decode_payload + end + + def algorithms(value = nil) + @algorithms = value unless value.nil? + Array(@algorithms || ::JWT::DefaultOptions::ALGORITHMS_DEFAULT) + end + + def jwk_resolver(&block) + @jwk_resolver = block if block_given? + @jwk_resolver + end + + def expiration_leeway(value = nil) + @expiration_leeway = value unless value.nil? + @expiration_leeway || ::JWT::DefaultOptions::LEEWAY_DEFAULT + end + + def decode!(token, options = {}) + payload, header = Internals.decode!(token, options, self) + + return yield(payload, header) if block_given? + + [payload, header] + end + + module Internals + class << self + def decode!(token, options, context) + ::JWT::DecodeToken.new(token, build_decode_options(options, context)).decoded_segments + end + + def build_decode_options(options, context) + JWT::DefaultOptions::VERIFY_CLAIMS_DEFAULTS.merge( + key: options[:key] || context.verification_key || context.signing_key, + decode_payload_proc: context.decode_payload, + leeway: context.expiration_leeway, + algorithms: (Array(options[:algorithms]) + Array(context.algorithm) + Array(context.algorithms)).uniq, + jwks: context.jwk_resolver + ).merge(options) + end + end + end + end + end +end diff --git a/lib/jwt/dsl/encode.rb b/lib/jwt/dsl/encode.rb new file mode 100644 index 00000000..c75e0858 --- /dev/null +++ b/lib/jwt/dsl/encode.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module JWT + module DSL + module Encode + def algorithm(value = nil) + @algorithm = value unless value.nil? + @algorithm + end + + def encode_payload(&block) + @encode_payload = block if block_given? + @encode_payload + end + + def expiration(value = nil) + @expiration = value unless value.nil? + @expiration + end + + def encode!(payload, options = {}) + Internals.encode!(payload, options, self) + end + + module Internals + class << self + def encode!(payload, options, context) + ::JWT::Encode.new(build_options(payload, options, context)).segments + end + + def build_options(payload, options, context) + opts = { + payload: payload, + key: options[:key] || context.signing_key, + encode_payload_proc: context.encode_payload, + headers: options[:headers], + algorithm: options[:algorithm] || context.algorithm, + expiration: options[:expiration] || context.expiration + } + + if opts[:algorithm].is_a?(String) && opts[:key].nil? + raise ::JWT::SigningKeyMissing, 'No key given for signing' + end + + opts + end + end + end + end + end +end diff --git a/lib/jwt/dsl/keys.rb b/lib/jwt/dsl/keys.rb new file mode 100644 index 00000000..ca2932e1 --- /dev/null +++ b/lib/jwt/dsl/keys.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module JWT + module DSL + module Keys + def signing_key(value = nil, &block) + @signing_key = value unless value.nil? + @signing_key = block if block_given? + @signing_key + end + + def verification_key(value = nil, &block) + @verification_key = value unless value.nil? + @verification_key = block if block_given? + @verification_key + end + + def key(value = nil, &block) + verification_key(value, &block) + signing_key(value, &block) + end + end + end +end diff --git a/lib/jwt/encode.rb b/lib/jwt/encode.rb index f5bca389..35ad3520 100644 --- a/lib/jwt/encode.rb +++ b/lib/jwt/encode.rb @@ -1,28 +1,45 @@ # frozen_string_literal: true -require_relative './algos' -require_relative './claims_validator' +require_relative 'algos' +require_relative 'claims_validator' -# JWT::Encode module module JWT - # Encoding logic for JWT class Encode - ALG_NONE = 'none'.freeze - ALG_KEY = 'alg'.freeze - def initialize(options) - @payload = options[:payload] - @key = options[:key] - _, @algorithm = Algos.find(options[:algorithm]) - @headers = options[:headers].each_with_object({}) { |(key, value), headers| headers[key.to_s] = value } + @options = options + + if (algo = options[:algorithm]).respond_to?(:sign) + @algorithm = algo + else + _, @alg = Algos.find(algo) + end + + @headers = (options[:headers] || {}).transform_keys(&:to_s) + + headers['alg'] = algorithm ? algorithm.alg : alg end def segments - @segments ||= combine(encoded_header_and_payload, encoded_signature) + validate_claims! + self.class.combine(encoded_header_and_payload, encoded_signature) end private + attr_reader :headers, :options, :algorithm, :alg + + def payload + @payload ||= append_exp(options[:payload]) + end + + def key + if (key = options[:key]).respond_to?(:call) + key.call + else + key + end + end + def encoded_header @encoded_header ||= encode_header end @@ -36,34 +53,54 @@ def encoded_signature end def encoded_header_and_payload - @encoded_header_and_payload ||= combine(encoded_header, encoded_payload) + @encoded_header_and_payload ||= self.class.combine(encoded_header, encoded_payload) end def encode_header - @headers[ALG_KEY] = @algorithm - encode(@headers) + self.class.encode(headers) end def encode_payload - if @payload && @payload.is_a?(Hash) - ClaimsValidator.new(@payload).validate! + if (encode_proc = options[:encode_payload_proc]) + return encode_proc.call(payload) end - encode(@payload) + self.class.encode(payload) end def encode_signature - return '' if @algorithm == ALG_NONE + Base64.urlsafe_encode64(signature, padding: false) + end + + def signature + return algorithm.sign(encoded_header_and_payload, key: key) if algorithm + + JWT::Signature.sign(alg, encoded_header_and_payload, key) + end + + def validate_claims! + return unless payload.is_a?(Hash) - Base64.urlsafe_encode64(JWT::Signature.sign(@algorithm, encoded_header_and_payload, @key), padding: false) + ClaimsValidator.new(payload).validate! end - def encode(data) - Base64.urlsafe_encode64(JWT::JSON.generate(data), padding: false) + def append_exp(payload) + return payload unless (expiration = options[:expiration]) + return payload if payload.key?('exp') || payload.key?(:exp) + + payload['exp'] = Time.now.to_i + expiration + + payload end - def combine(*parts) - parts.join('.') + class << self + def encode(data) + Base64.urlsafe_encode64(JWT::JSON.generate(data), padding: false) + end + + def combine(*parts) + parts.join('.') + end end end end diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb index ce3f3a9f..c716f122 100644 --- a/lib/jwt/error.rb +++ b/lib/jwt/error.rb @@ -17,6 +17,7 @@ class InvalidSubError < DecodeError; end class InvalidJtiError < DecodeError; end class InvalidPayload < DecodeError; end class MissingRequiredClaim < DecodeError; end + class SigningKeyMissing < EncodeError; end class JWKError < DecodeError; end end diff --git a/lib/jwt/jwk/ec.rb b/lib/jwt/jwk/ec.rb index e3634810..d5ef3924 100644 --- a/lib/jwt/jwk/ec.rb +++ b/lib/jwt/jwk/ec.rb @@ -75,10 +75,6 @@ def encode_octets(octets) Base64.urlsafe_encode64(octets, padding: false) end - def encode_open_ssl_bn(key_part) - Base64.urlsafe_encode64(key_part.to_s(BINARY), padding: false) - end - class << self def import(jwk_data) # See https://tools.ietf.org/html/rfc7518#section-6.2.1 for an diff --git a/lib/jwt/verify.rb b/lib/jwt/verify.rb index c5ba0a10..2ed625c3 100644 --- a/lib/jwt/verify.rb +++ b/lib/jwt/verify.rb @@ -18,7 +18,7 @@ class << self def verify_claims(payload, options) options.each do |key, val| - next unless key.to_s =~ /verify/ + next unless /verify./.match?(key.to_s) Verify.send(key, payload, options) if val end end diff --git a/spec/dsl/decode_spec.rb b/spec/dsl/decode_spec.rb new file mode 100644 index 00000000..8006a09e --- /dev/null +++ b/spec/dsl/decode_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'securerandom' + +RSpec.describe JWT::DSL do + subject(:jwt_class) do + secret_key = secret + + Class.new do + include JWT + algorithm 'HS256' + key secret_key + end + end + + let(:secret) { SecureRandom.hex } + let(:exp) { Time.now.to_i + 60 } + let(:payload) { { 'pay' => 'load', 'exp' => exp } } + let(:encoded_payload) { ::JWT.encode(payload, secret, 'HS256') } + + describe '.decode!' do + it { is_expected.to respond_to(:decode!) } + + context 'when nothing but algorithm is defined' do + it 'verifies a token and returns the data' do + expect(jwt_class.decode!(encoded_payload, key: secret)).to eq([payload, { 'alg' => 'HS256' }]) + end + end + + context 'when token is nil' do + it 'raises JWT::DecodeError' do + expect { jwt_class.decode!(nil) }.to raise_error(JWT::DecodeError, 'Provided token is not a String object') + end + end + + context 'when token is a 1' do + it 'raises JWT::DecodeError' do + expect { jwt_class.decode!(1) }.to raise_error(JWT::DecodeError, 'Provided token is not a String object') + end + end + + context 'when a decode_payload block manipulates the payload' do + before do + jwt_class.decode_payload do |raw_payload, _header, _signature| + payload_content = JWT::JSON.parse(Base64.urlsafe_decode64(raw_payload)) + payload_content['pay'].reverse! + payload_content + end + end + + it 'uses the defined decode_payload to process the raw payload' do + expect(jwt_class.decode!(encoded_payload).first['pay']).to eq('daol') + end + end + + context 'when block given' do + it 'calls it with payload and header' do + expect { |b| jwt_class.decode!(encoded_payload, &b) }.to yield_with_args(payload, { 'alg' => 'HS256' }) + end + end + + context 'when given block returns something' do + it 'returns what the block returned' do + expect(jwt_class.decode!(encoded_payload) { '123' }).to eq('123') + end + end + + context 'when signing key is invalid' do + it 'raises JWT::VerificationError' do + expect { jwt_class.decode!(encoded_payload, key: 'invalid') }.to raise_error(JWT::VerificationError, 'Signature verification failed') + end + end + + context 'when algorithm is not matching the one in the token' do + it 'raises JWT::VerificationError' do + expect { jwt_class.decode!(encoded_payload, algorithms: ['HS512']) }.to raise_error(JWT::IncorrectAlgorithm, 'Expected a different algorithm') + end + end + + context 'when one of the given algorithms match' do + it 'raises JWT::VerificationError' do + expect(jwt_class.decode!(encoded_payload, algorithms: ['HS512', 'HS256'])).to eq([payload, { 'alg' => 'HS256' }]) + end + end + + context 'when payload is invalid JSON' do + before do + jwt_class.encode_payload do |payload| + Base64.urlsafe_encode64(payload.inspect, padding: false) + end + end + + let(:encoded_payload) { jwt_class.encode!(payload) } + + it 'raises JWT::DecodeError' do + expect { jwt_class.decode!(encoded_payload) }.to raise_error(JWT::DecodeError, 'Invalid segment encoding') + end + end + + context 'when token is expired' do + let(:exp) { Time.now.to_i - 20 } + + it 'allows token to be 30 seconds overdue' do + expect { jwt_class.decode!(encoded_payload) }.to raise_error(JWT::ExpiredSignature, 'Signature has expired') + end + end + + context 'when expiration_leeway is set to 30 seconds' do + before do + jwt_class.expiration_leeway 30 + end + + let(:exp) { Time.now.to_i - 20 } + + it 'allows token to be 30 seconds overdue' do + expect(jwt_class.decode!(encoded_payload)).to eq([payload, { 'alg' => 'HS256' }]) + end + end + + context 'when key is given as block' do + let(:secret) { 'HS256' } + + before do + jwt_class.key { |header| header['alg'] } + end + + it 'uses the block to resolve the key' do + expect(jwt_class.decode!(encoded_payload)).to eq([payload, { 'alg' => 'HS256' }]) + end + end + end +end diff --git a/spec/dsl/encode_spec.rb b/spec/dsl/encode_spec.rb new file mode 100644 index 00000000..dacc0f27 --- /dev/null +++ b/spec/dsl/encode_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'securerandom' + +RSpec.describe JWT::DSL do + subject(:jwt_class) do + Class.new do + include JWT + end + end + + let(:secret) { SecureRandom.hex } + let(:payload) { { 'pay' => 'load'} } + + describe '.encode' do + it { is_expected.to respond_to(:encode!) } + + context 'when algorithm is configured and no signing key is given or configured' do + before do + jwt_class.algorithm('HS256') + end + + it 'raises an error about missing signing key' do + expect { jwt_class.encode!(payload) }.to raise_error(::JWT::SigningKeyMissing, 'No key given for signing') + end + end + + context 'when no algorithm is configured and key is given as a option' do + it 'raises an error about unsupported algoritm implementation' do + expect { jwt_class.encode!(payload, signing_key: secret) }.to raise_error(NotImplementedError, 'Unsupported signing method') + end + end + + context 'when algorithm and signing is configured' do + before do + jwt_class.algorithm('HS256') + jwt_class.signing_key(secret) + end + + it 'yields the same result as the raw encode' do + expect(jwt_class.encode!(payload)).to eq(::JWT.encode(payload, secret, 'HS256')) + end + end + + context 'when key is given as block' do + before do + jwt_class.algorithm('HS256') + jwt_class.key { secret } + end + + it 'uses the secret from the block' do + expect(jwt_class.encode!(payload)).to eq(::JWT.encode(payload, secret, 'HS256')) + end + end + + context 'when signing_key is given as block' do + before do + jwt_class.algorithm('HS256') + jwt_class.signing_key { secret } + end + + it 'uses the secret from the block' do + expect(jwt_class.encode!(payload)).to eq(::JWT.encode(payload, secret, 'HS256')) + end + end + + context 'when expiration is set on the class and is negative' do + before do + jwt_class.algorithm('HS256') + jwt_class.expiration(-10) + jwt_class.signing_key(secret) + end + + it 'will only generate expired tokens' do + token = jwt_class.encode!(payload) + expect { jwt_class.decode!(token) }.to raise_error(JWT::ExpiredSignature, 'Signature has expired') + end + end + end +end diff --git a/spec/dsl/examples/example_custom_algorithm_spec.rb b/spec/dsl/examples/example_custom_algorithm_spec.rb new file mode 100644 index 00000000..e885d8ba --- /dev/null +++ b/spec/dsl/examples/example_custom_algorithm_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +RSpec.describe 'Custom Signing algorithm' do + let(:payload) { { 'pay' => 'load'} } + + let(:signing_algo) do + Class.new do + class << self + def alg + 'CustomStatic' + end + + def valid_alg?(algorithm_from_header) + algorithm_from_header == self.alg + end + + def sign(_to_sign, _options) + 'static' + end + + def verify(_to_verify, signature, _options) + signature == 'static' + end + end + end + end + + subject(:extension) do + algo = signing_algo + + Class.new do + include JWT + algorithm algo + end + end + + context 'when encoding' do + it 'adds the custom signature to the end' do + expect(::Base64.decode64(subject.encode!(payload).split('.')[2])).to eq('static') + end + end + + context 'when decoding signed token' do + let(:presigned_token) { subject.encode!(payload) } + it 'verifies and decodes the payload' do + expect(subject.decode!(presigned_token)).to eq([{'pay' => 'load'}, {'alg' => 'CustomStatic'}]) + end + end +end diff --git a/spec/dsl/examples/example_deflating_spec.rb b/spec/dsl/examples/example_deflating_spec.rb new file mode 100644 index 00000000..8cd8f9af --- /dev/null +++ b/spec/dsl/examples/example_deflating_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'securerandom' +require 'zlib' + +RSpec.describe 'Deflating payload processor' do + let(:secret) { SecureRandom.hex } + let(:payload) { { 'pay' => 'load'} } + + subject(:extension) do + the_secret = secret + Class.new do + include JWT + + algorithm 'HS512' + algorithms 'HS5256' + signing_key the_secret + + encode_payload do |payload| + io = StringIO.new + Zlib::GzipWriter.new(io).tap do |gz| + gz.write(::JWT::JSON.generate(payload)) + gz.close + end + ::Base64.urlsafe_encode64(io.string, padding: true) + end + + decode_payload do |raw_payload| + raw_json = Zlib::GzipReader.new(StringIO.new(::Base64.urlsafe_decode64(raw_payload))).read + ::JWT::JSON.parse(raw_json) + end + end + end + + context 'when encoding' do + it 'the encoded payload looks like its zipped' do + expect(subject.encode!(payload).split('.')[1]).to match(/H4.*==/) + end + end + + context 'when decoding presigned and zipped token' do + let(:secret) { 's3cr3t' } + let(:presigned_token) { 'eyJhbGciOiJIUzUxMiJ9.H4sIAKTUyWEAA6tWKkisVLJSyslPTFGqBQAsM7zZDgAAAA==.GK1DXdMN7i6OA_1_xUYU3lThZwY94MgUYRivRIaLTIP-yrmZfxLrbpe3Llkrr1HIrDQhjPPwskiR5oob14hv9A' } + + it 'verifies and decodes the payload' do + expect(subject.decode!(presigned_token)).to eq([{'pay' => 'load'}, {'alg' => 'HS512'}]) + end + end +end diff --git a/spec/dsl/examples/example_smart_health_card_spec.rb b/spec/dsl/examples/example_smart_health_card_spec.rb new file mode 100644 index 00000000..afe39054 --- /dev/null +++ b/spec/dsl/examples/example_smart_health_card_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'securerandom' +require 'zlib' + +# Inspired by +# https://github.com/jwt/ruby-jwt/issues/428 + +RSpec.describe 'SMART Health Cards decoder and verifier' do + let(:jwk_keys) do + JSON.parse('{ "keys": [{ + "kty": "EC", + "kid": "3Kfdg-XwP-7gXyywtUfUADwBumDOPKMQx-iELL11W9s", + "use": "sig", + "alg": "ES256", + "crv": "P-256", + "x": "11XvRWy1I2S0EyJlyf_bWfw_TQ5CJJNLw78bHXNxcgw", + "y": "eZXwxvO1hvCY0KucrPfKo7yAyMT6Ajc3N7OkAB6VYy8", + "d": "FvOOk6hMixJ2o9zt4PCfan_UW7i4aOEnzj76ZaCI9Og" }]}') + end + + let(:smart_health_card_token) do + 'eyJ6aXAiOiJERUYiLCJhbGciOiJFUzI1NiIsImtpZCI6IjNLZmRnLVh3UC03Z1h5eXd0VWZVQUR3QnVtRE9QS01ReC1pRUxMMTFXOXMifQ.3ZJJb9swEIX_SjC9ytoSR5VutQt0Q4sWTXMpfKCpscWCi8BFsBvov3dIO2haJDn1VN1GM_PxvUfegXAOOhi8H11XFG5EnjvFrB-QST_knNneFXhgapToCpoOaCEDvd1BV11ftldtednU-XL5MoOJQ3cH_jgidN9_M__GvTgVi1gQ6uk5oVTQ4ifzwuhnB7mZRF-1sMmAW-xRe8Hk17D9gdxHSbtB2Fu0LnI6uMrLvCJe_LsKupcYZyw6EyzHmyQfzo3sbAe4kZJoJyV0gD2SRyIHKb9ZSQP3-11JA_fFI-DPZIf2Y4ZM4QnClJDEg1eaZqxLZ-zFhDrm-N4MsV7lsJnJ4FaQ-dfMR1bVLqtFWS3qEuY5e1RN9byad39G7DzzwSW78cI9xguaGOdC49r0icBNL_Q-CXdH51Gd3w_dzCCb3Nh9EZMtnOgLPh0IwNMm1GUD82bOYDxHkOTs0KKO2h4mSEOG82BTK5q9EeqEqJPhMtqiqHbGKnqPUQvj3tiI7IUbJUtxrtYXb1CjZfLirXGj8ExSUBSiNP5TUNu4CmX6qicTrP_LBOv2XyfYxAaFCFb09PPjh-P6MDTjdfhCjV8.F248favB7uvtKSo9GbwIC-QtmpWeAsB-AtiFq2iACiDZQE0s38603dJp50vc1HEvZAB80RXecKQ1LYdkZbq8Rw' + end + + subject(:smart_health_card_decoder) do + test_class = self + + Class.new do + include JWT + + algorithm 'ES256' + + jwk_resolver do |_options| + test_class.jwk_keys + end + + decode_payload do |raw_payload, headers| + decoded_payload = ::Base64.urlsafe_decode64(raw_payload) + + raw_json = if headers['zip'] == 'DEF' + begin + Zlib::Inflate.inflate(decoded_payload) + rescue Zlib::DataError + zinflate = Zlib::Inflate.new(-::Zlib::MAX_WBITS) + zinflate.inflate(decoded_payload) + end + else + decoded_payload + end + + ::JWT::JSON.parse(raw_json) + end + end + end + + context 'when valid token is given' do + it 'extracts the payload' do + payload, header = smart_health_card_decoder.decode!(smart_health_card_token) + expect(payload).to include('iss' => 'https://spec.smarthealth.cards/examples/issuer') + expect(header).to eq( + {'alg' => 'ES256', + 'kid' => '3Kfdg-XwP-7gXyywtUfUADwBumDOPKMQx-iELL11W9s', + 'zip' => 'DEF'} + ) + end + end +end diff --git a/spec/jwt_spec.rb b/spec/jwt_spec.rb index 239b1a4b..fab21818 100644 --- a/spec/jwt_spec.rb +++ b/spec/jwt_spec.rb @@ -712,9 +712,8 @@ describe 'when keyfinder given with 3 arguments' do let(:token) { JWT.encode(payload, 'HS256', 'HS256') } it 'decodes the token but does not pass the payload' do - expect(JWT.decode(token, nil, true, algorithm: 'HS256') do |header, token_payload, nothing| - expect(token_payload).to eq(nil) # This behaviour is not correct, the payload should be available in the keyfinder - expect(nothing).to eq(nil) + expect(JWT.decode(token, nil, true, algorithm: 'HS256') do |header, token_payload, _nothing| + expect(token_payload).to eq(payload) header['alg'] end).to include(payload) end