From 33ddcf35fd8cb2162f2bdce1e2c269fc9d5b051a Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sun, 29 Sep 2024 00:35:48 +0300 Subject: [PATCH] Signing and verification of tokens to a ::JWT::Token class --- .gitignore | 1 + CHANGELOG.md | 1 + README.md | 27 ++++- lib/jwt.rb | 1 + lib/jwt/claims.rb | 56 ++++++--- lib/jwt/claims/audience.rb | 2 +- lib/jwt/claims/decode.rb | 28 +++++ lib/jwt/claims/expiration.rb | 2 +- lib/jwt/claims/issued_at.rb | 2 +- lib/jwt/claims/issuer.rb | 2 +- lib/jwt/claims/jwt_id.rb | 2 +- lib/jwt/claims/not_before.rb | 2 +- lib/jwt/claims/numeric.rb | 32 ++---- lib/jwt/claims/required.rb | 2 +- lib/jwt/claims/subject.rb | 2 +- lib/jwt/decode.rb | 82 +++---------- lib/jwt/encode.rb | 62 +--------- lib/jwt/jwa.rb | 5 + lib/jwt/token.rb | 178 +++++++++++++++++++++++++++++ spec/jwt/claims/audience_spec.rb | 2 +- spec/jwt/claims/expiration_spec.rb | 2 +- spec/jwt/claims/issued_at_spec.rb | 2 +- spec/jwt/claims/issuer_spec.rb | 2 +- spec/jwt/claims/jwt_id_spec.rb | 4 +- spec/jwt/claims/not_before_spec.rb | 6 +- spec/jwt/claims/numeric_spec.rb | 4 +- spec/jwt/claims/required_spec.rb | 2 +- spec/jwt/encoded_token_spec.rb | 110 ++++++++++++++++++ spec/jwt/jwa_spec.rb | 25 ++++ spec/jwt/token_spec.rb | 45 ++++++++ spec/spec_helper.rb | 1 + spec/spec_support/token.rb | 5 + 32 files changed, 522 insertions(+), 177 deletions(-) create mode 100644 lib/jwt/claims/decode.rb create mode 100644 lib/jwt/token.rb create mode 100644 spec/jwt/encoded_token_spec.rb create mode 100644 spec/jwt/jwa_spec.rb create mode 100644 spec/jwt/token_spec.rb create mode 100644 spec/spec_support/token.rb diff --git a/.gitignore b/.gitignore index 04ad34a0..7f011da9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ coverage/ *gemfile.lock .byebug_history *.gem +doc/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index cf05db0f..989399d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:** diff --git a/README.md b/README.md index dfb44b09..ae326cea 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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. diff --git a/lib/jwt.rb b/lib/jwt.rb index f7f28d47..6fc14e4a 100644 --- a/lib/jwt.rb +++ b/lib/jwt.rb @@ -10,6 +10,7 @@ require 'jwt/error' require 'jwt/jwk' require 'jwt/claims' +require 'jwt/token' # JSON Web Token implementation # diff --git a/lib/jwt/claims.rb b/lib/jwt/claims.rb index 9bb71488..a7b3df36 100644 --- a/lib/jwt/claims.rb +++ b/lib/jwt/claims.rb @@ -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 diff --git a/lib/jwt/claims/audience.rb b/lib/jwt/claims/audience.rb index 5ca512c7..2c416d0d 100644 --- a/lib/jwt/claims/audience.rb +++ b/lib/jwt/claims/audience.rb @@ -2,7 +2,7 @@ module JWT module Claims - class Audience + class Audience # :nodoc: def initialize(expected_audience:) @expected_audience = expected_audience end diff --git a/lib/jwt/claims/decode.rb b/lib/jwt/claims/decode.rb new file mode 100644 index 00000000..f2665992 --- /dev/null +++ b/lib/jwt/claims/decode.rb @@ -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 diff --git a/lib/jwt/claims/expiration.rb b/lib/jwt/claims/expiration.rb index 071885ad..5d67d19c 100644 --- a/lib/jwt/claims/expiration.rb +++ b/lib/jwt/claims/expiration.rb @@ -2,7 +2,7 @@ module JWT module Claims - class Expiration + class Expiration # :nodoc: def initialize(leeway:) @leeway = leeway || 0 end diff --git a/lib/jwt/claims/issued_at.rb b/lib/jwt/claims/issued_at.rb index 6aaf5108..edb2bff3 100644 --- a/lib/jwt/claims/issued_at.rb +++ b/lib/jwt/claims/issued_at.rb @@ -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') diff --git a/lib/jwt/claims/issuer.rb b/lib/jwt/claims/issuer.rb index f973e7f0..35d6b419 100644 --- a/lib/jwt/claims/issuer.rb +++ b/lib/jwt/claims/issuer.rb @@ -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 diff --git a/lib/jwt/claims/jwt_id.rb b/lib/jwt/claims/jwt_id.rb index 9dc5b0d0..58326688 100644 --- a/lib/jwt/claims/jwt_id.rb +++ b/lib/jwt/claims/jwt_id.rb @@ -2,7 +2,7 @@ module JWT module Claims - class JwtId + class JwtId # :nodoc: def initialize(validator:) @validator = validator end diff --git a/lib/jwt/claims/not_before.rb b/lib/jwt/claims/not_before.rb index e53f6de3..55aa4e0c 100644 --- a/lib/jwt/claims/not_before.rb +++ b/lib/jwt/claims/not_before.rb @@ -2,7 +2,7 @@ module JWT module Claims - class NotBefore + class NotBefore # :nodoc: def initialize(leeway:) @leeway = leeway || 0 end diff --git a/lib/jwt/claims/numeric.rb b/lib/jwt/claims/numeric.rb index c537b8f3..9b17576d 100644 --- a/lib/jwt/claims/numeric.rb +++ b/lib/jwt/claims/numeric.rb @@ -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 diff --git a/lib/jwt/claims/required.rb b/lib/jwt/claims/required.rb index a360821e..0eabd2b9 100644 --- a/lib/jwt/claims/required.rb +++ b/lib/jwt/claims/required.rb @@ -2,7 +2,7 @@ module JWT module Claims - class Required + class Required # :nodoc: def initialize(required_claims:) @required_claims = required_claims end diff --git a/lib/jwt/claims/subject.rb b/lib/jwt/claims/subject.rb index dd1df517..fc3eaa0f 100644 --- a/lib/jwt/claims/subject.rb +++ b/lib/jwt/claims/subject.rb @@ -2,7 +2,7 @@ module JWT module Claims - class Subject + class Subject # :nodoc: def initialize(expected_subject:) @expected_subject = expected_subject.to_s end diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index e51a9582..ac43e67b 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -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::Decode.verify!(token, @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 @@ -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.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 diff --git a/lib/jwt/encode.rb b/lib/jwt/encode.rb index 973f5b2f..c99be282 100644 --- a/lib/jwt/encode.rb +++ b/lib/jwt/encode.rb @@ -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::Numeric.new(@payload).verify! - 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 diff --git a/lib/jwt/jwa.rb b/lib/jwt/jwa.rb index 8a975824..ba6d045b 100644 --- a/lib/jwt/jwa.rb +++ b/lib/jwt/jwa.rb @@ -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 diff --git a/lib/jwt/token.rb b/lib/jwt/token.rb new file mode 100644 index 00000000..54ac0478 --- /dev/null +++ b/lib/jwt/token.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +module JWT + # Represents a JWT token + # + # Basic token signed using the HS256 algorithm: + # + # token = JWT::Token.new(payload: {pay: 'load'}) + # token.sign!(algorithm: 'HS256', key: 'secret') + # token.jwt # => eyJhb.... + # + # Custom headers will be combined with generated headers: + # token = JWT::Token.new(payload: {pay: 'load'}, header: {custom: "value"}) + # token.sign!(algorithm: 'HS256', key: 'secret') + # token.header # => {"custom"=>"value", "alg"=>"HS256"} + # + class Token + # Initializes a new Token instance. + # + # @param header [Hash] the header of the JWT token. + # @param payload [Hash] the payload of the JWT token. + def initialize(payload:, header: {}) + @header = header&.transform_keys(&:to_s) + @payload = payload + end + + # Returns the decoded signature of the JWT token. + # + # @return [String] the decoded signature of the JWT token. + def signature + @signature ||= ::JWT::Base64.url_decode(encoded_signature || '') + end + + # Returns the encoded signature of the JWT token. + # + # @return [String] the encoded signature of the JWT token. + def encoded_signature + @encoded_signature ||= ::JWT::Base64.url_encode(signature) + end + + # Returns the decoded header of the JWT token. + # + # @return [Hash] the header of the JWT token. + def header + @header ||= parse_and_decode(encoded_header) + end + + # Returns the encoded header of the JWT token. + # + # @return [String] the encoded header of the JWT token. + def encoded_header + @encoded_header ||= ::JWT::Base64.url_encode(JWT::JSON.generate(header)) + end + + # Returns the payload of the JWT token. + # + # @return [Hash] the payload of the JWT token. + def payload + @payload ||= parse_and_decode(encoded_payload) + end + + # Returns the encoded payload of the JWT token. + # + # @return [String] the encoded payload of the JWT token. + def encoded_payload + @encoded_payload ||= ::JWT::Base64.url_encode(JWT::JSON.generate(payload)) + end + + # Returns the signing input of the JWT token. + # + # @return [String] the signing input of the JWT token. + def signing_input + [encoded_header, encoded_payload].join('.') + end + + # Returns the JWT token as a string. + # + # @return [String] the JWT token as a string. + # @raise [JWT::EncodeError] if the token is not signed or other encoding issues + def jwt + @jwt ||= (@signature && [encoded_header, encoded_payload, encoded_signature].join('.')) || raise(::JWT::EncodeError, 'Token is not signed') + end + + # Verifies the claims of the JWT token. + # + # @param options [Array] the options for verifying the claims. + # @return [void] + # @raise [JWT::DecodeError] if any claim is invalid. + def verify_claims!(*options) + Claims.verify!(self, *options) + end + + # Checks if the claims of the JWT token are valid. + # + # @param options [Array] the options for verifying the claims. + # @return [Boolean] true if the claims are valid, false otherwise. + def valid_claims?(*options) + claim_errors(*options).empty? + end + + # Returns the errors in the claims of the JWT token. + # + # @param options [Array] the options for verifying the claims. + # @return [Array] the errors in the claims of the JWT token. + def claim_errors(*options) + Claims.errors(self, *options) + end + + # Verifies the signature of the JWT token. + # + # @param algorithm [String, Array, Object, Array] the algorithm(s) to use for verification. + # @param key [String, Array] the key(s) to use for verification. + # @return [void] + # @raise [JWT::VerificationError] if the signature verification fails. + def verify_signature!(algorithm:, key:) + return if valid_signature?(algorithm: algorithm, key: key) + + raise JWT::VerificationError, 'Signature verification failed' + end + + # Checks if the signature of the JWT token is valid. + # + # @param algorithm [String, Array, Object, Array] the algorithm(s) to use for verification. + # @param key [String, Array] the key(s) to use for verification. + # @return [Boolean] true if the signature is valid, false otherwise. + def valid_signature?(algorithm:, key:) + Array(JWA.resolve_and_sort(algorithms: algorithm, preferred_algorithm: header['alg'])).any? do |algo| + Array(key).any? do |one_key| + algo.verify(data: signing_input, signature: signature, verification_key: one_key) + end + end + end + + # Signs the JWT token. + # + # @param algorithm [String, Object] the algorithm to use for signing. + # @param key [String] the key to use for signing. + # @return [void] + # @raise [JWT::EncodeError] if the token is already signed or other problems when signing + def sign!(algorithm:, key:) + raise ::JWT::EncodeError, 'Token already signed' if @signature + + JWA.resolve(algorithm).tap do |algo| + header.merge!(algo.header) + @signature = algo.sign(data: signing_input, signing_key: key) + end + end + + # Returns the JWT token as a string. + # + # @return [String] the JWT token as a string. + alias to_s jwt + + 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 + + class EncodedToken < Token + attr_reader :jwt + + # Initializes a new EncodedToken instance. + # + # @param jwt [String] the encoded JWT token. + def initialize(jwt) + raise ArgumentError 'Provided JWT must be a String' unless jwt.is_a?(String) + + @jwt = jwt + @encoded_header, @encoded_payload, @encoded_signature = jwt.split('.') + + super(header: nil, payload: nil) + end + end +end diff --git a/spec/jwt/claims/audience_spec.rb b/spec/jwt/claims/audience_spec.rb index 96f2326b..a7406ef0 100644 --- a/spec/jwt/claims/audience_spec.rb +++ b/spec/jwt/claims/audience_spec.rb @@ -7,7 +7,7 @@ let(:scalar_aud) { 'ruby-jwt-aud' } let(:array_aud) { %w[ruby-jwt-aud test-aud ruby-ruby-ruby] } - subject(:verify!) { described_class.new(expected_audience: expected_audience).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) } + subject(:verify!) { described_class.new(expected_audience: expected_audience).verify!(context: SpecSupport::Token.new(payload: payload)) } context 'when the singular audience does not match' do let(:expected_audience) { 'no-match' } diff --git a/spec/jwt/claims/expiration_spec.rb b/spec/jwt/claims/expiration_spec.rb index f8638ad9..bbd01876 100644 --- a/spec/jwt/claims/expiration_spec.rb +++ b/spec/jwt/claims/expiration_spec.rb @@ -4,7 +4,7 @@ let(:payload) { { 'exp' => (Time.now.to_i + 5) } } let(:leeway) { 0 } - subject(:verify!) { described_class.new(leeway: leeway).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) } + subject(:verify!) { described_class.new(leeway: leeway).verify!(context: SpecSupport::Token.new(payload: payload)) } context 'when token is expired' do let(:payload) { { 'exp' => (Time.now.to_i - 5) } } diff --git a/spec/jwt/claims/issued_at_spec.rb b/spec/jwt/claims/issued_at_spec.rb index c34e5e85..e5d3f822 100644 --- a/spec/jwt/claims/issued_at_spec.rb +++ b/spec/jwt/claims/issued_at_spec.rb @@ -3,7 +3,7 @@ RSpec.describe JWT::Claims::IssuedAt do let(:payload) { { 'iat' => Time.now.to_f } } - subject(:verify!) { described_class.new.verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) } + subject(:verify!) { described_class.new.verify!(context: SpecSupport::Token.new(payload: payload)) } context 'when iat is now' do it 'passes validation' do diff --git a/spec/jwt/claims/issuer_spec.rb b/spec/jwt/claims/issuer_spec.rb index 33d9470a..a97e80bc 100644 --- a/spec/jwt/claims/issuer_spec.rb +++ b/spec/jwt/claims/issuer_spec.rb @@ -5,7 +5,7 @@ let(:payload) { { 'iss' => issuer } } let(:expected_issuers) { 'ruby-jwt-gem' } - subject(:verify!) { described_class.new(issuers: expected_issuers).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) } + subject(:verify!) { described_class.new(issuers: expected_issuers).verify!(context: SpecSupport::Token.new(payload: payload)) } context 'when expected issuer is a string that matches the payload' do it 'passes validation' do diff --git a/spec/jwt/claims/jwt_id_spec.rb b/spec/jwt/claims/jwt_id_spec.rb index 89db8a7c..81a8a44d 100644 --- a/spec/jwt/claims/jwt_id_spec.rb +++ b/spec/jwt/claims/jwt_id_spec.rb @@ -5,7 +5,7 @@ let(:payload) { { 'jti' => jti } } let(:validator) { nil } - subject(:verify!) { described_class.new(validator: validator).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) } + subject(:verify!) { described_class.new(validator: validator).verify!(context: SpecSupport::Token.new(payload: payload)) } context 'when payload contains a jti' do it 'passes validation' do verify! @@ -56,7 +56,7 @@ context 'when jti validator has 2 args' do it 'the second arg is the payload' do - described_class.new(validator: ->(_jti, pl) { expect(pl).to eq(payload) }).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) + described_class.new(validator: ->(_jti, pl) { expect(pl).to eq(payload) }).verify!(context: SpecSupport::Token.new(payload: payload)) end end end diff --git a/spec/jwt/claims/not_before_spec.rb b/spec/jwt/claims/not_before_spec.rb index 1f8b4930..29677401 100644 --- a/spec/jwt/claims/not_before_spec.rb +++ b/spec/jwt/claims/not_before_spec.rb @@ -6,7 +6,7 @@ describe '#verify!' do context 'when nbf is in the future' do it 'raises JWT::ImmatureSignature' do - expect { described_class.new(leeway: 0).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) }.to raise_error JWT::ImmatureSignature + expect { described_class.new(leeway: 0).verify!(context: SpecSupport::Token.new(payload: payload)) }.to raise_error JWT::ImmatureSignature end end @@ -14,13 +14,13 @@ let(:payload) { { 'nbf' => (Time.now.to_i - 5) } } it 'does not raise error' do - expect { described_class.new(leeway: 0).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) }.not_to raise_error + expect { described_class.new(leeway: 0).verify!(context: SpecSupport::Token.new(payload: payload)) }.not_to raise_error end end context 'when leeway is given' do it 'does not raise error' do - expect { described_class.new(leeway: 10).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) }.not_to raise_error + expect { described_class.new(leeway: 10).verify!(context: SpecSupport::Token.new(payload: payload)) }.not_to raise_error end end end diff --git a/spec/jwt/claims/numeric_spec.rb b/spec/jwt/claims/numeric_spec.rb index 6cef1251..e625b9d4 100644 --- a/spec/jwt/claims/numeric_spec.rb +++ b/spec/jwt/claims/numeric_spec.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true RSpec.describe JWT::Claims::Numeric do - let(:validator) { described_class.new(claims) } + let(:validator) { described_class.new } describe '#verify!' do - subject { validator.verify! } + subject { validator.verify!(context: SpecSupport::Token.new(payload: claims)) } shared_examples_for 'a NumericDate claim' do |claim| context "when #{claim} payload is an integer" do diff --git a/spec/jwt/claims/required_spec.rb b/spec/jwt/claims/required_spec.rb index 97033460..e2c5d7a4 100644 --- a/spec/jwt/claims/required_spec.rb +++ b/spec/jwt/claims/required_spec.rb @@ -3,7 +3,7 @@ RSpec.describe JWT::Claims::Required do let(:payload) { { 'data' => 'value' } } - subject(:verify!) { described_class.new(required_claims: required_claims).verify!(context: JWT::Claims::VerificationContext.new(payload: payload)) } + subject(:verify!) { described_class.new(required_claims: required_claims).verify!(context: SpecSupport::Token.new(payload: payload)) } context 'when payload is missing the required claim' do let(:required_claims) { ['exp'] } diff --git a/spec/jwt/encoded_token_spec.rb b/spec/jwt/encoded_token_spec.rb new file mode 100644 index 00000000..a734b4c0 --- /dev/null +++ b/spec/jwt/encoded_token_spec.rb @@ -0,0 +1,110 @@ +# 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 '#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', key: 'secret')).to eq(nil) + end + end + + context 'when key is invalid' do + it 'raises an error' do + expect { token.verify_signature!(algorithm: 'HS256', 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 diff --git a/spec/jwt/jwa_spec.rb b/spec/jwt/jwa_spec.rb new file mode 100644 index 00000000..7eb59a75 --- /dev/null +++ b/spec/jwt/jwa_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.describe JWT::JWA do + describe '.resolve_and_sort' do + let(:subject) { described_class.resolve_and_sort(algorithms: algorithms, preferred_algorithm: preferred_algorithm).map(&:alg) } + + context 'when algorithms have the preferred last' do + let(:algorithms) { %w[HS256 HS512 RS512] } + let(:preferred_algorithm) { 'RS512' } + + it 'places the preferred algorithm first' do + is_expected.to eq(%w[RS512 HS256 HS512]) + end + end + + context 'when algorithms have the preferred in the middle' do + let(:algorithms) { %w[HS512 HS256 RS512] } + let(:preferred_algorithm) { 'HS256' } + + it 'places the preferred algorithm first' do + is_expected.to eq(%w[HS256 HS512 RS512]) + end + end + end +end diff --git a/spec/jwt/token_spec.rb b/spec/jwt/token_spec.rb new file mode 100644 index 00000000..1cf8ee78 --- /dev/null +++ b/spec/jwt/token_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +RSpec.describe JWT::Token do + let(:payload) { { 'pay' => 'load' } } + let(:header) { {} } + + subject(:token) { described_class.new(payload: payload, header: header) } + + describe '#sign!' do + it 'signs the token' do + token.sign!(algorithm: 'HS256', key: 'secret') + + expect(token.valid_signature?(algorithm: 'HS256', key: 'secret')).to be(true) + end + + context 'when signed twice' do + before do + token.sign!(algorithm: 'HS256', key: 'secret') + end + + it 'raises' do + expect { token.sign!(algorithm: 'HS256', key: 'secret') }.to raise_error(JWT::EncodeError) + end + end + end + + describe '#jwt' do + context 'when token is signed' do + before do + token.sign!(algorithm: 'HS256', key: 'secret') + end + + it 'returns a signed and encoded token' do + expect(token.jwt).to eq('eyJhbGciOiJIUzI1NiJ9.eyJwYXkiOiJsb2FkIn0.UEhDY1Qlj29ammxuVRA_-gBah4qTy5FngIWg0yEAlC0') + expect(JWT.decode(token.jwt, 'secret', true, algorithm: 'HS256')).to eq([{ 'pay' => 'load' }, { 'alg' => 'HS256' }]) + end + end + + context 'when token is not signed' do + it 'returns a signed and encoded token' do + expect { token.jwt }.to raise_error(JWT::EncodeError) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e6333c19..c1992e73 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,6 +5,7 @@ require 'jwt' require_relative 'spec_support/test_keys' +require_relative 'spec_support/token' puts "OpenSSL::VERSION: #{OpenSSL::VERSION}" puts "OpenSSL::OPENSSL_VERSION: #{OpenSSL::OPENSSL_VERSION}" diff --git a/spec/spec_support/token.rb b/spec/spec_support/token.rb new file mode 100644 index 00000000..8dd7de4b --- /dev/null +++ b/spec/spec_support/token.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module SpecSupport + Token = Struct.new(:payload, keyword_init: true) +end