Skip to content

Commit

Permalink
Detached payload support for token object
Browse files Browse the repository at this point in the history
  • Loading branch information
anakinj committed Oct 6, 2024
1 parent e180765 commit d9a87bc
Show file tree
Hide file tree
Showing 6 changed files with 150 additions and 53 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
114 changes: 68 additions & 46 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
12 changes: 7 additions & 5 deletions lib/jwt/encoded_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
#
Expand Down
12 changes: 11 additions & 1 deletion lib/jwt/token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
54 changes: 53 additions & 1 deletion spec/jwt/encoded_token_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions spec/jwt/token_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit d9a87bc

Please sign in to comment.