Skip to content

Commit

Permalink
Move claims into their own classes
Browse files Browse the repository at this point in the history
  • Loading branch information
anakinj committed Jul 22, 2024
1 parent 447802c commit 4e55e85
Show file tree
Hide file tree
Showing 27 changed files with 698 additions and 517 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

**Fixes and enhancements:**

- Refactor claim validators into their own classes [#605](https://github.com/jwt/ruby-jwt/pull/605) ([@anakinj](https://github.com/anakinj))
- Your contribution here

## [v2.8.2](https://github.com/jwt/ruby-jwt/tree/v2.8.2) (2024-06-18)
Expand Down
1 change: 1 addition & 0 deletions lib/jwt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
require 'jwt/encode'
require 'jwt/error'
require 'jwt/jwk'
require 'jwt/claims'

# JSON Web Token implementation
#
Expand Down
84 changes: 84 additions & 0 deletions lib/jwt/claims.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# frozen_string_literal: true

module JWT
module Claims
DEFAULTS = {
leeway: 0
}.freeze

ClaimsContext = Struct.new(:payload, keyword_init: true)

class << self
def verify!(payload, options)
options = DEFAULTS.merge(options)
verify_aud(payload, options)
verify_expiration(payload, options)
verify_iat(payload, options)
verify_iss(payload, options)
verify_jti(payload, options)
verify_not_before(payload, options)
verify_sub(payload, options)
verify_required_claims(payload, options)
end

def verify_aud(payload, options)
return unless options[:verify_aud]

Claims::Audience.new(expected_audience: options[:aud]).validate!(context: ClaimsContext.new(payload: payload))
end

def verify_expiration(payload, options)
return unless options[:verify_expiration]

Claims::Expiration.new(leeway: options[:exp_leeway] || options[:leeway]).validate!(context: ClaimsContext.new(payload: payload))
end

def verify_iat(payload, options)
return unless options[:verify_iat]

Claims::IssuedAt.new.validate!(context: ClaimsContext.new(payload: payload))
end

def verify_iss(payload, options)
return unless options[:verify_iss]

Claims::Issuer.new(issuers: options[:iss]).validate!(context: ClaimsContext.new(payload: payload))
end

def verify_jti(payload, options)
return unless options[:verify_jti]

Claims::JwtId.new(validator: options[:verify_jti]).validate!(context: ClaimsContext.new(payload: payload))
end

def verify_not_before(payload, options)
return unless options[:verify_not_before]

Claims::NotBefore.new(leeway: options[:nbf_leeway] || options[:leeway]).validate!(context: ClaimsContext.new(payload: payload))
end

def verify_sub(payload, options)
return unless options[:verify_sub]
return unless options[:sub]

Claims::Subject.new(expected_subject: options[:sub]).validate!(context: ClaimsContext.new(payload: payload))
end

def verify_required_claims(payload, options)
return unless (options_required_claims = options[:required_claims])

Claims::Required.new(required_claims: options_required_claims).validate!(context: ClaimsContext.new(payload: payload))
end
end
end
end

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'
20 changes: 20 additions & 0 deletions lib/jwt/claims/audience.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

module JWT
module Claims
class Audience
def initialize(expected_audience:)
@expected_audience = expected_audience
end

def validate!(context:, **_args)
aud = context.payload['aud']
raise JWT::InvalidAudError, "Invalid audience. Expected #{expected_audience}, received #{aud || '<none>'}" if ([*aud] & [*expected_audience]).empty?
end

private

attr_reader :expected_audience
end
end
end
22 changes: 22 additions & 0 deletions lib/jwt/claims/expiration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module JWT
module Claims
class Expiration
def initialize(leeway:)
@leeway = leeway
end

def validate!(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
15 changes: 15 additions & 0 deletions lib/jwt/claims/issued_at.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module JWT
module Claims
class IssuedAt
def validate!(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
24 changes: 24 additions & 0 deletions lib/jwt/claims/issuer.rb
Original file line number Diff line number Diff line change
@@ -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 validate!(context:, **_args)
case (iss = context.payload['iss'])
when *issuers
nil
else
raise JWT::InvalidIssuerError, "Invalid issuer. Expected #{issuers}, received #{iss || '<none>'}"
end
end

private

attr_reader :issuers
end
end
end
25 changes: 25 additions & 0 deletions lib/jwt/claims/jwt_id.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module JWT
module Claims
class JwtId
def initialize(validator:)
@validator = validator
end

def validate!(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
22 changes: 22 additions & 0 deletions lib/jwt/claims/not_before.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module JWT
module Claims
class NotBefore
def initialize(leeway:)
@leeway = leeway
end

def validate!(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
43 changes: 43 additions & 0 deletions lib/jwt/claims/numeric.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

module JWT
module Claims
class Numeric
def self.validate!(payload:, **_args)
return unless payload.is_a?(Hash)

new(payload).validate!
end

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
end
23 changes: 23 additions & 0 deletions lib/jwt/claims/required.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

module JWT
module Claims
class Required
def initialize(required_claims:)
@required_claims = required_claims
end

def validate!(context:, **_args)
required_claims.each do |required_claim|
next if context.payload.is_a?(Hash) && context.payload.include?(required_claim)

raise JWT::MissingRequiredClaim, "Missing required claim #{required_claim}"
end
end

private

attr_reader :required_claims
end
end
end
20 changes: 20 additions & 0 deletions lib/jwt/claims/subject.rb
Original file line number Diff line number Diff line change
@@ -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 validate!(context:, **_args)
sub = context.payload['sub']
raise(JWT::InvalidSubError, "Invalid subject. Expected #{expected_subject}, received #{sub || '<none>'}") unless sub.to_s == expected_subject
end

private

attr_reader :expected_subject
end
end
end
37 changes: 0 additions & 37 deletions lib/jwt/claims_validator.rb

This file was deleted.

5 changes: 1 addition & 4 deletions lib/jwt/decode.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# frozen_string_literal: true

require 'json'

require 'jwt/verify'
require 'jwt/x5c_key_finder'

# JWT::Decode module
Expand Down Expand Up @@ -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!
Expand Down
3 changes: 1 addition & 2 deletions lib/jwt/encode.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# frozen_string_literal: true

require_relative 'jwa'
require_relative 'claims_validator'

# JWT::Encode module
module JWT
Expand Down Expand Up @@ -55,7 +54,7 @@ def signature
def validate_claims!
return unless @payload.is_a?(Hash)

ClaimsValidator.new(@payload).validate!
Claims::Numeric.new(@payload).validate!
end

def encode_signature
Expand Down
Loading

0 comments on commit 4e55e85

Please sign in to comment.