From 8cc1fce6902935cb40dc612c14b7527cb4ba6557 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Wed, 2 Oct 2024 20:41:53 +0300 Subject: [PATCH 1/3] Official claim validation --- CHANGELOG.md | 1 + lib/jwt/claims.rb | 78 +++++++++++++++++----- lib/jwt/claims/decode_verifier.rb | 40 +++++++++++ lib/jwt/claims/numeric.rb | 40 +++++++---- lib/jwt/claims/verifier.rb | 62 +++++++++++++++++ lib/jwt/claims_validator.rb | 2 +- lib/jwt/decode.rb | 2 +- lib/jwt/encode.rb | 2 +- spec/jwt/claims/numeric_spec.rb | 106 ++++++++++++++++++++---------- spec/jwt/claims_spec.rb | 76 +++++++++++++++++++++ 10 files changed, 340 insertions(+), 69 deletions(-) create mode 100644 lib/jwt/claims/decode_verifier.rb create mode 100644 lib/jwt/claims/verifier.rb create mode 100644 spec/jwt/claims_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f0edc56..51861e18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ **Features:** +- Standalone claim verification interface [#626](https://github.com/jwt/ruby-jwt/pull/626) ([@anakinj](https://github.com/anakinj)) - Your contribution here **Fixes and enhancements:** diff --git a/lib/jwt/claims.rb b/lib/jwt/claims.rb index 199af0a6..f4e00b53 100644 --- a/lib/jwt/claims.rb +++ b/lib/jwt/claims.rb @@ -9,30 +9,74 @@ require_relative 'claims/numeric' require_relative 'claims/required' require_relative 'claims/subject' +require_relative 'claims/decode_verifier' +require_relative 'claims/verifier' module JWT + # JWT Claim verifications + # https://datatracker.ietf.org/doc/html/rfc7519#section-4 + # + # Verification is supported for the following claims: + # exp + # nbf + # iss + # iat + # jti + # aud + # sub + # required + # numeric + # module Claims - VerificationContext = Struct.new(:payload, 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]) } - }.freeze + # Represents a claim verification error + Error = Struct.new(:message, keyword_init: true) class << self + # @deprecated Use {verify_payload!} instead. Will be removed in the next major version of ruby-jwt. def verify!(payload, options) - VERIFIERS.each do |key, verifier_builder| - next unless options[key] || options[key.to_s] + Deprecations.warning('Calling ::JWT::Claims::verify! will be removed in the next major version of ruby-jwt') + DecodeVerifier.verify!(payload, options) + end + + # Checks if the claims in the JWT payload are valid. + # @example + # + # ::JWT::Claims.verify_payload!({"exp" => Time.now.to_i + 10}, :exp) + # ::JWT::Claims.verify_payload!({"exp" => Time.now.to_i - 10}, exp: { leeway: 11}) + # + # @param payload [Hash] the JWT payload. + # @param options [Array] the options for verifying the claims. + # @return [void] + # @raise [JWT::DecodeError] if any claim is invalid. + def verify_payload!(payload, *options) + verify_token!(VerificationContext.new(payload: payload), *options) + end + + # Checks if the claims in the JWT payload are valid. + # + # @param payload [Hash] the JWT payload. + # @param options [Array] the options for verifying the claims. + # @return [Boolean] true if the claims are valid, false otherwise + def valid_payload?(payload, *options) + payload_errors(payload, *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 + def payload_errors(payload, *options) + token_errors(VerificationContext.new(payload: payload), *options) + end + + private + + def verify_token!(token, *options) + Verifier.verify!(token, *options) + end - verifier_builder&.call(options)&.verify!(context: VerificationContext.new(payload: payload)) - end - nil + def token_errors(token, *options) + Verifier.errors(token, *options) end end end diff --git a/lib/jwt/claims/decode_verifier.rb b/lib/jwt/claims/decode_verifier.rb new file mode 100644 index 00000000..2548f4d3 --- /dev/null +++ b/lib/jwt/claims/decode_verifier.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module JWT + module Claims + # Context class to contain the data passed to individual claim validators + # + # @private + VerificationContext = Struct.new(:payload, keyword_init: true) + + # Verifiers to support the ::JWT.decode method + # + # @private + module DecodeVerifier + 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 + + private_constant(:VERIFIERS) + + class << self + # @private + def verify!(payload, options) + VERIFIERS.each do |key, verifier_builder| + next unless options[key] || options[key.to_s] + + verifier_builder&.call(options)&.verify!(context: VerificationContext.new(payload: payload)) + end + nil + end + end + end + end +end diff --git a/lib/jwt/claims/numeric.rb b/lib/jwt/claims/numeric.rb index c537b8f3..5df2c942 100644 --- a/lib/jwt/claims/numeric.rb +++ b/lib/jwt/claims/numeric.rb @@ -3,10 +3,14 @@ module JWT module Claims class Numeric - def self.verify!(payload:, **_args) - return unless payload.is_a?(Hash) + class Compat + def initialize(payload) + @payload = payload + end - new(payload).verify! + def verify! + JWT::Claims.verify_payload!(@payload, :numeric) + end end NUMERIC_CLAIMS = %i[ @@ -15,28 +19,38 @@ def self.verify!(payload:, **_args) nbf ].freeze - def initialize(payload) - @payload = payload.transform_keys(&:to_sym) + def self.new(*args) + return super if args.empty? + + Deprecations.warning('Calling ::JWT::Claims::Numeric.new with the payload will be removed in the next major version of ruby-jwt') + Compat.new(*args) end - def verify! - validate_numeric_claims + def verify!(context:) + validate_numeric_claims(context.payload) + end - true + def self.verify!(payload:, **_args) + Deprecations.warning('Calling ::JWT::Claims::Numeric.verify! with the payload will be removed in the next major version of ruby-jwt') + JWT::Claims.verify_payload!(payload, :numeric) 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/verifier.rb b/lib/jwt/claims/verifier.rb new file mode 100644 index 00000000..608c3fd4 --- /dev/null +++ b/lib/jwt/claims/verifier.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module JWT + module Claims + # @private + module Verifier + VERIFIERS = { + 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: ->(options) { Claims::Required.new(required_claims: options[:required]) }, + numeric: ->(*) { Claims::Numeric.new } + }.freeze + + private_constant(:VERIFIERS) + + class << self + # @private + def verify!(context, *options) + iterate_verifiers(*options) do |verifier, verifier_options| + verify_one!(context, verifier, verifier_options) + end + nil + end + + # @private + def errors(context, *options) + errors = [] + iterate_verifiers(*options) do |verifier, verifier_options| + verify_one!(context, verifier, verifier_options) + rescue ::JWT::DecodeError => e + errors << Error.new(message: e.message) + end + errors + end + + # @private + 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!(context, verifier, options) + verifier_builder = VERIFIERS.fetch(verifier) { raise ArgumentError, "#{verifier} not a valid claim verifier" } + verifier_builder.call(options || {}).verify!(context: context) + end + end + end + end +end diff --git a/lib/jwt/claims_validator.rb b/lib/jwt/claims_validator.rb index 72540ebd..05bf0abd 100644 --- a/lib/jwt/claims_validator.rb +++ b/lib/jwt/claims_validator.rb @@ -10,7 +10,7 @@ def initialize(payload) end def validate! - Claims::Numeric.verify!(payload: @payload) + Claims.verify_payload!(@payload, :numeric) end end end diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index e51a9582..e3d81d86 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -112,7 +112,7 @@ def find_key(&keyfinder) end def verify_claims - Claims.verify!(payload, @options) + Claims::DecodeVerifier.verify!(payload, @options) end def validate_segment_count! diff --git a/lib/jwt/encode.rb b/lib/jwt/encode.rb index 973f5b2f..e6829050 100644 --- a/lib/jwt/encode.rb +++ b/lib/jwt/encode.rb @@ -51,7 +51,7 @@ def signature def validate_claims! return unless @payload.is_a?(Hash) - Claims::Numeric.new(@payload).verify! + Claims.verify_payload!(@payload, :numeric) end def encode_signature diff --git a/spec/jwt/claims/numeric_spec.rb b/spec/jwt/claims/numeric_spec.rb index 6cef1251..d6a9e53b 100644 --- a/spec/jwt/claims/numeric_spec.rb +++ b/spec/jwt/claims/numeric_spec.rb @@ -1,68 +1,83 @@ # frozen_string_literal: true RSpec.describe JWT::Claims::Numeric do - let(:validator) { described_class.new(claims) } + shared_examples_for 'a NumericDate claim' do |claim| + context "when #{claim} payload is an integer" do + let(:claims) { { claim => 12_345 } } - describe '#verify!' do - subject { validator.verify! } + it 'does not raise error' do + expect { subject }.not_to raise_error + end - shared_examples_for 'a NumericDate claim' do |claim| - context "when #{claim} payload is an integer" do - let(:claims) { { claim => 12_345 } } + context 'and key is a string' do + let(:claims) { { claim.to_s => 43.32 } } it 'does not raise error' do expect { subject }.not_to raise_error end + end + end - context 'and key is a string' do - let(:claims) { { claim.to_s => 43.32 } } + context "when #{claim} payload is a float" do + let(:claims) { { claim => 43.32 } } - it 'does not raise error' do - expect { subject }.not_to raise_error - end - end + it 'does not raise error' do + expect { subject }.not_to raise_error end + end - context "when #{claim} payload is a float" do - let(:claims) { { claim => 43.32 } } + context "when #{claim} payload is a string" do + let(:claims) { { claim => '1' } } - it 'does not raise error' do - expect { subject }.not_to raise_error - end + it 'raises error' do + expect { subject }.to raise_error JWT::InvalidPayload end - context "when #{claim} payload is a string" do - let(:claims) { { claim => '1' } } + context 'and key is a string' do + let(:claims) { { claim.to_s => '1' } } it 'raises error' do expect { subject }.to raise_error JWT::InvalidPayload end + end + end - context 'and key is a string' do - let(:claims) { { claim.to_s => '1' } } + context "when #{claim} payload is a Time object" do + let(:claims) { { claim => Time.now } } - it 'raises error' do - expect { subject }.to raise_error JWT::InvalidPayload - end - end + it 'raises error' do + expect { subject }.to raise_error JWT::InvalidPayload end + end - context "when #{claim} payload is a Time object" do - let(:claims) { { claim => Time.now } } + context "when #{claim} payload is a string" do + let(:claims) { { claim => '1' } } - it 'raises error' do - expect { subject }.to raise_error JWT::InvalidPayload - end + it 'raises error' do + expect { subject }.to raise_error JWT::InvalidPayload end + end + end - context "when #{claim} payload is a string" do - let(:claims) { { claim => '1' } } + let(:validator) { described_class.new } - it 'raises error' do - expect { subject }.to raise_error JWT::InvalidPayload - end - end + describe '#verify!' do + subject { validator.verify!(context: JWT::Claims::VerificationContext.new(payload: claims)) } + context 'exp claim' do + it_should_behave_like 'a NumericDate claim', :exp + end + + context 'iat claim' do + it_should_behave_like 'a NumericDate claim', :iat + end + + context 'nbf claim' do + it_should_behave_like 'a NumericDate claim', :nbf end + end + + describe 'use via ::JWT::Claims.verify_payload!' do + subject { JWT::Claims.verify_payload!(claims, :numeric) } context 'exp claim' do it_should_behave_like 'a NumericDate claim', :exp @@ -76,4 +91,23 @@ it_should_behave_like 'a NumericDate claim', :nbf end end + + context 'Legacy use' do + let(:validator) { described_class.new(claims) } + describe '#verify!' do + subject { validator.verify! } + + context 'exp claim' do + it_should_behave_like 'a NumericDate claim', :exp + end + + context 'iat claim' do + it_should_behave_like 'a NumericDate claim', :iat + end + + context 'nbf claim' do + it_should_behave_like 'a NumericDate claim', :nbf + end + end + end end diff --git a/spec/jwt/claims_spec.rb b/spec/jwt/claims_spec.rb new file mode 100644 index 00000000..0f62ee2a --- /dev/null +++ b/spec/jwt/claims_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +RSpec.describe JWT::Claims do + let(:payload) { { 'pay' => 'load' } } + describe '.verify_payload!' do + context 'when required_claims is passed' do + it 'raises error' do + expect { described_class.verify_payload!(payload, 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 + described_class.verify_payload!(payload, required: ['exp']) + expect { described_class.verify_payload!(payload, exp: {}) }.to raise_error(JWT::ExpiredSignature, 'Signature has expired') + described_class.verify_payload!(payload, exp: { leeway: 1000 }) + end + + context 'when claims given as symbol' do + it 'validates the claim' do + expect { described_class.verify_payload!(payload, :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 { described_class.verify_payload!(payload, :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 { described_class.verify_payload!(payload, { exp: { leeway: 1000 }, nbf: {} }, :exp, :nbf) }.to raise_error(JWT::ExpiredSignature, 'Signature has expired') + end + end + end + end + + describe '.valid_payload?' 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(described_class.valid_payload?(payload, exp: { leeway: 1000 })).to be(true) + end + end + + context 'when claim is invalid' do + it 'returns false' do + expect(described_class.valid_payload?(payload, :exp)).to be(false) + end + end + end + end + + describe '.payload_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(described_class.payload_errors(payload, exp: { leeway: 1000 })).to be_empty + end + end + + context 'when claim is invalid' do + it 'returns array with error objects' do + expect(described_class.payload_errors(payload, :exp).map(&:message)).to eq(['Signature has expired']) + end + end + end + end +end From 833a11f596f761de8a8b81fcabae3d9aa927561d Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Wed, 2 Oct 2024 22:35:14 +0300 Subject: [PATCH 2/3] List of deprecations --- CHANGELOG.md | 22 ++++++++++++++++++++++ README.md | 18 ++++++++++++++++++ lib/jwt/claims.rb | 2 +- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51861e18..2c8c54f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## Upcoming breaking changes + +Notable changes in the upcoming **version 3.0**: + +- The indirect dependency to [rbnacl](https://github.com/RubyCrypto/rbnacl) will be removed: + - Support for the nonstandard SHA512256 algorithm will be removed. + - Support for Ed25519 will be moved to a [separate gem](https://github.com/anakinj/jwt-eddsa) for better dependency handling. + +- Base64 decoding will no longer fallback on the looser RFC 2045. + +- Claim verification has been [split into separate classes](https://github.com/jwt/ruby-jwt/pull/605) and has [a new api](https://github.com/jwt/ruby-jwt/pull/626) and lead to the following deprecations: + - The `::JWT::ClaimsValidator` class will be removed in favor of the functionality provided by `::JWT::Claims`. + - The `::JWT::Claims::verify!` method will be removed in favor of `::JWT::Claims::verify_payload!`. + - The `::JWT::JWA.create` method will be removed. No recommended alternatives. + - The `::JWT::Verify` class will be removed in favor of the functionality provided by `::JWT::Claims`. + - Calling `::JWT::Claims::Numeric.new` with a payload will be removed in favor of `::JWT::Claims::verify_payload!(payload, :numeric)` + - Calling `::JWT::Claims::Numeric.verify!` with a payload will be removed in favor of `::JWT::Claims::verify_payload!(payload, :numeric)` + +- The internal algorithms were [restructured](https://github.com/jwt/ruby-jwt/pull/607) to support extensions from separate libraries. The changes lead to a few deprecations and new requirements: + - The `sign` and `verify` static methods on all the algorithms (`::JWT::JWA`) will be removed. + - Custom algorithms are expected to include the `JWT::JWA::SigningAlgorithm` module. + ## [v2.9.2](https://github.com/jwt/ruby-jwt/tree/v2.9.2) (NEXT) [Full Changelog](https://github.com/jwt/ruby-jwt/compare/v2.9.1...main) diff --git a/README.md b/README.md index dfb44b09..1ae02d73 100644 --- a/README.md +++ b/README.md @@ -530,6 +530,24 @@ rescue JWT::InvalidSubError end ``` +### Standalone claim verification + +The JWT claim verifications can be used to verify any Hash to include expected keys and values. + +A few example on verifying the claims for a payload: +```ruby +JWT::Claims.verify_payload!({"exp" => Time.now.to_i + 10}, :numeric, :exp) +JWT::Claims.valid_payload?({"exp" => Time.now.to_i + 10}, :exp) +# => true +JWT::Claims.payload_errors({"exp" => Time.now.to_i - 10}, :exp) +# => [#] +JWT::Claims.verify_payload!({"exp" => Time.now.to_i - 10}, exp: { leeway: 11}) + +JWT::Claims.verify_payload!({"exp" => Time.now.to_i + 10, "sub" => "subject"}, :exp, sub: "subject") +``` + + + ### Finding a Key To dynamically find the key for verifying the JWT signature, pass a block to the decode block. The block receives headers and the original payload as parameters. It should return with the key to verify the signature that was used to sign the JWT. diff --git a/lib/jwt/claims.rb b/lib/jwt/claims.rb index f4e00b53..e1732d7b 100644 --- a/lib/jwt/claims.rb +++ b/lib/jwt/claims.rb @@ -34,7 +34,7 @@ module Claims class << self # @deprecated Use {verify_payload!} instead. Will be removed in the next major version of ruby-jwt. def verify!(payload, options) - Deprecations.warning('Calling ::JWT::Claims::verify! will be removed in the next major version of ruby-jwt') + Deprecations.warning('The ::JWT::Claims.verify! method is deprecated will be removed in the next major version of ruby-jwt') DecodeVerifier.verify!(payload, options) end From b9eeaaa8d1dbff8c9e5fd66d9d57d6c1ad335a7c Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Wed, 2 Oct 2024 23:41:56 +0300 Subject: [PATCH 3/3] More advanced test --- spec/jwt/claims_spec.rb | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/spec/jwt/claims_spec.rb b/spec/jwt/claims_spec.rb index 0f62ee2a..f6c96c9e 100644 --- a/spec/jwt/claims_spec.rb +++ b/spec/jwt/claims_spec.rb @@ -54,6 +54,34 @@ end end end + + context 'various types of params' do + context 'when payload is missing most of the claims' do + it 'raises an error' do + expect do + described_class.verify_payload!(payload, + :nbf, + iss: ['www.host.com', 'https://other.host.com'].freeze, + aud: 'aud', + exp: { leeway: 10 }) + end.to raise_error(JWT::InvalidIssuerError) + end + end + + context 'when payload has everything that is expected of it' do + let(:payload) { { 'iss' => 'www.host.com', 'aud' => 'audience', 'exp' => Time.now.to_i - 10, 'pay' => 'load' } } + + it 'does not raise' do + expect do + described_class.verify_payload!(payload, + :nbf, + iss: ['www.host.com', 'https://other.host.com'].freeze, + aud: 'audience', + exp: { leeway: 11 }) + end.not_to raise_error + end + end + end end describe '.payload_errors' do @@ -72,5 +100,22 @@ end end end + + context 'various types of params' do + let(:payload) { { 'exp' => Time.now.to_i - 10, 'pay' => 'load' } } + + context 'when payload is most of the claims' do + it 'raises an error' do + messages = described_class.payload_errors(payload, + :nbf, + iss: ['www.host.com', 'https://other.host.com'].freeze, + aud: 'aud', + exp: { leeway: 10 }).map(&:message) + expect(messages).to eq(['Invalid issuer. Expected ["www.host.com", "https://other.host.com"], received ', + 'Invalid audience. Expected aud, received ', + 'Signature has expired']) + end + end + end end end