|
1 | 1 | # frozen_string_literal: true |
2 | 2 |
|
3 | 3 | module JWT |
4 | | - # Provides functionality for creating and decoding Nested JWTs |
5 | | - # as defined in RFC 7519 Section 5.2, Section 7.1 Step 5, and Appendix A.2. |
| 4 | + # Represents a Nested JWT as defined in RFC 7519 Section 5.2, Section 7.1 Step 5, and Appendix A.2. |
6 | 5 | # |
7 | | - # A Nested JWT is a JWT that is used as the payload of another JWT, |
8 | | - # allowing for multiple layers of signing or encryption. |
| 6 | + # A Nested JWT wraps an existing JWT string as the payload of another signed JWT. |
9 | 7 | # |
10 | 8 | # @example Creating a Nested JWT |
11 | 9 | # inner_jwt = JWT.encode({ user_id: 123 }, 'inner_secret', 'HS256') |
12 | | - # nested_jwt = JWT::NestedToken.sign( |
13 | | - # inner_jwt, |
14 | | - # algorithm: 'RS256', |
15 | | - # key: rsa_private_key |
16 | | - # ) |
| 10 | + # nested = JWT::NestedToken.new(inner_jwt) |
| 11 | + # nested.sign!(algorithm: 'RS256', key: rsa_private_key) |
| 12 | + # nested.jwt |
17 | 13 | # |
18 | | - # @example Decoding a Nested JWT |
19 | | - # tokens = JWT::NestedToken.decode( |
20 | | - # nested_jwt, |
| 14 | + # @example Verifying a Nested JWT |
| 15 | + # nested = JWT::NestedToken.new(nested_jwt) |
| 16 | + # tokens = nested.verify!( |
21 | 17 | # keys: [ |
22 | 18 | # { algorithm: 'RS256', key: rsa_public_key }, |
23 | 19 | # { algorithm: 'HS256', key: 'inner_secret' } |
24 | 20 | # ] |
25 | 21 | # ) |
26 | | - # inner_payload = tokens.last.payload |
| 22 | + # tokens.last.payload |
27 | 23 | # |
28 | 24 | # @see https://datatracker.ietf.org/doc/html/rfc7519#section-5.2 RFC 7519 Section 5.2 |
29 | 25 | class NestedToken |
30 | | - # The content type header value for nested JWTs as per RFC 7519 |
31 | 26 | CTY_JWT = 'JWT' |
| 27 | + MAX_DEPTH = 10 |
32 | 28 |
|
33 | | - class << self |
34 | | - # Wraps an inner JWT with an outer JWS, creating a Nested JWT. |
35 | | - # Automatically sets the `cty` (content type) header to "JWT" as required by RFC 7519. |
36 | | - # |
37 | | - # @param inner_jwt [String] the inner JWT string to wrap |
38 | | - # @param algorithm [String] the signing algorithm for the outer JWS (e.g., 'RS256', 'HS256') |
39 | | - # @param key [Object] the signing key for the outer JWS |
40 | | - # @param header [Hash] additional header fields to include (cty is automatically set) |
41 | | - # @return [String] the Nested JWT string |
42 | | - # |
43 | | - # @raise [JWT::EncodeError] if signing fails |
44 | | - # |
45 | | - # @example Basic usage with HS256 |
46 | | - # inner_jwt = JWT.encode({ sub: 'user' }, 'secret', 'HS256') |
47 | | - # nested = JWT::NestedToken.sign(inner_jwt, algorithm: 'HS256', key: 'outer_secret') |
48 | | - # |
49 | | - # @example With RSA and custom headers |
50 | | - # nested = JWT::NestedToken.sign( |
51 | | - # inner_jwt, |
52 | | - # algorithm: 'RS256', |
53 | | - # key: rsa_private_key, |
54 | | - # header: { kid: 'my-key-id' } |
55 | | - # ) |
56 | | - def sign(inner_jwt, algorithm:, key:, header: {}) |
57 | | - outer_header = header.merge('cty' => CTY_JWT) |
58 | | - token = Token.new(payload: inner_jwt, header: outer_header) |
59 | | - token.sign!(algorithm: algorithm, key: key) |
60 | | - token.jwt |
61 | | - end |
| 29 | + # @return [String] the current JWT string represented by this instance. |
| 30 | + attr_reader :jwt |
| 31 | + |
| 32 | + # @return [Array<JWT::EncodedToken>, nil] verified tokens ordered from outermost to innermost. |
| 33 | + attr_reader :tokens |
| 34 | + |
| 35 | + # @param jwt [String] the JWT string to wrap or verify. |
| 36 | + # @raise [ArgumentError] if the provided JWT is not a String. |
| 37 | + def initialize(jwt) |
| 38 | + raise ArgumentError, 'Provided JWT must be a String' unless jwt.is_a?(String) |
| 39 | + |
| 40 | + @jwt = jwt |
| 41 | + end |
| 42 | + |
| 43 | + # Wraps the current JWT string in an outer JWS and replaces {#jwt} with the nested JWT. |
| 44 | + # The payload is base64url-encoded directly from the JWT string (without JSON string encoding). |
| 45 | + # |
| 46 | + # @param algorithm [String, Object] the algorithm to use for signing. |
| 47 | + # @param key [String, JWT::JWK::KeyBase] the key to use for signing. |
| 48 | + # @param header [Hash] additional header fields to include in the outer token. |
| 49 | + # @return [nil] |
| 50 | + def sign!(algorithm:, key:, header: {}) |
| 51 | + signer = JWA.create_signer(algorithm: algorithm, key: key) |
| 52 | + outer_header = (header || {}) |
| 53 | + .transform_keys(&:to_s) |
| 54 | + .merge('cty' => CTY_JWT) |
| 55 | + |
| 56 | + outer_header.merge!(signer.jwa.header) { |_header_key, old, _new| old } |
| 57 | + |
| 58 | + encoded_header = ::JWT::Base64.url_encode(JWT::JSON.generate(outer_header)) |
| 59 | + encoded_payload = ::JWT::Base64.url_encode(jwt) |
| 60 | + signing_input = [encoded_header, encoded_payload].join('.') |
| 61 | + signature = signer.sign(data: signing_input) |
| 62 | + |
| 63 | + @jwt = [encoded_header, encoded_payload, ::JWT::Base64.url_encode(signature)].join('.') |
| 64 | + @tokens = nil |
| 65 | + nil |
| 66 | + end |
62 | 67 |
|
63 | | - # Decodes and verifies a Nested JWT, unwrapping all nesting levels. |
64 | | - # Each level's signature is verified using the corresponding key configuration. |
65 | | - # |
66 | | - # @param token [String] the Nested JWT string to decode |
67 | | - # @param keys [Array<Hash>] an array of key configurations for each nesting level, |
68 | | - # ordered from outermost to innermost. Each hash should contain: |
69 | | - # - `:algorithm` [String] the expected algorithm |
70 | | - # - `:key` [Object] the verification key |
71 | | - # @return [Array<JWT::EncodedToken>] array of tokens from outermost to innermost |
72 | | - # |
73 | | - # @raise [JWT::DecodeError] if decoding fails at any level |
74 | | - # @raise [JWT::VerificationError] if signature verification fails at any level |
75 | | - # |
76 | | - # @example Decoding a two-level nested JWT |
77 | | - # tokens = JWT::NestedToken.decode( |
78 | | - # nested_jwt, |
79 | | - # keys: [ |
80 | | - # { algorithm: 'RS256', key: rsa_public_key }, |
81 | | - # { algorithm: 'HS256', key: 'inner_secret' } |
82 | | - # ] |
83 | | - # ) |
84 | | - # inner_token = tokens.last |
85 | | - # inner_token.payload # => { 'user_id' => 123 } |
86 | | - def decode(token, keys:) |
87 | | - tokens = [] |
88 | | - current_token = token |
89 | | - |
90 | | - keys.each_with_index do |key_config, index| |
91 | | - encoded_token = EncodedToken.new(current_token) |
92 | | - encoded_token.verify_signature!( |
93 | | - algorithm: key_config[:algorithm], |
94 | | - key: key_config[:key] |
95 | | - ) |
96 | | - |
97 | | - tokens << encoded_token |
98 | | - |
99 | | - if encoded_token.nested? |
100 | | - current_token = encoded_token.unverified_payload |
101 | | - elsif index < keys.length - 1 |
102 | | - raise JWT::DecodeError, 'Token is not nested but more keys were provided' |
103 | | - end |
104 | | - end |
105 | | - |
106 | | - tokens.each(&:verify_claims!) |
107 | | - tokens |
| 68 | + # Verifies signatures of all nested levels and the claims of the innermost token. |
| 69 | + # |
| 70 | + # @param keys [Array<Hash>] key configuration per nesting level (outermost to innermost). |
| 71 | + # @param claims [Array<Symbol>, Hash, nil] claim verification options for the innermost token. |
| 72 | + # @return [Array<JWT::EncodedToken>] verified tokens from outermost to innermost. |
| 73 | + def verify!(keys:, claims: nil) |
| 74 | + verify_signatures!(keys: keys) |
| 75 | + verify_claims!(claims: claims) |
| 76 | + tokens |
| 77 | + end |
| 78 | + |
| 79 | + # Verifies signatures of all nested levels. |
| 80 | + # |
| 81 | + # @param keys [Array<Hash>] key configuration per nesting level (outermost to innermost). |
| 82 | + # @return [Array<JWT::EncodedToken>] verified tokens from outermost to innermost. |
| 83 | + def verify_signatures!(keys:) |
| 84 | + @tokens = EncodedToken.new(jwt).unwrap_all(max_depth: MAX_DEPTH) |
| 85 | + validate_key_count!(keys) |
| 86 | + |
| 87 | + tokens.each_with_index do |token, index| |
| 88 | + key_config = keys[index] |
| 89 | + token.verify_signature!( |
| 90 | + algorithm: key_config[:algorithm], |
| 91 | + key: key_config[:key], |
| 92 | + key_finder: key_config[:key_finder] |
| 93 | + ) |
108 | 94 | end |
| 95 | + |
| 96 | + tokens |
| 97 | + end |
| 98 | + |
| 99 | + # Verifies claims of the innermost token after signatures have been verified. |
| 100 | + # |
| 101 | + # @param claims [Array<Symbol>, Hash, nil] claim verification options for the innermost token. |
| 102 | + # @return [Array<JWT::EncodedToken>] verified tokens from outermost to innermost. |
| 103 | + def verify_claims!(claims: nil) |
| 104 | + raise JWT::DecodeError, 'Verify nested token signatures before verifying claims' unless tokens |
| 105 | + |
| 106 | + innermost_token = tokens.last |
| 107 | + claims.is_a?(Array) ? innermost_token.verify_claims!(*claims) : innermost_token.verify_claims!(claims) |
| 108 | + tokens |
| 109 | + end |
| 110 | + |
| 111 | + private |
| 112 | + |
| 113 | + def validate_key_count!(keys) |
| 114 | + return if keys.length == tokens.length |
| 115 | + |
| 116 | + raise JWT::DecodeError, "Expected #{tokens.length} key configurations, got #{keys.length}" |
109 | 117 | end |
110 | 118 | end |
111 | 119 | end |
0 commit comments