diff --git a/README.md b/README.md index 17561564..e4600a9b 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,18 @@ If you have further questions releated to development or usage, join us: [ruby-j ## Installing +### Using Rubygems: ```bash sudo gem install jwt ``` +### Using Bundler: +Add the following to your Gemfile +``` +gem 'jwt' +``` +And run `bundle install` + ## Algorithms and Usage The JWT spec supports NONE, HMAC, RSASSA, ECDSA and RSASSA-PSS algorithms for cryptographic signing. Currently the jwt gem supports NONE, HMAC, RSASSA and ECDSA. If you are using cryptographic signing, you need to specify the algorithm in the options hash whenever you call JWT.decode to ensure that an attacker [cannot bypass the algorithm verification step](https://auth0.com/blog/2015/03/31/critical-vulnerabilities-in-json-web-token-libraries/). @@ -328,16 +336,16 @@ end # Development and Tests -We depend on [Echoe](http://rubygems.org/gems/echoe) for defining gemspec and performing releases to rubygems.org, which can be done with +We depend on [Bundler](http://rubygems.org/gems/bundler) for defining gemspec and performing releases to rubygems.org, which can be done with ```bash rake release ``` -The tests are written with rspec. Given you have rake and rspec, you can run tests with +The tests are written with rspec. Given you have installed the dependencies via bundler, you can run tests with ```bash -rake test +bundle exec rspec ``` **If you want a release cut with your PR, please include a version bump according to [Semantic Versioning](http://semver.org/)** diff --git a/lib/jwt.rb b/lib/jwt.rb index 238d11ca..a5cb6175 100644 --- a/lib/jwt.rb +++ b/lib/jwt.rb @@ -1,6 +1,7 @@ require 'base64' require 'openssl' require 'jwt/decode' +require 'jwt/error' require 'jwt/json' # JSON Web Token implementation @@ -8,16 +9,6 @@ # Should be up to date with the latest spec: # https://tools.ietf.org/html/rfc7519#section-4.1.5 module JWT - class DecodeError < StandardError; end - class VerificationError < DecodeError; end - class ExpiredSignature < DecodeError; end - class IncorrectAlgorithm < DecodeError; end - class ImmatureSignature < DecodeError; end - class InvalidIssuerError < DecodeError; end - class InvalidIatError < DecodeError; end - class InvalidAudError < DecodeError; end - class InvalidSubError < DecodeError; end - class InvalidJtiError < DecodeError; end extend JWT::Json NAMED_CURVES = { diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb new file mode 100644 index 00000000..16a5dc21 --- /dev/null +++ b/lib/jwt/error.rb @@ -0,0 +1,12 @@ +module JWT + class DecodeError < StandardError; end + class VerificationError < DecodeError; end + class ExpiredSignature < DecodeError; end + class IncorrectAlgorithm < DecodeError; end + class ImmatureSignature < DecodeError; end + class InvalidIssuerError < DecodeError; end + class InvalidIatError < DecodeError; end + class InvalidAudError < DecodeError; end + class InvalidSubError < DecodeError; end + class InvalidJtiError < DecodeError; end +end diff --git a/lib/jwt/verify.rb b/lib/jwt/verify.rb index 5c5d9cb2..23bfb7f3 100644 --- a/lib/jwt/verify.rb +++ b/lib/jwt/verify.rb @@ -1,73 +1,98 @@ +require 'jwt/error' + module JWT # JWT verify methods - module Verify - def self.verify_expiration(payload, options) - return unless payload.include?('exp') - - if payload['exp'].to_i < (Time.now.to_i - options[:leeway]) - fail(JWT::ExpiredSignature, 'Signature has expired') + class Verify + class << self + %i[verify_aud verify_expiration verify_iat verify_iss verify_jti verify_not_before verify_sub].each do |method_name| + define_method method_name do |payload, options| + new(payload, options).send(method_name) + end end end - def self.verify_not_before(payload, options) - return unless payload.include?('nbf') - - if payload['nbf'].to_i > (Time.now.to_i + options[:leeway]) - fail(JWT::ImmatureSignature, 'Signature nbf has not been reached') - end + def initialize(payload, options) + @payload = payload + @options = options end - def self.verify_iss(payload, options) - return unless options[:iss] + def verify_aud + return unless options_aud = extract_option(:aud) - if payload['iss'].to_s != options[:iss].to_s + if @payload['aud'].is_a?(Array) fail( - JWT::InvalidIssuerError, - "Invalid issuer. Expected #{options[:iss]}, received #{payload['iss'] || ''}" - ) + JWT::InvalidAudError, + 'Invalid audience' + ) unless @payload['aud'].include?(options_aud.to_s) + else + fail( + JWT::InvalidAudError, + "Invalid audience. Expected #{options_aud}, received #{@payload['aud'] || ''}" + ) unless @payload['aud'].to_s == options_aud.to_s end end - def self.verify_iat(payload, options) - return unless payload.include?('iat') + def verify_expiration + return unless @payload.include?('exp') - if !(payload['iat'].is_a?(Integer)) || payload['iat'].to_i > (Time.now.to_i + options[:leeway]) - fail(JWT::InvalidIatError, 'Invalid iat') + if @payload['exp'].to_i < (Time.now.to_i - leeway) + fail(JWT::ExpiredSignature, 'Signature has expired') end end - def self.verify_jti(payload, _options) - if _options[:verify_jti].class == Proc - fail(JWT::InvalidJtiError, 'Invalid jti') unless _options[:verify_jti].call(payload['jti']) - else - fail(JWT::InvalidJtiError, 'Missing jti') if payload['jti'].to_s == '' + def verify_iat + return unless @payload.include?('iat') + + if !(@payload['iat'].is_a?(Integer)) || @payload['iat'].to_i > (Time.now.to_i + leeway) + fail(JWT::InvalidIatError, 'Invalid iat') end end - def self.verify_aud(payload, options) - return unless options[:aud] + def verify_iss + return unless options_iss = extract_option(:iss) - if payload[:aud].is_a?(Array) + if @payload['iss'].to_s != options_iss.to_s fail( - JWT::InvalidAudError, - 'Invalid audience' - ) unless payload['aud'].include?(options[:aud].to_s) + JWT::InvalidIssuerError, + "Invalid issuer. Expected #{options_iss}, received #{@payload['iss'] || ''}" + ) + end + end + + def verify_jti + options_verify_jti = extract_option(:verify_jti) + if options_verify_jti.respond_to?(:call) + fail(JWT::InvalidJtiError, 'Invalid jti') unless options_verify_jti.call(@payload['jti']) else - fail( - JWT::InvalidAudError, - "Invalid audience. Expected #{options[:aud]}, received #{payload['aud'] || ''}" - ) unless payload['aud'].to_s == options[:aud].to_s + fail(JWT::InvalidJtiError, 'Missing jti') if @payload['jti'].to_s.strip.empty? end end - def self.verify_sub(payload, options) - return unless options[:sub] + def verify_not_before + return unless @payload.include?('nbf') + if @payload['nbf'].to_i > (Time.now.to_i + leeway) + fail(JWT::ImmatureSignature, 'Signature nbf has not been reached') + end + end + + def verify_sub + return unless options_sub = extract_option(:sub) fail( JWT::InvalidSubError, - "Invalid subject. Expected #{options[:sub]}, received #{payload['sub'] || ''}" - ) unless payload['sub'].to_s == options[:sub].to_s + "Invalid subject. Expected #{options_sub}, received #{@payload['sub'] || ''}" + ) unless @payload['sub'].to_s == options_sub.to_s + end + + private + + def extract_option(key) + @options.values_at(key.to_sym, key.to_s).compact.first + end + + def leeway + extract_option :leeway end end end diff --git a/lib/jwt/version.rb b/lib/jwt/version.rb index 94722f60..9e61b315 100644 --- a/lib/jwt/version.rb +++ b/lib/jwt/version.rb @@ -15,7 +15,7 @@ module VERSION # tiny version TINY = 3 # alpha, beta, etc. tag - PRE = 'dev' + PRE = nil # Build version string STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.') diff --git a/ruby-jwt.gemspec b/ruby-jwt.gemspec index 031a10a0..b93176ca 100644 --- a/ruby-jwt.gemspec +++ b/ruby-jwt.gemspec @@ -26,4 +26,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'simplecov' spec.add_development_dependency 'simplecov-json' spec.add_development_dependency 'codeclimate-test-reporter' + spec.add_development_dependency 'byebug' end diff --git a/spec/jwt/verify_spec.rb b/spec/jwt/verify_spec.rb new file mode 100644 index 00000000..4af201fb --- /dev/null +++ b/spec/jwt/verify_spec.rb @@ -0,0 +1,171 @@ +require 'spec_helper' +require 'jwt/verify' + +module JWT + RSpec.describe Verify do + let(:base_payload) { { 'user_id' => 'some@user.tld' } } + let(:options) { { leeway: 0} } + + context '.verify_aud(payload, options)' do + let(:scalar_aud) { 'ruby-jwt-audience' } + let(:array_aud) { %w(ruby-jwt-aud test-aud ruby-ruby-ruby) } + let(:scalar_payload) { base_payload.merge('aud' => scalar_aud) } + let(:array_payload) { base_payload.merge('aud' => array_aud) } + + it 'must raise JWT::InvalidAudError when the singular audience does not match' do + expect do + Verify.verify_aud(scalar_payload, options.merge(aud: 'no-match')) + end.to raise_error JWT::InvalidAudError + end + + it 'must raise JWT::InvalidAudError when the payload has an array and none match the supplied value' do + expect do + Verify.verify_aud(array_payload, options.merge(aud: 'no-match')) + end.to raise_error JWT::InvalidAudError + end + + it 'must raise JWT::InvalidAudError when the singular audience does not match and the options aud key is a string' do + expect do + Verify.verify_aud(scalar_payload, options.merge('aud' => 'no-match')) + end.to raise_error JWT::InvalidAudError + end + + it 'must allow a matching singular audience to pass' do + Verify.verify_aud(scalar_payload, options.merge(aud: scalar_aud)) + end + + it 'must allow a matching audence to pass when the options key is a string' do + Verify.verify_aud(scalar_payload, options.merge('aud' => scalar_aud)) + end + + it 'must allow an array with any value matching the one in the options' do + Verify.verify_aud(array_payload, options.merge(aud: array_aud.first)) + end + + it 'must allow an array with any value matching the one in the options with a string options key' do + Verify.verify_aud(array_payload, options.merge('aud' => array_aud.first)) + end + end + + context '.verify_expiration(payload, options)' do + let(:leeway) { 10 } + let(:payload) { base_payload.merge('exp' => (Time.now.to_i - 5)) } + + it 'must raise JWT::ExpiredSignature when the token has expired' do + expect do + Verify.verify_expiration(payload, options) + end.to raise_error JWT::ExpiredSignature + end + + it 'must allow some leeway in the expiration when configured' do + Verify.verify_expiration(payload, options.merge(leeway: 10)) + end + end + + context '.verify_iat(payload, options)' do + let(:iat) { Time.now.to_i } + let(:payload) { base_payload.merge('iat' => iat) } + + it 'must allow a valid iat' do + Verify.verify_iat(payload, options) + end + + it 'must allow configured leeway' do + Verify.verify_iat(payload.merge('iat' => (iat + 60)), options.merge(leeway: 70)) + end + + it 'must raise JWT::InvalidIatError when the iat value is not an Integer' do + expect do + Verify.verify_iat(payload.merge('iat' => 'not a number'), options) + end.to raise_error JWT::InvalidIatError + end + + it 'must raise JWT::InvalidIatError when the iat value is in the future' do + expect do + Verify.verify_iat(payload.merge('iat' => (iat + 120)), options) + end.to raise_error JWT::InvalidIatError + end + end + + context '.verify_iss(payload, options)' do + let(:iss) { 'ruby-jwt-gem' } + let(:payload) { base_payload.merge('iss' => iss) } + + let(:invalid_token) { JWT.encode base_payload, payload[:secret] } + + it 'must raise JWT::InvalidIssuerError when the configured issuer does not match the payload issuer' do + expect do + Verify.verify_iss(payload, options.merge(iss: 'mismatched-issuer')) + end.to raise_error JWT::InvalidIssuerError + end + + it 'must raise JWT::InvalidIssuerError when the payload does not include an issuer' do + expect do + Verify.verify_iss(base_payload, options.merge(iss: iss)) + end.to raise_error(JWT::InvalidIssuerError, /received /) + end + + it 'must allow a matching issuer to pass' do + Verify.verify_iss(payload, options.merge(iss: iss)) + end + end + + context '.verify_jti(payload, options)' do + let(:payload) { base_payload.merge('jti' => 'some-random-uuid-or-whatever') } + + it 'must allow any jti when the verfy_jti key in the options is truthy but not a proc' do + Verify.verify_jti(payload, options.merge(verify_jti: true)) + end + + it 'must raise JWT::InvalidJtiError when the jti is missing' do + expect do + Verify.verify_jti(base_payload, options) + end.to raise_error JWT::InvalidJtiError, /missing/i + end + + it 'must raise JWT::InvalidJtiError when the jti is an empty string' do + expect do + Verify.verify_jti(base_payload.merge('jti' => ' '), options) + end.to raise_error JWT::InvalidJtiError, /missing/i + end + + it 'must raise JWT::InvalidJtiError when verify_jti proc returns false' do + expect do + Verify.verify_jti(payload, options.merge(verify_jti: ->(jti) { false })) + end.to raise_error JWT::InvalidJtiError, /invalid/i + end + + it 'true proc should not raise JWT::InvalidJtiError' do + Verify.verify_jti(payload, options.merge(verify_jti: ->(jti) { true })) + end + end + + context '.verify_not_before(payload, options)' do + let(:payload) { base_payload.merge('nbf' => (Time.now.to_i + 5)) } + + it 'must raise JWT::ImmatureSignature when the nbf in the payload is in the future' do + expect do + Verify.verify_not_before(payload, options) + end.to raise_error JWT::ImmatureSignature + end + + it 'must allow some leeway in the token age when configured' do + Verify.verify_not_before(payload, options.merge(leeway: 10)) + end + end + + context '.verify_sub(payload, options)' do + let(:sub) { 'ruby jwt subject' } + + it 'must raise JWT::InvalidSubError when the subjects do not match' do + expect do + Verify.verify_sub(base_payload.merge('sub' => 'not-a-match'), options.merge(sub: sub)) + end.to raise_error JWT::InvalidSubError + end + + it 'must allow a matching sub' do + Verify.verify_sub(base_payload.merge('sub' => sub), options.merge(sub: sub)) + end + end + end +end diff --git a/spec/jwt_spec.rb b/spec/jwt_spec.rb index 8f77d1f5..9b6ad3af 100644 --- a/spec/jwt_spec.rb +++ b/spec/jwt_spec.rb @@ -189,52 +189,6 @@ end end - context 'expiration claim' do - let(:exp) { Time.now.to_i - 5 } - let(:leeway) { 10 } - - let :token do - payload.merge!(exp: exp) - - JWT.encode payload, data[:secret] - end - - it 'old token should raise JWT::ExpiredSignature' do - expect do - JWT.decode token, data[:secret] - end.to raise_error JWT::ExpiredSignature - end - - it 'should handle leeway' do - expect do - JWT.decode token, data[:secret], true, leeway: leeway - end.not_to raise_error - end - end - - context 'not before claim' do - let(:nbf) { Time.now.to_i + 5 } - let(:leeway) { 10 } - - let :token do - payload.merge!(nbf: nbf) - - JWT.encode payload, data[:secret] - end - - it 'future token should raise JWT::ImmatureSignature' do - expect do - JWT.decode token, data[:secret] - end.to raise_error JWT::ImmatureSignature - end - - it 'should handle leeway' do - expect do - JWT.decode token, data[:secret], true, leeway: leeway - end.not_to raise_error - end - end - context 'issuer claim' do let(:iss) { 'ruby-jwt-gem' } let(:invalid_token) { JWT.encode payload, data[:secret] } @@ -249,146 +203,6 @@ JWT.decode token, data[:secret], true, iss: iss end.not_to raise_error end - - it 'invalid iss should raise JWT::InvalidIssuerError' do - expect do - JWT.decode token, data[:secret], true, iss: 'wrong-issuer', verify_iss: true - end.to raise_error JWT::InvalidIssuerError - end - - it 'with missing iss claim should raise JWT::InvalidIssuerError' do - missing_iss_claim_token = JWT.encode payload, data[:secret] - - expect do - JWT.decode missing_iss_claim_token, data[:secret], true, verify_iss: true, iss: iss - end.to raise_error(JWT::InvalidIssuerError, /received /) - end - - it 'valid iss should not raise JWT::InvalidIssuerError' do - expect do - JWT.decode token, data[:secret], true, iss: iss, verify_iss: true - end.not_to raise_error - end - end - - context 'issued at claim' do - let(:iat) { Time.now.to_i } - let(:new_payload) { payload.merge(iat: iat) } - let(:token) { JWT.encode new_payload, data[:secret] } - let(:invalid_token) { JWT.encode new_payload.merge('iat' => iat + 60), data[:secret] } - let(:leeway) { 30 } - - it 'invalid iat should raise JWT::InvalidIatError' do - expect do - JWT.decode invalid_token, data[:secret], true, verify_iat: true - end.to raise_error JWT::InvalidIatError - end - - it 'should accept leeway' do - expect do - JWT.decode invalid_token, data[:secret], true, verify_iat: true, leeway: 70 - end.to_not raise_error - end - - it 'valid iat should not raise JWT::InvalidIatError' do - expect do - JWT.decode token, data[:secret], true, verify_iat: true - end.to_not raise_error - end - end - - context 'audience claim' do - let(:simple_aud) { 'ruby-jwt-audience' } - let(:array_aud) { %w(ruby-jwt-aud test-aud ruby-ruby-ruby) } - - let :simple_token do - new_payload = payload.merge('aud' => simple_aud) - JWT.encode new_payload, data[:secret] - end - - let :array_token do - new_payload = payload.merge('aud' => array_aud) - JWT.encode new_payload, data[:secret] - end - - it 'invalid aud should raise JWT::InvalidAudError' do - expect do - JWT.decode simple_token, data[:secret], true, aud: 'wrong audience', verify_aud: true - end.to raise_error JWT::InvalidAudError - - expect do - JWT.decode array_token, data[:secret], true, aud: %w(wrong audience), verify_aud: true - end.to raise_error JWT::InvalidAudError - end - - it 'valid aud should not raise JWT::InvalidAudError' do - expect do - JWT.decode simple_token, data[:secret], true, 'aud' => simple_aud, :verify_aud => true - end.to_not raise_error - - expect do - JWT.decode array_token, data[:secret], true, 'aud' => array_aud.first, :verify_aud => true - end.to_not raise_error - end - end - - context 'subject claim' do - let(:sub) { 'ruby jwt subject' } - - let :token do - new_payload = payload.merge('sub' => sub) - JWT.encode new_payload, data[:secret] - end - - let :invalid_token do - invalid_payload = payload.merge('sub' => 'we are not the druids you are looking for') - JWT.encode invalid_payload, data[:secret] - end - - it 'invalid sub should raise JWT::InvalidSubError' do - expect do - JWT.decode invalid_token, data[:secret], true, sub: sub, verify_sub: true - end.to raise_error JWT::InvalidSubError - end - - it 'valid sub should not raise JWT::InvalidSubError' do - expect do - JWT.decode token, data[:secret], true, 'sub' => sub, :verify_sub => true - end.to_not raise_error - end - end - - context 'jwt id claim' do - let :jti do - payload.merge('jti' => 'some-random-uuid-or-whatever') - end - - let(:token) { JWT.encode jti, data[:secret] } - let(:invalid_token) { JWT.encode payload, data[:secret] } - - it 'missing jti should raise JWT::InvalidJtiError' do - expect do - JWT.decode invalid_token, data[:secret], true, verify_jti: true - end.to raise_error JWT::InvalidJtiError - end - - it 'valid jti should not raise JWT::InvalidJtiError' do - expect do - JWT.decode token, data[:secret], true, verify_jti: true - end.to_not raise_error - end - - it 'false proc should raise JWT::InvalidJtiError' do - expect do - JWT.decode token, data[:secret], true, verify_jti: lambda { |jti| false } - end.to raise_error JWT::InvalidJtiError - end - - it 'true proc should not raise JWT::InvalidJtiError' do - expect do - JWT.decode invalid_token, data[:secret], true, verify_jti: lambda { |jti| true } - end.to_not raise_error - end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2faf1928..fce63f3a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,10 +6,10 @@ SimpleCov.configure do root File.join(File.dirname(__FILE__), '..') project_name 'Ruby JWT - Ruby JSON Web Token implementation' - SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[ + SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ SimpleCov::Formatter::HTMLFormatter, SimpleCov::Formatter::JSONFormatter - ] + ]) add_filter 'spec' end