From 952d95f4e7e005d311d0d407c69c8574a59fa2f7 Mon Sep 17 00:00:00 2001 From: ydah Date: Thu, 29 Jan 2026 22:40:10 +0900 Subject: [PATCH] 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 | 2 +- README.md | 60 +++++++ lib/jwt.rb | 1 + lib/jwt/encoded_token.rb | 54 +++++- 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, 669 insertions(+), 2 deletions(-) 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 047b5903..414b395e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:** 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 cbaec1c8..852b3225 100644 --- a/lib/jwt/encoded_token.rb +++ b/lib/jwt/encoded_token.rb @@ -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 @@ -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] 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