-
Notifications
You must be signed in to change notification settings - Fork 373
Implements Nested JWT functionality as defined in RFC 7519 Section 5.2, 7.1, 7.2, and Appendix A.2. #712
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Implements Nested JWT functionality as defined in RFC 7519 Section 5.2, 7.1, 7.2, and Appendix A.2. #712
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<JWT::EncodedToken>] 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? | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we limit the depth of the nesting to for example 10? There could be some security issue with having no limit. |
||
| current = current.inner_token | ||
| tokens << current | ||
| end | ||
|
|
||
| tokens | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def claims_options(options) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we represent such a NestedToken as an instance instead of having static methods doing everything (signing, claim/signature, verification)? Now for example there is no way to control what claims are being verified. |
||
| # 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Think we will be creating invalid tokens this way, as |
||
| 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<Hash>] 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<JWT::EncodedToken>] 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -127,5 +127,31 @@ def valid_claims?(*options) | |
| # | ||
| # @return [String] the JWT token as a string. | ||
| alias to_s jwt | ||
|
|
||
| class << self | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would be easier to follow if there would be only one way to create these for now. Fancying smaller iterations over different variations how to do things. |
||
| # 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we do the refactoring sketched out in #713 in a separate PR to deal with the class length?