Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
**Features:**

- Add `enforce_hmac_key_length` configuration option [#716](https://github.com/jwt/ruby-jwt/pull/716) - ([@304](https://github.com/304))
- Implements Nested JWT functionality as defined in RFC 7519 Section 5.2, 7.1, 7.2, and Appendix A.2. [#712](https://github.com/jwt/ruby-jwt/pull/712) ([@ydah](https://github.com/ydah))
- Your contribution here

**Fixes and enhancements:**
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,49 @@ encoded_token.verify_signature!(algorithm: 'HS256', key: "secret")
encoded_token.payload # => {"pay"=>"load"}
```

## Nested JWT

A Nested JWT is a JWT that is used as the payload of another JWT, as defined in RFC 7519 Section 5.2. This allows for multiple layers of signing.

### Creating a Nested JWT

```ruby
# Create the inner JWT
inner_payload = { user_id: 123, role: 'admin' }
inner_key = 'inner_secret'
inner_jwt = JWT.encode(inner_payload, inner_key, 'HS256')

# Wrap it in an outer JWT with a different key/algorithm
outer_key = OpenSSL::PKey::RSA.generate(2048)
nested = JWT::NestedToken.new(inner_jwt)
nested.sign!(algorithm: 'RS256', key: outer_key)
nested_jwt = nested.jwt
```

### Decoding a Nested JWT

```ruby
# Decode and verify all nesting levels
tokens = JWT::NestedToken.new(nested_jwt).verify!(
keys: [
{ algorithm: 'RS256', key: outer_key.public_key },
{ algorithm: 'HS256', key: inner_key }
]
)

inner_payload = tokens.last.payload
# => { 'user_id' => 123, 'role' => 'admin' }
```

### Checking for Nested JWTs

```ruby
token = JWT::EncodedToken.new(nested_jwt)
token.nested? # => true
token.inner_token # => JWT::EncodedToken of the inner JWT
token.unwrap_all # => [outer_token, inner_token]
```

## Claims

JSON Web Token defines some reserved claim names and defines how they should be
Expand Down
1 change: 1 addition & 0 deletions lib/jwt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require 'jwt/claims'
require 'jwt/encoded_token'
require 'jwt/token'
require 'jwt/nested_token'

# JSON Web Token implementation
#
Expand Down
64 changes: 63 additions & 1 deletion lib/jwt/encoded_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ module JWT
# encoded_token = JWT::EncodedToken.new(token.jwt)
# encoded_token.verify_signature!(algorithm: 'HS256', key: 'secret')
# encoded_token.payload # => {'pay' => 'load'}
class EncodedToken
class EncodedToken # rubocop:disable Metrics/ClassLength
DEFAULT_CLAIMS = [:exp].freeze

private_constant(:DEFAULT_CLAIMS)
Expand Down Expand Up @@ -178,6 +178,60 @@ def valid_claims?(*options)

alias to_s jwt

# Checks if this token is a Nested JWT.
# A token is considered nested if it has a `cty` header with value "JWT" (case-insensitive).
#
# @return [Boolean] true if this is a Nested JWT, false otherwise
#
# @example
# token = JWT::EncodedToken.new(nested_jwt_string)
# token.nested? # => true
#
# @see https://datatracker.ietf.org/doc/html/rfc7519#section-5.2 RFC 7519 Section 5.2
def nested?
cty = header['cty']
cty&.upcase == 'JWT'
end

# Returns the inner token if this is a Nested JWT.
# The inner token is created from the payload of this token.
#
# @return [JWT::EncodedToken, nil] the inner token if nested, nil otherwise
#
# @example
# outer_token = JWT::EncodedToken.new(nested_jwt_string)
# inner_token = outer_token.inner_token
# inner_token.header # => { 'alg' => 'HS256' }
def inner_token
return nil unless nested?

EncodedToken.new(decode_nested_payload)
end

# Unwraps all nesting levels and returns an array of tokens.
# The array is ordered from outermost to innermost token.
#
# @return [Array<JWT::EncodedToken>] array of all tokens from outer to inner
#
# @example
# token = JWT::EncodedToken.new(deeply_nested_jwt)
# all_tokens = token.unwrap_all
# all_tokens.first # => outermost token
# all_tokens.last # => innermost token
def unwrap_all(max_depth:)
tokens = [self]
current = self

while current.nested?
raise JWT::DecodeError, "Nested JWT exceeds maximum depth of #{max_depth}" if tokens.length >= max_depth

current = current.inner_token
tokens << current
end

tokens
end

private

def claims_options(options)
Expand All @@ -197,6 +251,14 @@ def decode_payload
parse_and_decode(encoded_payload)
end

def decode_nested_payload
raise JWT::DecodeError, 'Encoded payload is empty' if encoded_payload == ''

return encoded_payload if unencoded_payload?

::JWT::Base64.url_decode(encoded_payload || '')
end

def unencoded_payload?
header['b64'] == false
end
Expand Down
119 changes: 119 additions & 0 deletions lib/jwt/nested_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# frozen_string_literal: true

module JWT
# Represents a Nested JWT as defined in RFC 7519 Section 5.2, Section 7.1 Step 5, and Appendix A.2.
#
# A Nested JWT wraps an existing JWT string as the payload of another signed JWT.
#
# @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 Verifying a Nested JWT
# nested = JWT::NestedToken.new(nested_jwt)
# tokens = nested.verify!(
# keys: [
# { algorithm: 'RS256', key: rsa_public_key },
# { algorithm: 'HS256', key: 'inner_secret' }
# ]
# )
# tokens.last.payload
#
# @see https://datatracker.ietf.org/doc/html/rfc7519#section-5.2 RFC 7519 Section 5.2
class NestedToken
CTY_JWT = 'JWT'
MAX_DEPTH = 10

# @return [String] the current JWT string represented by this instance.
attr_reader :jwt

# @return [Array<JWT::EncodedToken>, nil] verified tokens ordered from outermost to innermost.
attr_reader :tokens

# @param jwt [String] the JWT string to wrap or verify.
# @raise [ArgumentError] if the provided JWT is not a String.
def initialize(jwt)
raise ArgumentError, 'Provided JWT must be a String' unless jwt.is_a?(String)

@jwt = jwt
end

# Wraps the current JWT string in an outer JWS and replaces {#jwt} with the nested JWT.
# The payload is base64url-encoded directly from the JWT string (without JSON string encoding).
#
# @param algorithm [String, Object] the algorithm to use for signing.
# @param key [String, JWT::JWK::KeyBase] the key to use for signing.
# @param header [Hash] additional header fields to include in the outer token.
# @return [nil]
def sign!(algorithm:, key:, header: {})
signer = JWA.create_signer(algorithm: algorithm, key: key)
outer_header = (header || {})
.transform_keys(&:to_s)
.merge('cty' => CTY_JWT)

outer_header.merge!(signer.jwa.header) { |_header_key, old, _new| old }

encoded_header = ::JWT::Base64.url_encode(JWT::JSON.generate(outer_header))
encoded_payload = ::JWT::Base64.url_encode(jwt)
signing_input = [encoded_header, encoded_payload].join('.')
signature = signer.sign(data: signing_input)

@jwt = [encoded_header, encoded_payload, ::JWT::Base64.url_encode(signature)].join('.')
@tokens = nil
nil
end

# Verifies signatures of all nested levels and the claims of the innermost token.
#
# @param keys [Array<Hash>] key configuration per nesting level (outermost to innermost).
# @param claims [Array<Symbol>, Hash, nil] claim verification options for the innermost token.
# @return [Array<JWT::EncodedToken>] verified tokens from outermost to innermost.
def verify!(keys:, claims: nil)
verify_signatures!(keys: keys)
verify_claims!(claims: claims)
tokens
end

# Verifies signatures of all nested levels.
#
# @param keys [Array<Hash>] key configuration per nesting level (outermost to innermost).
# @return [Array<JWT::EncodedToken>] verified tokens from outermost to innermost.
def verify_signatures!(keys:)
@tokens = EncodedToken.new(jwt).unwrap_all(max_depth: MAX_DEPTH)
validate_key_count!(keys)

tokens.each_with_index do |token, index|
key_config = keys[index]
token.verify_signature!(
algorithm: key_config[:algorithm],
key: key_config[:key],
key_finder: key_config[:key_finder]
)
end

tokens
end

# Verifies claims of the innermost token after signatures have been verified.
#
# @param claims [Array<Symbol>, Hash, nil] claim verification options for the innermost token.
# @return [Array<JWT::EncodedToken>] verified tokens from outermost to innermost.
def verify_claims!(claims: nil)
raise JWT::DecodeError, 'Verify nested token signatures before verifying claims' unless tokens

innermost_token = tokens.last
claims.is_a?(Array) ? innermost_token.verify_claims!(*claims) : innermost_token.verify_claims!(claims)
tokens
end

private

def validate_key_count!(keys)
return if keys.length == tokens.length

raise JWT::DecodeError, "Expected #{tokens.length} key configurations, got #{keys.length}"
end
end
end
Loading
Loading