Skip to content

Commit 3850bd0

Browse files
committed
Refine nested JWT API and validation
1 parent 1ad7049 commit 3850bd0

6 files changed

Lines changed: 217 additions & 221 deletions

File tree

README.md

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -339,19 +339,16 @@ inner_jwt = JWT.encode(inner_payload, inner_key, 'HS256')
339339

340340
# Wrap it in an outer JWT with a different key/algorithm
341341
outer_key = OpenSSL::PKey::RSA.generate(2048)
342-
nested_jwt = JWT::NestedToken.sign(
343-
inner_jwt,
344-
algorithm: 'RS256',
345-
key: outer_key
346-
)
342+
nested = JWT::NestedToken.new(inner_jwt)
343+
nested.sign!(algorithm: 'RS256', key: outer_key)
344+
nested_jwt = nested.jwt
347345
```
348346

349347
### Decoding a Nested JWT
350348

351349
```ruby
352350
# Decode and verify all nesting levels
353-
tokens = JWT::NestedToken.decode(
354-
nested_jwt,
351+
tokens = JWT::NestedToken.new(nested_jwt).verify!(
355352
keys: [
356353
{ algorithm: 'RS256', key: outer_key.public_key },
357354
{ algorithm: 'HS256', key: inner_key }
@@ -362,20 +359,6 @@ inner_payload = tokens.last.payload
362359
# => { 'user_id' => 123, 'role' => 'admin' }
363360
```
364361

365-
### Using JWT::Token.wrap
366-
367-
You can also use `JWT::Token.wrap` to create nested tokens:
368-
369-
```ruby
370-
inner = JWT::Token.new(payload: { sub: 'user' })
371-
inner.sign!(algorithm: 'HS256', key: 'inner_secret')
372-
373-
outer = JWT::Token.wrap(inner)
374-
outer.sign!(algorithm: 'RS256', key: rsa_private_key)
375-
376-
nested_jwt = outer.jwt
377-
```
378-
379362
### Checking for Nested JWTs
380363

381364
```ruby

lib/jwt/encoded_token.rb

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ module JWT
1313
# encoded_token = JWT::EncodedToken.new(token.jwt)
1414
# encoded_token.verify_signature!(algorithm: 'HS256', key: 'secret')
1515
# encoded_token.payload # => {'pay' => 'load'}
16-
class EncodedToken
16+
class EncodedToken # rubocop:disable Metrics/ClassLength
1717
DEFAULT_CLAIMS = [:exp].freeze
1818

1919
private_constant(:DEFAULT_CLAIMS)
@@ -205,7 +205,7 @@ def nested?
205205
def inner_token
206206
return nil unless nested?
207207

208-
EncodedToken.new(unverified_payload)
208+
EncodedToken.new(decode_nested_payload)
209209
end
210210

211211
# Unwraps all nesting levels and returns an array of tokens.
@@ -218,11 +218,13 @@ def inner_token
218218
# all_tokens = token.unwrap_all
219219
# all_tokens.first # => outermost token
220220
# all_tokens.last # => innermost token
221-
def unwrap_all
221+
def unwrap_all(max_depth:)
222222
tokens = [self]
223223
current = self
224224

225225
while current.nested?
226+
raise JWT::DecodeError, "Nested JWT exceeds maximum depth of #{max_depth}" if tokens.length >= max_depth
227+
226228
current = current.inner_token
227229
tokens << current
228230
end
@@ -249,6 +251,14 @@ def decode_payload
249251
parse_and_decode(encoded_payload)
250252
end
251253

254+
def decode_nested_payload
255+
raise JWT::DecodeError, 'Encoded payload is empty' if encoded_payload == ''
256+
257+
return encoded_payload if unencoded_payload?
258+
259+
::JWT::Base64.url_decode(encoded_payload || '')
260+
end
261+
252262
def unencoded_payload?
253263
header['b64'] == false
254264
end

lib/jwt/nested_token.rb

Lines changed: 96 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,119 @@
11
# frozen_string_literal: true
22

33
module JWT
4-
# Provides functionality for creating and decoding Nested JWTs
5-
# as defined in RFC 7519 Section 5.2, Section 7.1 Step 5, and Appendix A.2.
4+
# Represents a Nested JWT as defined in RFC 7519 Section 5.2, Section 7.1 Step 5, and Appendix A.2.
65
#
7-
# A Nested JWT is a JWT that is used as the payload of another JWT,
8-
# allowing for multiple layers of signing or encryption.
6+
# A Nested JWT wraps an existing JWT string as the payload of another signed JWT.
97
#
108
# @example Creating a Nested JWT
119
# inner_jwt = JWT.encode({ user_id: 123 }, 'inner_secret', 'HS256')
12-
# nested_jwt = JWT::NestedToken.sign(
13-
# inner_jwt,
14-
# algorithm: 'RS256',
15-
# key: rsa_private_key
16-
# )
10+
# nested = JWT::NestedToken.new(inner_jwt)
11+
# nested.sign!(algorithm: 'RS256', key: rsa_private_key)
12+
# nested.jwt
1713
#
18-
# @example Decoding a Nested JWT
19-
# tokens = JWT::NestedToken.decode(
20-
# nested_jwt,
14+
# @example Verifying a Nested JWT
15+
# nested = JWT::NestedToken.new(nested_jwt)
16+
# tokens = nested.verify!(
2117
# keys: [
2218
# { algorithm: 'RS256', key: rsa_public_key },
2319
# { algorithm: 'HS256', key: 'inner_secret' }
2420
# ]
2521
# )
26-
# inner_payload = tokens.last.payload
22+
# tokens.last.payload
2723
#
2824
# @see https://datatracker.ietf.org/doc/html/rfc7519#section-5.2 RFC 7519 Section 5.2
2925
class NestedToken
30-
# The content type header value for nested JWTs as per RFC 7519
3126
CTY_JWT = 'JWT'
27+
MAX_DEPTH = 10
3228

33-
class << self
34-
# Wraps an inner JWT with an outer JWS, creating a Nested JWT.
35-
# Automatically sets the `cty` (content type) header to "JWT" as required by RFC 7519.
36-
#
37-
# @param inner_jwt [String] the inner JWT string to wrap
38-
# @param algorithm [String] the signing algorithm for the outer JWS (e.g., 'RS256', 'HS256')
39-
# @param key [Object] the signing key for the outer JWS
40-
# @param header [Hash] additional header fields to include (cty is automatically set)
41-
# @return [String] the Nested JWT string
42-
#
43-
# @raise [JWT::EncodeError] if signing fails
44-
#
45-
# @example Basic usage with HS256
46-
# inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256')
47-
# nested = JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer_secret')
48-
#
49-
# @example With RSA and custom headers
50-
# nested = JWT::NestedToken.sign(
51-
# inner_jwt,
52-
# algorithm: 'RS256',
53-
# key: rsa_private_key,
54-
# header: { kid: 'my-key-id' }
55-
# )
56-
def sign(inner_jwt, algorithm:, key:, header: {})
57-
outer_header = header.merge('cty' => CTY_JWT)
58-
token = Token.new(payload: inner_jwt, header: outer_header)
59-
token.sign!(algorithm: algorithm, key: key)
60-
token.jwt
61-
end
29+
# @return [String] the current JWT string represented by this instance.
30+
attr_reader :jwt
31+
32+
# @return [Array<JWT::EncodedToken>, nil] verified tokens ordered from outermost to innermost.
33+
attr_reader :tokens
34+
35+
# @param jwt [String] the JWT string to wrap or verify.
36+
# @raise [ArgumentError] if the provided JWT is not a String.
37+
def initialize(jwt)
38+
raise ArgumentError, 'Provided JWT must be a String' unless jwt.is_a?(String)
39+
40+
@jwt = jwt
41+
end
42+
43+
# Wraps the current JWT string in an outer JWS and replaces {#jwt} with the nested JWT.
44+
# The payload is base64url-encoded directly from the JWT string (without JSON string encoding).
45+
#
46+
# @param algorithm [String, Object] the algorithm to use for signing.
47+
# @param key [String, JWT::JWK::KeyBase] the key to use for signing.
48+
# @param header [Hash] additional header fields to include in the outer token.
49+
# @return [nil]
50+
def sign!(algorithm:, key:, header: {})
51+
signer = JWA.create_signer(algorithm: algorithm, key: key)
52+
outer_header = (header || {})
53+
.transform_keys(&:to_s)
54+
.merge('cty' => CTY_JWT)
55+
56+
outer_header.merge!(signer.jwa.header) { |_header_key, old, _new| old }
57+
58+
encoded_header = ::JWT::Base64.url_encode(JWT::JSON.generate(outer_header))
59+
encoded_payload = ::JWT::Base64.url_encode(jwt)
60+
signing_input = [encoded_header, encoded_payload].join('.')
61+
signature = signer.sign(data: signing_input)
62+
63+
@jwt = [encoded_header, encoded_payload, ::JWT::Base64.url_encode(signature)].join('.')
64+
@tokens = nil
65+
nil
66+
end
6267

63-
# Decodes and verifies a Nested JWT, unwrapping all nesting levels.
64-
# Each level's signature is verified using the corresponding key configuration.
65-
#
66-
# @param token [String] the Nested JWT string to decode
67-
# @param keys [Array<Hash>] an array of key configurations for each nesting level,
68-
# ordered from outermost to innermost. Each hash should contain:
69-
# - `:algorithm` [String] the expected algorithm
70-
# - `:key` [Object] the verification key
71-
# @return [Array<JWT::EncodedToken>] array of tokens from outermost to innermost
72-
#
73-
# @raise [JWT::DecodeError] if decoding fails at any level
74-
# @raise [JWT::VerificationError] if signature verification fails at any level
75-
#
76-
# @example Decoding a two-level nested JWT
77-
# tokens = JWT::NestedToken.decode(
78-
# nested_jwt,
79-
# keys: [
80-
# { algorithm: 'RS256', key: rsa_public_key },
81-
# { algorithm: 'HS256', key: 'inner_secret' }
82-
# ]
83-
# )
84-
# inner_token = tokens.last
85-
# inner_token.payload # => { 'user_id' => 123 }
86-
def decode(token, keys:)
87-
tokens = []
88-
current_token = token
89-
90-
keys.each_with_index do |key_config, index|
91-
encoded_token = EncodedToken.new(current_token)
92-
encoded_token.verify_signature!(
93-
algorithm: key_config[:algorithm],
94-
key: key_config[:key]
95-
)
96-
97-
tokens << encoded_token
98-
99-
if encoded_token.nested?
100-
current_token = encoded_token.unverified_payload
101-
elsif index < keys.length - 1
102-
raise JWT::DecodeError, 'Token is not nested but more keys were provided'
103-
end
104-
end
105-
106-
tokens.each(&:verify_claims!)
107-
tokens
68+
# Verifies signatures of all nested levels and the claims of the innermost token.
69+
#
70+
# @param keys [Array<Hash>] key configuration per nesting level (outermost to innermost).
71+
# @param claims [Array<Symbol>, Hash, nil] claim verification options for the innermost token.
72+
# @return [Array<JWT::EncodedToken>] verified tokens from outermost to innermost.
73+
def verify!(keys:, claims: nil)
74+
verify_signatures!(keys: keys)
75+
verify_claims!(claims: claims)
76+
tokens
77+
end
78+
79+
# Verifies signatures of all nested levels.
80+
#
81+
# @param keys [Array<Hash>] key configuration per nesting level (outermost to innermost).
82+
# @return [Array<JWT::EncodedToken>] verified tokens from outermost to innermost.
83+
def verify_signatures!(keys:)
84+
@tokens = EncodedToken.new(jwt).unwrap_all(max_depth: MAX_DEPTH)
85+
validate_key_count!(keys)
86+
87+
tokens.each_with_index do |token, index|
88+
key_config = keys[index]
89+
token.verify_signature!(
90+
algorithm: key_config[:algorithm],
91+
key: key_config[:key],
92+
key_finder: key_config[:key_finder]
93+
)
10894
end
95+
96+
tokens
97+
end
98+
99+
# Verifies claims of the innermost token after signatures have been verified.
100+
#
101+
# @param claims [Array<Symbol>, Hash, nil] claim verification options for the innermost token.
102+
# @return [Array<JWT::EncodedToken>] verified tokens from outermost to innermost.
103+
def verify_claims!(claims: nil)
104+
raise JWT::DecodeError, 'Verify nested token signatures before verifying claims' unless tokens
105+
106+
innermost_token = tokens.last
107+
claims.is_a?(Array) ? innermost_token.verify_claims!(*claims) : innermost_token.verify_claims!(claims)
108+
tokens
109+
end
110+
111+
private
112+
113+
def validate_key_count!(keys)
114+
return if keys.length == tokens.length
115+
116+
raise JWT::DecodeError, "Expected #{tokens.length} key configurations, got #{keys.length}"
109117
end
110118
end
111119
end

lib/jwt/token.rb

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -127,31 +127,5 @@ def valid_claims?(*options)
127127
#
128128
# @return [String] the JWT token as a string.
129129
alias to_s jwt
130-
131-
class << self
132-
# Wraps another JWT token, creating a Nested JWT.
133-
# Sets the `cty` (content type) header to "JWT" as required by RFC 7519 Section 5.2.
134-
#
135-
# @param inner_token [JWT::Token, String] the token to wrap. Can be a JWT::Token instance
136-
# or a JWT string.
137-
# @param header [Hash] additional header fields for the outer token
138-
# @return [JWT::Token] a new token with the inner token as its payload and cty header set
139-
#
140-
# @example Wrapping a token
141-
# inner = JWT::Token.new(payload: { sub: 'user' })
142-
# inner.sign!(algorithm: 'HS256', key: 'secret')
143-
# outer = JWT::Token.wrap(inner)
144-
# outer.sign!(algorithm: 'RS256', key: rsa_private)
145-
#
146-
# @example Wrapping a JWT string
147-
# jwt_string = JWT.encode({ sub: 'user' }, 'secret', 'HS256')
148-
# outer = JWT::Token.wrap(jwt_string)
149-
#
150-
# @see https://datatracker.ietf.org/doc/html/rfc7519#section-5.2 RFC 7519 Section 5.2
151-
def wrap(inner_token, header: {})
152-
jwt_string = inner_token.is_a?(Token) ? inner_token.jwt : inner_token
153-
new(payload: jwt_string, header: header.merge('cty' => 'JWT'))
154-
end
155-
end
156130
end
157131
end

0 commit comments

Comments
 (0)