Skip to content

Support signing and verifying token using a JWK #692

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

Merged
merged 12 commits into from
Jun 23, 2025
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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
# Changelog

## [v3.0.1](https://github.com/jwt/ruby-jwt/tree/v3.0.1) (NEXT)
## [v3.1.0](https://github.com/jwt/ruby-jwt/tree/v3.1.0) (NEXT)

[Full Changelog](https://github.com/jwt/ruby-jwt/compare/v3.0.0...main)
[Full Changelog](https://github.com/jwt/ruby-jwt/compare/v3.0.0...v3.1.0)

**Features:**

- Add support for x5t header parameter for X.509 certificate thumbprint verification [#669](https://github.com/jwt/ruby-jwt/pull/669) ([@hieuk09](https://github.com/hieuk09))
- Raise an error if the ECDSA signing or verification key is not an instance of `OpenSSL::PKey::EC` [#688](https://github.com/jwt/ruby-jwt/pull/688) ([@anakinj](https://github.com/anakinj))
- Allow `OpenSSL::PKey::EC::Point` to be used as the verification key in ECDSA [#689](https://github.com/jwt/ruby-jwt/pull/689) ([@anakinj](https://github.com/anakinj))
- Require claims to have been verified before accessing the `JWT::EncodedToken#payload` [#690](https://github.com/jwt/ruby-jwt/pull/690) ([@anakinj](https://github.com/anakinj))
- Support signing and verifying tokens using a JWK [#692](https://github.com/jwt/ruby-jwt/pull/692) ([@anakinj](https://github.com/anakinj))
- Your contribution here

**Fixes and enhancements:**
Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ See [CHANGELOG.md](CHANGELOG.md) for a complete set of changes and [upgrade guid

## Sponsors

|Logo|Message|
|----|-------|
|![auth0 logo](https://user-images.githubusercontent.com/83319/31722733-de95bbde-b3ea-11e7-96bf-4f4e8f915588.png)|If you want to quickly add secure token-based authentication to Ruby projects, feel free to check Auth0's Ruby SDK and free plan at [auth0.com/developers](https://auth0.com/developers?utm_source=GHsponsor&utm_medium=GHsponsor&utm_campaign=rubyjwt&utm_content=auth)|
| Logo | Message |
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ![auth0 logo](https://user-images.githubusercontent.com/83319/31722733-de95bbde-b3ea-11e7-96bf-4f4e8f915588.png) | If you want to quickly add secure token-based authentication to Ruby projects, feel free to check Auth0's Ruby SDK and free plan at [auth0.com/developers](https://auth0.com/developers?utm_source=GHsponsor&utm_medium=GHsponsor&utm_campaign=rubyjwt&utm_content=auth) |

## Installing

Expand Down Expand Up @@ -251,6 +251,26 @@ encoded_token.payload # => { 'exp'=>1234, 'jti'=>'1234", 'sub'=>'my-subject' }
encoded_token.header # {'kid'=>'hmac', 'alg'=>'HS256'}
```

A JWK can be used to sign and verify the token if it's possible to derive the signing algorithm from the key.

```ruby
jwk_json = '{
"kty": "oct",
"k": "c2VjcmV0",
"alg": "HS256",
"kid": "hmac"
}'

jwk = JWT::JWK.import(JSON.parse(jwk_json))

token = JWT::Token.new(payload: payload, header: header)

token.sign!(key: jwk)

encoded_token = JWT::EncodedToken.new(token.jwt)
encoded_token.verify!(signature: { key: jwk})
```

#### Using a keyfinder

A keyfinder can be used to verify a signature. A keyfinder is an object responding to the `#call` method. The method expects to receive one argument, which is the token to be verified.
Expand Down
27 changes: 14 additions & 13 deletions lib/jwt/encoded_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -138,28 +138,29 @@ def valid?(signature:, claims: nil)
# @return [nil]
# @raise [JWT::VerificationError] if the signature verification fails.
# @raise [ArgumentError] if neither key nor key_finder is provided, or if both are provided.
def verify_signature!(algorithm:, key: nil, key_finder: nil)
raise ArgumentError, 'Provide either key or key_finder, not both or neither' if key.nil? == key_finder.nil?

key ||= key_finder.call(self)

return if valid_signature?(algorithm: algorithm, key: key)
def verify_signature!(algorithm: nil, key: nil, key_finder: nil)
return if valid_signature?(algorithm: algorithm, key: key, key_finder: key_finder)

raise JWT::VerificationError, 'Signature verification failed'
end

# Checks if the signature of the JWT token is valid.
#
# @param algorithm [String, Array<String>, Object, Array<Object>] the algorithm(s) to use for verification.
# @param key [String, Array<String>] the key(s) to use for verification.
# @param key [String, Array<String>, JWT::JWK::KeyBase, Array<JWT::JWK::KeyBase>] the key(s) to use for verification.
# @param key_finder [#call] an object responding to `call` to find the key for verification.
# @return [Boolean] true if the signature is valid, false otherwise.
def valid_signature?(algorithm:, key:)
valid = Array(JWA.resolve_and_sort(algorithms: algorithm, preferred_algorithm: header['alg'])).any? do |algo|
Array(key).any? do |one_key|
algo.verify(data: signing_input, signature: signature, verification_key: one_key)
end
end
def valid_signature?(algorithm: nil, key: nil, key_finder: nil)
raise ArgumentError, 'Provide either key or key_finder, not both or neither' if key.nil? == key_finder.nil?

keys = Array(key || key_finder.call(self))
verifiers = JWA.create_verifiers(algorithms: algorithm, keys: keys, preferred_algorithm: header['alg'])

raise JWT::VerificationError, 'No algorithm provided' if verifiers.empty?

valid = verifiers.any? do |jwa|
jwa.verify(data: signing_input, signature: signature)
end
valid.tap { |verified| @signature_verified = verified }
end

Expand Down
53 changes: 51 additions & 2 deletions lib/jwt/jwa.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,69 @@
module JWT
# The JWA module contains all supported algorithms.
module JWA
# @api private
class VerifierContext
def initialize(jwa:, keys:)
@jwa = jwa
@keys = Array(keys)
end

def verify(*args, **kwargs)
@keys.any? do |key|
@jwa.verify(*args, **kwargs, verification_key: key)
end
end
end

# @api private
class SignerContext
def initialize(jwa:, key:)
@jwa = jwa
@key = key
end

def sign(*args, **kwargs)
@jwa.sign(*args, **kwargs, signing_key: @key)
end

def jwa_header
@jwa.header
end
end

class << self
# @api private
def resolve(algorithm)
return find(algorithm) if algorithm.is_a?(String) || algorithm.is_a?(Symbol)

raise ArgumentError, 'Algorithm must be provided' if algorithm.nil?

raise ArgumentError, 'Custom algorithms are required to include JWT::JWA::SigningAlgorithm' unless algorithm.is_a?(SigningAlgorithm)

algorithm
end

# @api private
def resolve_and_sort(algorithms:, preferred_algorithm:)
algs = Array(algorithms).map { |alg| JWA.resolve(alg) }
algs.partition { |alg| alg.valid_alg?(preferred_algorithm) }.flatten
Array(algorithms).map { |alg| JWA.resolve(alg) }
.partition { |alg| alg.valid_alg?(preferred_algorithm) }
.flatten
end

# @api private
def create_signer(algorithm:, key:)
return key if key.is_a?(JWK::KeyBase)

SignerContext.new(jwa: resolve(algorithm), key: key)
end

# @api private
def create_verifiers(algorithms:, keys:, preferred_algorithm:)
jwks, other_keys = keys.partition { |key| key.is_a?(JWK::KeyBase) }

jwks + resolve_and_sort(algorithms: algorithms,
preferred_algorithm: preferred_algorithm)
.map { |jwa| VerifierContext.new(jwa: jwa, keys: other_keys) }
end
end
end
Expand Down
1 change: 1 addition & 0 deletions lib/jwt/jwa/ecdsa.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def verify(data:, signature:, verification_key:)
register_algorithm(new(v[:algorithm], v[:digest]))
end

# @api private
def self.curve_by_name(name)
NAMED_CURVES.fetch(name) do
raise UnsupportedEcdsaCurve, "The ECDSA curve '#{name}' is not supported"
Expand Down
7 changes: 7 additions & 0 deletions lib/jwt/jwk/ec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ def []=(key, value)

private

def jwa
return super if self[:alg]

curve_name = self.class.to_openssl_curve(self[:crv])
JWA.resolve(JWA::Ecdsa.curve_by_name(curve_name)[:algorithm])
end

def ec_key
@ec_key ||= create_ec_key(self[:crv], self[:x], self[:y], self[:d])
end
Expand Down
19 changes: 19 additions & 0 deletions lib/jwt/jwk/key_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ def ==(other)
other.is_a?(::JWT::JWK::KeyBase) && self[:kid] == other[:kid]
end

def verify(**kwargs)
jwa.verify(**kwargs, verification_key: verify_key)
end

def sign(**kwargs)
jwa.sign(**kwargs, signing_key: signing_key)
end

# @api private
def jwa_header
jwa.header
end

alias eql? ==

def <=>(other)
Expand All @@ -52,6 +65,12 @@ def <=>(other)

private

def jwa
raise JWT::JWKError, 'Could not resolve the JWA, the "alg" parameter is missing' unless self[:alg]

JWA.resolve(self[:alg])
end

attr_reader :parameters
end
end
Expand Down
10 changes: 5 additions & 5 deletions lib/jwt/token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,16 @@ def detach_payload!

# Signs the JWT token.
#
# @param key [String, JWT::JWK::KeyBase] the key to use for signing.
# @param algorithm [String, Object] the algorithm to use for signing.
# @param key [String] the key to use for signing.
# @return [void]
# @raise [JWT::EncodeError] if the token is already signed or other problems when signing
def sign!(algorithm:, key:)
def sign!(key:, algorithm: nil)
raise ::JWT::EncodeError, 'Token already signed' if @signature

JWA.resolve(algorithm).tap do |algo|
header.merge!(algo.header) { |_key, old, _new| old }
@signature = algo.sign(data: signing_input, signing_key: key)
JWA.create_signer(algorithm: algorithm, key: key).tap do |signer|
header.merge!(signer.jwa_header) { |_key, old, _new| old }
@signature = signer.sign(data: signing_input)
end

nil
Expand Down
4 changes: 2 additions & 2 deletions lib/jwt/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ def self.gem_version
# Version constants
module VERSION
MAJOR = 3
MINOR = 0
TINY = 1
MINOR = 1
TINY = 0
PRE = nil

STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.')
Expand Down
22 changes: 22 additions & 0 deletions spec/integration/readme_examples_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -456,4 +456,26 @@ def self.verify(data:, signature:, verification_key:)
expect(header).to include('alg' => 'HS512')
end
end

context 'JWK to verify a signature' do
it 'allows to verify a signature with a JWK' do
payload = { exp: Time.now.to_i + 60, jti: '1234', sub: 'my-subject' }
header = { kid: 'hmac' }

jwk_json = '{
"kty": "oct",
"k": "c2VjcmV0",
"alg": "HS256",
"kid": "hmac"
}'

jwk = JWT::JWK.import(JSON.parse(jwk_json))

token = JWT::Token.new(payload: payload, header: header)
token.sign!(key: jwk)

encoded_token = JWT::EncodedToken.new(token.jwt)
expect { encoded_token.verify!(signature: { key: jwk }) }.not_to raise_error
end
end
end
19 changes: 19 additions & 0 deletions spec/jwt/encoded_token_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@
end
end

context 'when algorithm is not given' do
it 'raises an error' do
expect { token.verify_signature!(key: 'secret') }.to raise_error(JWT::VerificationError, 'No algorithm provided')
end
end

context 'when header has invalid alg value' do
let(:header) { { 'alg' => 'HS123' } }

Expand Down Expand Up @@ -211,6 +217,19 @@
expect(token.verify_signature!(algorithm: 'RS256', key_finder: key_finder)).to eq(nil)
end
end

context 'when JWK is given as a key' do
let(:jwk) { JWT::JWK.new(test_pkey('rsa-2048-private.pem'), alg: 'RS256') }
let(:encoded_token) do
JWT::Token.new(payload: payload)
.tap { |t| t.sign!(algorithm: 'RS256', key: jwk.signing_key) }
.jwt
end

it 'uses the JWK for verification' do
expect(token.verify_signature!(key: jwk)).to eq(nil)
end
end
end

describe '#verify_claims!' do
Expand Down
31 changes: 31 additions & 0 deletions spec/jwt/jwk/ec_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,37 @@
end
end

describe '#verify' do
let(:data) { 'data_to_sign' }
let(:signature) { jwk.sign(data: data) }

context 'when jwk is missing the alg parameter' do
let(:jwk) { described_class.new(ec_key) }

context 'when the signature is valid' do
it 'returns true' do
expect(jwk.verify(data: data, signature: signature)).to be(true)
end
end
end

context 'when jwk has alg parameter' do
let(:jwk) { described_class.new(ec_key, alg: 'ES384') }

context 'when the signature is valid' do
it 'returns true' do
expect(jwk.verify(data: data, signature: signature)).to be(true)
end
end

context 'when the signature is invalid' do
it 'returns false' do
expect(jwk.verify(data: data, signature: 'invalid')).to be(false)
end
end
end
end

describe '.to_openssl_curve' do
context 'when a valid curve name is given' do
it 'returns the corresponding OpenSSL curve name' do
Expand Down
32 changes: 32 additions & 0 deletions spec/jwt/jwk/rsa_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,38 @@
end
end

describe '#verify' do
let(:rsa) { described_class.new(rsa_key, alg: 'RS256') }
let(:data) { 'data_to_sign' }
let(:signature) { rsa.sign(data: data) }

context 'when the signature is valid' do
it 'returns true' do
expect(rsa.verify(data: data, signature: signature)).to be(true)
end
end

context 'when the signature is invalid' do
it 'returns false' do
expect(rsa.verify(data: data, signature: 'invalid_signature')).to be(false)
end
end

context 'when the jwk is missing the alg header' do
let(:rsa) { described_class.new(rsa_key) }
it 'raises JWT::JWKError' do
expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::JWKError, 'Could not resolve the JWA, the "alg" parameter is missing')
end
end

context 'when the jwk has an invalid alg header' do
let(:rsa) { described_class.new(rsa_key, alg: 'INVALID') }
it 'raises JWT::JWKError' do
expect { rsa.verify(data: data, signature: 'signature') }.to raise_error(JWT::VerificationError, 'Algorithm not supported')
end
end
end

describe '.common_parameters' do
context 'when a common parameters hash is given' do
it 'imports the common parameter' do
Expand Down
Loading
Loading