Skip to content

Commit

Permalink
Signing and verification of tokens to a token class
Browse files Browse the repository at this point in the history
  • Loading branch information
anakinj committed Sep 29, 2024
1 parent fe7a3a3 commit 142fc0c
Show file tree
Hide file tree
Showing 14 changed files with 382 additions and 119 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:**

- JWT::Token and JWT::EncodedToken for signing and verifying tokens [#621](https://github.com/jwt/ruby-jwt/pull/621) ([@anakinj](https://github.com/anakinj))
- Your contribution here

**Fixes and enhancements:**
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,32 @@ decoded_token = JWT.decode token, rsa_public, true, { algorithm: 'PS256' }
puts decoded_token
```

### Using a Token object

An alternative to the `JWT.encode` and `JWT.decode` is to use the `JWT::Token` and `JWT::EncodedToken` objects to verify and sign JWTs.

```ruby
token = JWT::Token.new(payload: { exp: Time.now.to_i + 60, jti: '1234', sub: "my-subject" }, header: {kid: 'hmac'})
token.sign!(algorithm: 'HS256', key: "secret")

token.jwt # => "eyJhbGciOiJIUzI1N..."

token.verify_signature!(algorithm: 'HS256', key: "secret")
```

The `JWT::EncodedToken` can be used to create a token object that allows verification of signatures and claims
```ruby
encoded_token = JWT::EncodedToken.new(token.jwt)

encoded_token.verify_signature!(algorithm: 'HS256', key: "secret")
encoded_token.verify_signature!(algorithm: 'HS256', key: "wrong_secret") # raises JWT::VerificationError
encoded_token.verify_claims!(:exp, :jti)
encoded_token.verify_claims!(sub: ["not-my-subject"]) # raises JWT::InvalidSubError
encoded_token.claim_errors(sub: ["not-my-subject"]).map(&:message) # => ["Invalid subject. Expected [\"not-my-subject\"], received my-subject"]
encoded_token.payload # => { 'exp'=>1234, 'jti'=>'1234", 'sub'=>'my-subject' }
encoded_token.header # {'kid'=>'hmac', 'alg'=>'HS256'}
```

### **Custom algorithms**

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:
Expand Down Expand Up @@ -608,7 +634,6 @@ algorithms = jwks.map { |key| key[:alg] }.compact.uniq
JWT.decode(token, nil, true, algorithms: algorithms, jwks: jwks)
```


The `jwks` option can also be given as a lambda that evaluates every time a kid is resolved.
This can be used to implement caching of remotely fetched JWK Sets.

Expand Down
54 changes: 42 additions & 12 deletions lib/jwt/claims.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,57 @@
require_relative 'claims/numeric'
require_relative 'claims/required'
require_relative 'claims/subject'
require_relative 'claims/decode'

module JWT
module Claims
Error = Struct.new(:message, 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]) }
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_claims: ->(options) { Claims::Required.new(required_claims: options[:required_claims]) },
numeric: ->(*) { Claims::Numeric.new }
}.freeze

class << self
def verify!(token, options)
VERIFIERS.each do |key, verifier_builder|
next unless options[key]
def verify!(token, *options)
iterate_verifiers(*options) do |verifier, verifier_options|
verify_one!(token, verifier, verifier_options)
end
nil
end

verifier_builder&.call(options)&.verify!(context: token)
def errors(token, *options)
errors = []
iterate_verifiers(*options) do |verifier, verifier_options|
verify_one!(token, verifier, verifier_options)
rescue ::JWT::DecodeError => e
errors << Error.new(message: e.message)
end
errors
end

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!(token, verifier, options)
verifier_builder = VERIFIERS.fetch(verifier) { raise ArgumentError, "#{verifier} not a valid claim verifier" }
verifier_builder.call(options || {}).verify!(context: token)
end
end
end
Expand Down
28 changes: 28 additions & 0 deletions lib/jwt/claims/decode.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module JWT
module Claims
module Decode
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

class << self
def verify!(token, options)
VERIFIERS.each do |key, verifier_builder|
next unless options[key]

verifier_builder&.call(options)&.verify!(context: token)
end
end
end
end
end
end
30 changes: 11 additions & 19 deletions lib/jwt/claims/numeric.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,32 @@
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
def verify!(context:)
validate_numeric_claims(context.payload)
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
17 changes: 4 additions & 13 deletions lib/jwt/decode.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class Decode
def initialize(jwt, key, verify, options, &keyfinder)
raise JWT::DecodeError, 'Nil JSON web token' unless jwt

@token = Token.new(jwt)
@token = EncodedToken.new(jwt)
@key = key
@options = options
@verify = verify
Expand All @@ -23,7 +23,7 @@ def decode_segments
verify_algo
set_key
verify_signature
Claims.verify!(token, @options)
Claims::Decode.verify!(token, @options)
end

[token.payload, token.header]
Expand All @@ -38,7 +38,7 @@ def verify_signature

raise JWT::DecodeError, 'No verification key available' unless @key

token.verify!(algorithms: allowed_and_valid_algorithms, verification_keys: @key)
token.verify_signature!(algorithm: allowed_and_valid_algorithms, key: @key)
end

def verify_algo
Expand Down Expand Up @@ -78,16 +78,7 @@ def allowed_algorithms
end

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

sort_by_alg_header(algs)
end

# Move algorithms matching the JWT alg header to the beginning of the list
def sort_by_alg_header(algs)
return algs if algs.size <= 1

algs.partition { |alg| alg.valid_alg?(alg_in_header) }.flatten
given_algorithms.map { |alg| JWA.resolve(alg) }
end

def find_key(&keyfinder)
Expand Down
62 changes: 6 additions & 56 deletions lib/jwt/encode.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,68 +2,18 @@

require_relative 'jwa'

# JWT::Encode module
module JWT
# Encoding logic for JWT
class Encode
def initialize(options)
@payload = options[:payload]
@key = options[:key]
@algorithm = JWA.resolve(options[:algorithm])
@headers = options[:headers].transform_keys(&:to_s)
@token = Token.new(payload: options[:payload], header: options[:headers])
@key = options[:key]
@algorithm = options[:algorithm]
end

def segments
validate_claims!
combine(encoded_header_and_payload, encoded_signature)
end

private

def encoded_header
@encoded_header ||= encode_header
end

def encoded_payload
@encoded_payload ||= encode_payload
end

def encoded_signature
@encoded_signature ||= encode_signature
end

def encoded_header_and_payload
@encoded_header_and_payload ||= combine(encoded_header, encoded_payload)
end

def encode_header
encode_data(@headers.merge(@algorithm.header(signing_key: @key)))
end

def encode_payload
encode_data(@payload)
end

def signature
@algorithm.sign(data: encoded_header_and_payload, signing_key: @key)
end

def validate_claims!
return unless @payload.is_a?(Hash)

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

def encode_signature
::JWT::Base64.url_encode(signature)
end

def encode_data(data)
::JWT::Base64.url_encode(JWT::JSON.generate(data))
end

def combine(*parts)
parts.join('.')
@token.verify_claims!(:numeric)
@token.sign!(algorithm: @algorithm, key: @key)
@token.jwt
end
end
end
5 changes: 5 additions & 0 deletions lib/jwt/jwa.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ def resolve(algorithm)

algorithm
end

def resolve_and_sort(algorithms:, preferred_algorithm:)
algs = Array(algorithms).map { |alg| JWA.resolve(alg) }
algs.partition { |alg| alg.valid_alg?(preferred_algorithm) }.flatten
end
end
end
end
Loading

0 comments on commit 142fc0c

Please sign in to comment.