Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/jwt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
103 changes: 103 additions & 0 deletions lib/jwt/encoded_nested_token.rb
Original file line number Diff line number Diff line change
@@ -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<String>, Object, Array<Object>] the algorithm(s) to verify with.
# @param key [String, Array<String>, JWT::JWK::KeyBase, Array<JWT::JWK::KeyBase>] the key(s) to verify with.
# @param key_finder [#call] an object responding to `call` to find the key for verification.
# @param claims [Array<Symbol>, 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
33 changes: 33 additions & 0 deletions lib/jwt/nested_token.rb
Original file line number Diff line number Diff line change
@@ -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
191 changes: 191 additions & 0 deletions spec/jwt/encoded_nested_token_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading