Skip to content
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

Detached payload support for token object #630

Merged
merged 1 commit into from
Oct 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading