jwt 1.5.4 → 2.7.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -13
- data/AUTHORS +119 -0
- data/CHANGELOG.md +812 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/CONTRIBUTING.md +99 -0
- data/README.md +400 -79
- data/lib/jwt/algos/algo_wrapper.rb +30 -0
- data/lib/jwt/algos/ecdsa.rb +62 -0
- data/lib/jwt/algos/eddsa.rb +33 -0
- data/lib/jwt/algos/hmac.rb +73 -0
- data/lib/jwt/algos/hmac_rbnacl.rb +53 -0
- data/lib/jwt/algos/hmac_rbnacl_fixed.rb +52 -0
- data/lib/jwt/algos/none.rb +19 -0
- data/lib/jwt/algos/ps.rb +41 -0
- data/lib/jwt/algos/rsa.rb +21 -0
- data/lib/jwt/algos/unsupported.rb +19 -0
- data/lib/jwt/algos.rb +67 -0
- data/lib/jwt/base64.rb +19 -0
- data/lib/jwt/claims_validator.rb +37 -0
- data/lib/jwt/configuration/container.rb +21 -0
- data/lib/jwt/configuration/decode_configuration.rb +46 -0
- data/lib/jwt/configuration/jwk_configuration.rb +27 -0
- data/lib/jwt/configuration.rb +15 -0
- data/lib/jwt/decode.rb +141 -29
- data/lib/jwt/encode.rb +79 -0
- data/lib/jwt/error.rb +10 -0
- data/lib/jwt/json.rb +11 -9
- data/lib/jwt/jwk/ec.rb +236 -0
- data/lib/jwt/jwk/hmac.rb +103 -0
- data/lib/jwt/jwk/key_base.rb +55 -0
- data/lib/jwt/jwk/key_finder.rb +46 -0
- data/lib/jwt/jwk/kid_as_key_digest.rb +15 -0
- data/lib/jwt/jwk/okp_rbnacl.rb +110 -0
- data/lib/jwt/jwk/rsa.rb +203 -0
- data/lib/jwt/jwk/set.rb +80 -0
- data/lib/jwt/jwk/thumbprint.rb +26 -0
- data/lib/jwt/jwk.rb +55 -0
- data/lib/jwt/security_utils.rb +32 -0
- data/lib/jwt/verify.rb +59 -44
- data/lib/jwt/version.rb +25 -4
- data/lib/jwt/x5c_key_finder.rb +55 -0
- data/lib/jwt.rb +16 -162
- data/ruby-jwt.gemspec +19 -9
- metadata +64 -97
- data/.codeclimate.yml +0 -20
- data/.gitignore +0 -6
- data/.rspec +0 -2
- data/.rubocop.yml +0 -2
- data/.travis.yml +0 -13
- data/Gemfile +0 -4
- data/Manifest +0 -8
- data/Rakefile +0 -1
- data/spec/fixtures/certs/ec256-private.pem +0 -8
- data/spec/fixtures/certs/ec256-public.pem +0 -4
- data/spec/fixtures/certs/ec256-wrong-private.pem +0 -8
- data/spec/fixtures/certs/ec256-wrong-public.pem +0 -4
- data/spec/fixtures/certs/ec384-private.pem +0 -9
- data/spec/fixtures/certs/ec384-public.pem +0 -5
- data/spec/fixtures/certs/ec384-wrong-private.pem +0 -9
- data/spec/fixtures/certs/ec384-wrong-public.pem +0 -5
- data/spec/fixtures/certs/ec512-private.pem +0 -10
- data/spec/fixtures/certs/ec512-public.pem +0 -6
- data/spec/fixtures/certs/ec512-wrong-private.pem +0 -10
- data/spec/fixtures/certs/ec512-wrong-public.pem +0 -6
- data/spec/fixtures/certs/rsa-1024-private.pem +0 -15
- data/spec/fixtures/certs/rsa-1024-public.pem +0 -6
- data/spec/fixtures/certs/rsa-2048-private.pem +0 -27
- data/spec/fixtures/certs/rsa-2048-public.pem +0 -9
- data/spec/fixtures/certs/rsa-2048-wrong-private.pem +0 -27
- data/spec/fixtures/certs/rsa-2048-wrong-public.pem +0 -9
- data/spec/fixtures/certs/rsa-4096-private.pem +0 -51
- data/spec/fixtures/certs/rsa-4096-public.pem +0 -14
- data/spec/jwt/verify_spec.rb +0 -175
- data/spec/jwt_spec.rb +0 -232
- data/spec/spec_helper.rb +0 -31
data/README.md
CHANGED
@@ -1,24 +1,33 @@
|
|
1
1
|
# JWT
|
2
2
|
|
3
|
-
[![
|
3
|
+
[![Gem Version](https://badge.fury.io/rb/jwt.svg)](https://badge.fury.io/rb/jwt)
|
4
|
+
[![Build Status](https://github.com/jwt/ruby-jwt/workflows/test/badge.svg?branch=main)](https://github.com/jwt/ruby-jwt/actions)
|
4
5
|
[![Code Climate](https://codeclimate.com/github/jwt/ruby-jwt/badges/gpa.svg)](https://codeclimate.com/github/jwt/ruby-jwt)
|
5
6
|
[![Test Coverage](https://codeclimate.com/github/jwt/ruby-jwt/badges/coverage.svg)](https://codeclimate.com/github/jwt/ruby-jwt/coverage)
|
6
7
|
[![Issue Count](https://codeclimate.com/github/jwt/ruby-jwt/badges/issue_count.svg)](https://codeclimate.com/github/jwt/ruby-jwt)
|
7
8
|
|
8
|
-
A
|
9
|
+
A ruby implementation of the [RFC 7519 OAuth JSON Web Token (JWT)](https://tools.ietf.org/html/rfc7519) standard.
|
9
10
|
|
10
|
-
If you have further questions
|
11
|
+
If you have further questions related to development or usage, join us: [ruby-jwt google group](https://groups.google.com/forum/#!forum/ruby-jwt).
|
11
12
|
|
12
13
|
## Announcements
|
14
|
+
* Ruby 2.4 support was dropped in version 2.4.0
|
15
|
+
* Ruby 1.9.3 support was dropped at December 31st, 2016.
|
16
|
+
* Version 1.5.3 yanked. See: [#132](https://github.com/jwt/ruby-jwt/issues/132) and [#133](https://github.com/jwt/ruby-jwt/issues/133)
|
13
17
|
|
14
|
-
|
15
|
-
|
18
|
+
See [CHANGELOG.md](CHANGELOG.md) for a complete set of changes.
|
19
|
+
|
20
|
+
## Sponsors
|
21
|
+
|
22
|
+
|Logo|Message|
|
23
|
+
|-|-|
|
24
|
+
|![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
25
|
|
17
26
|
## Installing
|
18
27
|
|
19
28
|
### Using Rubygems:
|
20
29
|
```bash
|
21
|
-
|
30
|
+
gem install jwt
|
22
31
|
```
|
23
32
|
|
24
33
|
### Using Bundler:
|
@@ -30,23 +39,23 @@ And run `bundle install`
|
|
30
39
|
|
31
40
|
## Algorithms and Usage
|
32
41
|
|
33
|
-
The JWT spec supports NONE, HMAC, RSASSA, ECDSA and RSASSA-PSS algorithms for cryptographic signing. Currently the jwt gem supports NONE, HMAC, RSASSA and ECDSA. If you are using cryptographic signing, you need to specify the algorithm in the options hash whenever you call JWT.decode to ensure that an attacker [cannot bypass the algorithm verification step](https://auth0.com/blog/
|
42
|
+
The JWT spec supports NONE, HMAC, RSASSA, ECDSA and RSASSA-PSS algorithms for cryptographic signing. Currently the jwt gem supports NONE, HMAC, RSASSA and ECDSA. If you are using cryptographic signing, you need to specify the algorithm in the options hash whenever you call JWT.decode to ensure that an attacker [cannot bypass the algorithm verification step](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/). **It is strongly recommended that you hard code the algorithm, as you may leave yourself vulnerable by dynamically picking the algorithm**
|
34
43
|
|
35
44
|
See: [ JSON Web Algorithms (JWA) 3.1. "alg" (Algorithm) Header Parameter Values for JWS](https://tools.ietf.org/html/rfc7518#section-3.1)
|
36
45
|
|
37
|
-
**NONE**
|
46
|
+
### **NONE**
|
38
47
|
|
39
48
|
* none - unsigned token
|
40
49
|
|
41
50
|
```ruby
|
42
51
|
require 'jwt'
|
43
52
|
|
44
|
-
payload = {:
|
53
|
+
payload = { data: 'test' }
|
45
54
|
|
46
55
|
# IMPORTANT: set nil as password parameter
|
47
56
|
token = JWT.encode payload, nil, 'none'
|
48
57
|
|
49
|
-
#
|
58
|
+
# eyJhbGciOiJub25lIn0.eyJkYXRhIjoidGVzdCJ9.
|
50
59
|
puts token
|
51
60
|
|
52
61
|
# Set password to nil and validation to false otherwise this won't work
|
@@ -55,36 +64,44 @@ decoded_token = JWT.decode token, nil, false
|
|
55
64
|
# Array
|
56
65
|
# [
|
57
66
|
# {"data"=>"test"}, # payload
|
58
|
-
# {"
|
67
|
+
# {"alg"=>"none"} # header
|
59
68
|
# ]
|
60
69
|
puts decoded_token
|
61
70
|
```
|
62
71
|
|
63
|
-
**HMAC**
|
72
|
+
### **HMAC**
|
64
73
|
|
65
|
-
* HS256
|
66
|
-
*
|
74
|
+
* HS256 - HMAC using SHA-256 hash algorithm
|
75
|
+
* HS512256 - HMAC using SHA-512-256 hash algorithm (only available with RbNaCl; see note below)
|
76
|
+
* HS384 - HMAC using SHA-384 hash algorithm
|
67
77
|
* HS512 - HMAC using SHA-512 hash algorithm
|
68
78
|
|
69
79
|
```ruby
|
80
|
+
# The secret must be a string. With OpenSSL 3.0/openssl gem `<3.0.1`, JWT::DecodeError will be raised if it isn't provided.
|
70
81
|
hmac_secret = 'my$ecretK3y'
|
71
82
|
|
72
83
|
token = JWT.encode payload, hmac_secret, 'HS256'
|
73
84
|
|
74
|
-
#
|
85
|
+
# eyJhbGciOiJIUzI1NiJ9.eyJkYXRhIjoidGVzdCJ9.pNIWIL34Jo13LViZAJACzK6Yf0qnvT_BuwOxiMCPE-Y
|
75
86
|
puts token
|
76
87
|
|
77
|
-
decoded_token = JWT.decode token, hmac_secret, true, { :
|
88
|
+
decoded_token = JWT.decode token, hmac_secret, true, { algorithm: 'HS256' }
|
78
89
|
|
79
90
|
# Array
|
80
91
|
# [
|
81
92
|
# {"data"=>"test"}, # payload
|
82
|
-
# {"
|
93
|
+
# {"alg"=>"HS256"} # header
|
83
94
|
# ]
|
84
95
|
puts decoded_token
|
85
96
|
```
|
86
97
|
|
87
|
-
|
98
|
+
Note: If [RbNaCl](https://github.com/RubyCrypto/rbnacl) is loadable, ruby-jwt will use it for HMAC-SHA256, HMAC-SHA512-256, and HMAC-SHA512. RbNaCl prior to 6.0.0 only support a maximum key size of 32 bytes for these algorithms.
|
99
|
+
|
100
|
+
[RbNaCl](https://github.com/RubyCrypto/rbnacl) requires
|
101
|
+
[libsodium](https://github.com/jedisct1/libsodium), it can be installed
|
102
|
+
on MacOS with `brew install libsodium`.
|
103
|
+
|
104
|
+
### **RSA**
|
88
105
|
|
89
106
|
* RS256 - RSA using SHA-256 hash algorithm
|
90
107
|
* RS384 - RSA using SHA-384 hash algorithm
|
@@ -96,49 +113,130 @@ rsa_public = rsa_private.public_key
|
|
96
113
|
|
97
114
|
token = JWT.encode payload, rsa_private, 'RS256'
|
98
115
|
|
99
|
-
#
|
116
|
+
# eyJhbGciOiJSUzI1NiJ9.eyJkYXRhIjoidGVzdCJ9.GplO4w1spRgvEJQ3-FOtZr-uC8L45Jt7SN0J4woBnEXG_OZBSNcZjAJWpjadVYEe2ev3oUBFDYM1N_-0BTVeFGGYvMewu8E6aMjSZvOpf1cZBew-Vt4poSq7goG2YRI_zNPt3af2lkPqXD796IKC5URrEvcgF5xFQ-6h07XRDpSRx1ECrNsUOt7UM3l1IB4doY11GzwQA5sHDTmUZ0-kBT76ZMf12Srg_N3hZwphxBtudYtN5VGZn420sVrQMdPE_7Ni3EiWT88j7WCr1xrF60l8sZT3yKCVleG7D2BEXacTntB7GktBv4Xo8OKnpwpqTpIlC05dMowMkz3rEAAYbQ
|
100
117
|
puts token
|
101
118
|
|
102
|
-
decoded_token = JWT.decode token, rsa_public, true, { :
|
119
|
+
decoded_token = JWT.decode token, rsa_public, true, { algorithm: 'RS256' }
|
103
120
|
|
104
121
|
# Array
|
105
122
|
# [
|
106
123
|
# {"data"=>"test"}, # payload
|
107
|
-
# {"
|
124
|
+
# {"alg"=>"RS256"} # header
|
108
125
|
# ]
|
109
126
|
puts decoded_token
|
110
127
|
```
|
111
128
|
|
112
|
-
**ECDSA**
|
129
|
+
### **ECDSA**
|
113
130
|
|
114
131
|
* ES256 - ECDSA using P-256 and SHA-256
|
115
132
|
* ES384 - ECDSA using P-384 and SHA-384
|
116
133
|
* ES512 - ECDSA using P-521 and SHA-512
|
134
|
+
* ES256K - ECDSA using P-256K and SHA-256
|
117
135
|
|
118
136
|
```ruby
|
119
|
-
ecdsa_key = OpenSSL::PKey::EC.
|
120
|
-
ecdsa_key.generate_key
|
121
|
-
ecdsa_public = OpenSSL::PKey::EC.new ecdsa_key
|
122
|
-
ecdsa_public.private_key = nil
|
137
|
+
ecdsa_key = OpenSSL::PKey::EC.generate('prime256v1')
|
123
138
|
|
124
139
|
token = JWT.encode payload, ecdsa_key, 'ES256'
|
125
140
|
|
126
|
-
#
|
141
|
+
# eyJhbGciOiJFUzI1NiJ9.eyJkYXRhIjoidGVzdCJ9.AlLW--kaF7EX1NMX9WJRuIW8NeRJbn2BLXHns7Q5TZr7Hy3lF6MOpMlp7GoxBFRLISQ6KrD0CJOrR8aogEsPeg
|
127
142
|
puts token
|
128
143
|
|
129
|
-
decoded_token = JWT.decode token,
|
144
|
+
decoded_token = JWT.decode token, ecdsa_key, true, { algorithm: 'ES256' }
|
130
145
|
|
131
146
|
# Array
|
132
147
|
# [
|
133
148
|
# {"test"=>"data"}, # payload
|
134
|
-
# {"
|
149
|
+
# {"alg"=>"ES256"} # header
|
150
|
+
# ]
|
151
|
+
puts decoded_token
|
152
|
+
```
|
153
|
+
|
154
|
+
### **EDDSA**
|
155
|
+
|
156
|
+
In order to use this algorithm you need to add the `RbNaCl` gem to you `Gemfile`.
|
157
|
+
|
158
|
+
```ruby
|
159
|
+
gem 'rbnacl'
|
160
|
+
```
|
161
|
+
|
162
|
+
For more detailed installation instruction check the official [repository](https://github.com/RubyCrypto/rbnacl) on GitHub.
|
163
|
+
|
164
|
+
* ED25519
|
165
|
+
|
166
|
+
```ruby
|
167
|
+
private_key = RbNaCl::Signatures::Ed25519::SigningKey.new('abcdefghijklmnopqrstuvwxyzABCDEF')
|
168
|
+
public_key = private_key.verify_key
|
169
|
+
token = JWT.encode payload, private_key, 'ED25519'
|
170
|
+
|
171
|
+
# eyJhbGciOiJFRDI1NTE5In0.eyJkYXRhIjoidGVzdCJ9.6xIztXyOupskddGA_RvKU76V9b2dCQUYhoZEVFnRimJoPYIzZ2Fm47CWw8k2NTCNpgfAuxg9OXjaiVK7MvrbCQ
|
172
|
+
puts token
|
173
|
+
|
174
|
+
decoded_token = JWT.decode token, public_key, true, { algorithm: 'ED25519' }
|
175
|
+
# Array
|
176
|
+
# [
|
177
|
+
# {"test"=>"data"}, # payload
|
178
|
+
# {"alg"=>"ED25519"} # header
|
179
|
+
# ]
|
180
|
+
|
181
|
+
```
|
182
|
+
|
183
|
+
### **RSASSA-PSS**
|
184
|
+
|
185
|
+
In order to use this algorithm you need to add the `openssl` gem to your `Gemfile` with a version greater or equal to `2.1`.
|
186
|
+
|
187
|
+
```ruby
|
188
|
+
gem 'openssl', '~> 2.1'
|
189
|
+
```
|
190
|
+
|
191
|
+
* PS256 - RSASSA-PSS using SHA-256 hash algorithm
|
192
|
+
* PS384 - RSASSA-PSS using SHA-384 hash algorithm
|
193
|
+
* PS512 - RSASSA-PSS using SHA-512 hash algorithm
|
194
|
+
|
195
|
+
```ruby
|
196
|
+
rsa_private = OpenSSL::PKey::RSA.generate 2048
|
197
|
+
rsa_public = rsa_private.public_key
|
198
|
+
|
199
|
+
token = JWT.encode payload, rsa_private, 'PS256'
|
200
|
+
|
201
|
+
# eyJhbGciOiJQUzI1NiJ9.eyJkYXRhIjoidGVzdCJ9.KEmqagMUHM-NcmXo6818ZazVTIAkn9qU9KQFT1c5Iq91n0KRpAI84jj4ZCdkysDlWokFs3Dmn4MhcXP03oJKLFgnoPL40_Wgg9iFr0jnIVvnMUp1kp2RFUbL0jqExGTRA3LdAhuvw6ZByGD1bkcWjDXygjQw-hxILrT1bENjdr0JhFd-cB0-ps5SB0mwhFNcUw-OM3Uu30B1-mlFaelUY8jHJYKwLTZPNxHzndt8RGXF8iZLp7dGb06HSCKMcVzhASGMH4ZdFystRe2hh31cwcvnl-Eo_D4cdwmpN3Abhk_8rkxawQJR3duh8HNKc4AyFPo7SabEaSu2gLnLfN3yfg
|
202
|
+
puts token
|
203
|
+
|
204
|
+
decoded_token = JWT.decode token, rsa_public, true, { algorithm: 'PS256' }
|
205
|
+
|
206
|
+
# Array
|
207
|
+
# [
|
208
|
+
# {"data"=>"test"}, # payload
|
209
|
+
# {"alg"=>"PS256"} # header
|
135
210
|
# ]
|
136
211
|
puts decoded_token
|
137
212
|
```
|
138
213
|
|
139
|
-
**
|
214
|
+
### **Custom algorithms**
|
215
|
+
|
216
|
+
An object implementing custom signing or verification behaviour can be passed in the `algorithm` option when encoding and decoding. The given object needs to implement the method `valid_alg?` and `verify` and/or `alg` and `sign`, depending if object is used for encoding or decoding.
|
217
|
+
|
218
|
+
```ruby
|
219
|
+
module CustomHS512Algorithm
|
220
|
+
def self.alg
|
221
|
+
'HS512'
|
222
|
+
end
|
223
|
+
|
224
|
+
def self.valid_alg?(alg_to_validate)
|
225
|
+
alg_to_validate == alg
|
226
|
+
end
|
227
|
+
|
228
|
+
def self.sign(data:, signing_key:)
|
229
|
+
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha512'), data, signing_key)
|
230
|
+
end
|
231
|
+
|
232
|
+
def self.verify(data:, signature:, verification_key:)
|
233
|
+
::OpenSSL.secure_compare(sign(data: data, signing_key: verification_key), signature)
|
234
|
+
end
|
235
|
+
end
|
140
236
|
|
141
|
-
|
237
|
+
token = ::JWT.encode({'pay' => 'load'}, 'secret', CustomHS512Algorithm)
|
238
|
+
payload, header = ::JWT.decode(token, 'secret', true, algorithm: CustomHS512Algorithm)
|
239
|
+
```
|
142
240
|
|
143
241
|
## Support for reserved claim names
|
144
242
|
JSON Web Token defines some reserved claim names and defines how they should be
|
@@ -152,6 +250,38 @@ used. JWT supports these reserved claim names:
|
|
152
250
|
- 'iat' (Issued At) Claim
|
153
251
|
- 'sub' (Subject) Claim
|
154
252
|
|
253
|
+
## Add custom header fields
|
254
|
+
Ruby-jwt gem supports custom [header fields](https://tools.ietf.org/html/rfc7519#section-5)
|
255
|
+
To add custom header fields you need to pass `header_fields` parameter
|
256
|
+
|
257
|
+
```ruby
|
258
|
+
token = JWT.encode payload, key, algorithm='HS256', header_fields={}
|
259
|
+
```
|
260
|
+
|
261
|
+
**Example:**
|
262
|
+
|
263
|
+
```ruby
|
264
|
+
require 'jwt'
|
265
|
+
|
266
|
+
payload = { data: 'test' }
|
267
|
+
|
268
|
+
# IMPORTANT: set nil as password parameter
|
269
|
+
token = JWT.encode payload, nil, 'none', { typ: 'JWT' }
|
270
|
+
|
271
|
+
# eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJkYXRhIjoidGVzdCJ9.
|
272
|
+
puts token
|
273
|
+
|
274
|
+
# Set password to nil and validation to false otherwise this won't work
|
275
|
+
decoded_token = JWT.decode token, nil, false
|
276
|
+
|
277
|
+
# Array
|
278
|
+
# [
|
279
|
+
# {"data"=>"test"}, # payload
|
280
|
+
# {"typ"=>"JWT", "alg"=>"none"} # header
|
281
|
+
# ]
|
282
|
+
puts decoded_token
|
283
|
+
```
|
284
|
+
|
155
285
|
### Expiration Time Claim
|
156
286
|
|
157
287
|
From [Oauth JSON Web Token 4.1.4. "exp" (Expiration Time) Claim](https://tools.ietf.org/html/rfc7519#section-4.1.4):
|
@@ -162,31 +292,37 @@ From [Oauth JSON Web Token 4.1.4. "exp" (Expiration Time) Claim](https://tools.i
|
|
162
292
|
|
163
293
|
```ruby
|
164
294
|
exp = Time.now.to_i + 4 * 3600
|
165
|
-
exp_payload = { :
|
295
|
+
exp_payload = { data: 'data', exp: exp }
|
166
296
|
|
167
297
|
token = JWT.encode exp_payload, hmac_secret, 'HS256'
|
168
298
|
|
169
299
|
begin
|
170
|
-
decoded_token = JWT.decode token, hmac_secret, true, { :
|
300
|
+
decoded_token = JWT.decode token, hmac_secret, true, { algorithm: 'HS256' }
|
171
301
|
rescue JWT::ExpiredSignature
|
172
302
|
# Handle expired token, e.g. logout user or deny access
|
173
303
|
end
|
174
304
|
```
|
175
305
|
|
306
|
+
The Expiration Claim verification can be disabled.
|
307
|
+
```ruby
|
308
|
+
# Decode token without raising JWT::ExpiredSignature error
|
309
|
+
JWT.decode token, hmac_secret, true, { verify_expiration: false, algorithm: 'HS256' }
|
310
|
+
```
|
311
|
+
|
176
312
|
**Adding Leeway**
|
177
313
|
|
178
314
|
```ruby
|
179
315
|
exp = Time.now.to_i - 10
|
180
316
|
leeway = 30 # seconds
|
181
317
|
|
182
|
-
exp_payload = { :
|
318
|
+
exp_payload = { data: 'data', exp: exp }
|
183
319
|
|
184
320
|
# build expired token
|
185
321
|
token = JWT.encode exp_payload, hmac_secret, 'HS256'
|
186
322
|
|
187
323
|
begin
|
188
324
|
# add leeway to ensure the token is still accepted
|
189
|
-
decoded_token = JWT.decode token, hmac_secret, true, { :
|
325
|
+
decoded_token = JWT.decode token, hmac_secret, true, { exp_leeway: leeway, algorithm: 'HS256' }
|
190
326
|
rescue JWT::ExpiredSignature
|
191
327
|
# Handle expired token, e.g. logout user or deny access
|
192
328
|
end
|
@@ -202,31 +338,37 @@ From [Oauth JSON Web Token 4.1.5. "nbf" (Not Before) Claim](https://tools.ietf.o
|
|
202
338
|
|
203
339
|
```ruby
|
204
340
|
nbf = Time.now.to_i - 3600
|
205
|
-
nbf_payload = { :
|
341
|
+
nbf_payload = { data: 'data', nbf: nbf }
|
206
342
|
|
207
343
|
token = JWT.encode nbf_payload, hmac_secret, 'HS256'
|
208
344
|
|
209
345
|
begin
|
210
|
-
decoded_token = JWT.decode token, hmac_secret, true, { :
|
346
|
+
decoded_token = JWT.decode token, hmac_secret, true, { algorithm: 'HS256' }
|
211
347
|
rescue JWT::ImmatureSignature
|
212
348
|
# Handle invalid token, e.g. logout user or deny access
|
213
349
|
end
|
214
350
|
```
|
215
351
|
|
352
|
+
The Not Before Claim verification can be disabled.
|
353
|
+
```ruby
|
354
|
+
# Decode token without raising JWT::ImmatureSignature error
|
355
|
+
JWT.decode token, hmac_secret, true, { verify_not_before: false, algorithm: 'HS256' }
|
356
|
+
```
|
357
|
+
|
216
358
|
**Adding Leeway**
|
217
359
|
|
218
360
|
```ruby
|
219
361
|
nbf = Time.now.to_i + 10
|
220
362
|
leeway = 30
|
221
363
|
|
222
|
-
nbf_payload = { :
|
364
|
+
nbf_payload = { data: 'data', nbf: nbf }
|
223
365
|
|
224
366
|
# build expired token
|
225
367
|
token = JWT.encode nbf_payload, hmac_secret, 'HS256'
|
226
368
|
|
227
369
|
begin
|
228
370
|
# add leeway to ensure the token is valid
|
229
|
-
decoded_token = JWT.decode token, hmac_secret, true, { :
|
371
|
+
decoded_token = JWT.decode token, hmac_secret, true, { nbf_leeway: leeway, algorithm: 'HS256' }
|
230
372
|
rescue JWT::ImmatureSignature
|
231
373
|
# Handle invalid token, e.g. logout user or deny access
|
232
374
|
end
|
@@ -238,20 +380,52 @@ From [Oauth JSON Web Token 4.1.1. "iss" (Issuer) Claim](https://tools.ietf.org/h
|
|
238
380
|
|
239
381
|
> The `iss` (issuer) claim identifies the principal that issued the JWT. The processing of this claim is generally application specific. The `iss` value is a case-sensitive string containing a ***StringOrURI*** value. Use of this claim is OPTIONAL.
|
240
382
|
|
383
|
+
You can pass multiple allowed issuers as an Array, verification will pass if one of them matches the `iss` value in the payload.
|
384
|
+
|
241
385
|
```ruby
|
242
386
|
iss = 'My Awesome Company Inc. or https://my.awesome.website/'
|
243
|
-
iss_payload = { :
|
387
|
+
iss_payload = { data: 'data', iss: iss }
|
244
388
|
|
245
389
|
token = JWT.encode iss_payload, hmac_secret, 'HS256'
|
246
390
|
|
247
391
|
begin
|
248
392
|
# Add iss to the validation to check if the token has been manipulated
|
249
|
-
decoded_token = JWT.decode token, hmac_secret, true, { :
|
393
|
+
decoded_token = JWT.decode token, hmac_secret, true, { iss: iss, verify_iss: true, algorithm: 'HS256' }
|
250
394
|
rescue JWT::InvalidIssuerError
|
251
395
|
# Handle invalid token, e.g. logout user or deny access
|
252
396
|
end
|
253
397
|
```
|
254
398
|
|
399
|
+
You can also pass a Regexp or Proc (with arity 1), verification will pass if the regexp matches or the proc returns truthy.
|
400
|
+
On supported ruby versions (>= 2.5) you can also delegate to methods, on older versions you will have
|
401
|
+
to convert them to proc (using `to_proc`)
|
402
|
+
|
403
|
+
```ruby
|
404
|
+
JWT.decode token, hmac_secret, true,
|
405
|
+
iss: %r'https://my.awesome.website/',
|
406
|
+
verify_iss: true,
|
407
|
+
algorithm: 'HS256'
|
408
|
+
```
|
409
|
+
|
410
|
+
```ruby
|
411
|
+
JWT.decode token, hmac_secret, true,
|
412
|
+
iss: ->(issuer) { issuer.start_with?('My Awesome Company Inc') },
|
413
|
+
verify_iss: true,
|
414
|
+
algorithm: 'HS256'
|
415
|
+
```
|
416
|
+
|
417
|
+
```ruby
|
418
|
+
JWT.decode token, hmac_secret, true,
|
419
|
+
iss: method(:valid_issuer?),
|
420
|
+
verify_iss: true,
|
421
|
+
algorithm: 'HS256'
|
422
|
+
|
423
|
+
# somewhere in the same class:
|
424
|
+
def valid_issuer?(issuer)
|
425
|
+
# custom validation
|
426
|
+
end
|
427
|
+
```
|
428
|
+
|
255
429
|
### Audience Claim
|
256
430
|
|
257
431
|
From [Oauth JSON Web Token 4.1.3. "aud" (Audience) Claim](https://tools.ietf.org/html/rfc7519#section-4.1.3):
|
@@ -260,13 +434,13 @@ From [Oauth JSON Web Token 4.1.3. "aud" (Audience) Claim](https://tools.ietf.org
|
|
260
434
|
|
261
435
|
```ruby
|
262
436
|
aud = ['Young', 'Old']
|
263
|
-
aud_payload = { :
|
437
|
+
aud_payload = { data: 'data', aud: aud }
|
264
438
|
|
265
439
|
token = JWT.encode aud_payload, hmac_secret, 'HS256'
|
266
440
|
|
267
441
|
begin
|
268
442
|
# Add aud to the validation to check if the token has been manipulated
|
269
|
-
decoded_token = JWT.decode token, hmac_secret, true, { :
|
443
|
+
decoded_token = JWT.decode token, hmac_secret, true, { aud: aud, verify_aud: true, algorithm: 'HS256' }
|
270
444
|
rescue JWT::InvalidAudError
|
271
445
|
# Handle invalid token, e.g. logout user or deny access
|
272
446
|
puts 'Audience Error'
|
@@ -283,37 +457,40 @@ From [Oauth JSON Web Token 4.1.7. "jti" (JWT ID) Claim](https://tools.ietf.org/h
|
|
283
457
|
# Use the secret and iat to create a unique key per request to prevent replay attacks
|
284
458
|
jti_raw = [hmac_secret, iat].join(':').to_s
|
285
459
|
jti = Digest::MD5.hexdigest(jti_raw)
|
286
|
-
jti_payload = { :
|
460
|
+
jti_payload = { data: 'data', iat: iat, jti: jti }
|
287
461
|
|
288
462
|
token = JWT.encode jti_payload, hmac_secret, 'HS256'
|
289
463
|
|
290
464
|
begin
|
291
465
|
# If :verify_jti is true, validation will pass if a JTI is present
|
292
|
-
#decoded_token = JWT.decode token, hmac_secret, true, { :
|
466
|
+
#decoded_token = JWT.decode token, hmac_secret, true, { verify_jti: true, algorithm: 'HS256' }
|
293
467
|
# Alternatively, pass a proc with your own code to check if the JTI has already been used
|
294
|
-
decoded_token = JWT.decode token, hmac_secret, true, { :
|
468
|
+
decoded_token = JWT.decode token, hmac_secret, true, { verify_jti: proc { |jti| my_validation_method(jti) }, algorithm: 'HS256' }
|
469
|
+
# or
|
470
|
+
decoded_token = JWT.decode token, hmac_secret, true, { verify_jti: proc { |jti, payload| my_validation_method(jti, payload) }, algorithm: 'HS256' }
|
295
471
|
rescue JWT::InvalidJtiError
|
296
472
|
# Handle invalid token, e.g. logout user or deny access
|
297
473
|
puts 'Error'
|
298
474
|
end
|
299
|
-
|
300
475
|
```
|
301
476
|
|
302
477
|
### Issued At Claim
|
303
478
|
|
304
479
|
From [Oauth JSON Web Token 4.1.6. "iat" (Issued At) Claim](https://tools.ietf.org/html/rfc7519#section-4.1.6):
|
305
480
|
|
306
|
-
> The `iat` (issued at) claim identifies the time at which the JWT was issued. This claim can be used to determine the age of the JWT. Its value MUST be a number containing a ***NumericDate*** value. Use of this claim is OPTIONAL.
|
481
|
+
> The `iat` (issued at) claim identifies the time at which the JWT was issued. This claim can be used to determine the age of the JWT. The `leeway` option is not taken into account when verifying this claim. The `iat_leeway` option was removed in version 2.2.0. Its value MUST be a number containing a ***NumericDate*** value. Use of this claim is OPTIONAL.
|
482
|
+
|
483
|
+
**Handle Issued At Claim**
|
307
484
|
|
308
485
|
```ruby
|
309
486
|
iat = Time.now.to_i
|
310
|
-
iat_payload = { :
|
487
|
+
iat_payload = { data: 'data', iat: iat }
|
311
488
|
|
312
489
|
token = JWT.encode iat_payload, hmac_secret, 'HS256'
|
313
490
|
|
314
491
|
begin
|
315
492
|
# Add iat to the validation to check if the token has been manipulated
|
316
|
-
decoded_token = JWT.decode token, hmac_secret, true, { :
|
493
|
+
decoded_token = JWT.decode token, hmac_secret, true, { verify_iat: true, algorithm: 'HS256' }
|
317
494
|
rescue JWT::InvalidIatError
|
318
495
|
# Handle invalid token, e.g. logout user or deny access
|
319
496
|
end
|
@@ -327,18 +504,182 @@ From [Oauth JSON Web Token 4.1.2. "sub" (Subject) Claim](https://tools.ietf.org/
|
|
327
504
|
|
328
505
|
```ruby
|
329
506
|
sub = 'Subject'
|
330
|
-
sub_payload = { :
|
507
|
+
sub_payload = { data: 'data', sub: sub }
|
331
508
|
|
332
509
|
token = JWT.encode sub_payload, hmac_secret, 'HS256'
|
333
510
|
|
334
511
|
begin
|
335
512
|
# Add sub to the validation to check if the token has been manipulated
|
336
|
-
decoded_token = JWT.decode token, hmac_secret, true, {
|
513
|
+
decoded_token = JWT.decode token, hmac_secret, true, { sub: sub, verify_sub: true, algorithm: 'HS256' }
|
337
514
|
rescue JWT::InvalidSubError
|
338
515
|
# Handle invalid token, e.g. logout user or deny access
|
339
516
|
end
|
340
517
|
```
|
341
518
|
|
519
|
+
### Finding a Key
|
520
|
+
|
521
|
+
To dynamically find the key for verifying the JWT signature, pass a block to the decode block. The block receives headers and the original payload as parameters. It should return with the key to verify the signature that was used to sign the JWT.
|
522
|
+
|
523
|
+
```ruby
|
524
|
+
issuers = %w[My_Awesome_Company1 My_Awesome_Company2]
|
525
|
+
iss_payload = { data: 'data', iss: issuers.first }
|
526
|
+
|
527
|
+
secrets = { issuers.first => hmac_secret, issuers.last => 'hmac_secret2' }
|
528
|
+
|
529
|
+
token = JWT.encode iss_payload, hmac_secret, 'HS256'
|
530
|
+
|
531
|
+
begin
|
532
|
+
# Add iss to the validation to check if the token has been manipulated
|
533
|
+
decoded_token = JWT.decode(token, nil, true, { iss: issuers, verify_iss: true, algorithm: 'HS256' }) do |_headers, payload|
|
534
|
+
secrets[payload['iss']]
|
535
|
+
end
|
536
|
+
rescue JWT::InvalidIssuerError
|
537
|
+
# Handle invalid token, e.g. logout user or deny access
|
538
|
+
end
|
539
|
+
```
|
540
|
+
|
541
|
+
### Required Claims
|
542
|
+
|
543
|
+
You can specify claims that must be present for decoding to be successful. JWT::MissingRequiredClaim will be raised if any are missing
|
544
|
+
```ruby
|
545
|
+
# Will raise a JWT::MissingRequiredClaim error if the 'exp' claim is absent
|
546
|
+
JWT.decode token, hmac_secret, true, { required_claims: ['exp'], algorithm: 'HS256' }
|
547
|
+
```
|
548
|
+
|
549
|
+
### X.509 certificates in x5c header
|
550
|
+
|
551
|
+
A JWT signature can be verified using certificate(s) given in the `x5c` header. Before doing that, the trustworthiness of these certificate(s) must be established. This is done in accordance with RFC 5280 which (among other things) verifies the certificate(s) are issued by a trusted root certificate, the timestamps are valid, and none of the certificate(s) are revoked (i.e. being present in the root certificate's Certificate Revocation List).
|
552
|
+
|
553
|
+
```ruby
|
554
|
+
root_certificates = [] # trusted `OpenSSL::X509::Certificate` objects
|
555
|
+
crl_uris = root_certificates.map(&:crl_uris)
|
556
|
+
crls = crl_uris.map do |uri|
|
557
|
+
# look up cached CRL by `uri` and return it if found, otherwise continue
|
558
|
+
crl = Net::HTTP.get(uri)
|
559
|
+
crl = OpenSSL::X509::CRL.new(crl)
|
560
|
+
# cache `crl` using `uri` as the key, expiry set to `crl.next_update` timestamp
|
561
|
+
end
|
562
|
+
|
563
|
+
begin
|
564
|
+
JWT.decode(token, nil, true, { x5c: { root_certificates: root_certificates, crls: crls })
|
565
|
+
rescue JWT::DecodeError
|
566
|
+
# Handle error, e.g. x5c header certificate revoked or expired
|
567
|
+
end
|
568
|
+
```
|
569
|
+
|
570
|
+
### JSON Web Key (JWK)
|
571
|
+
|
572
|
+
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.
|
573
|
+
|
574
|
+
To encode a JWT using your JWK:
|
575
|
+
|
576
|
+
```ruby
|
577
|
+
optional_parameters = { kid: 'my-kid', use: 'sig', alg: 'RS512' }
|
578
|
+
jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), optional_parameters)
|
579
|
+
|
580
|
+
# Encoding
|
581
|
+
payload = { data: 'data' }
|
582
|
+
token = JWT.encode(payload, jwk.signing_key, jwk[:alg], kid: jwk[:kid])
|
583
|
+
|
584
|
+
# JSON Web Key Set for advertising your signing keys
|
585
|
+
jwks_hash = JWT::JWK::Set.new(jwk).export
|
586
|
+
```
|
587
|
+
|
588
|
+
To decode a JWT using a trusted entity's JSON Web Key Set (JWKS):
|
589
|
+
|
590
|
+
```ruby
|
591
|
+
jwks = JWT::JWK::Set.new(jwks_hash)
|
592
|
+
jwks.filter! {|key| key[:use] == 'sig' } # Signing keys only!
|
593
|
+
algorithms = jwks.map { |key| key[:alg] }.compact.uniq
|
594
|
+
JWT.decode(token, nil, true, algorithms: algorithms, jwks: jwks)
|
595
|
+
```
|
596
|
+
|
597
|
+
|
598
|
+
The `jwks` option can also be given as a lambda that evaluates every time a kid is resolved.
|
599
|
+
This can be used to implement caching of remotely fetched JWK Sets.
|
600
|
+
|
601
|
+
If the requested `kid` is not found from the given set the loader will be called a second time with the `kid_not_found` option set to `true`.
|
602
|
+
The application can choose to implement some kind of JWK cache invalidation or other mechanism to handle such cases.
|
603
|
+
|
604
|
+
Tokens without a specified `kid` are rejected by default.
|
605
|
+
This behaviour may be overwritten by setting the `allow_nil_jwks` option for `decode` to `true`.
|
606
|
+
|
607
|
+
```ruby
|
608
|
+
jwks_loader = ->(options) do
|
609
|
+
# The jwk loader would fetch the set of JWKs from a trusted source.
|
610
|
+
# To avoid malicious requests triggering cache invalidations there needs to be
|
611
|
+
# some kind of grace time or other logic for determining the validity of the invalidation.
|
612
|
+
# This example only allows cache invalidations every 5 minutes.
|
613
|
+
if options[:kid_not_found] && @cache_last_update < Time.now.to_i - 300
|
614
|
+
logger.info("Invalidating JWK cache. #{options[:kid]} not found from previous cache")
|
615
|
+
@cached_keys = nil
|
616
|
+
end
|
617
|
+
@cached_keys ||= begin
|
618
|
+
@cache_last_update = Time.now.to_i
|
619
|
+
# Replace with your own JWKS fetching routine
|
620
|
+
jwks = JWT::JWK::Set.new(jwks_hash)
|
621
|
+
jwks.select! { |key| key[:use] == 'sig' } # Signing Keys only
|
622
|
+
jwks
|
623
|
+
end
|
624
|
+
end
|
625
|
+
|
626
|
+
begin
|
627
|
+
JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwks_loader })
|
628
|
+
rescue JWT::JWKError
|
629
|
+
# Handle problems with the provided JWKs
|
630
|
+
rescue JWT::DecodeError
|
631
|
+
# Handle other decode related issues e.g. no kid in header, no matching public key found etc.
|
632
|
+
end
|
633
|
+
```
|
634
|
+
|
635
|
+
### Importing and exporting JSON Web Keys
|
636
|
+
|
637
|
+
The ::JWT::JWK class can be used to import both JSON Web Keys and OpenSSL keys
|
638
|
+
and export to either format with and without the private key included.
|
639
|
+
|
640
|
+
To include the private key in the export pass the `include_private` parameter to the export method.
|
641
|
+
|
642
|
+
```ruby
|
643
|
+
# Import a JWK Hash (showing an HMAC example)
|
644
|
+
jwk = JWT::JWK.new({ kty: 'oct', k: 'my-secret', kid: 'my-kid' })
|
645
|
+
|
646
|
+
# Import an OpenSSL key
|
647
|
+
# You can optionally add descriptive parameters to the JWK
|
648
|
+
desc_params = { kid: 'my-kid', use: 'sig' }
|
649
|
+
jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), desc_params)
|
650
|
+
|
651
|
+
# Export as JWK Hash (public key only by default)
|
652
|
+
jwk_hash = jwk.export
|
653
|
+
jwk_hash_with_private_key = jwk.export(include_private: true)
|
654
|
+
|
655
|
+
# Export as OpenSSL key
|
656
|
+
public_key = jwk.verify_key
|
657
|
+
private_key = jwk.signing_key if jwk.private?
|
658
|
+
|
659
|
+
# You can also import and export entire JSON Web Key Sets
|
660
|
+
jwks_hash = { keys: [{ kty: 'oct', k: 'my-secret', kid: 'my-kid' }] }
|
661
|
+
jwks = JWT::JWK::Set.new(jwks_hash)
|
662
|
+
jwks_hash = jwks.export
|
663
|
+
```
|
664
|
+
|
665
|
+
### Key ID (kid) and JWKs
|
666
|
+
|
667
|
+
The key id (kid) generation in the gem is a custom algorithm and not based on any standards.
|
668
|
+
To use a standardized JWK thumbprint (RFC 7638) as the kid for JWKs a generator type can be specified in the global configuration
|
669
|
+
or can be given to the JWK instance on initialization.
|
670
|
+
|
671
|
+
```ruby
|
672
|
+
JWT.configuration.jwk.kid_generator_type = :rfc7638_thumbprint
|
673
|
+
# OR
|
674
|
+
JWT.configuration.jwk.kid_generator = ::JWT::JWK::Thumbprint
|
675
|
+
# OR
|
676
|
+
jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), nil, kid_generator: ::JWT::JWK::Thumbprint)
|
677
|
+
|
678
|
+
jwk_hash = jwk.export
|
679
|
+
|
680
|
+
thumbprint_as_the_kid = jwk_hash[:kid]
|
681
|
+
```
|
682
|
+
|
342
683
|
# Development and Tests
|
343
684
|
|
344
685
|
We depend on [Bundler](http://rubygems.org/gems/bundler) for defining gemspec and performing releases to rubygems.org, which can be done with
|
@@ -347,40 +688,20 @@ We depend on [Bundler](http://rubygems.org/gems/bundler) for defining gemspec an
|
|
347
688
|
rake release
|
348
689
|
```
|
349
690
|
|
350
|
-
The tests are written with rspec.
|
691
|
+
The tests are written with rspec. [Appraisal](https://github.com/thoughtbot/appraisal) is used to ensure compatibility with 3rd party dependencies providing cryptographic features.
|
351
692
|
|
352
693
|
```bash
|
353
|
-
bundle
|
694
|
+
bundle install
|
695
|
+
bundle exec appraisal rake test
|
354
696
|
```
|
355
697
|
|
356
|
-
|
698
|
+
## How to contribute
|
699
|
+
See [CONTRIBUTING](CONTRIBUTING.md).
|
357
700
|
|
358
701
|
## Contributors
|
359
702
|
|
360
|
-
|
361
|
-
* Ilya Zhitomirskiy <ilya@joindiaspora.com>
|
362
|
-
* Daniel Grippi <daniel@joindiaspora.com>
|
363
|
-
* Jeff Lindsay <progrium@gmail.com>
|
364
|
-
* Bob Aman <bob@sporkmonger.com>
|
365
|
-
* Micah Gates <github@mgates.com>
|
366
|
-
* Rob Wygand <rob@wygand.com>
|
367
|
-
* Ariel Salomon (Oscil8)
|
368
|
-
* Paul Battley <pbattley@gmail.com>
|
369
|
-
* Zane Shannon [@zshannon](https://github.com/zshannon)
|
370
|
-
* Brian Fletcher [@punkle](https://github.com/punkle)
|
371
|
-
* Alex [@ZhangHanDong](https://github.com/ZhangHanDong)
|
372
|
-
* John Downey [@jtdowney](https://github.com/jtdowney)
|
373
|
-
* Adam Greene [@skippy](https://github.com/skippy)
|
374
|
-
* Tim Rudat [@excpt](https://github.com/excpt) <timrudat@gmail.com> - Maintainer
|
703
|
+
See [AUTHORS](AUTHORS).
|
375
704
|
|
376
705
|
## License
|
377
706
|
|
378
|
-
|
379
|
-
|
380
|
-
Copyright (c) 2011 Jeff Lindsay
|
381
|
-
|
382
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
383
|
-
|
384
|
-
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
385
|
-
|
386
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
707
|
+
See [LICENSE](LICENSE).
|