diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f6589bba..cd8e91eea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ **Features:** +- Allow a leeway to be given for the `iat` claim verification [#747](https://github.com/jwt/ruby-jwt/pull/747) - ([@denis1011101](https://github.com/denis1011101)) - Your contribution here **Fixes and enhancements:** diff --git a/README.md b/README.md index 0e8122edc..d0d4dffea 100644 --- a/README.md +++ b/README.md @@ -548,6 +548,12 @@ rescue JWT::InvalidIatError end ``` +The comparison is exact by default. When the clock of the issuer can drift ahead of the clock of the verifier, the tolerated drift can be given explicitly: + +```ruby +decoded_token = JWT.decode(token, hmac_secret, true, { verify_iat: { leeway: 30 }, algorithm: 'HS256' }) +``` + ### Subject Claim From [Oauth JSON Web Token 4.1.2. "sub" (Subject) Claim](https://tools.ietf.org/html/rfc7519#section-4.1.2): diff --git a/lib/jwt/claims/decode_verifier.rb b/lib/jwt/claims/decode_verifier.rb index 411bb97cd..403c3fce9 100644 --- a/lib/jwt/claims/decode_verifier.rb +++ b/lib/jwt/claims/decode_verifier.rb @@ -15,7 +15,7 @@ module DecodeVerifier 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_iat: ->(options) { Claims::IssuedAt.new(leeway: options[:verify_iat].is_a?(Hash) ? options[:verify_iat][:leeway] : nil) }, 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]) }, diff --git a/lib/jwt/claims/issued_at.rb b/lib/jwt/claims/issued_at.rb index 0eb08446b..1aae5695f 100644 --- a/lib/jwt/claims/issued_at.rb +++ b/lib/jwt/claims/issued_at.rb @@ -4,6 +4,13 @@ module JWT module Claims # The IssuedAt class is responsible for validating the issued at claim ('iat') in a JWT token. class IssuedAt + # Initializes a new IssuedAt instance. + # + # @param leeway [Integer] the drift (in seconds) to allow between the clock of the issuer and the clock of the verifier. Default: 0. + def initialize(leeway: 0) + @leeway = leeway || 0 + end + # Verifies the issued at claim ('iat') in the JWT token. # # @param context [Object] the context containing the JWT payload. @@ -15,8 +22,12 @@ def verify!(context:, **_args) return unless context.payload.key?('iat') iat = context.payload['iat'] - raise(JWT::InvalidIatError, 'Invalid iat') if !iat.is_a?(::Numeric) || iat.to_f > Time.now.to_f + raise(JWT::InvalidIatError, 'Invalid iat') if !iat.is_a?(::Numeric) || iat.to_f > (Time.now.to_f + leeway) end + + private + + attr_reader :leeway end end end diff --git a/lib/jwt/claims/verifier.rb b/lib/jwt/claims/verifier.rb index 81ce8a23d..3b47d47e6 100644 --- a/lib/jwt/claims/verifier.rb +++ b/lib/jwt/claims/verifier.rb @@ -8,7 +8,7 @@ module Verifier 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 }, + iat: ->(options) { Claims::IssuedAt.new(leeway: options.dig(:iat, :leeway)) }, 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]) }, diff --git a/lib/jwt/configuration/decode_configuration.rb b/lib/jwt/configuration/decode_configuration.rb index 7506ce89f..f3d7480a1 100644 --- a/lib/jwt/configuration/decode_configuration.rb +++ b/lib/jwt/configuration/decode_configuration.rb @@ -11,7 +11,7 @@ class DecodeConfiguration # @!attribute [rw] verify_iss # @return [Boolean] whether to verify the issuer claim. # @!attribute [rw] verify_iat - # @return [Boolean] whether to verify the issued at claim. + # @return [Boolean, Hash] whether to verify the issued at claim. A hash can be given to configure the claim, currently only `leeway` is supported. # @!attribute [rw] verify_jti # @return [Boolean] whether to verify the JWT ID claim. # @!attribute [rw] verify_aud diff --git a/spec/jwt/claims/issued_at_spec.rb b/spec/jwt/claims/issued_at_spec.rb index e5d3f8222..3926be05b 100644 --- a/spec/jwt/claims/issued_at_spec.rb +++ b/spec/jwt/claims/issued_at_spec.rb @@ -3,7 +3,9 @@ RSpec.describe JWT::Claims::IssuedAt do let(:payload) { { 'iat' => Time.now.to_f } } - subject(:verify!) { described_class.new.verify!(context: SpecSupport::Token.new(payload: payload)) } + let(:options) { {} } + + subject(:verify!) { described_class.new(**options).verify!(context: SpecSupport::Token.new(payload: payload)) } context 'when iat is now' do it 'passes validation' do @@ -18,6 +20,42 @@ verify! end end + context 'when the issuer clock is ahead of the verifier clock' do + let(:now) { Time.at(1_609_459_200.5) } + let(:payload) { { 'iat' => 1_609_459_201 } } + + before { allow(Time).to receive(:now) { now } } + + it 'fails validation' do + expect { verify! }.to raise_error(JWT::InvalidIatError) + end + + context 'when a leeway covering the drift is given' do + let(:options) { { leeway: 1 } } + + it 'passes validation' do + verify! + end + end + + context 'when a leeway smaller than the drift is given' do + let(:payload) { { 'iat' => 1_609_459_260 } } + let(:options) { { leeway: 1 } } + + it 'fails validation' do + expect { verify! }.to raise_error(JWT::InvalidIatError) + end + end + end + + context 'when iat is positive infinity' do + let(:payload) { { 'iat' => Float::INFINITY } } + + it 'fails validation' do + expect { verify! }.to raise_error(JWT::InvalidIatError) + end + end + context 'when iat is not a number' do let(:payload) { { 'iat' => 'not_a_number' } } diff --git a/spec/jwt/claims_spec.rb b/spec/jwt/claims_spec.rb index f6c96c9e2..2b2257100 100644 --- a/spec/jwt/claims_spec.rb +++ b/spec/jwt/claims_spec.rb @@ -9,6 +9,15 @@ end end + context 'iat claim' do + let(:payload) { { 'iat' => Time.now.to_i + 10, 'pay' => 'load' } } + + it 'verifies the iat' do + expect { described_class.verify_payload!(payload, iat: {}) }.to raise_error(JWT::InvalidIatError, 'Invalid iat') + described_class.verify_payload!(payload, iat: { leeway: 1000 }) + end + end + context 'exp claim' do let(:payload) { { 'exp' => Time.now.to_i - 10, 'pay' => 'load' } } diff --git a/spec/jwt/jwt_spec.rb b/spec/jwt/jwt_spec.rb index 0c66bc94d..9e9f007bf 100644 --- a/spec/jwt/jwt_spec.rb +++ b/spec/jwt/jwt_spec.rb @@ -645,11 +645,19 @@ end end - context 'when iat is 1 second before Time.now' do + context 'when iat is 1 second after Time.now' do let(:iat) { time_now.to_i + 1 } it 'raises an error' do expect { decoded_token }.to raise_error(JWT::InvalidIatError, 'Invalid iat') end + + context 'when a leeway covering the drift is given' do + subject(:decoded_token) { JWT.decode(token, 'secret', true, verify_iat: { leeway: 1 }) } + + it 'considers iat valid' do + expect(decoded_token).to be_an(Array) + end + end end end