Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to extend JWT encoding and decoding behaviour #460

Closed
wants to merge 33 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
daaf09a
Base from previous branch
anakinj Dec 27, 2021
3ae159a
Base from previous branch
anakinj Dec 27, 2021
9f18792
Basic algorithm and signing key handling
anakinj Dec 27, 2021
46b8b47
Example of Smart healthcard encoder
anakinj Dec 27, 2021
5dc71b2
Lets not have private methods
anakinj Dec 27, 2021
fb255aa
Fix typo
anakinj Dec 27, 2021
71beeed
Decode class rewritten to increase understanding and to soon support …
anakinj Jan 7, 2022
ff613e4
Extracted verify_claims
anakinj Jan 7, 2022
f35ba82
Extracted even more methods and tests are passing
anakinj Jan 7, 2022
08b2d50
Avoid instance variables
anakinj Jan 7, 2022
4181ecb
Renamed shared methods module
anakinj Jan 7, 2022
4b78ebe
Decrease differences on old behaviour
anakinj Jan 7, 2022
4f965d1
Methods with bangs
anakinj Jan 7, 2022
589542d
Unify raising
anakinj Jan 7, 2022
9388723
Raise the no verification key error from one place only
anakinj Jan 7, 2022
d7dad0f
block support for the .decode! method
anakinj Jan 8, 2022
61fa3d5
Refactor ::JWT::Decode class to support custom algorithms
anakinj Jan 8, 2022
f0b7d96
Some refactorings
anakinj Jan 9, 2022
604a23e
Refactor decoding a litte more
anakinj Jan 9, 2022
e13bdbd
Renamed ::JWT::Extension to ::JWT:DSL
anakinj Jan 9, 2022
6b3611d
Disabled MissingSafeMethod
anakinj Jan 9, 2022
581d4b7
No need to check the keyfinder arity before calling
anakinj Jan 9, 2022
d467551
Leeway configuration for DSL
anakinj Jan 9, 2022
9a3bb7e
Nil token handling
anakinj Jan 9, 2022
2153057
Fix minor reek issue
anakinj Jan 9, 2022
73855bf
Reduce amount of instance variables in ::JWT:Encode
anakinj Jan 9, 2022
c8921db
Added newline in .reek.yml
anakinj Jan 9, 2022
1edad1b
Do not include default options
anakinj Jan 9, 2022
1e48820
Split defaults even more
anakinj Jan 9, 2022
11bb8b3
Added a few lines in the README
anakinj Jan 9, 2022
c37595d
Allow keys to be procs
anakinj Jan 9, 2022
1ef54ff
Possibility to give the expiration time on the JWT class
anakinj Jan 9, 2022
03b1adb
Allow parameters to be specified
anakinj Jan 13, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .reek.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
detectors:
IrresponsibleModule:
enabled: false
MissingSafeMethod:
enabled: false
NilCheck:
enabled: false
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Metrics/AbcSize:
Max: 25

Metrics/ClassLength:
Max: 103
Max: 140

Metrics/ModuleLength:
Max: 100
Expand Down
7 changes: 0 additions & 7 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions lib/jwt.rb
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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

Expand All @@ -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
4 changes: 3 additions & 1 deletion lib/jwt/algos/none.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ module None

SUPPORTED = %w[none].freeze

def sign(*); end
def sign(*)
''
end

def verify(*)
true
Expand Down
2 changes: 1 addition & 1 deletion lib/jwt/claims_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
141 changes: 41 additions & 100 deletions lib/jwt/decode.rb
Original file line number Diff line number Diff line change
@@ -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!
anakinj marked this conversation as resolved.
Show resolved Hide resolved
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!
anakinj marked this conversation as resolved.
Show resolved Hide resolved
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)
anakinj marked this conversation as resolved.
Show resolved Hide resolved
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
98 changes: 98 additions & 0 deletions lib/jwt/decode_methods.rb
Original file line number Diff line number Diff line change
@@ -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
anakinj marked this conversation as resolved.
Show resolved Hide resolved
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)
anakinj marked this conversation as resolved.
Show resolved Hide resolved
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
Loading