From 6e0fc09c0af4edcba4f62ef27b6e0c511af2768d Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Fri, 4 Oct 2024 22:18:20 +0300 Subject: [PATCH] JWT::EnocdedToken and JWT::Token for verifying and signing JWT tokens --- .gitignore | 1 + CHANGELOG.md | 1 + README.md | 25 +++++- lib/jwt.rb | 2 + lib/jwt/claims.rb | 15 +--- lib/jwt/claims/decode.rb | 30 +++++++ lib/jwt/claims/decode_verifier.rb | 6 +- lib/jwt/claims/verification_methods.rb | 20 +++++ lib/jwt/claims/verifier.rb | 11 ++- lib/jwt/decode.rb | 82 ++++-------------- lib/jwt/encode.rb | 62 ++------------ lib/jwt/encoded_token.rb | 109 ++++++++++++++++++++++++ lib/jwt/jwa.rb | 6 ++ lib/jwt/token.rb | 102 +++++++++++++++++++++++ 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/required_spec.rb | 2 +- spec/jwt/encoded_token_spec.rb | 110 +++++++++++++++++++++++++ spec/jwt/jwa_spec.rb | 21 +++++ spec/jwt/token_spec.rb | 45 ++++++++++ spec/spec_helper.rb | 1 + spec/spec_support/token.rb | 5 ++ 26 files changed, 520 insertions(+), 154 deletions(-) create mode 100644 lib/jwt/claims/decode.rb create mode 100644 lib/jwt/claims/verification_methods.rb create mode 100644 lib/jwt/encoded_token.rb create mode 100644 lib/jwt/token.rb create mode 100644 spec/jwt/encoded_token_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 4069d061..91d73925 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ coverage/ .byebug_history *.gem doc/ +.yardoc/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a98361..7fa2ed97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:** diff --git a/README.md b/README.md index 1ae02d73..48c4568e 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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. diff --git a/lib/jwt.rb b/lib/jwt.rb index dd5cdff5..779ec83e 100644 --- a/lib/jwt.rb +++ b/lib/jwt.rb @@ -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' diff --git a/lib/jwt/claims.rb b/lib/jwt/claims.rb index b63c1d25..461263ca 100644 --- a/lib/jwt/claims.rb +++ b/lib/jwt/claims.rb @@ -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 @@ -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. @@ -65,17 +66,7 @@ def valid_payload?(payload, *options) # @param options [Array] the options for verifying the claims. # @return [Array] 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 diff --git a/lib/jwt/claims/decode.rb b/lib/jwt/claims/decode.rb new file mode 100644 index 00000000..8b112fed --- /dev/null +++ b/lib/jwt/claims/decode.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module JWT + module Claims + # @api private + 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 + # @api private + 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/decode_verifier.rb b/lib/jwt/claims/decode_verifier.rb index 2548f4d3..411bb97c 100644 --- a/lib/jwt/claims/decode_verifier.rb +++ b/lib/jwt/claims/decode_verifier.rb @@ -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]) }, @@ -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] diff --git a/lib/jwt/claims/verification_methods.rb b/lib/jwt/claims/verification_methods.rb new file mode 100644 index 00000000..534d3369 --- /dev/null +++ b/lib/jwt/claims/verification_methods.rb @@ -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 diff --git a/lib/jwt/claims/verifier.rb b/lib/jwt/claims/verifier.rb index 608c3fd4..c75a4eb9 100644 --- a/lib/jwt/claims/verifier.rb +++ b/lib/jwt/claims/verifier.rb @@ -2,7 +2,7 @@ module JWT module Claims - # @private + # @api private module Verifier VERIFIERS = { exp: ->(options) { Claims::Expiration.new(leeway: options.dig(:exp, :leeway)) }, @@ -20,7 +20,7 @@ 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) @@ -28,7 +28,7 @@ def verify!(context, *options) nil end - # @private + # @api private def errors(context, *options) errors = [] iterate_verifiers(*options) do |verifier, verifier_options| @@ -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) @@ -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) diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index e3d81d86..c05f7213 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::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 @@ -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 diff --git a/lib/jwt/encode.rb b/lib/jwt/encode.rb index e6829050..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.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 diff --git a/lib/jwt/encoded_token.rb b/lib/jwt/encoded_token.rb new file mode 100644 index 00000000..95c73626 --- /dev/null +++ b/lib/jwt/encoded_token.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module JWT + # Represents an encoded JWT token + # + # Processing an encoded and signed token: + # + # token = JWT::Token.new(payload: {pay: 'load'}) + # token.sign!(algorithm: 'HS256', key: 'secret') + # + # encoded_token = JWT::EncodedToken.new(token.jwt) + # encoded_token.verify_signature!algorithm: 'HS256', key: 'secret') + # encoded_token.payload # => {'pay' => 'load'} + class EncodedToken + include Claims::VerificationMethods + + # Returns the original token provided to the class. + # @return [String] The JWT token. + attr_reader :jwt + + # Initializes a new EncodedToken instance. + # + # @param jwt [String] the encoded JWT token. + # @raise [ArgumentError] if the provided JWT is not a String. + 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('.') + @signing_input = [encoded_header, encoded_payload].join('.') + end + + # Returns the decoded signature of the JWT token. + # + # @return [String] the decoded signature. + def signature + @signature ||= ::JWT::Base64.url_decode(encoded_signature || '') + end + + # Returns the encoded signature of the JWT token. + # + # @return [String] the encoded signature. + attr_reader :encoded_signature + + # Returns the decoded header of the JWT token. + # + # @return [Hash] the header. + def header + @header ||= parse_and_decode(@encoded_header) + end + + # Returns the encoded header of the JWT token. + # + # @return [String] the encoded header. + attr_reader :encoded_header + + # Returns the payload of the JWT token. + # + # @return [Hash] the payload. + def payload + @payload ||= parse_and_decode(encoded_payload) + end + + # Returns the encoded payload of the JWT token. + # + # @return [String] the encoded payload. + attr_reader :encoded_payload + + # Returns the signing input of the JWT token. + # + # @return [String] the signing input. + attr_reader :signing_input + + # 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 [nil] + # @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 + + 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 +end diff --git a/lib/jwt/jwa.rb b/lib/jwt/jwa.rb index 57b2a185..914c54a1 100644 --- a/lib/jwt/jwa.rb +++ b/lib/jwt/jwa.rb @@ -42,6 +42,12 @@ def resolve(algorithm) algorithm end + # @api private + 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 + def create(algorithm) resolve(algorithm) end diff --git a/lib/jwt/token.rb b/lib/jwt/token.rb new file mode 100644 index 00000000..aa0ce5f4 --- /dev/null +++ b/lib/jwt/token.rb @@ -0,0 +1,102 @@ +# 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 + include Claims::VerificationMethods + + # 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. + attr_reader :header + + # 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. + attr_reader :payload + + # 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 + @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 + + # 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 + 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/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..02ad8e70 --- /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: ['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: ['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 index 97afb405..d7106cab 100644 --- a/spec/jwt/jwa_spec.rb +++ b/spec/jwt/jwa_spec.rb @@ -13,4 +13,25 @@ end end end + 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..57ac9df6 --- /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(JWT::EncodedToken.new(token.jwt).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