diff --git a/.rubocop.yml b/.rubocop.yml index 82def84f..89b34a85 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -29,7 +29,7 @@ Metrics/AbcSize: Max: 25 Metrics/ClassLength: - Max: 121 + Max: 140 Metrics/ModuleLength: Max: 100 diff --git a/README.md b/README.md index 2c3a2250..abd426dd 100644 --- a/README.md +++ b/README.md @@ -680,6 +680,72 @@ jwk_hash = jwk.export thumbprint_as_the_kid = jwk_hash[:kid] ``` +### Unencoded and Detached Payloads + +#### Unencoded Payloads + +To generate a JWT with an unencoded payload, you may use the `b64` header set to false as described by RFC 7797. When you do this, the `crit` header will be added if it doesn't already exist, and the `b64` value will be appended to it. + +```ruby +private_key = RbNaCl::Signatures::Ed25519::SigningKey.new('abcdefghijklmnopqrstuvwxyzABCDEF') +public_key = private_key.verify_key +token = JWT.encode payload, private_key, 'ED25519', { b64: false } + +# eyJiNjQiOmZhbHNlLCJhbGciOiJFRDI1NTE5IiwiY3JpdCI6WyJiNjQiXX0.{\"data\":\"test\"}.RL6jDz7h_fbQQds1x_ABOVE_dp646ZIbzvBB_DlixrTTMAiG7k0q4wH8dpcQ7KUeGgqI0tqj7B4JG_jTwM6fCg +puts token + +decoded_token = JWT.decode token, public_key, true, { algorithm: 'ED25519' } +# Array +# [ +# {"data"=>"test"}, # payload +# {"b64"=>false, "alg"=>"ED25519", "crit"=>["b64"]} # header +# ] +``` + +It is extremely important that one take great care when using unencoded payloads, as the payload must be url safe if it is intended to be transmitted, etc. Also, because `.` is used to delineate between JWT segments, the payload must not have any `.` characters. If the paylod contains `.` then an `InvalidUnencodedPayload` error is raised. + +For the above reasons, detached payloads are often used in combination with unencoded payloads. + +#### Detached Payloads + +To generate a JWT with a detached payload, you must call `encode_detached` instead of `encode`. Then, when decoding and verifying the token, you must pass the `payload` option with the value of the detached payload before encoding. + +```ruby +private_key = RbNaCl::Signatures::Ed25519::SigningKey.new('abcdefghijklmnopqrstuvwxyzABCDEF') +public_key = private_key.verify_key +token = JWT.encode_detached payload, private_key, 'ED25519' + +# eyJhbGciOiJFRDI1NTE5In0..6xIztXyOupskddGA_RvKU76V9b2dCQUYhoZEVFnRimJoPYIzZ2Fm47CWw8k2NTCNpgfAuxg9OXjaiVK7MvrbCQ +puts token + +decoded_token = JWT.decode token, public_key, true, { algorithm: 'ED25519', payload: payload } +# Array +# [ +# {"test"=>"data"}, # payload +# {"alg"=>"ED25519"} # header +# ] +``` + +#### Combining Unencoded and Detached Payload Support + +Unencoded and detached payloads are often used hand in hand, such as in proof signatures of Verifiable Credentials. You may use the `b64` header in combination with `encode_detached` to combine both features. + +```ruby +private_key = RbNaCl::Signatures::Ed25519::SigningKey.new('abcdefghijklmnopqrstuvwxyzABCDEF') +public_key = private_key.verify_key +token = JWT.encode_detached payload, private_key, 'ED25519', { b64: false } + +# eyJiNjQiOmZhbHNlLCJhbGciOiJFRDI1NTE5IiwiY3JpdCI6WyJiNjQiXX0..RL6jDz7h_fbQQds1x_ABOVE_dp646ZIbzvBB_DlixrTTMAiG7k0q4wH8dpcQ7KUeGgqI0tqj7B4JG_jTwM6fCg +puts token + +decoded_token = JWT.decode token, public_key, true, { algorithm: 'ED25519', payload: payload } +# Array +# [ +# {"data"=>"test"}, # payload +# {"b64"=>false, "alg"=>"ED25519", "crit"=>["b64"]} # header +# ] +``` + # Development and Tests We depend on [Bundler](http://rubygems.org/gems/bundler) for defining gemspec and performing releases to rubygems.org, which can be done with diff --git a/lib/jwt.rb b/lib/jwt.rb index d42aaa66..391e9755 100644 --- a/lib/jwt.rb +++ b/lib/jwt.rb @@ -22,7 +22,16 @@ def encode(payload, key, algorithm = 'HS256', header_fields = {}) Encode.new(payload: payload, key: key, algorithm: algorithm, - headers: header_fields).segments + headers: header_fields, + detached: false).segments + end + + def encode_detached(payload, key, algorithm = 'HS256', header_fields = {}) + Encode.new(payload: payload, + key: key, + algorithm: algorithm, + headers: header_fields, + detached: true).segments end def decode(jwt, key = nil, verify = true, options = {}, &keyfinder) # rubocop:disable Style/OptionalBooleanParameter diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index 16217943..98c75093 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -14,6 +14,7 @@ def initialize(jwt, key, verify, options, &keyfinder) @jwt = jwt @key = key + @detached_payload = options[:payload] @options = options @segments = jwt.split('.') @verify = verify @@ -152,15 +153,35 @@ def header end def payload - @payload ||= parse_and_decode @segments[1] + @payload ||= parse_and_decode(encoded_payload, decode: decode_payload?) + end + + def encoded_payload + payload = encoded_detached_payload if !@detached_payload.nil? && @segments[1].empty? + payload ||= @segments[1] + payload + end + + def encoded_detached_payload + payload ||= ::JWT::Base64.url_encode(JWT::JSON.generate(@detached_payload)) if decode_payload? + payload ||= @detached_payload.to_json + payload + end + + def decode_payload? + header['b64'].nil? || !!header['b64'] end def signing_input - @segments.first(2).join('.') + [@segments[0], encoded_payload].join('.') end - def parse_and_decode(segment) - JWT::JSON.parse(::JWT::Base64.url_decode(segment)) + def parse_and_decode(segment, decode: true) + if decode + JWT::JSON.parse(::JWT::Base64.url_decode(segment)) + else + JWT::JSON.parse(segment) + end rescue ::JSON::ParserError raise JWT::DecodeError, 'Invalid segment encoding' end diff --git a/lib/jwt/encode.rb b/lib/jwt/encode.rb index 252ddf9b..ab085d46 100644 --- a/lib/jwt/encode.rb +++ b/lib/jwt/encode.rb @@ -15,11 +15,24 @@ def initialize(options) @algorithm = resolve_algorithm(options[:algorithm]) @headers = options[:headers].transform_keys(&:to_s) @headers[ALG_KEY] = @algorithm.alg + @detached = options[:detached] + + # add b64 claim to crit as per RFC7797 proposed standard + unless encode_payload? + @headers['crit'] ||= [] + @headers['crit'] << 'b64' unless @headers['crit'].include?('b64') + end end def segments validate_claims! - combine(encoded_header_and_payload, encoded_signature) + + parts = [] + parts << encoded_header + parts << (@detached ? '' : encoded_payload) + parts << encoded_signature + + combine(*parts) end private @@ -51,7 +64,21 @@ def encode_header end def encode_payload - encode_data(@payload) + # if b64 header is present and false, do not encode payload as per RFC7797 proposed standard + encode_payload? ? encode_data(@payload) : prepare_unencoded_payload + end + + def encode_payload? + # if b64 header is left out, default to true as per RFC7797 proposed standard + @headers['b64'].nil? || !!@headers['b64'] + end + + def prepare_unencoded_payload + json = @payload.to_json + + raise(JWT::InvalidUnencodedPayload, 'An unencoded payload cannot contain period/dot characters (i.e. ".").') if json.include?('.') + + json end def signature diff --git a/lib/jwt/error.rb b/lib/jwt/error.rb index ce3f3a9f..11186b2c 100644 --- a/lib/jwt/error.rb +++ b/lib/jwt/error.rb @@ -5,6 +5,7 @@ class EncodeError < StandardError; end class DecodeError < StandardError; end class RequiredDependencyError < StandardError; end + class InvalidUnencodedPayload < EncodeError; end class VerificationError < DecodeError; end class ExpiredSignature < DecodeError; end class IncorrectAlgorithm < DecodeError; end diff --git a/spec/jwt_spec.rb b/spec/jwt_spec.rb index 6ed99682..b47bf9a3 100644 --- a/spec/jwt_spec.rb +++ b/spec/jwt_spec.rb @@ -2,6 +2,8 @@ RSpec.describe JWT do let(:payload) { { 'user_id' => 'some@user.tld' } } + let(:unencoded_payload) { { 'user_id' => 'safe_value' } } + let(:unencoded_payload_unsafe) { { 'user_id' => 'unsafe.value' } } let :data do data = { @@ -24,6 +26,9 @@ 'ES256K_public' => OpenSSL::PKey.read(File.read(File.join(CERT_PATH, 'ec256k-public.pem'))), 'NONE' => 'eyJhbGciOiJub25lIn0.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.', 'HS256' => 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.kWOVtIOpWcG7JnyJG0qOkTDbOy636XrrQhMm_8JrRQ8', + 'HS256_unencoded_payload' => 'eyJiNjQiOmZhbHNlLCJhbGciOiJIUzI1NiIsImNyaXQiOlsiYjY0Il19.{"user_id":"safe_value"}.eW4NSHANyJpL6ivfFut7a5CM5lpaif8vEQYr-CRzrUc', + 'HS256_detached_payload' => 'eyJhbGciOiJIUzI1NiJ9..oDy7wJe4wR3YcvGx5EmVm42H68g3L8nFfKnx3yeH25o', + 'HS256_unencoded_detached_payload' => 'eyJiNjQiOmZhbHNlLCJhbGciOiJIUzI1NiIsImNyaXQiOlsiYjY0Il19..eW4NSHANyJpL6ivfFut7a5CM5lpaif8vEQYr-CRzrUc', 'HS512256' => 'eyJhbGciOiJIUzUxMjI1NiJ9.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.Ds_4ibvf7z4QOBoKntEjDfthy3WJ-3rKMspTEcHE2bA', 'HS384' => 'eyJhbGciOiJIUzM4NCJ9.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.VuV4j4A1HKhWxCNzEcwc9qVF3frrEu-BRLzvYPkbWO0LENRGy5dOiBQ34remM3XH', 'HS512' => 'eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoic29tZUB1c2VyLnRsZCJ9.8zNtCBTJIZTHpZ-BkhR-6sZY1K85Nm5YCKqV3AxRdsBJDt_RR-REH2db4T3Y0uQwNknhrCnZGvhNHrvhDwV1kA', @@ -933,4 +938,55 @@ def valid_alg?(alg) end end end + + context 'when payload is not encoded' do + it 'should generate a valid token' do + token = JWT.encode unencoded_payload, data[:secret], 'HS256', { b64: false } + + expect(token).to eq data['HS256_unencoded_payload'] + end + + it 'should decode a valid token' do + jwt_payload, header = JWT.decode data['HS256_unencoded_payload'], data[:secret], true, algorithm: 'HS256' + + expect(header['alg']).to eq 'HS256' + expect(jwt_payload).to eq unencoded_payload + end + + it 'should raise error when payload is unsafe for decoding' do + expect do + JWT.encode unencoded_payload_unsafe, 'secret', 'HS256', { b64: false } + end.to raise_error JWT::InvalidUnencodedPayload + end + end + + context 'when payload is detached' do + it 'should generate a valid token' do + token = JWT.encode_detached unencoded_payload, data[:secret], 'HS256' + + expect(token).to eq data['HS256_detached_payload'] + end + + it 'should decode a valid token' do + jwt_payload, header = JWT.decode data['HS256_detached_payload'], data[:secret], true, algorithm: 'HS256', payload: unencoded_payload + + expect(header['alg']).to eq 'HS256' + expect(jwt_payload).to eq unencoded_payload + end + end + + context 'when payload is unencoded and detached' do + it 'should generate a valid token' do + token = JWT.encode_detached unencoded_payload, data[:secret], 'HS256', { b64: false } + + expect(token).to eq data['HS256_unencoded_detached_payload'] + end + + it 'should decode a valid token' do + jwt_payload, header = JWT.decode data['HS256_unencoded_detached_payload'], data[:secret], true, algorithm: 'HS256', payload: unencoded_payload + + expect(header['alg']).to eq 'HS256' + expect(jwt_payload).to eq unencoded_payload + end + end end