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