Skip to content

Commit 9d094e3

Browse files
committed
Official claim validation
1 parent ab63588 commit 9d094e3

File tree

10 files changed

+340
-69
lines changed

10 files changed

+340
-69
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
**Features:**
88

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

1112
**Fixes and enhancements:**

lib/jwt/claims.rb

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,74 @@
99
require_relative 'claims/numeric'
1010
require_relative 'claims/required'
1111
require_relative 'claims/subject'
12+
require_relative 'claims/decode_verifier'
13+
require_relative 'claims/verifier'
1214

1315
module JWT
16+
# JWT Claim verifications
17+
# https://datatracker.ietf.org/doc/html/rfc7519#section-4
18+
#
19+
# Verification is supported for the following claims:
20+
# exp
21+
# nbf
22+
# iss
23+
# iat
24+
# jti
25+
# aud
26+
# sub
27+
# required
28+
# numeric
29+
#
1430
module Claims
15-
VerificationContext = Struct.new(:payload, keyword_init: true)
16-
17-
VERIFIERS = {
18-
verify_expiration: ->(options) { Claims::Expiration.new(leeway: options[:exp_leeway] || options[:leeway]) },
19-
verify_not_before: ->(options) { Claims::NotBefore.new(leeway: options[:nbf_leeway] || options[:leeway]) },
20-
verify_iss: ->(options) { options[:iss] && Claims::Issuer.new(issuers: options[:iss]) },
21-
verify_iat: ->(*) { Claims::IssuedAt.new },
22-
verify_jti: ->(options) { Claims::JwtId.new(validator: options[:verify_jti]) },
23-
verify_aud: ->(options) { options[:aud] && Claims::Audience.new(expected_audience: options[:aud]) },
24-
verify_sub: ->(options) { options[:sub] && Claims::Subject.new(expected_subject: options[:sub]) },
25-
required_claims: ->(options) { Claims::Required.new(required_claims: options[:required_claims]) }
26-
}.freeze
31+
# Represents a claim verification error
32+
Error = Struct.new(:message, keyword_init: true)
2733

2834
class << self
35+
# @deprecated Use {verify_payload!} instead. Will be removed in the next major version of ruby-jwt.
2936
def verify!(payload, options)
30-
VERIFIERS.each do |key, verifier_builder|
31-
next unless options[key] || options[key.to_s]
37+
Deprecations.warning('Calling ::JWT::Claims::verify! will be removed in the next major version of ruby-jwt')
38+
DecodeVerifier.verify!(payload, options)
39+
end
40+
41+
# Checks if the claims in the JWT payload are valid.
42+
# @example
43+
#
44+
# ::JWT::Claims.verify_payload!({"exp" => Time.now.to_i + 10}, :exp)
45+
# ::JWT::Claims.verify_payload!({"exp" => Time.now.to_i - 10}, exp: { leeway: 11})
46+
#
47+
# @param payload [Hash] the JWT payload.
48+
# @param options [Array] the options for verifying the claims.
49+
# @return [void]
50+
# @raise [JWT::DecodeError] if any claim is invalid.
51+
def verify_payload!(payload, *options)
52+
verify_token!(VerificationContext.new(payload: payload), *options)
53+
end
54+
55+
# Checks if the claims in the JWT payload are valid.
56+
#
57+
# @param payload [Hash] the JWT payload.
58+
# @param options [Array] the options for verifying the claims.
59+
# @return [Boolean] true if the claims are valid, false otherwise
60+
def valid_payload?(payload, *options)
61+
payload_errors(payload, *options).empty?
62+
end
63+
64+
# Returns the errors in the claims of the JWT token.
65+
#
66+
# @param options [Array] the options for verifying the claims.
67+
# @return [Array<JWT::Claims::Error>] the errors in the claims of the JWT
68+
def payload_errors(payload, *options)
69+
token_errors(VerificationContext.new(payload: payload), *options)
70+
end
71+
72+
private
73+
74+
def verify_token!(token, *options)
75+
Verifier.verify!(token, *options)
76+
end
3277

33-
verifier_builder&.call(options)&.verify!(context: VerificationContext.new(payload: payload))
34-
end
35-
nil
78+
def token_errors(token, *options)
79+
Verifier.errors(token, *options)
3680
end
3781
end
3882
end

lib/jwt/claims/decode_verifier.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# frozen_string_literal: true
2+
3+
module JWT
4+
module Claims
5+
# Context class to contain the data passed to individual claim validators
6+
#
7+
# @private
8+
VerificationContext = Struct.new(:payload, keyword_init: true)
9+
10+
# Verifiers to support the ::JWT.decode method
11+
#
12+
# @private
13+
module DecodeVerifier
14+
VERIFIERS = {
15+
verify_expiration: ->(options) { Claims::Expiration.new(leeway: options[:exp_leeway] || options[:leeway]) },
16+
verify_not_before: ->(options) { Claims::NotBefore.new(leeway: options[:nbf_leeway] || options[:leeway]) },
17+
verify_iss: ->(options) { options[:iss] && Claims::Issuer.new(issuers: options[:iss]) },
18+
verify_iat: ->(*) { Claims::IssuedAt.new },
19+
verify_jti: ->(options) { Claims::JwtId.new(validator: options[:verify_jti]) },
20+
verify_aud: ->(options) { options[:aud] && Claims::Audience.new(expected_audience: options[:aud]) },
21+
verify_sub: ->(options) { options[:sub] && Claims::Subject.new(expected_subject: options[:sub]) },
22+
required_claims: ->(options) { Claims::Required.new(required_claims: options[:required_claims]) }
23+
}.freeze
24+
25+
private_constant(:VERIFIERS)
26+
27+
class << self
28+
# @private
29+
def verify!(payload, options)
30+
VERIFIERS.each do |key, verifier_builder|
31+
next unless options[key] || options[key.to_s]
32+
33+
verifier_builder&.call(options)&.verify!(context: VerificationContext.new(payload: payload))
34+
end
35+
nil
36+
end
37+
end
38+
end
39+
end
40+
end

