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..2f508fe6 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,49 @@ encoded_token.verify_signature!(algorithm: 'HS256', key: "secret") encoded_token.payload # => {"pay"=>"load"} ``` +## Nested JWT + +A Nested JWT is a JWT that is used as the payload of another JWT, as defined in RFC 7519 Section 5.2. This allows for multiple layers of signing. + +### Creating a Nested JWT + +```ruby +# Create the inner JWT +inner_payload = { user_id: 123, role: 'admin' } +inner_key = 'inner_secret' +inner_jwt = JWT.encode(inner_payload, inner_key, 'HS256') + +# Wrap it in an outer JWT with a different key/algorithm +outer_key = OpenSSL::PKey::RSA.generate(2048) +nested = JWT::NestedToken.new(inner_jwt) +nested.sign!(algorithm: 'RS256', key: outer_key) +nested_jwt = nested.jwt +``` + +### Decoding a Nested JWT + +```ruby +# Decode and verify all nesting levels +tokens = JWT::NestedToken.new(nested_jwt).verify!( + keys: [ + { algorithm: 'RS256', key: outer_key.public_key }, + { algorithm: 'HS256', key: inner_key } + ] +) + +inner_payload = tokens.last.payload +# => { 'user_id' => 123, 'role' => 'admin' } +``` + +### Checking for Nested JWTs + +```ruby +token = JWT::EncodedToken.new(nested_jwt) +token.nested? # => true +token.inner_token # => JWT::EncodedToken of the inner JWT +token.unwrap_all # => [outer_token, inner_token] +``` + ## Claims JSON Web Token defines some reserved claim names and defines how they should be 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..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) @@ -178,6 +178,60 @@ def valid_claims?(*options) alias to_s jwt + # Checks if this token is a Nested JWT. + # A token is considered nested if it has a `cty` header with value "JWT" (case-insensitive). + # + # @return [Boolean] true if this is a Nested JWT, false otherwise + # + # @example + # token = JWT::EncodedToken.new(nested_jwt_string) + # token.nested? # => true + # + # @see https://datatracker.ietf.org/doc/html/rfc7519#section-5.2 RFC 7519 Section 5.2 + def nested? + cty = header['cty'] + cty&.upcase == 'JWT' + end + + # Returns the inner token if this is a Nested JWT. + # The inner token is created from the payload of this token. + # + # @return [JWT::EncodedToken, nil] the inner token if nested, nil otherwise + # + # @example + # outer_token = JWT::EncodedToken.new(nested_jwt_string) + # inner_token = outer_token.inner_token + # inner_token.header # => { 'alg' => 'HS256' } + def inner_token + return nil unless nested? + + EncodedToken.new(decode_nested_payload) + end + + # Unwraps all nesting levels and returns an array of tokens. + # The array is ordered from outermost to innermost token. + # + # @return [Array] array of all tokens from outer to inner + # + # @example + # token = JWT::EncodedToken.new(deeply_nested_jwt) + # all_tokens = token.unwrap_all + # all_tokens.first # => outermost token + # all_tokens.last # => innermost token + def unwrap_all(max_depth:) + tokens = [self] + current = self + + while current.nested? + raise JWT::DecodeError, "Nested JWT exceeds maximum depth of #{max_depth}" if tokens.length >= max_depth + + current = current.inner_token + tokens << current + end + + tokens + end + private def claims_options(options) @@ -197,6 +251,14 @@ def decode_payload parse_and_decode(encoded_payload) end + def decode_nested_payload + raise JWT::DecodeError, 'Encoded payload is empty' if encoded_payload == '' + + return encoded_payload if unencoded_payload? + + ::JWT::Base64.url_decode(encoded_payload || '') + end + def unencoded_payload? header['b64'] == false end diff --git a/lib/jwt/nested_token.rb b/lib/jwt/nested_token.rb new file mode 100644 index 00000000..7af27c62 --- /dev/null +++ b/lib/jwt/nested_token.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module JWT + # Represents a Nested JWT as defined in RFC 7519 Section 5.2, Section 7.1 Step 5, and Appendix A.2. + # + # A Nested JWT wraps an existing JWT string as the payload of another signed JWT. + # + # @example Creating a Nested JWT + # inner_jwt = JWT.encode({ user_id: 123 }, 'inner_secret', 'HS256') + # nested = JWT::NestedToken.new(inner_jwt) + # nested.sign!(algorithm: 'RS256', key: rsa_private_key) + # nested.jwt + # + # @example Verifying a Nested JWT + # nested = JWT::NestedToken.new(nested_jwt) + # tokens = nested.verify!( + # keys: [ + # { algorithm: 'RS256', key: rsa_public_key }, + # { algorithm: 'HS256', key: 'inner_secret' } + # ] + # ) + # tokens.last.payload + # + # @see https://datatracker.ietf.org/doc/html/rfc7519#section-5.2 RFC 7519 Section 5.2 + class NestedToken + CTY_JWT = 'JWT' + MAX_DEPTH = 10 + + # @return [String] the current JWT string represented by this instance. + attr_reader :jwt + + # @return [Array, nil] verified tokens ordered from outermost to innermost. + attr_reader :tokens + + # @param jwt [String] the JWT string to wrap or verify. + # @raise [ArgumentError] if the provided JWT is not a String. + def initialize(jwt) + raise ArgumentError, 'Provided JWT must be a String' unless jwt.is_a?(String) + + @jwt = jwt + end + + # Wraps the current JWT string in an outer JWS and replaces {#jwt} with the nested JWT. + # The payload is base64url-encoded directly from the JWT string (without JSON string encoding). + # + # @param algorithm [String, Object] the algorithm to use for signing. + # @param key [String, JWT::JWK::KeyBase] the key to use for signing. + # @param header [Hash] additional header fields to include in the outer token. + # @return [nil] + def sign!(algorithm:, key:, header: {}) + signer = JWA.create_signer(algorithm: algorithm, key: key) + outer_header = (header || {}) + .transform_keys(&:to_s) + .merge('cty' => CTY_JWT) + + outer_header.merge!(signer.jwa.header) { |_header_key, old, _new| old } + + encoded_header = ::JWT::Base64.url_encode(JWT::JSON.generate(outer_header)) + encoded_payload = ::JWT::Base64.url_encode(jwt) + signing_input = [encoded_header, encoded_payload].join('.') + signature = signer.sign(data: signing_input) + + @jwt = [encoded_header, encoded_payload, ::JWT::Base64.url_encode(signature)].join('.') + @tokens = nil + nil + end + + # Verifies signatures of all nested levels and the claims of the innermost token. + # + # @param keys [Array] 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/spec/jwt/integration/nested_jwt_spec.rb b/spec/jwt/integration/nested_jwt_spec.rb new file mode 100644 index 00000000..1df6d209 --- /dev/null +++ b/spec/jwt/integration/nested_jwt_spec.rb @@ -0,0 +1,221 @@ +# 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 = create_nested_jwt(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 = create_nested_jwt(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 = create_nested_jwt(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::NestedToken instance API' do + it 'creates a nested token with cty header' do + inner = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + + nested = JWT::NestedToken.new(inner) + nested.sign!(algorithm: 'HS256', key: 'outer_secret') + + outer = JWT::EncodedToken.new(nested.jwt) + expect(outer.header['cty']).to eq('JWT') + expect(outer.inner_token.to_s).to eq(inner) + end + + it 'allows additional headers' do + inner = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + + 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 + 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) { create_nested_jwt(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(max_depth: 10) + + 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 = 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(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(max_depth: 10) + + 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 verify!' do + inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + nested_jwt = create_nested_jwt(inner_jwt, algorithm: 'HS256', key: 'outer') + + expect do + JWT::NestedToken.new(nested_jwt).verify!( + 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 verify!' do + inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') + nested_jwt = create_nested_jwt(inner_jwt, algorithm: 'HS256', key: 'outer') + + expect do + JWT::NestedToken.new(nested_jwt).verify!( + 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::NestedToken.new(inner_jwt) + nested.sign!( + algorithm: 'RS256', + key: outer_key + ) + + tokens = JWT::NestedToken.new(nested.jwt).verify!( + 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..b96f063f --- /dev/null +++ b/spec/jwt/nested_token_spec.rb @@ -0,0 +1,217 @@ +# 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 + 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_token.sign!(algorithm: 'HS256', key: outer_secret) + + 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 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_token.jwt) + expect(outer_token.inner_token.to_s).to eq(inner_jwt) + end + + it 'allows additional header fields (NEST-02)' do + nested_token.sign!( + algorithm: 'HS256', + key: outer_secret, + header: { 'kid' => 'my-key-id' } + ) + + 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 + 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_token.sign!(algorithm: 'RS256', key: rsa_private) + + 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.inner_token.to_s).to eq(inner_jwt) + end + end + end + + describe '#verify!' do + let(:inner_jwt) { JWT.encode(inner_payload, inner_secret, 'HS256') } + 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.new(nested_jwt).verify!( + 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 + 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 } + ] + ) + + 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') + 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.new(level3.jwt).verify!( + 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 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 } + ] + ) + + 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.new(nested_jwt).verify!( + 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.new(nested_jwt).verify!( + 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 key count does not match nesting depth' do + simple_jwt = JWT.encode(inner_payload, inner_secret, 'HS256') + + expect do + described_class.new(simple_jwt).verify!( + keys: [ + { algorithm: 'HS256', key: inner_secret }, + { algorithm: 'HS256', key: 'extra_key' } + ] + ) + end.to raise_error(JWT::DecodeError, 'Expected 1 key configurations, got 2') + 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 = described_class.new(inner_jwt) + nested.sign!(algorithm: 'RS256', key: rsa_private) + + tokens = described_class.new(nested.jwt).verify!( + 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 = described_class.new(inner_jwt) + nested.sign!(algorithm: 'HS256', key: outer_secret) + + tokens = described_class.new(nested.jwt).verify!( + 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