Skip to content

Commit

Permalink
Official claim validation
Browse files Browse the repository at this point in the history
  • Loading branch information
anakinj committed Oct 2, 2024
1 parent ab63588 commit 9d094e3
Show file tree
Hide file tree
Showing 10 changed files with 340 additions and 69 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

**Features:**

- Standalone claim verification interface [#626](https://github.com/jwt/ruby-jwt/pull/626) ([@anakinj](https://github.com/anakinj))
- Your contribution here

**Fixes and enhancements:**
Expand Down
78 changes: 61 additions & 17 deletions lib/jwt/claims.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,74 @@
require_relative 'claims/numeric'
require_relative 'claims/required'
require_relative 'claims/subject'
require_relative 'claims/decode_verifier'
require_relative 'claims/verifier'

module JWT
# JWT Claim verifications
# https://datatracker.ietf.org/doc/html/rfc7519#section-4
#
# Verification is supported for the following claims:
# exp
# nbf
# iss
# iat
# jti
# aud
# sub
# required
# numeric
#
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]) },
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
# Represents a claim verification error
Error = Struct.new(:message, keyword_init: true)

class << self
# @deprecated Use {verify_payload!} instead. Will be removed in the next major version of ruby-jwt.
def verify!(payload, options)
VERIFIERS.each do |key, verifier_builder|
next unless options[key] || options[key.to_s]
Deprecations.warning('Calling ::JWT::Claims::verify! will be removed in the next major version of ruby-jwt')
DecodeVerifier.verify!(payload, options)
end

# Checks if the claims in the JWT payload are valid.
# @example
#
# ::JWT::Claims.verify_payload!({"exp" => Time.now.to_i + 10}, :exp)
# ::JWT::Claims.verify_payload!({"exp" => Time.now.to_i - 10}, exp: { leeway: 11})
#
# @param payload [Hash] the JWT payload.
# @param options [Array] the options for verifying the claims.
# @return [void]
# @raise [JWT::DecodeError] if any claim is invalid.
def verify_payload!(payload, *options)
verify_token!(VerificationContext.new(payload: payload), *options)
end

# Checks if the claims in the JWT payload are valid.
#
# @param payload [Hash] the JWT payload.
# @param options [Array] the options for verifying the claims.
# @return [Boolean] true if the claims are valid, false otherwise
def valid_payload?(payload, *options)
payload_errors(payload, *options).empty?
end

# Returns the errors in the claims of the JWT token.
#
# @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

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

module JWT
module Claims
# Context class to contain the data passed to individual claim validators
#
# @private
VerificationContext = Struct.new(:payload, keyword_init: true)

# Verifiers to support the ::JWT.decode method
#
# @private
module DecodeVerifier
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

private_constant(:VERIFIERS)

class << self
# @private
def verify!(payload, options)
VERIFIERS.each do |key, verifier_builder|
next unless options[key] || options[key.to_s]

verifier_builder&.call(options)&.verify!(context: VerificationContext.new(payload: payload))
end
nil
end
end
end
end
end
40 changes: 27 additions & 13 deletions lib/jwt/claims/numeric.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
module JWT
module Claims
class Numeric
def self.verify!(payload:, **_args)
return unless payload.is_a?(Hash)
class Compat
def initialize(payload)
@payload = payload
end

new(payload).verify!
def verify!
JWT::Claims.verify_payload!(@payload, :numeric)
end
end

NUMERIC_CLAIMS = %i[
Expand All @@ -15,28 +19,38 @@ def self.verify!(payload:, **_args)
nbf
].freeze

def initialize(payload)
@payload = payload.transform_keys(&:to_sym)
def self.new(*args)
return super if args.empty?

Deprecations.warning('Calling ::JWT::Claims::Numeric.new with the payload will be removed in the next major version of ruby-jwt')
Compat.new(*args)
end

def verify!
validate_numeric_claims
def verify!(context:)
validate_numeric_claims(context.payload)
end

true
def self.verify!(payload:, **_args)
Deprecations.warning('Calling ::JWT::Claims::Numeric.verify! with the payload will be removed in the next major version of ruby-jwt')
JWT::Claims.verify_payload!(payload, :numeric)
end

private

def validate_numeric_claims
def validate_numeric_claims(payload)
NUMERIC_CLAIMS.each do |claim|
validate_is_numeric(claim) if @payload.key?(claim)
validate_is_numeric(payload, claim)
end
end

def validate_is_numeric(claim)
return if @payload[claim].is_a?(::Numeric)
def validate_is_numeric(payload, claim)
return unless payload.is_a?(Hash)
return unless payload.key?(claim) ||
payload.key?(claim.to_s)

return if payload[claim].is_a?(::Numeric) || payload[claim.to_s].is_a?(::Numeric)

raise InvalidPayload, "#{claim} claim must be a Numeric value but it is a #{@payload[claim].class}"
raise InvalidPayload, "#{claim} claim must be a Numeric value but it is a #{(payload[claim] || payload[claim.to_s]).class}"
end
end
end
Expand Down
62 changes: 62 additions & 0 deletions lib/jwt/claims/verifier.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

module JWT
module Claims
# @private
module Verifier
VERIFIERS = {
exp: ->(options) { Claims::Expiration.new(leeway: options.dig(:exp, :leeway)) },
nbf: ->(options) { Claims::NotBefore.new(leeway: options.dig(:nbf, :leeway)) },
iss: ->(options) { Claims::Issuer.new(issuers: options[:iss]) },
iat: ->(*) { Claims::IssuedAt.new },
jti: ->(options) { Claims::JwtId.new(validator: options[:jti]) },
aud: ->(options) { Claims::Audience.new(expected_audience: options[:aud]) },
sub: ->(options) { Claims::Subject.new(expected_subject: options[:sub]) },

required: ->(options) { Claims::Required.new(required_claims: options[:required]) },
numeric: ->(*) { Claims::Numeric.new }
}.freeze

private_constant(:VERIFIERS)

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

# @private
def errors(context, *options)
errors = []
iterate_verifiers(*options) do |verifier, verifier_options|
verify_one!(context, verifier, verifier_options)
rescue ::JWT::DecodeError => e
errors << Error.new(message: e.message)
end
errors
end

# @private
def iterate_verifiers(*options)
options.each do |element|
if element.is_a?(Hash)
element.each_key { |key| yield(key, element) }
else
yield(element, {})
end
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)
end
end
end
end
end
2 changes: 1 addition & 1 deletion lib/jwt/claims_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def initialize(payload)
end

def validate!
Claims::Numeric.verify!(payload: @payload)
Claims.verify_payload!(@payload, :numeric)
end
end
end
2 changes: 1 addition & 1 deletion lib/jwt/decode.rb
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def find_key(&keyfinder)
end

def verify_claims
Claims.verify!(payload, @options)
Claims::DecodeVerifier.verify!(payload, @options)
end

def validate_segment_count!
Expand Down
2 changes: 1 addition & 1 deletion lib/jwt/encode.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def signature
def validate_claims!
return unless @payload.is_a?(Hash)

Claims::Numeric.new(@payload).verify!
Claims.verify_payload!(@payload, :numeric)
end

def encode_signature
Expand Down
Loading

0 comments on commit 9d094e3

Please sign in to comment.