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

Token object for verifying and signing tokens #621

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
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
Loading