Skip to content

Commit e96ed6f

Browse files
committed
Nested token Friday night PoC
1 parent ffef4f2 commit e96ed6f

5 files changed

Lines changed: 402 additions & 0 deletions

File tree

lib/jwt.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
require 'jwt/claims'
1212
require 'jwt/encoded_token'
1313
require 'jwt/token'
14+
require 'jwt/nested_token'
15+
require 'jwt/encoded_nested_token'
1416

1517
# JSON Web Token implementation
1618
#

lib/jwt/encoded_nested_token.rb

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# frozen_string_literal: true
2+
3+
module JWT
4+
# Represents an encoded Nested JWT for verification, as defined in RFC 7519 Section 5.2.
5+
#
6+
# Unwraps all nesting levels and provides an Enumerable interface over the token layers
7+
# (outermost to innermost).
8+
#
9+
# @example Verifying a Nested JWT
10+
# nested = JWT::EncodedNestedToken.new(nested_jwt_string)
11+
# nested.verify!(
12+
# keys: [
13+
# { algorithm: 'RS256', key: rsa_public },
14+
# { algorithm: 'HS256', key: 'inner_secret' }
15+
# ]
16+
# )
17+
# nested.last.payload # => { 'user_id' => 123 }
18+
#
19+
# @example Inspecting layers
20+
# nested = JWT::EncodedNestedToken.new(nested_jwt_string)
21+
# nested.count # => 2
22+
# nested.map(&:header) # => [outer_header, inner_header]
23+
#
24+
# @see https://datatracker.ietf.org/doc/html/rfc7519#section-5.2 RFC 7519 Section 5.2
25+
class EncodedNestedToken
26+
include Enumerable
27+
28+
MAX_DEPTH = 10
29+
30+
def initialize(jwt)
31+
raise ArgumentError, 'Provided JWT must be a String' unless jwt.is_a?(String)
32+
33+
@tokens = unwrap(jwt)
34+
end
35+
36+
def each(&block)
37+
@tokens.each(&block)
38+
end
39+
40+
def last
41+
@tokens.last
42+
end
43+
44+
# Verifies signatures at each nesting level and claims on the innermost token.
45+
#
46+
# @param keys [Array<Hash>] key configurations ordered outermost to innermost.
47+
# Each hash should contain :algorithm and :key (or :key_finder).
48+
# @param claims [Array<Symbol>, Hash, nil] claim verification options for the innermost token.
49+
# @return [self]
50+
# @raise [JWT::DecodeError] if key count doesn't match nesting depth.
51+
# @raise [JWT::VerificationError] if any signature verification fails.
52+
def verify!(keys:, claims: nil)
53+
raise JWT::DecodeError, "Expected #{count} key configurations, got #{keys.length}" unless keys.length == count
54+
55+
each_with_index do |token, index|
56+
token.verify_signature!(algorithm: keys[index][:algorithm], key: keys[index][:key])
57+
end
58+
59+
last.verify_claims!(*Array(claims).compact)
60+
self
61+
end
62+
63+
private
64+
65+
def unwrap(jwt)
66+
tokens = []
67+
current = jwt
68+
69+
loop do
70+
raise JWT::DecodeError, "Nested JWT exceeds maximum depth of #{MAX_DEPTH}" if tokens.length >= MAX_DEPTH
71+
72+
token = EncodedToken.new(current)
73+
tokens << token
74+
break unless token.header['cty']&.upcase == 'JWT'
75+
76+
current = ::JWT::Base64.url_decode(token.encoded_payload)
77+
end
78+
79+
tokens
80+
end
81+
end
82+
end

