From e96ed6fa322ec81eb812716e18c5fe38a7fdcbb5 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Fri, 20 Mar 2026 21:01:05 +0200 Subject: [PATCH 1/3] Nested token Friday night PoC --- lib/jwt.rb | 2 + lib/jwt/encoded_nested_token.rb | 82 ++++++++++ lib/jwt/nested_token.rb | 31 ++++ spec/jwt/encoded_nested_token_spec.rb | 207 ++++++++++++++++++++++++++ spec/jwt/nested_token_spec.rb | 80 ++++++++++ 5 files changed, 402 insertions(+) create mode 100644 lib/jwt/encoded_nested_token.rb create mode 100644 lib/jwt/nested_token.rb create mode 100644 spec/jwt/encoded_nested_token_spec.rb create mode 100644 spec/jwt/nested_token_spec.rb diff --git a/lib/jwt.rb b/lib/jwt.rb index 86ac2e6a..3a004fd6 100644 --- a/lib/jwt.rb +++ b/lib/jwt.rb @@ -11,6 +11,8 @@ require 'jwt/claims' require 'jwt/encoded_token' require 'jwt/token' +require 'jwt/nested_token' +require 'jwt/encoded_nested_token' # JSON Web Token implementation # diff --git a/lib/jwt/encoded_nested_token.rb b/lib/jwt/encoded_nested_token.rb new file mode 100644 index 00000000..5efc242e --- /dev/null +++ b/lib/jwt/encoded_nested_token.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module JWT + # Represents an encoded Nested JWT for verification, as defined in RFC 7519 Section 5.2. + # + # Unwraps all nesting levels and provides an Enumerable interface over the token layers + # (outermost to innermost). + # + # @example Verifying a Nested JWT + # nested = JWT::EncodedNestedToken.new(nested_jwt_string) + # nested.verify!( + # keys: [ + # { algorithm: 'RS256', key: rsa_public }, + # { algorithm: 'HS256', key: 'inner_secret' } + # ] + # ) + # nested.last.payload # => { 'user_id' => 123 } + # + # @example Inspecting layers + # nested = JWT::EncodedNestedToken.new(nested_jwt_string) + # nested.count # => 2 + # nested.map(&:header) # => [outer_header, inner_header] + # + # @see https://datatracker.ietf.org/doc/html/rfc7519#section-5.2 RFC 7519 Section 5.2 + class EncodedNestedToken + include Enumerable + + MAX_DEPTH = 10 + + def initialize(jwt) + raise ArgumentError, 'Provided JWT must be a String' unless jwt.is_a?(String) + + @tokens = unwrap(jwt) + end + + def each(&block) + @tokens.each(&block) + end + + def last + @tokens.last + end + + # Verifies signatures at each nesting level and claims on the innermost token. + # + # @param keys [Array] key configurations ordered outermost to innermost. + # Each hash should contain :algorithm and :key (or :key_finder). + # @param claims [Array, Hash, nil] claim verification options for the innermost token. + # @return [self] + # @raise [JWT::DecodeError] if key count doesn't match nesting depth. + # @raise [JWT::VerificationError] if any signature verification fails. + def verify!(keys:, claims: nil) + raise JWT::DecodeError, "Expected #{count} key configurations, got #{keys.length}" unless keys.length == count + + each_with_index do |token, index| + token.verify_signature!(algorithm: keys[index][:algorithm], key: keys[index][:key]) + end + + last.verify_claims!(*Array(claims).compact) + self + end + + private + + def unwrap(jwt) + tokens = [] + current = jwt + + loop do + raise JWT::DecodeError, "Nested JWT exceeds maximum depth of #{MAX_DEPTH}" if tokens.length >= MAX_DEPTH + + token = EncodedToken.new(current) + tokens << token + break unless token.header['cty']&.upcase == 'JWT' + + current = ::JWT::Base64.url_decode(token.encoded_payload) + end + + tokens + end + end +end diff --git a/lib/jwt/nested_token.rb b/lib/jwt/nested_token.rb new file mode 100644 index 00000000..61b8b22b --- /dev/null +++ b/lib/jwt/nested_token.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module JWT + # Represents a Nested JWT for creation, as defined in RFC 7519 Section 5.2. + # + # A Nested JWT wraps an existing JWT string as the payload of another signed JWT. + # The payload is base64url-encoded directly (not JSON-encoded). + # + # @example Creating a Nested JWT + # inner_jwt = JWT.encode({ user_id: 123 }, 'inner_secret', 'HS256') + # nested = JWT::NestedToken.new(inner_jwt) + # nested.sign!(algorithm: 'RS256', key: rsa_private_key) + # nested.jwt + # + # @example Multi-level nesting + # deeper = JWT::NestedToken.new(nested.jwt) + # deeper.sign!(algorithm: 'HS384', key: another_key) + # deeper.jwt + # + # @see https://datatracker.ietf.org/doc/html/rfc7519#section-5.2 RFC 7519 Section 5.2 + class NestedToken < Token + def initialize(inner_jwt) + super(payload: inner_jwt, header: { 'cty' => 'JWT' }) + end + + # Override to skip JSON encoding — payload is already a raw JWT string. + def encoded_payload + @encoded_payload ||= ::JWT::Base64.url_encode(payload) + end + end +end diff --git a/spec/jwt/encoded_nested_token_spec.rb b/spec/jwt/encoded_nested_token_spec.rb new file mode 100644 index 00000000..aefa8320 --- /dev/null +++ b/spec/jwt/encoded_nested_token_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +RSpec.describe JWT::EncodedNestedToken do + let(:inner_secret) { 'inner_secret_key' } + let(:outer_secret) { 'outer_secret_key' } + let(:inner_payload) { { 'user_id' => 123, 'role' => 'admin' } } + + def create_signed_jwt(payload: inner_payload, algorithm: 'HS256', key: inner_secret) + token = JWT::Token.new(payload: payload) + token.sign!(algorithm: algorithm, key: key) + token.jwt + end + + def create_nested(inner, algorithm:, key:) + JWT::NestedToken.new(inner).tap { |n| n.sign!(algorithm: algorithm, key: key) }.jwt + end + + let(:inner_jwt) { create_signed_jwt } + + describe 'Enumerable interface' do + let(:nested_jwt) { create_nested(inner_jwt, algorithm: 'HS256', key: outer_secret) } + + it 'has the correct number of tokens' do + nested = described_class.new(nested_jwt) + expect(nested.count).to eq(2) + end + + it 'orders tokens from outermost to innermost' do + nested = described_class.new(nested_jwt) + headers = nested.map(&:header) + + expect(headers.first['cty']).to eq('JWT') + expect(headers.last).not_to have_key('cty') + end + + it 'returns a single token for a non-nested JWT' do + nested = described_class.new(inner_jwt) + expect(nested.count).to eq(1) + end + + it 'supports three nesting levels' do + level2 = create_nested(inner_jwt, algorithm: 'HS256', key: 'key2') + level3 = create_nested(level2, algorithm: 'HS384', key: 'key3') + + nested = described_class.new(level3) + expect(nested.count).to eq(3) + + algorithms = nested.map { |t| t.header['alg'] } + expect(algorithms).to eq(%w[HS384 HS256 HS256]) + end + end + + describe '#last' do + it 'returns the innermost token' do + nested_jwt = create_nested(inner_jwt, algorithm: 'HS256', key: outer_secret) + nested = described_class.new(nested_jwt) + + expect(nested.last.unverified_payload).to eq(inner_payload) + end + end + + describe '#verify!' do + let(:nested_jwt) { create_nested(inner_jwt, algorithm: 'HS256', key: outer_secret) } + + it 'verifies signatures and returns self' do + nested = described_class.new(nested_jwt) + result = nested.verify!( + keys: [ + { algorithm: 'HS256', key: outer_secret }, + { algorithm: 'HS256', key: inner_secret } + ] + ) + + expect(result).to eq(nested) + end + + it 'allows accessing innermost payload after verification' do + nested = described_class.new(nested_jwt) + nested.verify!( + keys: [ + { algorithm: 'HS256', key: outer_secret }, + { algorithm: 'HS256', key: inner_secret } + ] + ) + + expect(nested.last.payload).to eq(inner_payload) + end + + it 'raises VerificationError for invalid outer signature' do + nested = described_class.new(nested_jwt) + + expect do + nested.verify!( + keys: [ + { algorithm: 'HS256', key: 'wrong_key' }, + { algorithm: 'HS256', key: inner_secret } + ] + ) + end.to raise_error(JWT::VerificationError, 'Signature verification failed') + end + + it 'raises VerificationError for invalid inner signature' do + nested = described_class.new(nested_jwt) + + expect do + nested.verify!( + keys: [ + { algorithm: 'HS256', key: outer_secret }, + { algorithm: 'HS256', key: 'wrong_key' } + ] + ) + end.to raise_error(JWT::VerificationError, 'Signature verification failed') + end + + it 'raises DecodeError when key count does not match nesting depth' do + nested = described_class.new(nested_jwt) + + expect do + nested.verify!(keys: [{ algorithm: 'HS256', key: outer_secret }]) + end.to raise_error(JWT::DecodeError, 'Expected 2 key configurations, got 1') + end + + it 'handles case-insensitive cty header' do + signer = JWT::JWA.create_signer(algorithm: 'HS256', key: outer_secret) + header = { 'cty' => 'jwt' }.merge(signer.jwa.header) { |_k, old, _new| old } + encoded_header = JWT::Base64.url_encode(JWT::JSON.generate(header)) + encoded_payload = JWT::Base64.url_encode(inner_jwt) + signature = signer.sign(data: "#{encoded_header}.#{encoded_payload}") + lowercase_nested = "#{encoded_header}.#{encoded_payload}.#{JWT::Base64.url_encode(signature)}" + + nested = described_class.new(lowercase_nested) + nested.verify!( + keys: [ + { algorithm: 'HS256', key: outer_secret }, + { algorithm: 'HS256', key: inner_secret } + ] + ) + + expect(nested.last.payload).to eq(inner_payload) + end + + context 'with different algorithms at each level' do + let(:rsa_private) { test_pkey('rsa-2048-private.pem') } + let(:rsa_public) { rsa_private.public_key } + + it 'supports HS256 inner with RS256 outer' do + nested_jwt = create_nested(inner_jwt, algorithm: 'RS256', key: rsa_private) + nested = described_class.new(nested_jwt) + + nested.verify!( + keys: [ + { algorithm: 'RS256', key: rsa_public }, + { algorithm: 'HS256', key: inner_secret } + ] + ) + + expect(nested.last.payload).to eq(inner_payload) + end + + it 'supports RS256 inner with HS256 outer' do + rsa_inner_jwt = create_signed_jwt(algorithm: 'RS256', key: rsa_private) + nested_jwt = create_nested(rsa_inner_jwt, algorithm: 'HS256', key: outer_secret) + nested = described_class.new(nested_jwt) + + nested.verify!( + keys: [ + { algorithm: 'HS256', key: outer_secret }, + { algorithm: 'RS256', key: rsa_public } + ] + ) + + expect(nested.last.payload).to eq(inner_payload) + end + end + + context 'with multiple nesting levels' do + it 'verifies all levels' do + level2 = create_nested(inner_jwt, algorithm: 'HS384', key: 'key2') + level3 = create_nested(level2, algorithm: 'HS512', key: 'key3') + + nested = described_class.new(level3) + nested.verify!( + keys: [ + { algorithm: 'HS512', key: 'key3' }, + { algorithm: 'HS384', key: 'key2' }, + { algorithm: 'HS256', key: inner_secret } + ] + ) + + expect(nested.last.payload).to eq(inner_payload) + end + end + end + + describe 'max depth protection' do + it 'raises DecodeError when nesting exceeds MAX_DEPTH' do + current = inner_jwt + (described_class::MAX_DEPTH + 1).times do |i| + current = create_nested(current, algorithm: 'HS256', key: "key_#{i}") + end + + expect do + described_class.new(current) + end.to raise_error(JWT::DecodeError, /exceeds maximum depth/) + end + end +end diff --git a/spec/jwt/nested_token_spec.rb b/spec/jwt/nested_token_spec.rb new file mode 100644 index 00000000..fc5dc262 --- /dev/null +++ b/spec/jwt/nested_token_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +RSpec.describe JWT::NestedToken do + let(:inner_secret) { 'inner_secret_key' } + let(:inner_payload) { { 'user_id' => 123, 'role' => 'admin' } } + + def create_inner_jwt(payload: inner_payload, algorithm: 'HS256', key: inner_secret) + token = JWT::Token.new(payload: payload) + token.sign!(algorithm: algorithm, key: key) + token.jwt + end + + describe '#sign!' do + it 'creates a nested JWT with cty header set to JWT' do + nested = described_class.new(create_inner_jwt) + nested.sign!(algorithm: 'HS256', key: 'outer_secret') + + outer = JWT::EncodedToken.new(nested.jwt) + expect(outer.header['cty']).to eq('JWT') + expect(outer.header['alg']).to eq('HS256') + end + + it 'base64url-encodes the inner JWT directly without JSON wrapping' do + inner_jwt = create_inner_jwt + nested = described_class.new(inner_jwt) + nested.sign!(algorithm: 'HS256', key: 'outer_secret') + + encoded_payload = nested.jwt.split('.')[1] + expect(JWT::Base64.url_decode(encoded_payload)).to eq(inner_jwt) + end + + it 'produces a verifiable signature' do + nested = described_class.new(create_inner_jwt) + nested.sign!(algorithm: 'HS256', key: 'outer_secret') + + outer = JWT::EncodedToken.new(nested.jwt) + expect { outer.verify_signature!(algorithm: 'HS256', key: 'outer_secret') }.not_to raise_error + end + + it 'allows additional header fields' do + nested = described_class.new(create_inner_jwt) + nested.header['kid'] = 'my-key-id' + nested.sign!(algorithm: 'HS256', key: 'outer_secret') + + outer = JWT::EncodedToken.new(nested.jwt) + expect(outer.header['kid']).to eq('my-key-id') + expect(outer.header['cty']).to eq('JWT') + end + + context 'with RSA algorithm' do + let(:rsa_private) { test_pkey('rsa-2048-private.pem') } + let(:rsa_public) { rsa_private.public_key } + + it 'creates a nested JWT signed with RSA' do + nested = described_class.new(create_inner_jwt) + nested.sign!(algorithm: 'RS256', key: rsa_private) + + outer = JWT::EncodedToken.new(nested.jwt) + expect(outer.header['alg']).to eq('RS256') + expect(outer.header['cty']).to eq('JWT') + + expect { outer.verify_signature!(algorithm: 'RS256', key: rsa_public) }.not_to raise_error + end + end + end + + describe 'multi-level nesting' do + it 'supports wrapping a nested JWT again' do + level1 = described_class.new(create_inner_jwt) + level1.sign!(algorithm: 'HS256', key: 'key1') + + level2 = described_class.new(level1.jwt) + level2.sign!(algorithm: 'HS384', key: 'key2') + + outer = JWT::EncodedToken.new(level2.jwt) + expect(outer.header['alg']).to eq('HS384') + expect(outer.header['cty']).to eq('JWT') + end + end +end From c9cb9a569504e6c5ecef7cb42ca40655ceb74824 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Fri, 20 Mar 2026 21:18:43 +0200 Subject: [PATCH 2/3] Comments somewhat addressed --- lib/jwt/encoded_nested_token.rb | 36 ++++----- lib/jwt/nested_token.rb | 6 +- spec/jwt/encoded_nested_token_spec.rb | 103 ++++++++++---------------- 3 files changed, 62 insertions(+), 83 deletions(-) diff --git a/lib/jwt/encoded_nested_token.rb b/lib/jwt/encoded_nested_token.rb index 5efc242e..e74b91ae 100644 --- a/lib/jwt/encoded_nested_token.rb +++ b/lib/jwt/encoded_nested_token.rb @@ -8,12 +8,7 @@ module JWT # # @example Verifying a Nested JWT # nested = JWT::EncodedNestedToken.new(nested_jwt_string) - # nested.verify!( - # keys: [ - # { algorithm: 'RS256', key: rsa_public }, - # { algorithm: 'HS256', key: 'inner_secret' } - # ] - # ) + # nested.verify!(algorithm: ['RS256', 'HS256'], key: [rsa_public, 'inner_secret']) # nested.last.payload # => { 'user_id' => 123 } # # @example Inspecting layers @@ -27,9 +22,12 @@ class EncodedNestedToken MAX_DEPTH = 10 - def initialize(jwt) + # @param jwt [String] the encoded JWT string. + # @param max_depth [Integer] maximum nesting depth allowed. + def initialize(jwt, max_depth: MAX_DEPTH) raise ArgumentError, 'Provided JWT must be a String' unless jwt.is_a?(String) + @max_depth = max_depth @tokens = unwrap(jwt) end @@ -43,20 +41,24 @@ def last # Verifies signatures at each nesting level and claims on the innermost token. # - # @param keys [Array] key configurations ordered outermost to innermost. - # Each hash should contain :algorithm and :key (or :key_finder). + # Each token layer tries all provided algorithms and keys to find a match, + # following the same pattern as {EncodedToken#verify_signature!}. + # + # Only the innermost token carries JSON claims (exp, iss, etc.). + # Outer tokens' payloads are raw JWT strings, not JSON objects with claims. + # + # @param algorithm [String, Array, Object, Array] the algorithm(s) to verify with. + # @param key [String, Array, JWT::JWK::KeyBase, Array] the key(s) to verify with. + # @param key_finder [#call] an object responding to `call` to find the key for verification. # @param claims [Array, Hash, nil] claim verification options for the innermost token. # @return [self] - # @raise [JWT::DecodeError] if key count doesn't match nesting depth. # @raise [JWT::VerificationError] if any signature verification fails. - def verify!(keys:, claims: nil) - raise JWT::DecodeError, "Expected #{count} key configurations, got #{keys.length}" unless keys.length == count - - each_with_index do |token, index| - token.verify_signature!(algorithm: keys[index][:algorithm], key: keys[index][:key]) + def verify!(algorithm:, key: nil, key_finder: nil, claims: nil) + each do |token| + token.verify_signature!(algorithm: algorithm, key: key, key_finder: key_finder) end - last.verify_claims!(*Array(claims).compact) + claims.is_a?(Array) ? last.verify_claims!(*claims) : last.verify_claims!(claims) self end @@ -67,7 +69,7 @@ def unwrap(jwt) current = jwt loop do - raise JWT::DecodeError, "Nested JWT exceeds maximum depth of #{MAX_DEPTH}" if tokens.length >= MAX_DEPTH + raise JWT::DecodeError, "Nested JWT exceeds maximum depth of #{@max_depth}" if tokens.length >= @max_depth token = EncodedToken.new(current) tokens << token diff --git a/lib/jwt/nested_token.rb b/lib/jwt/nested_token.rb index 61b8b22b..44a59640 100644 --- a/lib/jwt/nested_token.rb +++ b/lib/jwt/nested_token.rb @@ -7,8 +7,10 @@ module JWT # The payload is base64url-encoded directly (not JSON-encoded). # # @example Creating a Nested JWT - # inner_jwt = JWT.encode({ user_id: 123 }, 'inner_secret', 'HS256') - # nested = JWT::NestedToken.new(inner_jwt) + # inner = JWT::Token.new(payload: { user_id: 123 }) + # inner.sign!(algorithm: 'HS256', key: 'inner_secret') + # + # nested = JWT::NestedToken.new(inner.jwt) # nested.sign!(algorithm: 'RS256', key: rsa_private_key) # nested.jwt # diff --git a/spec/jwt/encoded_nested_token_spec.rb b/spec/jwt/encoded_nested_token_spec.rb index aefa8320..658a4bad 100644 --- a/spec/jwt/encoded_nested_token_spec.rb +++ b/spec/jwt/encoded_nested_token_spec.rb @@ -64,60 +64,42 @@ def create_nested(inner, algorithm:, key:) it 'verifies signatures and returns self' do nested = described_class.new(nested_jwt) - result = nested.verify!( - keys: [ - { algorithm: 'HS256', key: outer_secret }, - { algorithm: 'HS256', key: inner_secret } - ] - ) + result = nested.verify!(algorithm: 'HS256', key: [outer_secret, inner_secret]) expect(result).to eq(nested) end it 'allows accessing innermost payload after verification' do nested = described_class.new(nested_jwt) - nested.verify!( - keys: [ - { algorithm: 'HS256', key: outer_secret }, - { algorithm: 'HS256', key: inner_secret } - ] - ) + nested.verify!(algorithm: 'HS256', key: [outer_secret, inner_secret]) expect(nested.last.payload).to eq(inner_payload) end - it 'raises VerificationError for invalid outer signature' do + it 'verifies claims on the innermost token' do nested = described_class.new(nested_jwt) + nested.verify!(algorithm: 'HS256', key: [outer_secret, inner_secret]) - expect do - nested.verify!( - keys: [ - { algorithm: 'HS256', key: 'wrong_key' }, - { algorithm: 'HS256', key: inner_secret } - ] - ) - end.to raise_error(JWT::VerificationError, 'Signature verification failed') + expect { nested.last.payload }.not_to raise_error end - it 'raises VerificationError for invalid inner signature' do + it 'raises VerificationError when no key matches' do nested = described_class.new(nested_jwt) expect do - nested.verify!( - keys: [ - { algorithm: 'HS256', key: outer_secret }, - { algorithm: 'HS256', key: 'wrong_key' } - ] - ) + nested.verify!(algorithm: 'HS256', key: 'wrong_key') end.to raise_error(JWT::VerificationError, 'Signature verification failed') end - it 'raises DecodeError when key count does not match nesting depth' do + it 'works with a single shared key for all levels' do + shared_key = 'shared_secret' + inner = create_signed_jwt(key: shared_key) + nested_jwt = create_nested(inner, algorithm: 'HS256', key: shared_key) + nested = described_class.new(nested_jwt) + nested.verify!(algorithm: 'HS256', key: shared_key) - expect do - nested.verify!(keys: [{ algorithm: 'HS256', key: outer_secret }]) - end.to raise_error(JWT::DecodeError, 'Expected 2 key configurations, got 1') + expect(nested.last.payload).to eq(inner_payload) end it 'handles case-insensitive cty header' do @@ -129,12 +111,7 @@ def create_nested(inner, algorithm:, key:) lowercase_nested = "#{encoded_header}.#{encoded_payload}.#{JWT::Base64.url_encode(signature)}" nested = described_class.new(lowercase_nested) - nested.verify!( - keys: [ - { algorithm: 'HS256', key: outer_secret }, - { algorithm: 'HS256', key: inner_secret } - ] - ) + nested.verify!(algorithm: 'HS256', key: [outer_secret, inner_secret]) expect(nested.last.payload).to eq(inner_payload) end @@ -143,30 +120,18 @@ def create_nested(inner, algorithm:, key:) let(:rsa_private) { test_pkey('rsa-2048-private.pem') } let(:rsa_public) { rsa_private.public_key } - it 'supports HS256 inner with RS256 outer' do + it 'supports mixed algorithms via key_finder' do nested_jwt = create_nested(inner_jwt, algorithm: 'RS256', key: rsa_private) - nested = described_class.new(nested_jwt) - nested.verify!( - keys: [ - { algorithm: 'RS256', key: rsa_public }, - { algorithm: 'HS256', key: inner_secret } - ] - ) + key_map = { + 'RS256' => rsa_public, + 'HS256' => inner_secret + } - expect(nested.last.payload).to eq(inner_payload) - end - - it 'supports RS256 inner with HS256 outer' do - rsa_inner_jwt = create_signed_jwt(algorithm: 'RS256', key: rsa_private) - nested_jwt = create_nested(rsa_inner_jwt, algorithm: 'HS256', key: outer_secret) nested = described_class.new(nested_jwt) - nested.verify!( - keys: [ - { algorithm: 'HS256', key: outer_secret }, - { algorithm: 'RS256', key: rsa_public } - ] + algorithm: %w[RS256 HS256], + key_finder: ->(token) { key_map[token.header['alg']] } ) expect(nested.last.payload).to eq(inner_payload) @@ -174,17 +139,14 @@ def create_nested(inner, algorithm:, key:) end context 'with multiple nesting levels' do - it 'verifies all levels' do + it 'verifies all levels with same algorithm family' do level2 = create_nested(inner_jwt, algorithm: 'HS384', key: 'key2') level3 = create_nested(level2, algorithm: 'HS512', key: 'key3') nested = described_class.new(level3) nested.verify!( - keys: [ - { algorithm: 'HS512', key: 'key3' }, - { algorithm: 'HS384', key: 'key2' }, - { algorithm: 'HS256', key: inner_secret } - ] + algorithm: %w[HS512 HS384 HS256], + key: ['key3', 'key2', inner_secret] ) expect(nested.last.payload).to eq(inner_payload) @@ -192,7 +154,7 @@ def create_nested(inner, algorithm:, key:) end end - describe 'max depth protection' do + describe 'max_depth' do it 'raises DecodeError when nesting exceeds MAX_DEPTH' do current = inner_jwt (described_class::MAX_DEPTH + 1).times do |i| @@ -203,5 +165,18 @@ def create_nested(inner, algorithm:, key:) described_class.new(current) end.to raise_error(JWT::DecodeError, /exceeds maximum depth/) end + + it 'allows overriding max_depth via constructor' do + level2 = create_nested(inner_jwt, algorithm: 'HS256', key: 'key2') + level3 = create_nested(level2, algorithm: 'HS256', key: 'key3') + + expect do + described_class.new(level3, max_depth: 2) + end.to raise_error(JWT::DecodeError, 'Nested JWT exceeds maximum depth of 2') + + expect do + described_class.new(level3, max_depth: 5) + end.not_to raise_error + end end end From d71b8dd75024b8767466babfe9d642fc2871987b Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Fri, 20 Mar 2026 21:28:36 +0200 Subject: [PATCH 3/3] More comments addressed --- lib/jwt/encoded_nested_token.rb | 29 ++++++++++++++++++++++----- spec/jwt/encoded_nested_token_spec.rb | 19 +++++++++++++----- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/lib/jwt/encoded_nested_token.rb b/lib/jwt/encoded_nested_token.rb index e74b91ae..991aa170 100644 --- a/lib/jwt/encoded_nested_token.rb +++ b/lib/jwt/encoded_nested_token.rb @@ -6,9 +6,17 @@ module JWT # Unwraps all nesting levels and provides an Enumerable interface over the token layers # (outermost to innermost). # - # @example Verifying a Nested JWT + # @example Verifying a Nested JWT with a shared algorithm # nested = JWT::EncodedNestedToken.new(nested_jwt_string) - # nested.verify!(algorithm: ['RS256', 'HS256'], key: [rsa_public, 'inner_secret']) + # nested.verify!(algorithm: 'HS256', key: [outer_secret, inner_secret]) + # nested.last.payload # => { 'user_id' => 123 } + # + # @example Verifying with mixed algorithms using key_finder + # nested = JWT::EncodedNestedToken.new(nested_jwt_string) + # nested.verify!( + # algorithm: %w[RS256 HS256], + # key_finder: ->(token) { key_map[token.header['alg']] } + # ) # nested.last.payload # => { 'user_id' => 123 } # # @example Inspecting layers @@ -27,16 +35,22 @@ class EncodedNestedToken def initialize(jwt, max_depth: MAX_DEPTH) raise ArgumentError, 'Provided JWT must be a String' unless jwt.is_a?(String) + @jwt = jwt @max_depth = max_depth - @tokens = unwrap(jwt) + @verified = false end def each(&block) - @tokens.each(&block) + tokens.each(&block) end + # Returns the innermost token. Requires {#verify!} to have been called first. + # @return [JWT::EncodedToken] the innermost token. + # @raise [JWT::DecodeError] if the token has not been verified. def last - @tokens.last + raise JWT::DecodeError, 'Verify the token before accessing the innermost token' unless @verified + + tokens.last end # Verifies signatures at each nesting level and claims on the innermost token. @@ -58,12 +72,17 @@ def verify!(algorithm:, key: nil, key_finder: nil, claims: nil) token.verify_signature!(algorithm: algorithm, key: key, key_finder: key_finder) end + @verified = true claims.is_a?(Array) ? last.verify_claims!(*claims) : last.verify_claims!(claims) self end private + def tokens + @tokens ||= unwrap(@jwt) + end + def unwrap(jwt) tokens = [] current = jwt diff --git a/spec/jwt/encoded_nested_token_spec.rb b/spec/jwt/encoded_nested_token_spec.rb index 658a4bad..f3b556a4 100644 --- a/spec/jwt/encoded_nested_token_spec.rb +++ b/spec/jwt/encoded_nested_token_spec.rb @@ -51,11 +51,19 @@ def create_nested(inner, algorithm:, key:) end describe '#last' do - it 'returns the innermost token' do + it 'raises DecodeError before verification' do nested_jwt = create_nested(inner_jwt, algorithm: 'HS256', key: outer_secret) nested = described_class.new(nested_jwt) - expect(nested.last.unverified_payload).to eq(inner_payload) + expect { nested.last }.to raise_error(JWT::DecodeError, /Verify the token before/) + end + + it 'returns the innermost token after verification' do + nested_jwt = create_nested(inner_jwt, algorithm: 'HS256', key: outer_secret) + nested = described_class.new(nested_jwt) + nested.verify!(algorithm: 'HS256', key: [outer_secret, inner_secret]) + + expect(nested.last.payload).to eq(inner_payload) end end @@ -161,8 +169,9 @@ def create_nested(inner, algorithm:, key:) current = create_nested(current, algorithm: 'HS256', key: "key_#{i}") end + nested = described_class.new(current) expect do - described_class.new(current) + nested.count end.to raise_error(JWT::DecodeError, /exceeds maximum depth/) end @@ -171,11 +180,11 @@ def create_nested(inner, algorithm:, key:) level3 = create_nested(level2, algorithm: 'HS256', key: 'key3') expect do - described_class.new(level3, max_depth: 2) + described_class.new(level3, max_depth: 2).count end.to raise_error(JWT::DecodeError, 'Nested JWT exceeds maximum depth of 2') expect do - described_class.new(level3, max_depth: 5) + described_class.new(level3, max_depth: 5).count end.not_to raise_error end end