Skip to content

Commit

Permalink
Algorithm handling to 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 554a643
Show file tree
Hide file tree
Showing 10 changed files with 286 additions and 74 deletions.
2 changes: 1 addition & 1 deletion lib/jwt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
require 'jwt/error'
require 'jwt/jwk'
require 'jwt/claims'
require 'jwt/token'
require 'jwt/encoded_token'

# JSON Web Token implementation
#
Expand Down
50 changes: 39 additions & 11 deletions lib/jwt/claims.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,55 @@
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]) },
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]) }
}.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
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
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, verification_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
68 changes: 68 additions & 0 deletions lib/jwt/encoded_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# frozen_string_literal: true

module JWT
class EncodedToken
attr_reader :segments, :jwt, :raw_header, :raw_payload, :raw_signature

def initialize(jwt)
raise ArgumentError 'jwt is nil' if jwt.nil?

@jwt = jwt
@raw_header, @raw_payload, @raw_signature = jwt.split('.')
end

def segment_count
jwt.count('.') + 1
end

def signature
@signature ||= ::JWT::Base64.url_decode(raw_signature || '')
end

def header
@header ||= parse_and_decode(raw_header)
end

def payload
@payload ||= parse_and_decode(raw_payload)
end

def signing_input
[raw_header, raw_payload].join('.')
end

def verify_claims!(*options)
Claims.verify!(self, *options)
end

def valid_claims?(*options)
claim_errors(*options).empty?
end

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

def verify_signature!(algorithm:, verification_key:)
return if valid_signature?(algorithm: algorithm, verification_key: verification_key)

raise JWT::VerificationError, 'Signature verification failed'
end

def valid_signature?(algorithm:, verification_key:)
Array(JWA.resolve_and_sort(algorithms: algorithm, preferred_algorithm: header['alg'])).any? do |algo|
Array(verification_key).any? do |key|
algo.verify(data: signing_input, signature: signature, verification_key: key)
end
end
end

private

def parse_and_decode(segment)
JWT::JSON.parse(::JWT::Base64.url_decode(segment))
rescue ::JSON::ParserError
raise JWT::DecodeError, 'Invalid segment encoding'
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
49 changes: 0 additions & 49 deletions lib/jwt/token.rb

This file was deleted.

114 changes: 114 additions & 0 deletions spec/jwt/encoded_token_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# frozen_string_literal: true

RSpec.describe JWT::EncodedToken do
let(:payload) { { 'pay' => 'load' } }
let(:encoded_token) { JWT.encode(payload, 'secret', 'HS256') }

subject(:token) { described_class.new(encoded_token) }

describe '#payload' do
it { expect(token.payload).to eq(payload) }
end

describe '#header' do
it { expect(token.header).to eq({ 'alg' => 'HS256' }) }
end

describe '#signature' do
it { expect(token.signature).to be_a(String) }
end

describe '#segment_count' do
it { expect(token.segment_count).to eq(3) }
end

describe '#signing_input' do
it { expect(token.signing_input).to eq('eyJhbGciOiJIUzI1NiJ9.eyJwYXkiOiJsb2FkIn0') }
end

describe '#verify_signature!' do
context 'when key is valid' do
it 'returns nil' do
expect(token.verify_signature!(algorithm: 'HS256', verification_key: 'secret')).to eq(nil)
end
end

context 'when key is invalid' do
it 'raises an error' do
expect { token.verify_signature!(algorithm: 'HS256', verification_key: 'wrong') }.to raise_error(JWT::VerificationError, 'Signature verification failed')
end
end
end

describe '#verify_claims!' do
context 'when required_claims is passed' do
it 'raises error' do
expect { token.verify_claims!(required_claims: ['exp']) }.to raise_error(JWT::MissingRequiredClaim, 'Missing required claim exp')
end
end

context 'exp claim' do
let(:payload) { { 'exp' => Time.now.to_i - 10, 'pay' => 'load' } }

it 'verifies the exp' do
token.verify_claims!(required_claims: ['exp'])
expect { token.verify_claims!(exp: {}) }.to raise_error(JWT::ExpiredSignature, 'Signature has expired')
token.verify_claims!(exp: { leeway: 1000 })
end

context 'when claims given as symbol' do
it 'validates the claim' do
expect { token.verify_claims!(:exp) }.to raise_error(JWT::ExpiredSignature, 'Signature has expired')
end
end

context 'when claims given as a list of symbols' do
it 'validates the claim' do
expect { token.verify_claims!(:exp, :nbf) }.to raise_error(JWT::ExpiredSignature, 'Signature has expired')
end
end

context 'when claims given as a list of symbols and hashes' do
it 'validates the claim' do
expect { token.verify_claims!({ exp: { leeway: 1000 }, nbf: {} }, :exp, :nbf) }.to raise_error(JWT::ExpiredSignature, 'Signature has expired')
end
end
end
end

describe '#valid_claims?' do
context 'exp claim' do
let(:payload) { { 'exp' => Time.now.to_i - 10, 'pay' => 'load' } }

context 'when claim is valid' do
it 'returns true' do
expect(token.valid_claims?(exp: { leeway: 1000 })).to be(true)
end
end

context 'when claim is invalid' do
it 'returns true' do
expect(token.valid_claims?(:exp)).to be(false)
end
end
end
end

describe '#claim_errors' do
context 'exp claim' do
let(:payload) { { 'exp' => Time.now.to_i - 10, 'pay' => 'load' } }

context 'when claim is valid' do
it 'returns empty array' do
expect(token.claim_errors(exp: { leeway: 1000 })).to be_empty
end
end

context 'when claim is invalid' do
it 'returns array with error objects' do
expect(token.claim_errors(:exp).map(&:message)).to eq(['Signature has expired'])
end
end
end
end
end
Loading

0 comments on commit 554a643

Please sign in to comment.