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..991aa170 --- /dev/null +++ b/lib/jwt/encoded_nested_token.rb @@ -0,0 +1,103 @@ +# 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 with a shared algorithm + # nested = JWT::EncodedNestedToken.new(nested_jwt_string) + # 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 + # 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 + + # @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) + + @jwt = jwt + @max_depth = max_depth + @verified = false + end + + def 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 + 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. + # + # 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::VerificationError] if any signature verification fails. + 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 + + @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 + + 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..44a59640 --- /dev/null +++ b/lib/jwt/nested_token.rb @@ -0,0 +1,33 @@ +# 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::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 + # + # @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..f3b556a4 --- /dev/null +++ b/spec/jwt/encoded_nested_token_spec.rb @@ -0,0 +1,191 @@ +# 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 '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 }.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 + + 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!(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!(algorithm: 'HS256', key: [outer_secret, inner_secret]) + + expect(nested.last.payload).to eq(inner_payload) + end + + it 'verifies claims on the innermost token' do + nested = described_class.new(nested_jwt) + nested.verify!(algorithm: 'HS256', key: [outer_secret, inner_secret]) + + expect { nested.last.payload }.not_to raise_error + end + + it 'raises VerificationError when no key matches' do + nested = described_class.new(nested_jwt) + + expect do + nested.verify!(algorithm: 'HS256', key: 'wrong_key') + end.to raise_error(JWT::VerificationError, 'Signature verification failed') + end + + 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(nested.last.payload).to eq(inner_payload) + 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!(algorithm: 'HS256', key: [outer_secret, 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 mixed algorithms via key_finder' do + nested_jwt = create_nested(inner_jwt, algorithm: 'RS256', key: rsa_private) + + key_map = { + 'RS256' => rsa_public, + 'HS256' => inner_secret + } + + nested = described_class.new(nested_jwt) + nested.verify!( + algorithm: %w[RS256 HS256], + key_finder: ->(token) { key_map[token.header['alg']] } + ) + + expect(nested.last.payload).to eq(inner_payload) + end + end + + context 'with multiple nesting 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!( + algorithm: %w[HS512 HS384 HS256], + key: ['key3', 'key2', inner_secret] + ) + + expect(nested.last.payload).to eq(inner_payload) + end + end + end + + describe 'max_depth' 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 + + nested = described_class.new(current) + expect do + nested.count + 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).count + end.to raise_error(JWT::DecodeError, 'Nested JWT exceeds maximum depth of 2') + + expect do + described_class.new(level3, max_depth: 5).count + end.not_to raise_error + 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