From 1ad7049f295cb4fbdb9623062c4a35051185809d Mon Sep 17 00:00:00 2001 From: ydah Date: Thu, 29 Jan 2026 22:40:10 +0900 Subject: [PATCH 1/2] Implements Nested JWT functionality as defined in RFC 7519 Section 5.2, 7.1, 7.2, and Appendix A.2. Implements Nested JWT functionality as defined in RFC 7519 Section 5.2, 7.1, 7.2, and Appendix A.2. A Nested JWT is a JWT used as the payload of another JWT, allowing multiple layers of signing with different keys/algorithms. --- CHANGELOG.md | 1 + README.md | 60 +++++++ lib/jwt.rb | 1 + lib/jwt/encoded_token.rb | 52 ++++++ lib/jwt/nested_token.rb | 111 +++++++++++++ lib/jwt/token.rb | 26 +++ spec/jwt/integration/nested_jwt_spec.rb | 208 +++++++++++++++++++++++ spec/jwt/nested_token_spec.rb | 209 ++++++++++++++++++++++++ 8 files changed, 668 insertions(+) create mode 100644 lib/jwt/nested_token.rb create mode 100644 spec/jwt/integration/nested_jwt_spec.rb create mode 100644 spec/jwt/nested_token_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 84239f87..4f73942b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:** diff --git a/README.md b/README.md index 0e8122ed..5eea4fe9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/jwt.rb b/lib/jwt.rb index 86ac2e6a..524136b6 100644 --- a/lib/jwt.rb +++ b/lib/jwt.rb @@ -11,6 +11,7 @@ require 'jwt/claims' require 'jwt/encoded_token' require 'jwt/token' +require 'jwt/nested_token' # JSON Web Token implementation # diff --git a/lib/jwt/encoded_token.rb b/lib/jwt/encoded_token.rb index 214981df..4a3eaa46 100644 --- a/lib/jwt/encoded_token.rb +++ b/lib/jwt/encoded_token.rb @@ -178,6 +178,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] 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) diff --git a/lib/jwt/nested_token.rb b/lib/jwt/nested_token.rb new file mode 100644 index 00000000..1b60d760 --- /dev/null +++ b/lib/jwt/nested_token.rb @@ -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] 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] 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 diff --git a/lib/jwt/token.rb b/lib/jwt/token.rb index 0c643886..43ebe60d 100644 --- a/lib/jwt/token.rb +++ b/lib/jwt/token.rb @@ -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 diff --git a/spec/jwt/integration/nested_jwt_spec.rb b/spec/jwt/integration/nested_jwt_spec.rb new file mode 100644 index 00000000..8ba2c8e3 --- /dev/null +++ b/spec/jwt/integration/nested_jwt_spec.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +RSpec.describe 'Nested JWT Integration' do + describe 'RFC 7519 Compliance' do + describe 'Section 5.2 "cty" Header Parameter' do + it 'MUST be present for Nested JWTs' do + inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + nested_jwt = JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer') + + token = JWT::EncodedToken.new(nested_jwt) + expect(token.header).to have_key('cty') + end + + it 'value MUST be "JWT"' do + inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + nested_jwt = JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer') + + token = JWT::EncodedToken.new(nested_jwt) + expect(token.header['cty']).to eq('JWT') + end + end + + describe 'Section 7.2 Validating a JWT - Step 8' do + it 'handles cty="JWT" by identifying as nested' do + inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + nested_jwt = JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer') + + token = JWT::EncodedToken.new(nested_jwt) + expect(token.nested?).to be(true) + end + + it 'handles cty="jwt" (lowercase) by identifying as nested' do + inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + + token = JWT::Token.new(payload: inner_jwt, header: { 'cty' => 'jwt' }) + token.sign!(algorithm: 'HS256', key: 'outer') + + encoded = JWT::EncodedToken.new(token.jwt) + expect(encoded.nested?).to be(true) + end + + it 'does not identify non-nested tokens as nested' do + simple_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + + token = JWT::EncodedToken.new(simple_jwt) + expect(token.nested?).to be(false) + end + end + end + + describe 'JWT::Token.wrap' do + it 'creates a nested token with cty header' do + inner = JWT::Token.new(payload: { sub: 'user' }) + inner.sign!(algorithm: 'HS256', key: 'secret') + + outer = JWT::Token.wrap(inner) + expect(outer.header['cty']).to eq('JWT') + expect(outer.payload).to eq(inner.jwt) + end + + it 'wraps a JWT string' do + jwt_string = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + + outer = JWT::Token.wrap(jwt_string) + expect(outer.header['cty']).to eq('JWT') + expect(outer.payload).to eq(jwt_string) + end + + it 'allows additional headers' do + jwt_string = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + + outer = JWT::Token.wrap(jwt_string, header: { 'kid' => 'key-1' }) + expect(outer.header['cty']).to eq('JWT') + expect(outer.header['kid']).to eq('key-1') + end + end + + describe 'JWT::EncodedToken nested methods' do + let(:inner_payload) { { 'user_id' => 123 } } + let(:inner_jwt) { JWT.encode(inner_payload, 'inner_secret', 'HS256') } + let(:nested_jwt) { JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer_secret') } + + describe '#nested?' do + it 'returns true for nested JWTs' do + token = JWT::EncodedToken.new(nested_jwt) + expect(token.nested?).to be(true) + end + + it 'returns false for simple JWTs' do + token = JWT::EncodedToken.new(inner_jwt) + expect(token.nested?).to be(false) + end + end + + describe '#inner_token' do + it 'returns the inner token for nested JWTs' do + outer = JWT::EncodedToken.new(nested_jwt) + inner = outer.inner_token + + expect(inner).to be_a(JWT::EncodedToken) + expect(inner.header['alg']).to eq('HS256') + expect(inner.unverified_payload).to eq(inner_payload) + end + + it 'returns nil for non-nested JWTs' do + token = JWT::EncodedToken.new(inner_jwt) + expect(token.inner_token).to be_nil + end + end + + describe '#unwrap_all' do + it 'returns all tokens for a two-level nested JWT' do + outer = JWT::EncodedToken.new(nested_jwt) + tokens = outer.unwrap_all + + expect(tokens.length).to eq(2) + expect(tokens.first).to eq(outer) + expect(tokens.last.unverified_payload).to eq(inner_payload) + end + + it 'returns all tokens for a deeply nested JWT' do + level1 = JWT.encode(inner_payload, 's1', 'HS256') + level2 = JWT::NestedToken.sign(level1, algorithm: 'HS256', key: 's2') + level3 = JWT::NestedToken.sign(level2, algorithm: 'HS256', key: 's3') + + outer = JWT::EncodedToken.new(level3) + tokens = outer.unwrap_all + + expect(tokens.length).to eq(3) + expect(tokens.last.unverified_payload).to eq(inner_payload) + end + + it 'returns single-element array for non-nested JWT' do + token = JWT::EncodedToken.new(inner_jwt) + tokens = token.unwrap_all + + expect(tokens.length).to eq(1) + expect(tokens.first).to eq(token) + end + end + end + + describe 'error handling' do + it 'raises DecodeError for malformed nested JWT' do + expect do + JWT::EncodedToken.new('not.a.valid.jwt.at.all') + end.not_to raise_error + + malformed = JWT::EncodedToken.new('invalid') + expect { malformed.header }.to raise_error(JWT::DecodeError) + end + + it 'raises VerificationError for invalid inner signature during decode' do + inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + nested_jwt = JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer') + + expect do + JWT::NestedToken.decode( + nested_jwt, + keys: [ + { algorithm: 'HS256', key: 'outer' }, + { algorithm: 'HS256', key: 'wrong_secret' } + ] + ) + end.to raise_error(JWT::VerificationError) + end + + it 'raises VerificationError for invalid outer signature during decode' do + inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + nested_jwt = JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer') + + expect do + JWT::NestedToken.decode( + nested_jwt, + keys: [ + { algorithm: 'HS256', key: 'wrong_outer' }, + { algorithm: 'HS256', key: 'secret' } + ] + ) + end.to raise_error(JWT::VerificationError) + end + end + + describe 'end-to-end usage example' do + it 'demonstrates complete nested JWT workflow' do + inner_payload = { 'user_id' => 123, 'role' => 'admin' } + inner_key = 'inner_secret' + inner_jwt = JWT.encode(inner_payload, inner_key, 'HS256') + + outer_key = test_pkey('rsa-2048-private.pem') + nested_jwt = JWT::NestedToken.sign( + inner_jwt, + algorithm: 'RS256', + key: outer_key + ) + + tokens = JWT::NestedToken.decode( + nested_jwt, + keys: [ + { algorithm: 'RS256', key: outer_key.public_key }, + { algorithm: 'HS256', key: inner_key } + ] + ) + + expect(tokens.last.payload).to eq({ 'user_id' => 123, 'role' => 'admin' }) + end + end +end diff --git a/spec/jwt/nested_token_spec.rb b/spec/jwt/nested_token_spec.rb new file mode 100644 index 00000000..08ba8f04 --- /dev/null +++ b/spec/jwt/nested_token_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +RSpec.describe JWT::NestedToken do + let(:inner_secret) { 'inner_secret_key' } + let(:outer_secret) { 'outer_secret_key' } + let(:inner_payload) { { 'user_id' => 123, 'role' => 'admin' } } + + describe '.sign' do + context 'with HMAC algorithms' do + let(:inner_jwt) { JWT.encode(inner_payload, inner_secret, 'HS256') } + + it 'creates a nested JWT with cty header set to JWT (NEST-01, NEST-02)' do + nested_jwt = described_class.sign(inner_jwt, algorithm: 'HS256', key: outer_secret) + + outer_token = JWT::EncodedToken.new(nested_jwt) + expect(outer_token.header['cty']).to eq('JWT') + expect(outer_token.header['alg']).to eq('HS256') + end + + it 'preserves the inner JWT as the payload (NEST-01)' do + nested_jwt = described_class.sign(inner_jwt, algorithm: 'HS256', key: outer_secret) + + outer_token = JWT::EncodedToken.new(nested_jwt) + outer_token.verify_signature!(algorithm: 'HS256', key: outer_secret) + expect(outer_token.unverified_payload).to eq(inner_jwt) + end + + it 'allows additional header fields (NEST-02)' do + nested_jwt = described_class.sign( + inner_jwt, + algorithm: 'HS256', + key: outer_secret, + header: { 'kid' => 'my-key-id' } + ) + + outer_token = JWT::EncodedToken.new(nested_jwt) + expect(outer_token.header['kid']).to eq('my-key-id') + expect(outer_token.header['cty']).to eq('JWT') + end + end + + context 'with RSA algorithm' do + let(:rsa_private) { test_pkey('rsa-2048-private.pem') } + let(:rsa_public) { rsa_private.public_key } + let(:inner_jwt) { JWT.encode(inner_payload, inner_secret, 'HS256') } + + it 'creates a nested JWT signed with RSA' do + nested_jwt = described_class.sign(inner_jwt, algorithm: 'RS256', key: rsa_private) + + outer_token = JWT::EncodedToken.new(nested_jwt) + expect(outer_token.header['alg']).to eq('RS256') + expect(outer_token.header['cty']).to eq('JWT') + + outer_token.verify_signature!(algorithm: 'RS256', key: rsa_public) + expect(outer_token.unverified_payload).to eq(inner_jwt) + end + end + end + + describe '.decode' do + let(:inner_jwt) { JWT.encode(inner_payload, inner_secret, 'HS256') } + let(:nested_jwt) { described_class.sign(inner_jwt, algorithm: 'HS256', key: outer_secret) } + + it 'decodes a nested JWT and returns all levels (NEST-03)' do + tokens = described_class.decode( + nested_jwt, + keys: [ + { algorithm: 'HS256', key: outer_secret }, + { algorithm: 'HS256', key: inner_secret } + ] + ) + + expect(tokens.length).to eq(2) + expect(tokens.first.header['cty']).to eq('JWT') + expect(tokens.last.payload).to eq(inner_payload) + end + + it 'handles case-insensitive cty header values (NEST-04)' do + token = JWT::Token.new(payload: inner_jwt, header: { 'cty' => 'jwt' }) + token.sign!(algorithm: 'HS256', key: outer_secret) + nested_jwt_lowercase = token.jwt + + tokens = described_class.decode( + nested_jwt_lowercase, + keys: [ + { algorithm: 'HS256', key: outer_secret }, + { algorithm: 'HS256', key: inner_secret } + ] + ) + + expect(tokens.length).to eq(2) + expect(tokens.last.payload).to eq(inner_payload) + end + + it 'supports multiple nesting levels (NEST-05)' do + level_1_jwt = JWT.encode(inner_payload, 'secret_1', 'HS256') + level_2_jwt = described_class.sign(level_1_jwt, algorithm: 'HS384', key: 'secret_2') + level_3_jwt = described_class.sign(level_2_jwt, algorithm: 'HS512', key: 'secret_3') + + tokens = described_class.decode( + level_3_jwt, + keys: [ + { algorithm: 'HS512', key: 'secret_3' }, + { algorithm: 'HS384', key: 'secret_2' }, + { algorithm: 'HS256', key: 'secret_1' } + ] + ) + + expect(tokens.length).to eq(3) + expect(tokens[0].header['alg']).to eq('HS512') + expect(tokens[1].header['alg']).to eq('HS384') + expect(tokens[2].header['alg']).to eq('HS256') + expect(tokens.last.payload).to eq(inner_payload) + end + + it 'verifies signatures at each nesting level (NEST-06)' do + tokens = described_class.decode( + nested_jwt, + keys: [ + { algorithm: 'HS256', key: outer_secret }, + { algorithm: 'HS256', key: inner_secret } + ] + ) + + tokens.each do |token| + expect { token.payload }.not_to raise_error + end + end + + it 'raises an error if outer signature verification fails (NEST-06)' do + expect do + described_class.decode( + nested_jwt, + keys: [ + { algorithm: 'HS256', key: 'wrong_key' }, + { algorithm: 'HS256', key: inner_secret } + ] + ) + end.to raise_error(JWT::VerificationError, 'Signature verification failed') + end + + it 'raises an error if inner signature verification fails (NEST-06)' do + expect do + described_class.decode( + nested_jwt, + keys: [ + { algorithm: 'HS256', key: outer_secret }, + { algorithm: 'HS256', key: 'wrong_key' } + ] + ) + end.to raise_error(JWT::VerificationError, 'Signature verification failed') + end + + it 'raises DecodeError when non-nested token has more keys provided' do + simple_jwt = JWT.encode(inner_payload, inner_secret, 'HS256') + + expect do + described_class.decode( + simple_jwt, + keys: [ + { algorithm: 'HS256', key: inner_secret }, + { algorithm: 'HS256', key: 'extra_key' } + ] + ) + end.to raise_error(JWT::DecodeError, 'Token is not nested but more keys were provided') + 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 HS256 inner with RS256 outer' do + inner_jwt = JWT.encode(inner_payload, inner_secret, 'HS256') + nested_jwt = described_class.sign(inner_jwt, algorithm: 'RS256', key: rsa_private) + + tokens = described_class.decode( + nested_jwt, + keys: [ + { algorithm: 'RS256', key: rsa_public }, + { algorithm: 'HS256', key: inner_secret } + ] + ) + + expect(tokens.length).to eq(2) + expect(tokens.first.header['alg']).to eq('RS256') + expect(tokens.last.header['alg']).to eq('HS256') + expect(tokens.last.payload).to eq(inner_payload) + end + + it 'supports RS256 inner with HS256 outer' do + inner_jwt = JWT.encode(inner_payload, rsa_private, 'RS256') + nested_jwt = described_class.sign(inner_jwt, algorithm: 'HS256', key: outer_secret) + + tokens = described_class.decode( + nested_jwt, + keys: [ + { algorithm: 'HS256', key: outer_secret }, + { algorithm: 'RS256', key: rsa_public } + ] + ) + + expect(tokens.length).to eq(2) + expect(tokens.first.header['alg']).to eq('HS256') + expect(tokens.last.header['alg']).to eq('RS256') + expect(tokens.last.payload).to eq(inner_payload) + end + end + end +end From 3850bd0a2662dbd323278439d19bf213234070bf Mon Sep 17 00:00:00 2001 From: ydah Date: Tue, 24 Feb 2026 20:51:28 +0900 Subject: [PATCH 2/2] Refine nested JWT API and validation --- README.md | 25 +--- lib/jwt/encoded_token.rb | 16 ++- lib/jwt/nested_token.rb | 184 ++++++++++++------------ lib/jwt/token.rb | 26 ---- spec/jwt/integration/nested_jwt_spec.rb | 83 ++++++----- spec/jwt/nested_token_spec.rb | 104 +++++++------- 6 files changed, 217 insertions(+), 221 deletions(-) diff --git a/README.md b/README.md index 5eea4fe9..2f508fe6 100644 --- a/README.md +++ b/README.md @@ -339,19 +339,16 @@ 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 -) +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.decode( - nested_jwt, +tokens = JWT::NestedToken.new(nested_jwt).verify!( keys: [ { algorithm: 'RS256', key: outer_key.public_key }, { algorithm: 'HS256', key: inner_key } @@ -362,20 +359,6 @@ 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 diff --git a/lib/jwt/encoded_token.rb b/lib/jwt/encoded_token.rb index 4a3eaa46..18280e30 100644 --- a/lib/jwt/encoded_token.rb +++ b/lib/jwt/encoded_token.rb @@ -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) @@ -205,7 +205,7 @@ def nested? def inner_token return nil unless nested? - EncodedToken.new(unverified_payload) + EncodedToken.new(decode_nested_payload) end # Unwraps all nesting levels and returns an array of tokens. @@ -218,11 +218,13 @@ def inner_token # all_tokens = token.unwrap_all # all_tokens.first # => outermost token # all_tokens.last # => innermost token - def unwrap_all + 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 @@ -249,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 diff --git a/lib/jwt/nested_token.rb b/lib/jwt/nested_token.rb index 1b60d760..7af27c62 100644 --- a/lib/jwt/nested_token.rb +++ b/lib/jwt/nested_token.rb @@ -1,111 +1,119 @@ # 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. + # Represents a Nested JWT 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. + # 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 = JWT::NestedToken.sign( - # inner_jwt, - # algorithm: 'RS256', - # key: rsa_private_key - # ) + # nested = JWT::NestedToken.new(inner_jwt) + # nested.sign!(algorithm: 'RS256', key: rsa_private_key) + # nested.jwt # - # @example Decoding a Nested JWT - # tokens = JWT::NestedToken.decode( - # 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' } # ] # ) - # inner_payload = tokens.last.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' + MAX_DEPTH = 10 - 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 + # @return [String] the current JWT string represented by this instance. + attr_reader :jwt + + # @return [Array, 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 - # 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] 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] 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 + # Verifies signatures of all nested levels and the claims of the innermost token. + # + # @param keys [Array] key configuration per nesting level (outermost to innermost). + # @param claims [Array, Hash, nil] claim verification options for the innermost token. + # @return [Array] 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] key configuration per nesting level (outermost to innermost). + # @return [Array] 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, Hash, nil] claim verification options for the innermost token. + # @return [Array] 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 diff --git a/lib/jwt/token.rb b/lib/jwt/token.rb index 43ebe60d..0c643886 100644 --- a/lib/jwt/token.rb +++ b/lib/jwt/token.rb @@ -127,31 +127,5 @@ 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 diff --git a/spec/jwt/integration/nested_jwt_spec.rb b/spec/jwt/integration/nested_jwt_spec.rb index 8ba2c8e3..1df6d209 100644 --- a/spec/jwt/integration/nested_jwt_spec.rb +++ b/spec/jwt/integration/nested_jwt_spec.rb @@ -1,11 +1,21 @@ # frozen_string_literal: true RSpec.describe 'Nested JWT Integration' do + def create_nested_jwt(inner_jwt, algorithm:, key:, header: nil) + JWT::NestedToken.new(inner_jwt).tap do |nested| + if header + nested.sign!(algorithm: algorithm, key: key, header: header) + else + nested.sign!(algorithm: algorithm, key: key) + end + end.jwt + end + describe 'RFC 7519 Compliance' do describe 'Section 5.2 "cty" Header Parameter' do it 'MUST be present for Nested JWTs' do inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') - nested_jwt = JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer') + nested_jwt = create_nested_jwt(inner_jwt, algorithm: 'HS256', key: 'outer') token = JWT::EncodedToken.new(nested_jwt) expect(token.header).to have_key('cty') @@ -13,7 +23,7 @@ it 'value MUST be "JWT"' do inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') - nested_jwt = JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer') + nested_jwt = create_nested_jwt(inner_jwt, algorithm: 'HS256', key: 'outer') token = JWT::EncodedToken.new(nested_jwt) expect(token.header['cty']).to eq('JWT') @@ -23,7 +33,7 @@ describe 'Section 7.2 Validating a JWT - Step 8' do it 'handles cty="JWT" by identifying as nested' do inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') - nested_jwt = JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer') + nested_jwt = create_nested_jwt(inner_jwt, algorithm: 'HS256', key: 'outer') token = JWT::EncodedToken.new(nested_jwt) expect(token.nested?).to be(true) @@ -48,28 +58,25 @@ end end - describe 'JWT::Token.wrap' do + describe 'JWT::NestedToken instance API' do it 'creates a nested token with cty header' do - inner = JWT::Token.new(payload: { sub: 'user' }) - inner.sign!(algorithm: 'HS256', key: 'secret') - - outer = JWT::Token.wrap(inner) - expect(outer.header['cty']).to eq('JWT') - expect(outer.payload).to eq(inner.jwt) - end + inner = JWT.encode({ sub: 'user' }, 'secret', 'HS256') - it 'wraps a JWT string' do - jwt_string = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + nested = JWT::NestedToken.new(inner) + nested.sign!(algorithm: 'HS256', key: 'outer_secret') - outer = JWT::Token.wrap(jwt_string) + outer = JWT::EncodedToken.new(nested.jwt) expect(outer.header['cty']).to eq('JWT') - expect(outer.payload).to eq(jwt_string) + expect(outer.inner_token.to_s).to eq(inner) end it 'allows additional headers' do - jwt_string = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + inner = JWT.encode({ sub: 'user' }, 'secret', 'HS256') - outer = JWT::Token.wrap(jwt_string, header: { 'kid' => 'key-1' }) + nested = JWT::NestedToken.new(inner) + nested.sign!(algorithm: 'HS256', key: 'outer_secret', header: { 'kid' => 'key-1' }) + + outer = JWT::EncodedToken.new(nested.jwt) expect(outer.header['cty']).to eq('JWT') expect(outer.header['kid']).to eq('key-1') end @@ -78,7 +85,7 @@ describe 'JWT::EncodedToken nested methods' do let(:inner_payload) { { 'user_id' => 123 } } let(:inner_jwt) { JWT.encode(inner_payload, 'inner_secret', 'HS256') } - let(:nested_jwt) { JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer_secret') } + let(:nested_jwt) { create_nested_jwt(inner_jwt, algorithm: 'HS256', key: 'outer_secret') } describe '#nested?' do it 'returns true for nested JWTs' do @@ -111,7 +118,7 @@ describe '#unwrap_all' do it 'returns all tokens for a two-level nested JWT' do outer = JWT::EncodedToken.new(nested_jwt) - tokens = outer.unwrap_all + tokens = outer.unwrap_all(max_depth: 10) expect(tokens.length).to eq(2) expect(tokens.first).to eq(outer) @@ -120,19 +127,28 @@ it 'returns all tokens for a deeply nested JWT' do level1 = JWT.encode(inner_payload, 's1', 'HS256') - level2 = JWT::NestedToken.sign(level1, algorithm: 'HS256', key: 's2') - level3 = JWT::NestedToken.sign(level2, algorithm: 'HS256', key: 's3') + level2 = create_nested_jwt(level1, algorithm: 'HS256', key: 's2') + level3 = create_nested_jwt(level2, algorithm: 'HS256', key: 's3') outer = JWT::EncodedToken.new(level3) - tokens = outer.unwrap_all + tokens = outer.unwrap_all(max_depth: 10) expect(tokens.length).to eq(3) expect(tokens.last.unverified_payload).to eq(inner_payload) end + it 'raises DecodeError when nesting exceeds max depth' do + level1 = JWT.encode(inner_payload, 's1', 'HS256') + level2 = create_nested_jwt(level1, algorithm: 'HS256', key: 's2') + level3 = create_nested_jwt(level2, algorithm: 'HS256', key: 's3') + + outer = JWT::EncodedToken.new(level3) + expect { outer.unwrap_all(max_depth: 2) }.to raise_error(JWT::DecodeError, 'Nested JWT exceeds maximum depth of 2') + end + it 'returns single-element array for non-nested JWT' do token = JWT::EncodedToken.new(inner_jwt) - tokens = token.unwrap_all + tokens = token.unwrap_all(max_depth: 10) expect(tokens.length).to eq(1) expect(tokens.first).to eq(token) @@ -150,13 +166,12 @@ expect { malformed.header }.to raise_error(JWT::DecodeError) end - it 'raises VerificationError for invalid inner signature during decode' do + it 'raises VerificationError for invalid inner signature during verify!' do inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') - nested_jwt = JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer') + nested_jwt = create_nested_jwt(inner_jwt, algorithm: 'HS256', key: 'outer') expect do - JWT::NestedToken.decode( - nested_jwt, + JWT::NestedToken.new(nested_jwt).verify!( keys: [ { algorithm: 'HS256', key: 'outer' }, { algorithm: 'HS256', key: 'wrong_secret' } @@ -165,13 +180,12 @@ end.to raise_error(JWT::VerificationError) end - it 'raises VerificationError for invalid outer signature during decode' do + it 'raises VerificationError for invalid outer signature during verify!' do inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') - nested_jwt = JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer') + nested_jwt = create_nested_jwt(inner_jwt, algorithm: 'HS256', key: 'outer') expect do - JWT::NestedToken.decode( - nested_jwt, + JWT::NestedToken.new(nested_jwt).verify!( keys: [ { algorithm: 'HS256', key: 'wrong_outer' }, { algorithm: 'HS256', key: 'secret' } @@ -188,14 +202,13 @@ inner_jwt = JWT.encode(inner_payload, inner_key, 'HS256') outer_key = test_pkey('rsa-2048-private.pem') - nested_jwt = JWT::NestedToken.sign( - inner_jwt, + nested = JWT::NestedToken.new(inner_jwt) + nested.sign!( algorithm: 'RS256', key: outer_key ) - tokens = JWT::NestedToken.decode( - nested_jwt, + tokens = JWT::NestedToken.new(nested.jwt).verify!( keys: [ { algorithm: 'RS256', key: outer_key.public_key }, { algorithm: 'HS256', key: inner_key } diff --git a/spec/jwt/nested_token_spec.rb b/spec/jwt/nested_token_spec.rb index 08ba8f04..b96f063f 100644 --- a/spec/jwt/nested_token_spec.rb +++ b/spec/jwt/nested_token_spec.rb @@ -5,35 +5,42 @@ let(:outer_secret) { 'outer_secret_key' } let(:inner_payload) { { 'user_id' => 123, 'role' => 'admin' } } - describe '.sign' do + describe '#sign!' do + subject(:nested_token) { described_class.new(inner_jwt) } + context 'with HMAC algorithms' do let(:inner_jwt) { JWT.encode(inner_payload, inner_secret, 'HS256') } it 'creates a nested JWT with cty header set to JWT (NEST-01, NEST-02)' do - nested_jwt = described_class.sign(inner_jwt, algorithm: 'HS256', key: outer_secret) + nested_token.sign!(algorithm: 'HS256', key: outer_secret) - outer_token = JWT::EncodedToken.new(nested_jwt) + outer_token = JWT::EncodedToken.new(nested_token.jwt) expect(outer_token.header['cty']).to eq('JWT') expect(outer_token.header['alg']).to eq('HS256') end - it 'preserves the inner JWT as the payload (NEST-01)' do - nested_jwt = described_class.sign(inner_jwt, algorithm: 'HS256', key: outer_secret) + it 'preserves the inner JWT as the payload bytes (NEST-01)' do + nested_token.sign!(algorithm: 'HS256', key: outer_secret) + + encoded_payload = nested_token.jwt.split('.')[1] + expect(JWT::Base64.url_decode(encoded_payload)).to eq(inner_jwt) + end + + it 'allows traversal to the inner token after signing' do + nested_token.sign!(algorithm: 'HS256', key: outer_secret) - outer_token = JWT::EncodedToken.new(nested_jwt) - outer_token.verify_signature!(algorithm: 'HS256', key: outer_secret) - expect(outer_token.unverified_payload).to eq(inner_jwt) + outer_token = JWT::EncodedToken.new(nested_token.jwt) + expect(outer_token.inner_token.to_s).to eq(inner_jwt) end it 'allows additional header fields (NEST-02)' do - nested_jwt = described_class.sign( - inner_jwt, + nested_token.sign!( algorithm: 'HS256', key: outer_secret, header: { 'kid' => 'my-key-id' } ) - outer_token = JWT::EncodedToken.new(nested_jwt) + outer_token = JWT::EncodedToken.new(nested_token.jwt) expect(outer_token.header['kid']).to eq('my-key-id') expect(outer_token.header['cty']).to eq('JWT') end @@ -45,25 +52,28 @@ let(:inner_jwt) { JWT.encode(inner_payload, inner_secret, 'HS256') } it 'creates a nested JWT signed with RSA' do - nested_jwt = described_class.sign(inner_jwt, algorithm: 'RS256', key: rsa_private) + nested_token.sign!(algorithm: 'RS256', key: rsa_private) - outer_token = JWT::EncodedToken.new(nested_jwt) + outer_token = JWT::EncodedToken.new(nested_token.jwt) expect(outer_token.header['alg']).to eq('RS256') expect(outer_token.header['cty']).to eq('JWT') outer_token.verify_signature!(algorithm: 'RS256', key: rsa_public) - expect(outer_token.unverified_payload).to eq(inner_jwt) + expect(outer_token.inner_token.to_s).to eq(inner_jwt) end end end - describe '.decode' do + describe '#verify!' do let(:inner_jwt) { JWT.encode(inner_payload, inner_secret, 'HS256') } - let(:nested_jwt) { described_class.sign(inner_jwt, algorithm: 'HS256', key: outer_secret) } + let(:nested_jwt) do + described_class.new(inner_jwt).tap do |token| + token.sign!(algorithm: 'HS256', key: outer_secret) + end.jwt + end it 'decodes a nested JWT and returns all levels (NEST-03)' do - tokens = described_class.decode( - nested_jwt, + tokens = described_class.new(nested_jwt).verify!( keys: [ { algorithm: 'HS256', key: outer_secret }, { algorithm: 'HS256', key: inner_secret } @@ -76,12 +86,14 @@ end it 'handles case-insensitive cty header values (NEST-04)' do - token = JWT::Token.new(payload: inner_jwt, header: { 'cty' => 'jwt' }) - token.sign!(algorithm: 'HS256', key: outer_secret) - nested_jwt_lowercase = token.jwt - - tokens = described_class.decode( - nested_jwt_lowercase, + signer = JWT::JWA.create_signer(algorithm: 'HS256', key: outer_secret) + header = { 'cty' => 'jwt' }.merge(signer.jwa.header) { |_key, 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].join('.')) + nested_jwt_lowercase = [encoded_header, encoded_payload, JWT::Base64.url_encode(signature)].join('.') + + tokens = described_class.new(nested_jwt_lowercase).verify!( keys: [ { algorithm: 'HS256', key: outer_secret }, { algorithm: 'HS256', key: inner_secret } @@ -94,11 +106,12 @@ it 'supports multiple nesting levels (NEST-05)' do level_1_jwt = JWT.encode(inner_payload, 'secret_1', 'HS256') - level_2_jwt = described_class.sign(level_1_jwt, algorithm: 'HS384', key: 'secret_2') - level_3_jwt = described_class.sign(level_2_jwt, algorithm: 'HS512', key: 'secret_3') + level2 = described_class.new(level_1_jwt) + level2.sign!(algorithm: 'HS384', key: 'secret_2') + level3 = described_class.new(level2.jwt) + level3.sign!(algorithm: 'HS512', key: 'secret_3') - tokens = described_class.decode( - level_3_jwt, + tokens = described_class.new(level3.jwt).verify!( keys: [ { algorithm: 'HS512', key: 'secret_3' }, { algorithm: 'HS384', key: 'secret_2' }, @@ -113,24 +126,21 @@ expect(tokens.last.payload).to eq(inner_payload) end - it 'verifies signatures at each nesting level (NEST-06)' do - tokens = described_class.decode( - nested_jwt, + it 'verifies signatures and claims of the innermost token (NEST-06)' do + tokens = described_class.new(nested_jwt).verify!( keys: [ { algorithm: 'HS256', key: outer_secret }, { algorithm: 'HS256', key: inner_secret } ] ) - tokens.each do |token| - expect { token.payload }.not_to raise_error - end + expect { tokens.last.payload }.not_to raise_error + expect(tokens.first.header['cty']).to eq('JWT') end it 'raises an error if outer signature verification fails (NEST-06)' do expect do - described_class.decode( - nested_jwt, + described_class.new(nested_jwt).verify!( keys: [ { algorithm: 'HS256', key: 'wrong_key' }, { algorithm: 'HS256', key: inner_secret } @@ -141,8 +151,7 @@ it 'raises an error if inner signature verification fails (NEST-06)' do expect do - described_class.decode( - nested_jwt, + described_class.new(nested_jwt).verify!( keys: [ { algorithm: 'HS256', key: outer_secret }, { algorithm: 'HS256', key: 'wrong_key' } @@ -151,18 +160,17 @@ end.to raise_error(JWT::VerificationError, 'Signature verification failed') end - it 'raises DecodeError when non-nested token has more keys provided' do + it 'raises DecodeError when key count does not match nesting depth' do simple_jwt = JWT.encode(inner_payload, inner_secret, 'HS256') expect do - described_class.decode( - simple_jwt, + described_class.new(simple_jwt).verify!( keys: [ { algorithm: 'HS256', key: inner_secret }, { algorithm: 'HS256', key: 'extra_key' } ] ) - end.to raise_error(JWT::DecodeError, 'Token is not nested but more keys were provided') + end.to raise_error(JWT::DecodeError, 'Expected 1 key configurations, got 2') end context 'with different algorithms at each level' do @@ -171,10 +179,10 @@ it 'supports HS256 inner with RS256 outer' do inner_jwt = JWT.encode(inner_payload, inner_secret, 'HS256') - nested_jwt = described_class.sign(inner_jwt, algorithm: 'RS256', key: rsa_private) + nested = described_class.new(inner_jwt) + nested.sign!(algorithm: 'RS256', key: rsa_private) - tokens = described_class.decode( - nested_jwt, + tokens = described_class.new(nested.jwt).verify!( keys: [ { algorithm: 'RS256', key: rsa_public }, { algorithm: 'HS256', key: inner_secret } @@ -189,10 +197,10 @@ it 'supports RS256 inner with HS256 outer' do inner_jwt = JWT.encode(inner_payload, rsa_private, 'RS256') - nested_jwt = described_class.sign(inner_jwt, algorithm: 'HS256', key: outer_secret) + nested = described_class.new(inner_jwt) + nested.sign!(algorithm: 'HS256', key: outer_secret) - tokens = described_class.decode( - nested_jwt, + tokens = described_class.new(nested.jwt).verify!( keys: [ { algorithm: 'HS256', key: outer_secret }, { algorithm: 'RS256', key: rsa_public }