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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

**Features:**

- Your contribution here
- 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))

**Fixes and enhancements:**

Expand Down
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,66 @@ 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 = JWT::NestedToken.sign(
inner_jwt,
algorithm: 'RS256',
key: outer_key
)
```

### Decoding a Nested JWT

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

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

### Using JWT::Token.wrap

You can also use `JWT::Token.wrap` to create nested tokens:

```ruby
inner = JWT::Token.new(payload: { sub: 'user' })
inner.sign!(algorithm: 'HS256', key: 'inner_secret')

outer = JWT::Token.wrap(inner)
outer.sign!(algorithm: 'RS256', key: rsa_private_key)

nested_jwt = outer.jwt
```

### 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
54 changes: 53 additions & 1 deletion lib/jwt/encoded_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,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
# @private
# Allow access to the unverified payload for claim verification.
class ClaimsContext
Expand Down Expand Up @@ -192,6 +192,58 @@ 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(unverified_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
tokens = [self]
current = self

while current.nested?
current = current.inner_token
tokens << current
end

tokens
end

private

def claims_options(options)
Expand Down
111 changes: 111 additions & 0 deletions lib/jwt/nested_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# frozen_string_literal: true

module JWT
# Provides functionality for creating and decoding Nested JWTs
# as defined in RFC 7519 Section 5.2, Section 7.1 Step 5, and Appendix A.2.
#
# A Nested JWT is a JWT that is used as the payload of another JWT,
# allowing for multiple layers of signing or encryption.
#
# @example Creating a Nested JWT
# inner_jwt = JWT.encode({ user_id: 123 }, 'inner_secret', 'HS256')
# nested_jwt = JWT::NestedToken.sign(
# inner_jwt,
# algorithm: 'RS256',
# key: rsa_private_key
# )
#
# @example Decoding a Nested JWT
# tokens = JWT::NestedToken.decode(
# nested_jwt,
# keys: [
# { algorithm: 'RS256', key: rsa_public_key },
# { algorithm: 'HS256', key: 'inner_secret' }
# ]
# )
# inner_payload = tokens.last.payload
#
# @see https://datatracker.ietf.org/doc/html/rfc7519#section-5.2 RFC 7519 Section 5.2
class NestedToken
# The content type header value for nested JWTs as per RFC 7519
CTY_JWT = 'JWT'

class << self
# Wraps an inner JWT with an outer JWS, creating a Nested JWT.
# Automatically sets the `cty` (content type) header to "JWT" as required by RFC 7519.
#
# @param inner_jwt [String] the inner JWT string to wrap
# @param algorithm [String] the signing algorithm for the outer JWS (e.g., 'RS256', 'HS256')
# @param key [Object] the signing key for the outer JWS
# @param header [Hash] additional header fields to include (cty is automatically set)
# @return [String] the Nested JWT string
#
# @raise [JWT::EncodeError] if signing fails
#
# @example Basic usage with HS256
# inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256')
# nested = JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer_secret')
#
# @example With RSA and custom headers
# nested = JWT::NestedToken.sign(
# inner_jwt,
# algorithm: 'RS256',
# key: rsa_private_key,
# header: { kid: 'my-key-id' }
# )
def sign(inner_jwt, algorithm:, key:, header: {})
outer_header = header.merge('cty' => CTY_JWT)
token = Token.new(payload: inner_jwt, header: outer_header)
token.sign!(algorithm: algorithm, key: key)
token.jwt
end

# Decodes and verifies a Nested JWT, unwrapping all nesting levels.
# Each level's signature is verified using the corresponding key configuration.
#
# @param token [String] the Nested JWT string to decode
# @param keys [Array<Hash>] an array of key configurations for each nesting level,
# ordered from outermost to innermost. Each hash should contain:
# - `:algorithm` [String] the expected algorithm
# - `:key` [Object] the verification key
# @return [Array<JWT::EncodedToken>] array of tokens from outermost to innermost
#
# @raise [JWT::DecodeError] if decoding fails at any level
# @raise [JWT::VerificationError] if signature verification fails at any level
#
# @example Decoding a two-level nested JWT
# tokens = JWT::NestedToken.decode(
# nested_jwt,
# keys: [
# { algorithm: 'RS256', key: rsa_public_key },
# { algorithm: 'HS256', key: 'inner_secret' }
# ]
# )
# inner_token = tokens.last
# inner_token.payload # => { 'user_id' => 123 }
def decode(token, keys:)
tokens = []
current_token = token

keys.each_with_index do |key_config, index|
encoded_token = EncodedToken.new(current_token)
encoded_token.verify_signature!(
algorithm: key_config[:algorithm],
key: key_config[:key]
)

tokens << encoded_token

if encoded_token.nested?
current_token = encoded_token.unverified_payload
elsif index < keys.length - 1
raise JWT::DecodeError, 'Token is not nested but more keys were provided'
end
end

tokens.each(&:verify_claims!)
tokens
end
end
end
end
26 changes: 26 additions & 0 deletions lib/jwt/token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -127,5 +127,31 @@ def valid_claims?(*options)
#
# @return [String] the JWT token as a string.
alias to_s jwt

class << self
# Wraps another JWT token, creating a Nested JWT.
# Sets the `cty` (content type) header to "JWT" as required by RFC 7519 Section 5.2.
#
# @param inner_token [JWT::Token, String] the token to wrap. Can be a JWT::Token instance
# or a JWT string.
# @param header [Hash] additional header fields for the outer token
# @return [JWT::Token] a new token with the inner token as its payload and cty header set
#
# @example Wrapping a token
# inner = JWT::Token.new(payload: { sub: 'user' })
# inner.sign!(algorithm: 'HS256', key: 'secret')
# outer = JWT::Token.wrap(inner)
# outer.sign!(algorithm: 'RS256', key: rsa_private)
#
# @example Wrapping a JWT string
# jwt_string = JWT.encode({ sub: 'user' }, 'secret', 'HS256')
# outer = JWT::Token.wrap(jwt_string)
#
# @see https://datatracker.ietf.org/doc/html/rfc7519#section-5.2 RFC 7519 Section 5.2
def wrap(inner_token, header: {})
jwt_string = inner_token.is_a?(Token) ? inner_token.jwt : inner_token
new(payload: jwt_string, header: header.merge('cty' => 'JWT'))
end
end
end
end
Loading
Loading