From d9a87bc355ed1c93a388637d73203e053a5f3df1 Mon Sep 17 00:00:00 2001 From: Joakim Antman Date: Sun, 6 Oct 2024 14:52:46 +0300 Subject: [PATCH] Detached payload support for token object --- CHANGELOG.md | 1 + README.md | 114 ++++++++++++++++++++------------- lib/jwt/encoded_token.rb | 12 ++-- lib/jwt/token.rb | 12 +++- spec/jwt/encoded_token_spec.rb | 54 +++++++++++++++- spec/jwt/token_spec.rb | 10 +++ 6 files changed, 150 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c97df46e..90f56f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ **Features:** - JWT::Token and JWT::EncodedToken for signing and verifying tokens [#621](https://github.com/jwt/ruby-jwt/pull/621) ([@anakinj](https://github.com/anakinj)) +- Detached payload support for JWT::Token and JWT::EncodedToken [#630](https://github.com/jwt/ruby-jwt/pull/630) ([@anakinj](https://github.com/anakinj)) - Your contribution here **Fixes and enhancements:** diff --git a/README.md b/README.md index 9a0f6816..1ea5dc9b 100644 --- a/README.md +++ b/README.md @@ -249,28 +249,35 @@ decoded_token = JWT.decode(token, rsa_public, true, { algorithm: 'PS256' }) puts decoded_token ``` -### Using a Token object - -The `JWT::Token` and `JWT::EncodedToken` classes can be used to manage your JWTs. +### Add custom header fields +Ruby-jwt gem supports custom [header fields](https://tools.ietf.org/html/rfc7519#section-5) +To add custom header fields you need to pass `header_fields` parameter ```ruby -token = JWT::Token.new(payload: { exp: Time.now.to_i + 60, jti: '1234', sub: "my-subject" }, header: { kid: 'hmac' }) -token.sign!(algorithm: 'HS256', key: "secret") - -token.jwt # => "eyJhbGciOiJIUzI1N..." +token = JWT.encode(payload, key, algorithm='HS256', header_fields={}) ``` -The `JWT::EncodedToken` can be used to create a token object that allows verification of signatures and claims +**Example:** + ```ruby -encoded_token = JWT::EncodedToken.new(token.jwt) -encoded_token.verify_signature!(algorithm: 'HS256', key: "secret") -encoded_token.verify_signature!(algorithm: 'HS256', key: "wrong_secret") # raises JWT::VerificationError -encoded_token.verify_claims!(:exp, :jti) -encoded_token.verify_claims!(sub: ["not-my-subject"]) # raises JWT::InvalidSubError -encoded_token.claim_errors(sub: ["not-my-subject"]).map(&:message) # => ["Invalid subject. Expected [\"not-my-subject\"], received my-subject"] -encoded_token.payload # => { 'exp'=>1234, 'jti'=>'1234", 'sub'=>'my-subject' } -encoded_token.header # {'kid'=>'hmac', 'alg'=>'HS256'} +payload = { data: 'test' } + +# IMPORTANT: set nil as password parameter +token = JWT.encode(payload, nil, 'none', { typ: 'JWT' }) + +# eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJkYXRhIjoidGVzdCJ9. +puts token + +# Set password to nil and validation to false otherwise this won't work +decoded_token = JWT.decode(token, nil, false) + +# Array +# [ +# {"data"=>"test"}, # payload +# {"typ"=>"JWT", "alg"=>"none"} # header +# ] +puts decoded_token ``` ### **Custom algorithms** @@ -304,48 +311,63 @@ token = ::JWT.encode({'pay' => 'load'}, 'secret', CustomHS512Algorithm) payload, header = ::JWT.decode(token, 'secret', true, algorithm: CustomHS512Algorithm) ``` -## Support for reserved claim names -JSON Web Token defines some reserved claim names and defines how they should be -used. JWT supports these reserved claim names: +## `JWT::Token` and `JWT::EncodedToken` - - 'exp' (Expiration Time) Claim - - 'nbf' (Not Before Time) Claim - - 'iss' (Issuer) Claim - - 'aud' (Audience) Claim - - 'jti' (JWT ID) Claim - - 'iat' (Issued At) Claim - - 'sub' (Subject) Claim +The `JWT::Token` and `JWT::EncodedToken` classes can be used to manage your JWTs. -## Add custom header fields -Ruby-jwt gem supports custom [header fields](https://tools.ietf.org/html/rfc7519#section-5) -To add custom header fields you need to pass `header_fields` parameter +```ruby +token = JWT::Token.new(payload: { exp: Time.now.to_i + 60, jti: '1234', sub: "my-subject" }, header: { kid: 'hmac' }) +token.sign!(algorithm: 'HS256', key: "secret") + +token.jwt # => "eyJhbGciOiJIUzI1N..." +``` +The `JWT::EncodedToken` can be used to create a token object that allows verification of signatures and claims ```ruby -token = JWT.encode(payload, key, algorithm='HS256', header_fields={}) +encoded_token = JWT::EncodedToken.new(token.jwt) + +encoded_token.verify_signature!(algorithm: 'HS256', key: "secret") +encoded_token.verify_signature!(algorithm: 'HS256', key: "wrong_secret") # raises JWT::VerificationError +encoded_token.verify_claims!(:exp, :jti) +encoded_token.verify_claims!(sub: ["not-my-subject"]) # raises JWT::InvalidSubError +encoded_token.claim_errors(sub: ["not-my-subject"]).map(&:message) # => ["Invalid subject. Expected [\"not-my-subject\"], received my-subject"] +encoded_token.payload # => { 'exp'=>1234, 'jti'=>'1234", 'sub'=>'my-subject' } +encoded_token.header # {'kid'=>'hmac', 'alg'=>'HS256'} ``` -**Example:** +### Detached payload + +The `::JWT::Token#detach_payload!` method can be use to detach the payload from the JWT. ```ruby +token = JWT::Token.new(payload: { pay: 'load' }) +token.sign!(algorithm: 'HS256', key: "secret") +token.detach_payload! +token.jwt # => "eyJhbGciOiJIUzI1NiJ9..UEhDY1Qlj29ammxuVRA_-gBah4qTy5FngIWg0yEAlC0" +token.encoded_payload # => "eyJwYXkiOiJsb2FkIn0" +``` -payload = { data: 'test' } +The `JWT::EncodedToken` class can be used to decode a token with a detached payload by providing the payload to the token instance in separate. -# IMPORTANT: set nil as password parameter -token = JWT.encode(payload, nil, 'none', { typ: 'JWT' }) +```ruby +encoded_token = JWT::EncodedToken.new(token.jwt) +encoded_token.encoded_payload = "eyJwYXkiOiJsb2FkIn0" +encoded_token.verify_signature!(algorithm: 'HS256', key: "secret") +encoded_token.payload # => {"pay"=>"load"} +``` -# eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJkYXRhIjoidGVzdCJ9. -puts token +## Claims -# Set password to nil and validation to false otherwise this won't work -decoded_token = JWT.decode(token, nil, false) +JSON Web Token defines some reserved claim names and defines how they should be +used. JWT supports these reserved claim names: -# Array -# [ -# {"data"=>"test"}, # payload -# {"typ"=>"JWT", "alg"=>"none"} # header -# ] -puts decoded_token -``` + - 'exp' (Expiration Time) Claim + - 'nbf' (Not Before Time) Claim + - 'iss' (Issuer) Claim + - 'aud' (Audience) Claim + - 'jti' (JWT ID) Claim + - 'iat' (Issued At) Claim + - 'sub' (Subject) Claim ### Expiration Time Claim @@ -648,7 +670,7 @@ rescue JWT::DecodeError end ``` -### JSON Web Key (JWK) +## JSON Web Key (JWK) JWK is a JSON structure representing a cryptographic key. This gem currently supports RSA, EC, OKP and HMAC keys. OKP support requires [RbNaCl](https://github.com/RubyCrypto/rbnacl) and currently only supports the Ed25519 curve. diff --git a/lib/jwt/encoded_token.rb b/lib/jwt/encoded_token.rb index ca30545e..6c1ba86a 100644 --- a/lib/jwt/encoded_token.rb +++ b/lib/jwt/encoded_token.rb @@ -27,7 +27,6 @@ def initialize(jwt) @jwt = jwt @encoded_header, @encoded_payload, @encoded_signature = jwt.split('.') - @signing_input = [encoded_header, encoded_payload].join('.') end # Returns the decoded signature of the JWT token. @@ -58,18 +57,21 @@ def header # # @return [Hash] the payload. def payload - @payload ||= parse_and_decode(encoded_payload) + @payload ||= encoded_payload == '' ? raise(JWT::DecodeError, 'Encoded payload is empty') : parse_and_decode(encoded_payload) end - # Returns the encoded payload of the JWT token. + # Sets or returns the encoded payload of the JWT token. # # @return [String] the encoded payload. - attr_reader :encoded_payload + # @param value [String] the encoded payload to set. + attr_accessor :encoded_payload # Returns the signing input of the JWT token. # # @return [String] the signing input. - attr_reader :signing_input + def signing_input + [encoded_header, encoded_payload].join('.') + end # Verifies the signature of the JWT token. # diff --git a/lib/jwt/token.rb b/lib/jwt/token.rb index aa0ce5f4..8a7fb2dc 100644 --- a/lib/jwt/token.rb +++ b/lib/jwt/token.rb @@ -76,7 +76,15 @@ def signing_input # @return [String] the JWT token as a string. # @raise [JWT::EncodeError] if the token is not signed or other encoding issues def jwt - @jwt ||= (@signature && [encoded_header, encoded_payload, encoded_signature].join('.')) || raise(::JWT::EncodeError, 'Token is not signed') + @jwt ||= (@signature && [encoded_header, @detached_payload ? '' : encoded_payload, encoded_signature].join('.')) || raise(::JWT::EncodeError, 'Token is not signed') + end + + # Detaches the payload according to https://datatracker.ietf.org/doc/html/rfc7515#appendix-F + # + def detach_payload! + @detached_payload = true + + nil end # Signs the JWT token. @@ -92,6 +100,8 @@ def sign!(algorithm:, key:) header.merge!(algo.header) @signature = algo.sign(data: signing_input, signing_key: key) end + + nil end # Returns the JWT token as a string. diff --git a/spec/jwt/encoded_token_spec.rb b/spec/jwt/encoded_token_spec.rb index b357241c..77688d10 100644 --- a/spec/jwt/encoded_token_spec.rb +++ b/spec/jwt/encoded_token_spec.rb @@ -3,11 +3,31 @@ RSpec.describe JWT::EncodedToken do let(:payload) { { 'pay' => 'load' } } let(:encoded_token) { JWT.encode(payload, 'secret', 'HS256') } - + let(:detached_payload_token) do + JWT::Token.new(payload: payload).tap do |t| + t.detach_payload! + t.sign!(algorithm: 'HS256', key: 'secret') + end + end subject(:token) { described_class.new(encoded_token) } describe '#payload' do it { expect(token.payload).to eq(payload) } + + context 'when payload is detached' do + let(:encoded_token) { detached_payload_token.jwt } + + context 'when payload provided in separate' do + before { token.encoded_payload = detached_payload_token.encoded_payload } + it { expect(token.payload).to eq(payload) } + end + + context 'when payload is not provided' do + it 'raises decode error' do + expect { token.payload }.to raise_error(JWT::DecodeError, 'Encoded payload is empty') + end + end + end end describe '#header' do @@ -41,6 +61,23 @@ end end + context 'when payload is detached' do + let(:encoded_token) { detached_payload_token.jwt } + + context 'when payload provided in separate' do + before { token.encoded_payload = detached_payload_token.encoded_payload } + it 'does not raise' do + expect(token.verify_signature!(algorithm: 'HS256', key: 'secret')).to eq(nil) + end + end + + context 'when payload is not provided' do + it 'raises VerificationError' do + expect { token.verify_signature!(algorithm: 'HS256', key: 'secret') }.to raise_error(JWT::VerificationError, 'Signature verification failed') + end + end + end + context 'when key_finder is given' do it 'uses key provided by keyfinder' do expect(token.verify_signature!(algorithm: 'HS256', key_finder: ->(_token) { 'secret' })).to eq(nil) @@ -97,6 +134,21 @@ expect { token.verify_claims!({ exp: { leeway: 1000 }, nbf: {} }, :exp, :nbf) }.to raise_error(JWT::ExpiredSignature, 'Signature has expired') end end + + context 'when payload is detached' do + let(:encoded_token) { detached_payload_token.jwt } + context 'when payload provided in separate' do + before { token.encoded_payload = detached_payload_token.encoded_payload } + it 'raises claim verification error' do + expect { token.verify_claims!(:exp, :nbf) }.to raise_error(JWT::ExpiredSignature, 'Signature has expired') + end + end + context 'when payload is not provided' do + it 'raises decode error' do + expect { token.verify_claims!(:exp, :nbf) }.to raise_error(JWT::DecodeError, 'Encoded payload is empty') + end + end + end end end diff --git a/spec/jwt/token_spec.rb b/spec/jwt/token_spec.rb index 57ac9df6..20d2131f 100644 --- a/spec/jwt/token_spec.rb +++ b/spec/jwt/token_spec.rb @@ -42,4 +42,14 @@ end end end + + describe '#detach_payload!' do + context 'before token is signed' do + it 'detaches the payload' do + token.detach_payload! + token.sign!(algorithm: 'HS256', key: 'secret') + expect(token.jwt).to eq('eyJhbGciOiJIUzI1NiJ9..UEhDY1Qlj29ammxuVRA_-gBah4qTy5FngIWg0yEAlC0') + end + end + end end