Skip to content

Commit

Permalink
Make extending algorithms easier
Browse files Browse the repository at this point in the history
Co-authored-by: Matteo Pierro <[email protected]>
  • Loading branch information
anakinj and MatteoPierro committed Sep 7, 2024
1 parent e674ca3 commit d7f680b
Show file tree
Hide file tree
Showing 43 changed files with 1,105 additions and 784 deletions.
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

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

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

**Features:**

- Your contribution here

**Fixes and enhancements:**

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

## [v2.8.2](https://github.com/jwt/ruby-jwt/tree/v2.8.2) (2024-06-18)

[Full Changelog](https://github.com/jwt/ruby-jwt/compare/v2.8.1...v2.8.2)
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,18 +223,22 @@ puts decoded_token

### **Custom algorithms**

An object implementing custom signing or verification behaviour can be passed in the `algorithm` option when encoding and decoding. The given object needs to implement the method `valid_alg?` and `verify` and/or `alg` and `sign`, depending if object is used for encoding or decoding.
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:

- For decoding/verifying: The object must implement the methods `alg` and `verify`.
- For encoding/signing: The object must implement the methods `alg` and `sign`.

For customization options check the details from `JWT::JWA::SigningAlgorithm`.


```ruby
module CustomHS512Algorithm
extend JWT::JWA::SigningAlgorithm

def self.alg
'HS512'
end

def self.valid_alg?(alg_to_validate)
alg_to_validate == alg
end

def self.sign(data:, signing_key:)
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha512'), data, signing_key)
end
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
38 changes: 38 additions & 0 deletions lib/jwt/claims.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

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'

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

verifier_builder&.call(options)&.verify!(context: VerificationContext.new(payload: payload))
end
end
end
end
end
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 verify!(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 || 0
end

def verify!(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 verify!(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 verify!(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 verify!(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 || 0
end

def verify!(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.verify!(payload:, **_args)
return unless payload.is_a?(Hash)

new(payload).verify!
end

NUMERIC_CLAIMS = %i[
exp
iat
nbf
].freeze

def initialize(payload)
@payload = payload.transform_keys(&:to_sym)
end

def verify!
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 verify!(context:, **_args)
required_claims.each do |required_claim|
next if context.payload.is_a?(Hash) && context.payload.key?(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 verify!(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.

7 changes: 2 additions & 5 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 @@ -92,7 +90,7 @@ def allowed_algorithms
end

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

sort_by_alg_header(algs)
end
Expand All @@ -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
Loading

0 comments on commit d7f680b

Please sign in to comment.