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

Standalone and documented claims api #626

Merged
merged 3 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
# Changelog

## Upcoming breaking changes

Notable changes in the upcoming **version 3.0**:

- The indirect dependency to [rbnacl](https://github.com/RubyCrypto/rbnacl) will be removed:
- Support for the nonstandard SHA512256 algorithm will be removed.
- Support for Ed25519 will be moved to a [separate gem](https://github.com/anakinj/jwt-eddsa) for better dependency handling.

- Base64 decoding will no longer fallback on the looser RFC 2045.

- Claim verification has been [split into separate classes](https://github.com/jwt/ruby-jwt/pull/605) and has [a new api](https://github.com/jwt/ruby-jwt/pull/626) and lead to the following deprecations:
- The `::JWT::ClaimsValidator` class will be removed in favor of the functionality provided by `::JWT::Claims`.
- The `::JWT::Claims::verify!` method will be removed in favor of `::JWT::Claims::verify_payload!`.
- The `::JWT::JWA.create` method will be removed. No recommended alternatives.
- The `::JWT::Verify` class will be removed in favor of the functionality provided by `::JWT::Claims`.
- Calling `::JWT::Claims::Numeric.new` with a payload will be removed in favor of `::JWT::Claims::verify_payload!(payload, :numeric)`
- Calling `::JWT::Claims::Numeric.verify!` with a payload will be removed in favor of `::JWT::Claims::verify_payload!(payload, :numeric)`

- The internal algorithms were [restructured](https://github.com/jwt/ruby-jwt/pull/607) to support extensions from separate libraries. The changes lead to a few deprecations and new requirements:
- The `sign` and `verify` static methods on all the algorithms (`::JWT::JWA`) will be removed.
- Custom algorithms are expected to include the `JWT::JWA::SigningAlgorithm` module.

## [v2.9.2](https://github.com/jwt/ruby-jwt/tree/v2.9.2) (NEXT)

[Full Changelog](https://github.com/jwt/ruby-jwt/compare/v2.9.1...main)

**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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,24 @@ rescue JWT::InvalidSubError
end
```

### Standalone claim verification

The JWT claim verifications can be used to verify any Hash to include expected keys and values.

A few example on verifying the claims for a payload:
```ruby
JWT::Claims.verify_payload!({"exp" => Time.now.to_i + 10}, :numeric, :exp)
JWT::Claims.valid_payload?({"exp" => Time.now.to_i + 10}, :exp)
# => true
JWT::Claims.payload_errors({"exp" => Time.now.to_i - 10}, :exp)
# => [#<struct JWT::Claims::Error message="Signature has expired">]
JWT::Claims.verify_payload!({"exp" => Time.now.to_i - 10}, exp: { leeway: 11})

JWT::Claims.verify_payload!({"exp" => Time.now.to_i + 10, "sub" => "subject"}, :exp, sub: "subject")
```



### Finding a Key

To dynamically find the key for verifying the JWT signature, pass a block to the decode block. The block receives headers and the original payload as parameters. It should return with the key to verify the signature that was used to sign the JWT.
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('The ::JWT::Claims.verify! method is deprecated 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
Loading