Skip to content

Commit 66547db

Browse files
authored
Support signing and verifying token using a JWK (#692)
* Move key_finder lower * Resolve alg from JWK * Changelog entry * Example and more specs * Resolve JWA based on curve * Changelog entry * Readme example spec * Fix specs * Support signing with jwk * Fix changelog entry * Aim for 3.1.0
1 parent 2b33d5d commit 66547db

File tree

14 files changed

+245
-27
lines changed

14 files changed

+245
-27
lines changed

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
# Changelog
22

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

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

77
**Features:**
88

99
- 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))
1010
- 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))
1111
- 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))
1212
- 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))
13+
- Support signing and verifying tokens using a JWK [#692](https://github.com/jwt/ruby-jwt/pull/692) ([@anakinj](https://github.com/anakinj))
1314
- Your contribution here
1415

1516
**Fixes and enhancements:**

README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ See [CHANGELOG.md](CHANGELOG.md) for a complete set of changes and [upgrade guid
1313

1414
## Sponsors
1515

16-
|Logo|Message|
17-
|----|-------|
18-
|![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)|
16+
| Logo | Message |
17+
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
18+
| ![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) |
1919

2020
## Installing
2121

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

254+
A JWK can be used to sign and verify the token if it's possible to derive the signing algorithm from the key.
255+
256+
```ruby
257+
jwk_json = '{
258+
"kty": "oct",
259+
"k": "c2VjcmV0",
260+
"alg": "HS256",
261+
"kid": "hmac"
262+
}'
263+
264+
jwk = JWT::JWK.import(JSON.parse(jwk_json))
265+
266+
token = JWT::Token.new(payload: payload, header: header)
267+
268+
token.sign!(key: jwk)
269+
270+
encoded_token = JWT::EncodedToken.new(token.jwt)
271+
encoded_token.verify!(signature: { key: jwk})
272+
```
273+
254274
#### Using a keyfinder
255275

256276
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.

lib/jwt/encoded_token.rb

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -138,28 +138,29 @@ def valid?(signature:, claims: nil)
138138
# @return [nil]
139139
# @raise [JWT::VerificationError] if the signature verification fails.
140140
# @raise [ArgumentError] if neither key nor key_finder is provided, or if both are provided.
141-
def verify_signature!(algorithm:, key: nil, key_finder: nil)
142-
raise ArgumentError, 'Provide either key or key_finder, not both or neither' if key.nil? == key_finder.nil?
143-
144-
key ||= key_finder.call(self)
145-
146-
return if valid_signature?(algorithm: algorithm, key: key)
141+
def verify_signature!(algorithm: nil, key: nil, key_finder: nil)
142+
return if valid_signature?(algorithm: algorithm, key: key, key_finder: key_finder)
147143

148144
raise JWT::VerificationError, 'Signature verification failed'
149145
end
150146

151147
# Checks if the signature of the JWT token is valid.
152148
#
153149
# @param algorithm [String, Array<String>, Object, Array<Object>] the algorithm(s) to use for verification.
154-
# @param key [String, Array<String>] the key(s) to use for verification.
150+
# @param key [String, Array<String>, JWT::JWK::KeyBase, Array<JWT::JWK::KeyBase>] the key(s) to use for verification.
151+
# @param key_finder [#call] an object responding to `call` to find the key for verification.
155152
# @return [Boolean] true if the signature is valid, false otherwise.
156-
def valid_signature?(algorithm:, key:)
157-
valid = Array(JWA.resolve_and_sort(algorithms: algorithm, preferred_algorithm: header['alg'])).any? do |algo|
158-
Array(key).any? do |one_key|
159-
algo.verify(data: signing_input, signature: signature, verification_key: one_key)
160-
end
161-
end
153+
def valid_signature?(algorithm: nil, key: nil, key_finder: nil)
154+
raise ArgumentError, 'Provide either key or key_finder, not both or neither' if key.nil? == key_finder.nil?
155+
156+
keys = Array(key || key_finder.call(self))
157+
verifiers = JWA.create_verifiers(algorithms: algorithm, keys: keys, preferred_algorithm: header['alg'])
162158

159+
raise JWT::VerificationError, 'No algorithm provided' if verifiers.empty?
160+
161+
valid = verifiers.any? do |jwa|
162+
jwa.verify(data: signing_input, signature: signature)
163+
end
163164
valid.tap { |verified| @signature_verified = verified }
164165
end
165166

lib/jwt/jwa.rb

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,69 @@
1313
module JWT
1414
# The JWA module contains all supported algorithms.
1515
module JWA
16+
# @api private
17+
class VerifierContext
18+
def initialize(jwa:, keys:)
19+
@jwa = jwa
20+
@keys = Array(keys)
21+
end
22+
23+
def verify(*args, **kwargs)
24+
@keys.any? do |key|
25+
@jwa.verify(*args, **kwargs, verification_key: key)
26+
end
27+
end
28+
end
29+
30+
# @api private
31+
class SignerContext
32+
def initialize(jwa:, key:)
33+
@jwa = jwa
34+
@key = key
35+
end
36+
37+
def sign(*args, **kwargs)
38+
@jwa.sign(*args, **kwargs, signing_key: @key)
39+
end
40+
41+
def jwa_header
42+
@jwa.header
43+
end
44+
end
45+
1646
class << self
1747
# @api private
1848
def resolve(algorithm)
1949
return find(algorithm) if algorithm.is_a?(String) || algorithm.is_a?(Symbol)
2050

51+
raise ArgumentError, 'Algorithm must be provided' if algorithm.nil?
52+
2153
raise ArgumentError, 'Custom algorithms are required to include JWT::JWA::SigningAlgorithm' unless algorithm.is_a?(SigningAlgorithm)
2254

2355
algorithm
2456
end
2557

2658
# @api private
2759
def resolve_and_sort(algorithms:, preferred_algorithm:)
28-
algs = Array(algorithms).map { |alg| JWA.resolve(alg) }
29-
algs.partition { |alg| alg.valid_alg?(preferred_algorithm) }.flatten
60+
Array(algorithms).map { |alg| JWA.resolve(alg) }
61+
.partition { |alg| alg.valid_alg?(preferred_algorithm) }
62+
.flatten
63+
end
64+
65+
# @api private
66+
def create_signer(algorithm:, key:)
67+
return key if key.is_a?(JWK::KeyBase)
68+
69+
SignerContext.new(jwa: resolve(algorithm), key: key)
70+
end
71+
72+
# @api private
73+
def create_verifiers(algorithms:, keys:, preferred_algorithm:)
74+
jwks, other_keys = keys.partition { |key| key.is_a?(JWK::KeyBase) }
75+
76+
jwks + resolve_and_sort(algorithms: algorithms,
77+
preferred_algorithm: preferred_algorithm)
78+
.map { |jwa| VerifierContext.new(jwa: jwa, keys: other_keys) }
3079
end
3180
end
3281
end

lib/jwt/jwa/ecdsa.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def verify(data:, signature:, verification_key:)
6464
register_algorithm(new(v[:algorithm], v[:digest]))
6565
end
6666

67+
# @api private
6768
def self.curve_by_name(name)
6869
NAMED_CURVES.fetch(name) do
6970
raise UnsupportedEcdsaCurve, "The ECDSA curve '#{name}' is not supported"

lib/jwt/jwk/ec.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ def []=(key, value)
7373

7474
private
7575

76+
def jwa
77+
return super if self[:alg]
78+
79+
curve_name = self.class.to_openssl_curve(self[:crv])
80+
JWA.resolve(JWA::Ecdsa.curve_by_name(curve_name)[:algorithm])
81+
end
82+
7683
def ec_key
7784
@ec_key ||= create_ec_key(self[:crv], self[:x], self[:y], self[:d])
7885
end

lib/jwt/jwk/key_base.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,19 @@ def ==(other)
4242
other.is_a?(::JWT::JWK::KeyBase) && self[:kid] == other[:kid]
4343
end
4444

45+
def verify(**kwargs)
46+
jwa.verify(**kwargs, verification_key: verify_key)
47+
end
48+
49+
def sign(**kwargs)
50+
jwa.sign(**kwargs, signing_key: signing_key)
51+
end
52+
53+
# @api private
54+
def jwa_header
55+
jwa.header
56+
end
57+
4558
alias eql? ==
4659

4760
def <=>(other)
@@ -52,6 +65,12 @@ def <=>(other)
5265

5366
private
5467

68+
def jwa
69+
raise JWT::JWKError, 'Could not resolve the JWA, the "alg" parameter is missing' unless self[:alg]
70+
71+
JWA.resolve(self[:alg])
72+
end
73+
5574
attr_reader :parameters
5675
end
5776
end

lib/jwt/token.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,16 @@ def detach_payload!
8787

8888
# Signs the JWT token.
8989
#
90+
# @param key [String, JWT::JWK::KeyBase] the key to use for signing.
9091
# @param algorithm [String, Object] the algorithm to use for signing.
91-
# @param key [String] the key to use for signing.
9292
# @return [void]
9393
# @raise [JWT::EncodeError] if the token is already signed or other problems when signing
94-
def sign!(algorithm:, key:)
94+
def sign!(key:, algorithm: nil)
9595
raise ::JWT::EncodeError, 'Token already signed' if @signature
9696

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

102102
nil

lib/jwt/version.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ def self.gem_version
1515
# Version constants
1616
module VERSION
1717
MAJOR = 3
18-
MINOR = 0
19-
TINY = 1
18+
MINOR = 1
19+
TINY = 0
2020
PRE = nil
2121

2222
STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.')

spec/integration/readme_examples_spec.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,4 +456,26 @@ def self.verify(data:, signature:, verification_key:)
456456
expect(header).to include('alg' => 'HS512')
457457
end
458458
end
459+
460+
context 'JWK to verify a signature' do
461+
it 'allows to verify a signature with a JWK' do
462+
payload = { exp: Time.now.to_i + 60, jti: '1234', sub: 'my-subject' }
463+
header = { kid: 'hmac' }
464+
465+
jwk_json = '{
466+
"kty": "oct",
467+
"k": "c2VjcmV0",
468+
"alg": "HS256",
469+
"kid": "hmac"
470+
}'
471+
472+
jwk = JWT::JWK.import(JSON.parse(jwk_json))
473+
474+
token = JWT::Token.new(payload: payload, header: header)
475+
token.sign!(key: jwk)
476+
477+
encoded_token = JWT::EncodedToken.new(token.jwt)
478+
expect { encoded_token.verify!(signature: { key: jwk }) }.not_to raise_error
479+
end
480+
end
459481
end

0 commit comments

Comments
 (0)