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

Merged
merged 3 commits into from
Oct 5, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ coverage/
.byebug_history
*.gem
doc/
.yardoc/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Notable changes in the upcoming **version 3.0**:

**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
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,30 @@ decoded_token = JWT.decode token, rsa_public, true, { algorithm: 'PS256' }
puts decoded_token
```

### Using a Token object

The `JWT::Token` and `JWT::EncodedToken` classes can be used to manage your 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..."
```

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 @@ -626,7 +650,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
2 changes: 2 additions & 0 deletions lib/jwt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
require 'jwt/error'
require 'jwt/jwk'
require 'jwt/claims'
require 'jwt/encoded_token'
require 'jwt/token'

require 'jwt/claims_validator'
require 'jwt/verify'
Expand Down
15 changes: 3 additions & 12 deletions lib/jwt/claims.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require_relative 'claims/subject'
require_relative 'claims/decode_verifier'
require_relative 'claims/verifier'
require_relative 'claims/verification_methods'

module JWT
# JWT Claim verifications
Expand Down Expand Up @@ -48,7 +49,7 @@ def verify!(payload, options)
# @return [void]
# @raise [JWT::DecodeError] if any claim is invalid.
def verify_payload!(payload, *options)
verify_token!(VerificationContext.new(payload: payload), *options)
Verifier.verify!(VerificationContext.new(payload: payload), *options)
end

# Checks if the claims in the JWT payload are valid.
Expand All @@ -65,17 +66,7 @@ def valid_payload?(payload, *options)
# @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

def token_errors(token, *options)
Verifier.errors(token, *options)
Verifier.errors(VerificationContext.new(payload: payload), *options)
end
end
end
Expand Down
6 changes: 3 additions & 3 deletions lib/jwt/claims/decode_verifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ module JWT
module Claims
# Context class to contain the data passed to individual claim validators
#
# @private
# @api private
VerificationContext = Struct.new(:payload, keyword_init: true)

# Verifiers to support the ::JWT.decode method
#
# @private
# @api private
module DecodeVerifier
VERIFIERS = {
verify_expiration: ->(options) { Claims::Expiration.new(leeway: options[:exp_leeway] || options[:leeway]) },
Expand All @@ -25,7 +25,7 @@ module DecodeVerifier
private_constant(:VERIFIERS)

class << self
# @private
# @api private
def verify!(payload, options)
VERIFIERS.each do |key, verifier_builder|
next unless options[key] || options[key.to_s]
Expand Down
20 changes: 20 additions & 0 deletions lib/jwt/claims/verification_methods.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

module JWT
module Claims
# @api private
module VerificationMethods
def verify_claims!(*options)
Verifier.verify!(self, *options)
end

def claim_errors(*options)
Verifier.errors(self, *options)
end

def valid_claims?(*options)
claim_errors(*options).empty?
end
end
end
end
11 changes: 5 additions & 6 deletions lib/jwt/claims/verifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module JWT
module Claims
# @private
# @api private
module Verifier
VERIFIERS = {
exp: ->(options) { Claims::Expiration.new(leeway: options.dig(:exp, :leeway)) },
Expand All @@ -20,15 +20,15 @@ module Verifier
private_constant(:VERIFIERS)

class << self
# @private
# @api private
def verify!(context, *options)
iterate_verifiers(*options) do |verifier, verifier_options|
verify_one!(context, verifier, verifier_options)
end
nil
end

# @private
# @api private
def errors(context, *options)
errors = []
iterate_verifiers(*options) do |verifier, verifier_options|
Expand All @@ -39,7 +39,8 @@ def errors(context, *options)
errors
end

# @private
private

def iterate_verifiers(*options)
options.each do |element|
if element.is_a?(Hash)
Expand All @@ -50,8 +51,6 @@ def iterate_verifiers(*options)
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)
Expand Down
82 changes: 16 additions & 66 deletions lib/jwt/decode.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,62 +10,50 @@ class Decode
def initialize(jwt, key, verify, options, &keyfinder)
raise JWT::DecodeError, 'Nil JSON web token' unless jwt

@jwt = jwt
@token = EncodedToken.new(jwt)
@key = key
@options = options
@segments = jwt.split('.')
@verify = verify
@signature = ''
@keyfinder = keyfinder
end

def decode_segments
validate_segment_count!
if @verify
decode_signature
verify_algo
set_key
verify_signature
verify_claims
Claims::DecodeVerifier.verify!(token.payload, @options)
end
raise JWT::DecodeError, 'Not enough or too many segments' unless header && payload

[payload, header]
[token.payload, token.header]
end

private

def verify_signature
return unless @key || @verify
attr_reader :token

def verify_signature
return if none_algorithm?

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

return if Array(@key).any? { |key| verify_signature_for?(key) }

raise JWT::VerificationError, 'Signature verification failed'
token.verify_signature!(algorithm: allowed_and_valid_algorithms, key: @key)
end

def verify_algo
raise JWT::IncorrectAlgorithm, 'An algorithm must be specified' if allowed_algorithms.empty?
raise JWT::DecodeError, 'Token header not a JSON object' unless header.is_a?(Hash)
raise JWT::DecodeError, 'Token header not a JSON object' unless token.header.is_a?(Hash)
raise JWT::IncorrectAlgorithm, 'Token is missing alg header' unless alg_in_header
raise JWT::IncorrectAlgorithm, 'Expected a different algorithm' if allowed_and_valid_algorithms.empty?
end

def set_key
@key = find_key(&@keyfinder) if @keyfinder
@key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks], allow_nil_kid: @options[:allow_nil_kid]).key_for(header['kid']) if @options[:jwks]
@key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks], allow_nil_kid: @options[:allow_nil_kid]).key_for(token.header['kid']) if @options[:jwks]
return unless (x5c_options = @options[:x5c])

@key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(header['x5c'])
end

def verify_signature_for?(key)
allowed_and_valid_algorithms.any? do |alg|
alg.verify(data: signing_input, signature: @signature, verification_key: key)
end
@key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(token.header['x5c'])
end

def allowed_and_valid_algorithms
Expand All @@ -91,70 +79,32 @@ 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)
key = (keyfinder.arity == 2 ? yield(header, payload) : yield(header))
key = (keyfinder.arity == 2 ? yield(token.header, token.payload) : yield(token.header))
# key can be of type [string, nil, OpenSSL::PKey, Array]
return key if key && !Array(key).empty?

raise JWT::DecodeError, 'No verification key available'
end

def verify_claims
Claims::DecodeVerifier.verify!(payload, @options)
end

def validate_segment_count!
return if segment_length == 3
return if !@verify && segment_length == 2 # If no verifying required, the signature is not needed
return if segment_length == 2 && none_algorithm?
segment_count = token.jwt.count('.') + 1
return if segment_count == 3
return if !@verify && segment_count == 2 # If no verifying required, the signature is not needed
return if segment_count == 2 && none_algorithm?

raise JWT::DecodeError, 'Not enough or too many segments'
end

def segment_length
@segments.count
end

def none_algorithm?
alg_in_header == 'none'
end

def decode_signature
@signature = ::JWT::Base64.url_decode(@segments[2] || '')
end

def alg_in_header
header['alg']
end

def header
@header ||= parse_and_decode @segments[0]
end

def payload
@payload ||= parse_and_decode @segments[1]
end

def signing_input
@segments.first(2).join('.')
end

def parse_and_decode(segment)
JWT::JSON.parse(::JWT::Base64.url_decode(segment))
rescue ::JSON::ParserError
raise JWT::DecodeError, 'Invalid segment encoding'
token.header['alg']
end
end
end
Loading
Loading