Skip to content

Commit

Permalink
Signing and verification of tokens to a ::JWT::Token class
Browse files Browse the repository at this point in the history
  • Loading branch information
anakinj committed Sep 30, 2024
1 parent 685a245 commit 33ddcf3
Show file tree
Hide file tree
Showing 32 changed files with 522 additions and 177 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ coverage/
*gemfile.lock
.byebug_history
*.gem
doc/
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
1 change: 1 addition & 0 deletions lib/jwt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
require 'jwt/error'
require 'jwt/jwk'
require 'jwt/claims'
require 'jwt/token'

# JSON Web Token implementation
#
Expand Down
56 changes: 42 additions & 14 deletions lib/jwt/claims.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,58 @@
require_relative 'claims/numeric'
require_relative 'claims/required'
require_relative 'claims/subject'
require_relative 'claims/decode'

module JWT
module Claims
VerificationContext = Struct.new(:payload, keyword_init: true)
module Claims # :nodoc:
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!(payload, 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

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

verifier_builder&.call(options)&.verify!(context: VerificationContext.new(payload: payload))
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
end
2 changes: 1 addition & 1 deletion lib/jwt/claims/audience.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module JWT
module Claims
class Audience
class Audience # :nodoc:
def initialize(expected_audience:)
@expected_audience = expected_audience
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 # :nodoc:
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
2 changes: 1 addition & 1 deletion lib/jwt/claims/expiration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module JWT
module Claims
class Expiration
class Expiration # :nodoc:
def initialize(leeway:)
@leeway = leeway || 0
end
Expand Down
2 changes: 1 addition & 1 deletion lib/jwt/claims/issued_at.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module JWT
module Claims
class IssuedAt
class IssuedAt # :nodoc:
def verify!(context:, **_args)
return unless context.payload.is_a?(Hash)
return unless context.payload.key?('iat')
Expand Down
2 changes: 1 addition & 1 deletion lib/jwt/claims/issuer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module JWT
module Claims
class Issuer
class Issuer # :nodoc:
def initialize(issuers:)
@issuers = Array(issuers).map { |item| item.is_a?(Symbol) ? item.to_s : item }
end
Expand Down
2 changes: 1 addition & 1 deletion lib/jwt/claims/jwt_id.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module JWT
module Claims
class JwtId
class JwtId # :nodoc:
def initialize(validator:)
@validator = validator
end
Expand Down
2 changes: 1 addition & 1 deletion lib/jwt/claims/not_before.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module JWT
module Claims
class NotBefore
class NotBefore # :nodoc:
def initialize(leeway:)
@leeway = leeway || 0
end
Expand Down
32 changes: 12 additions & 20 deletions lib/jwt/claims/numeric.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,33 @@

module JWT
module Claims
class Numeric
def self.verify!(payload:, **_args)
return unless payload.is_a?(Hash)

new(payload).verify!
end

class Numeric # :nodoc:
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
2 changes: 1 addition & 1 deletion lib/jwt/claims/required.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module JWT
module Claims
class Required
class Required # :nodoc:
def initialize(required_claims:)
@required_claims = required_claims
end
Expand Down
2 changes: 1 addition & 1 deletion lib/jwt/claims/subject.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module JWT
module Claims
class Subject
class Subject # :nodoc:
def initialize(expected_subject:)
@expected_subject = expected_subject.to_s
end
Expand Down
Loading

0 comments on commit 33ddcf3

Please sign in to comment.