lib/jwt/nested_token.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
module JWT
4+
# Represents a Nested JWT for creation, as defined in RFC 7519 Section 5.2.
5+
#
6+
# A Nested JWT wraps an existing JWT string as the payload of another signed JWT.
7+
# The payload is base64url-encoded directly (not JSON-encoded).
8+
#
9+
# @example Creating a Nested JWT
10+
# inner_jwt = JWT.encode({ user_id: 123 }, 'inner_secret', 'HS256')
11+
# nested = JWT::NestedToken.new(inner_jwt)
12+
# nested.sign!(algorithm: 'RS256', key: rsa_private_key)
13+
# nested.jwt
14+
#
15+
# @example Multi-level nesting
16+
# deeper = JWT::NestedToken.new(nested.jwt)
17+
# deeper.sign!(algorithm: 'HS384', key: another_key)
18+
# deeper.jwt
19+
#
20+
# @see https://datatracker.ietf.org/doc/html/rfc7519#section-5.2 RFC 7519 Section 5.2
21+
class NestedToken < Token
22+
def initialize(inner_jwt)
23+
super(payload: inner_jwt, header: { 'cty' => 'JWT' })
24+
end
25+
26+
# Override to skip JSON encoding — payload is already a raw JWT string.
27+
def encoded_payload
28+
@encoded_payload ||= ::JWT::Base64.url_encode(payload)
29+
end
30+
end
31+
end
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe JWT::EncodedNestedToken do
4+
let(:inner_secret) { 'inner_secret_key' }
5+
let(:outer_secret) { 'outer_secret_key' }
6+
let(:inner_payload) { { 'user_id' => 123, 'role' => 'admin' } }
7+
8+
def create_signed_jwt(payload: inner_payload, algorithm: 'HS256', key: inner_secret)
9+
token = JWT::Token.new(payload: payload)
10+
token.sign!(algorithm: algorithm, key: key)
11+
token.jwt
12+
end
13+
14+
def create_nested(inner, algorithm:, key:)
15+
JWT::NestedToken.new(inner).tap { |n| n.sign!(algorithm: algorithm, key: key) }.jwt
16+
end
17+
18+
let(:inner_jwt) { create_signed_jwt }
19+
20+
describe 'Enumerable interface' do
21+
let(:nested_jwt) { create_nested(inner_jwt, algorithm: 'HS256', key: outer_secret) }
22+
23+
it 'has the correct number of tokens' do
24+
nested = described_class.new(nested_jwt)
25+
expect(nested.count).to eq(2)
26+
end
27+
28+
it 'orders tokens from outermost to innermost' do
29+
nested = described_class.new(nested_jwt)
30+
headers = nested.map(&:header)
31+
32+
expect(headers.first['cty']).to eq('JWT')
33+
expect(headers.last).not_to have_key('cty')
34+
end
35+
36+
it 'returns a single token for a non-nested JWT' do
37+
nested = described_class.new(inner_jwt)
38+
expect(nested.count).to eq(1)
39+
end
40+
41+
it 'supports three nesting levels' do
42+
level2 = create_nested(inner_jwt, algorithm: 'HS256', key: 'key2')
43+
level3 = create_nested(level2, algorithm: 'HS384', key: 'key3')
44+
45+
nested = described_class.new(level3)
46+
expect(nested.count).to eq(3)
47+
48+
algorithms = nested.map { |t| t.header['alg'] }
49+
expect(algorithms).to eq(%w[HS384 HS256 HS256])
50+
end
51+
end
52+
53+
describe '#last' do
54+
it 'returns the innermost token' do
55+
nested_jwt = create_nested(inner_jwt, algorithm: 'HS256', key: outer_secret)
56+
nested = described_class.new(nested_jwt)
57+
58+
expect(nested.last.unverified_payload).to eq(inner_payload)
59+
end
60+
end
61+
62+
describe '#verify!' do
63+
let(:nested_jwt) { create_nested(inner_jwt, algorithm: 'HS256', key: outer_secret) }
64+
65+
it 'verifies signatures and returns self' do
66+
nested = described_class.new(nested_jwt)
67+
result = nested.verify!(
68+
keys: [
69+
{ algorithm: 'HS256', key: outer_secret },
70+
{ algorithm: 'HS256', key: inner_secret }
71+
]
72+
)
73+
74+
expect(result).to eq(nested)
75+
end
76+
77+
it 'allows accessing innermost payload after verification' do
78+
nested = described_class.new(nested_jwt)
79+
nested.verify!(
80+
keys: [
81+
{ algorithm: 'HS256', key: outer_secret },
82+
{ algorithm: 'HS256', key: inner_secret }
83+
]
84+
)
85+
86+
expect(nested.last.payload).to eq(inner_payload)
87+
end
88+
89+
it 'raises VerificationError for invalid outer signature' do
90+
nested = described_class.new(nested_jwt)
91+
92+
expect do
93+
nested.verify!(
94+
keys: [
95+
{ algorithm: 'HS256', key: 'wrong_key' },
96+
{ algorithm: 'HS256', key: inner_secret }
97+
]
98+
)
99+
end.to raise_error(JWT::VerificationError, 'Signature verification failed')
100+
end
101+
102+
it 'raises VerificationError for invalid inner signature' do
103+
nested = described_class.new(nested_jwt)
104+
105+
expect do
106+
nested.verify!(
107+
keys: [
108+
{ algorithm: 'HS256', key: outer_secret },
109+
{ algorithm: 'HS256', key: 'wrong_key' }
110+
]
111+
)
112+
end.to raise_error(JWT::VerificationError, 'Signature verification failed')
113+
end
114+
115+
it 'raises DecodeError when key count does not match nesting depth' do
116+
nested = described_class.new(nested_jwt)
117+
118+
expect do
119+
nested.verify!(keys: [{ algorithm: 'HS256', key: outer_secret }])
120+
end.to raise_error(JWT::DecodeError, 'Expected 2 key configurations, got 1')
121+
end
122+
123+
it 'handles case-insensitive cty header' do
124+
signer = JWT::JWA.create_signer(algorithm: 'HS256', key: outer_secret)
125+
header = { 'cty' => 'jwt' }.merge(signer.jwa.header) { |_k, old, _new| old }
126+
encoded_header = JWT::Base64.url_encode(JWT::JSON.generate(header))
127+
encoded_payload = JWT::Base64.url_encode(inner_jwt)
128+
signature = signer.sign(data: "#{encoded_header}.#{encoded_payload}")
129+
lowercase_nested = "#{encoded_header}.#{encoded_payload}.#{JWT::Base64.url_encode(signature)}"
130+
131+
nested = described_class.new(lowercase_nested)
132+
nested.verify!(
133+
keys: [
134+
{ algorithm: 'HS256', key: outer_secret },
135+
{ algorithm: 'HS256', key: inner_secret }
136+
]
137+
)
138+
139+
expect(nested.last.payload).to eq(inner_payload)
140+
end
141+
142+
context 'with different algorithms at each level' do
143+
let(:rsa_private) { test_pkey('rsa-2048-private.pem') }
144+
let(:rsa_public) { rsa_private.public_key }
145+
146+
it 'supports HS256 inner with RS256 outer' do
147+
nested_jwt = create_nested(inner_jwt, algorithm: 'RS256', key: rsa_private)
148+
nested = described_class.new(nested_jwt)
149+
150+
nested.verify!(
151+
keys: [
152+
{ algorithm: 'RS256', key: rsa_public },
153+
{ algorithm: 'HS256', key: inner_secret }
154+
]
155+
)
156+
157+
expect(nested.last.payload).to eq(inner_payload)
158+
end
159+
160+
it 'supports RS256 inner with HS256 outer' do
161+
rsa_inner_jwt = create_signed_jwt(algorithm: 'RS256', key: rsa_private)
162+
nested_jwt = create_nested(rsa_inner_jwt, algorithm: 'HS256', key: outer_secret)
163+
nested = described_class.new(nested_jwt)
164+
165+
nested.verify!(
166+
keys: [
167+
{ algorithm: 'HS256', key: outer_secret },
168+
{ algorithm: 'RS256', key: rsa_public }
169+
]
170+
)
171+
172+
expect(nested.last.payload).to eq(inner_payload)
173+
end
174+
end
175+
176+
context 'with multiple nesting levels' do
177+
it 'verifies all levels' do
178+
level2 = create_nested(inner_jwt, algorithm: 'HS384', key: 'key2')
179+
level3 = create_nested(level2, algorithm: 'HS512', key: 'key3')
180+
181+
nested = described_class.new(level3)
182+
nested.verify!(
183+
keys: [
184+
{ algorithm: 'HS512', key: 'key3' },
185+
{ algorithm: 'HS384', key: 'key2' },
186+
{ algorithm: 'HS256', key: inner_secret }
187+
]
188+
)
189+
190+
expect(nested.last.payload).to eq(inner_payload)
191+
end
192+
end
193+
end
194+
195+
describe 'max depth protection' do
196+
it 'raises DecodeError when nesting exceeds MAX_DEPTH' do
197+
current = inner_jwt
198+
(described_class::MAX_DEPTH + 1).times do |i|
199+
current = create_nested(current, algorithm: 'HS256', key: "key_#{i}")
200+
end
201+
202+
expect do
203+
described_class.new(current)
204+
end.to raise_error(JWT::DecodeError, /exceeds maximum depth/)
205+
end
206+
end
207+
end

0 commit comments

Comments
 (0)