Skip to content

Commit

Permalink
JWT::EnocdedToken and JWT::Token for verifying and signing JWT tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
anakinj committed Oct 5, 2024
1 parent 64453e4 commit 6e0fc09
Show file tree
Hide file tree
Showing 26 changed files with 520 additions and 154 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ coverage/
.byebug_history
*.gem
doc/
.yardoc/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Notable changes in the upcoming **version 3.0**:

**Features:**

- JWT::Token and JWT::EncodedToken for signing and verifying tokens [#621](https://github.com/jwt/ruby-jwt/pull/621) ([@anakinj](https://github.com/anakinj))
- Your contribution here

**Fixes and enhancements:**
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,30 @@ decoded_token = JWT.decode token, rsa_public, true, { algorithm: 'PS256' }
puts decoded_token
```

### Using a Token object

The `JWT::Token` and `JWT::EncodedToken` classes can be used to manage your JWTs.

```ruby
token = JWT::Token.new(payload: { exp: Time.now.to_i + 60, jti: '1234', sub: "my-subject" }, header: { kid: 'hmac' })
token.sign!(algorithm: 'HS256', key: "secret")

token.jwt # => "eyJhbGciOiJIUzI1N..."
```

The `JWT::EncodedToken` can be used to create a token object that allows verification of signatures and claims
```ruby
encoded_token = JWT::EncodedToken.new(token.jwt)

encoded_token.verify_signature!(algorithm: 'HS256', key: "secret")
encoded_token.verify_signature!(algorithm: 'HS256', key: "wrong_secret") # raises JWT::VerificationError
encoded_token.verify_claims!(:exp, :jti)
encoded_token.verify_claims!(sub: ["not-my-subject"]) # raises JWT::InvalidSubError
encoded_token.claim_errors(sub: ["not-my-subject"]).map(&:message) # => ["Invalid subject. Expected [\"not-my-subject\"], received my-subject"]
encoded_token.payload # => { 'exp'=>1234, 'jti'=>'1234", 'sub'=>'my-subject' }
encoded_token.header # {'kid'=>'hmac', 'alg'=>'HS256'}
```

### **Custom algorithms**

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:
Expand Down Expand Up @@ -626,7 +650,6 @@ algorithms = jwks.map { |key| key[:alg] }.compact.uniq
JWT.decode(token, nil, true, algorithms: algorithms, jwks: jwks)
```


The `jwks` option can also be given as a lambda that evaluates every time a kid is resolved.
This can be used to implement caching of remotely fetched JWK Sets.

Expand Down
2 changes: 2 additions & 0 deletions lib/jwt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
require 'jwt/error'
require 'jwt/jwk'
require 'jwt/claims'
require 'jwt/encoded_token'
require 'jwt/token'

require 'jwt/claims_validator'
require 'jwt/verify'
Expand Down
15 changes: 3 additions & 12 deletions lib/jwt/claims.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require_relative 'claims/subject'
require_relative 'claims/decode_verifier'
require_relative 'claims/verifier'
require_relative 'claims/verification_methods'

module JWT
# JWT Claim verifications
Expand Down Expand Up @@ -48,7 +49,7 @@ def verify!(payload, options)
# @return [void]
# @raise [JWT::DecodeError] if any claim is invalid.
def verify_payload!(payload, *options)
verify_token!(VerificationContext.new(payload: payload), *options)
Verifier.verify!(VerificationContext.new(payload: payload), *options)
end

# Checks if the claims in the JWT payload are valid.
Expand All @@ -65,17 +66,7 @@ def valid_payload?(payload, *options)
# @param options [Array] the options for verifying the claims.
# @return [Array<JWT::Claims::Error>] the errors in the claims of the JWT
def payload_errors(payload, *options)
token_errors(VerificationContext.new(payload: payload), *options)
end

private

def verify_token!(token, *options)
Verifier.verify!(token, *options)
end

def token_errors(token, *options)
Verifier.errors(token, *options)
Verifier.errors(VerificationContext.new(payload: payload), *options)
end
end
end
Expand Down
30 changes: 30 additions & 0 deletions lib/jwt/claims/decode.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

module JWT
module Claims
# @api private
module Decode
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) { options[:iss] && Claims::Issuer.new(issuers: options[:iss]) },
verify_iat: ->(*) { Claims::IssuedAt.new },
verify_jti: ->(options) { Claims::JwtId.new(validator: options[:verify_jti]) },
verify_aud: ->(options) { options[:aud] && 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
# @api private
def verify!(token, options)
VERIFIERS.each do |key, verifier_builder|
next unless options[key]

verifier_builder&.call(options)&.verify!(context: token)
end
end
end
end
end
end
6 changes: 3 additions & 3 deletions lib/jwt/claims/decode_verifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ module JWT
module Claims
# Context class to contain the data passed to individual claim validators
#
# @private
# @api private
VerificationContext = Struct.new(:payload, keyword_init: true)

# Verifiers to support the ::JWT.decode method
#
# @private
# @api private
module DecodeVerifier
VERIFIERS = {
verify_expiration: ->(options) { Claims::Expiration.new(leeway: options[:exp_leeway] || options[:leeway]) },
Expand All @@ -25,7 +25,7 @@ module DecodeVerifier
private_constant(:VERIFIERS)

class << self
# @private
# @api private
def verify!(payload, options)
VERIFIERS.each do |key, verifier_builder|
next unless options[key] || options[key.to_s]
Expand Down
20 changes: 20 additions & 0 deletions lib/jwt/claims/verification_methods.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

module JWT
module Claims
# @api private
module VerificationMethods
def verify_claims!(*options)
Verifier.verify!(self, *options)
end

def claim_errors(*options)
Verifier.errors(self, *options)
end

def valid_claims?(*options)
claim_errors(*options).empty?
end
end
end
end
11 changes: 5 additions & 6 deletions lib/jwt/claims/verifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module JWT
module Claims
# @private
# @api private
module Verifier
VERIFIERS = {
exp: ->(options) { Claims::Expiration.new(leeway: options.dig(:exp, :leeway)) },
Expand All @@ -20,15 +20,15 @@ module Verifier
private_constant(:VERIFIERS)

class << self
# @private
# @api private
def verify!(context, *options)
iterate_verifiers(*options) do |verifier, verifier_options|
verify_one!(context, verifier, verifier_options)
end
nil
end

# @private
# @api private
def errors(context, *options)
errors = []
iterate_verifiers(*options) do |verifier, verifier_options|
Expand All @@ -39,7 +39,8 @@ def errors(context, *options)
errors
end

# @private
private

def iterate_verifiers(*options)
options.each do |element|
if element.is_a?(Hash)
Expand All @@ -50,8 +51,6 @@ def iterate_verifiers(*options)
end
end

private

def verify_one!(context, verifier, options)
verifier_builder = VERIFIERS.fetch(verifier) { raise ArgumentError, "#{verifier} not a valid claim verifier" }
verifier_builder.call(options || {}).verify!(context: context)
Expand Down
82 changes: 16 additions & 66 deletions lib/jwt/decode.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,62 +10,50 @@ class Decode
def initialize(jwt, key, verify, options, &keyfinder)
raise JWT::DecodeError, 'Nil JSON web token' unless jwt

@jwt = jwt
@token = EncodedToken.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::DecodeVerifier.verify!(token.payload, @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_signature!(algorithm: allowed_and_valid_algorithms, key: @key)
end

def verify_algo
raise JWT::IncorrectAlgorithm, 'An algorithm must be specified' if allowed_algorithms.empty?
raise JWT::DecodeError, 'Token header not a JSON object' unless header.is_a?(Hash)
raise JWT::DecodeError, 'Token header not a JSON object' unless token.header.is_a?(Hash)
raise JWT::IncorrectAlgorithm, 'Token is missing alg header' unless alg_in_header
raise JWT::IncorrectAlgorithm, 'Expected a different algorithm' if allowed_and_valid_algorithms.empty?
end

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
Expand All @@ -91,70 +79,32 @@ def allowed_algorithms
end

def resolve_allowed_algorithms
algs = given_algorithms.map { |alg| JWA.resolve(alg) }

sort_by_alg_header(algs)
end

# Move algorithms matching the JWT alg header to the beginning of the list
def sort_by_alg_header(algs)
return algs if algs.size <= 1

algs.partition { |alg| alg.valid_alg?(alg_in_header) }.flatten
given_algorithms.map { |alg| JWA.resolve(alg) }
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::DecodeVerifier.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?
segment_count = token.jwt.count('.') + 1
return if segment_count == 3
return if !@verify && segment_count == 2 # If no verifying required, the signature is not needed
return if 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
Loading

0 comments on commit 6e0fc09

Please sign in to comment.