lib/jwt/claims/numeric.rb

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
module JWT
44
module Claims
55
class Numeric
6-
def self.verify!(payload:, **_args)
7-
return unless payload.is_a?(Hash)
6+
class Compat
7+
def initialize(payload)
8+
@payload = payload
9+
end
810

9-
new(payload).verify!
11+
def verify!
12+
JWT::Claims.verify_payload!(@payload, :numeric)
13+
end
1014
end
1115

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

18-
def initialize(payload)
19-
@payload = payload.transform_keys(&:to_sym)
22+
def self.new(*args)
23+
return super if args.empty?
24+
25+
Deprecations.warning('Calling ::JWT::Claims::Numeric.new with the payload will be removed in the next major version of ruby-jwt')
26+
Compat.new(*args)
2027
end
2128

22-
def verify!
23-
validate_numeric_claims
29+
def verify!(context:)
30+
validate_numeric_claims(context.payload)
31+
end
2432

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

2838
private
2939

30-
def validate_numeric_claims
40+
def validate_numeric_claims(payload)
3141
NUMERIC_CLAIMS.each do |claim|
32-
validate_is_numeric(claim) if @payload.key?(claim)
42+
validate_is_numeric(payload, claim)
3343
end
3444
end
3545

36-
def validate_is_numeric(claim)
37-
return if @payload[claim].is_a?(::Numeric)
46+
def validate_is_numeric(payload, claim)
47+
return unless payload.is_a?(Hash)
48+
return unless payload.key?(claim) ||
49+
payload.key?(claim.to_s)
50+
51+
return if payload[claim].is_a?(::Numeric) || payload[claim.to_s].is_a?(::Numeric)
3852

39-
raise InvalidPayload, "#{claim} claim must be a Numeric value but it is a #{@payload[claim].class}"
53+
raise InvalidPayload, "#{claim} claim must be a Numeric value but it is a #{(payload[claim] || payload[claim.to_s]).class}"
4054
end
4155
end
4256
end

lib/jwt/claims/verifier.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# frozen_string_literal: true
2+
3+
module JWT
4+
module Claims
5+
# @private
6+
module Verifier
7+
VERIFIERS = {
8+
exp: ->(options) { Claims::Expiration.new(leeway: options.dig(:exp, :leeway)) },
9+
nbf: ->(options) { Claims::NotBefore.new(leeway: options.dig(:nbf, :leeway)) },
10+
iss: ->(options) { Claims::Issuer.new(issuers: options[:iss]) },
11+
iat: ->(*) { Claims::IssuedAt.new },
12+
jti: ->(options) { Claims::JwtId.new(validator: options[:jti]) },
13+
aud: ->(options) { Claims::Audience.new(expected_audience: options[:aud]) },
14+
sub: ->(options) { Claims::Subject.new(expected_subject: options[:sub]) },
15+
16+
required: ->(options) { Claims::Required.new(required_claims: options[:required]) },
17+
numeric: ->(*) { Claims::Numeric.new }
18+
}.freeze
19+
20+
private_constant(:VERIFIERS)
21+
22+
class << self
23+
# @private
24+
def verify!(context, *options)
25+
iterate_verifiers(*options) do |verifier, verifier_options|
26+
verify_one!(context, verifier, verifier_options)
27+
end
28+
nil
29+
end
30+
31+
# @private
32+
def errors(context, *options)
33+
errors = []
34+
iterate_verifiers(*options) do |verifier, verifier_options|
35+
verify_one!(context, verifier, verifier_options)
36+
rescue ::JWT::DecodeError => e
37+
errors << Error.new(message: e.message)
38+
end
39+
errors
40+
end
41+
42+
# @private
43+
def iterate_verifiers(*options)
44+
options.each do |element|
45+
if element.is_a?(Hash)
46+
element.each_key { |key| yield(key, element) }
47+
else
48+
yield(element, {})
49+
end
50+
end
51+
end
52+
53+
private
54+
55+
def verify_one!(context, verifier, options)
56+
verifier_builder = VERIFIERS.fetch(verifier) { raise ArgumentError, "#{verifier} not a valid claim verifier" }
57+
verifier_builder.call(options || {}).verify!(context: context)
58+
end
59+
end
60+
end
61+
end
62+
end

lib/jwt/claims_validator.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def initialize(payload)
1010
end
1111

1212
def validate!
13-
Claims::Numeric.verify!(payload: @payload)
13+
Claims.verify_payload!(@payload, :numeric)
1414
end
1515
end
1616
end

lib/jwt/decode.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ def find_key(&keyfinder)
112112
end
113113

114114
def verify_claims
115-
Claims.verify!(payload, @options)
115+
Claims::DecodeVerifier.verify!(payload, @options)
116116
end
117117

118118
def validate_segment_count!

lib/jwt/encode.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def signature
5151
def validate_claims!
5252
return unless @payload.is_a?(Hash)
5353

54-
Claims::Numeric.new(@payload).verify!
54+
Claims.verify_payload!(@payload, :numeric)
5555
end
5656

5757
def encode_signature

0 commit comments

Comments
 (0)