Skip to content

Commit

Permalink
Token object
Browse files Browse the repository at this point in the history
  • Loading branch information
anakinj committed Oct 4, 2024
1 parent 64453e4 commit 44fa6fc
Show file tree
Hide file tree
Showing 30 changed files with 462 additions and 140 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ coverage/
*gemfile.lock
.byebug_history
*.gem
doc/
doc/
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
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 @@ -626,7 +652,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'

require 'jwt/claims_validator'
require 'jwt/verify'
Expand Down
2 changes: 0 additions & 2 deletions lib/jwt/claims.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,6 @@ def payload_errors(payload, *options)
token_errors(VerificationContext.new(payload: payload), *options)
end

private

def verify_token!(token, *options)
Verifier.verify!(token, *options)
end
Expand Down
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
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
78 changes: 16 additions & 62 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,20 +79,11 @@ 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?

Expand All @@ -116,45 +95,20 @@ def verify_claims
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
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.verify_payload!(@payload, :numeric)
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
Loading

0 comments on commit 44fa6fc

Please sign in to comment.