rack-jwt-verifier 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +101 -0
- data/README.md +136 -80
- data/lib/rack-jwt-verifier.rb +5 -0
- data/lib/rack_jwt_verifier/errors.rb +8 -0
- data/lib/rack_jwt_verifier/in_process_cache.rb +13 -7
- data/lib/rack_jwt_verifier/jwt_helper.rb +22 -31
- data/lib/rack_jwt_verifier/key_source.rb +299 -0
- data/lib/rack_jwt_verifier/middleware.rb +158 -30
- data/lib/rack_jwt_verifier/verifier.rb +110 -62
- data/lib/rack_jwt_verifier/version.rb +1 -1
- data/lib/rack_jwt_verifier.rb +7 -11
- metadata +27 -90
- data/.rspec_status +0 -26
- data/Gemfile +0 -14
- data/Gemfile.lock +0 -63
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "jwt"
|
|
6
|
+
require "logger"
|
|
7
|
+
require "net/http"
|
|
8
|
+
require "openssl"
|
|
9
|
+
require "uri"
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
require_relative "version"
|
|
12
|
+
|
|
13
|
+
module RackJwtVerifier
|
|
14
|
+
# Where the verification key material comes from.
|
|
15
|
+
#
|
|
16
|
+
# Every source answers #jwks? and #refresh!. A single-key source (Static,
|
|
17
|
+
# RemotePem) also answers #verification_key; a JWKS source answers
|
|
18
|
+
# #jwks_loader, in the shape ruby-jwt's :jwks decode option expects.
|
|
19
|
+
module KeySource
|
|
20
|
+
# Turns PEM text into an OpenSSL public key. Accepts a bare public key
|
|
21
|
+
# (RSA, EC, Ed25519, ...) or an X.509 certificate, which is what many SSO
|
|
22
|
+
# providers serve at their ".pem" endpoints. An OpenSSL::PKey passes through.
|
|
23
|
+
def self.parse_public_key(pem)
|
|
24
|
+
return pem if pem.is_a?(OpenSSL::PKey::PKey)
|
|
25
|
+
|
|
26
|
+
text = pem.to_s
|
|
27
|
+
if text.include?("-----BEGIN CERTIFICATE-----")
|
|
28
|
+
OpenSSL::X509::Certificate.new(text).public_key
|
|
29
|
+
else
|
|
30
|
+
OpenSSL::PKey.read(text)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A key handed over directly: a PEM string, a certificate PEM, or an
|
|
35
|
+
# OpenSSL::PKey. Nothing to fetch, cache or refresh.
|
|
36
|
+
class Static
|
|
37
|
+
attr_reader :verification_key
|
|
38
|
+
|
|
39
|
+
def initialize(key)
|
|
40
|
+
@verification_key = KeySource.parse_public_key(key)
|
|
41
|
+
rescue OpenSSL::PKey::PKeyError, OpenSSL::X509::CertificateError => e
|
|
42
|
+
raise ArgumentError, "public_key is not a valid PEM public key or certificate: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def jwks?
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def refresh!
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Shared machinery for key material fetched over HTTPS: caching, strict
|
|
55
|
+
# timeouts, a size cap, single-flight fetching within the process, and a
|
|
56
|
+
# rate-limited refresh for key rotation. Subclasses say how to parse the
|
|
57
|
+
# body and which cache namespace to use.
|
|
58
|
+
class Remote
|
|
59
|
+
# Timeout (seconds) applied separately to opening the connection and to
|
|
60
|
+
# reading the response. Kept short so a slow endpoint cannot pin every
|
|
61
|
+
# request thread on a cache miss.
|
|
62
|
+
DEFAULT_HTTP_TIMEOUT = 5
|
|
63
|
+
# Largest response body (bytes) we are willing to read. A PEM key is well
|
|
64
|
+
# under 1 KB and a JWKS with a handful of keys a few KB.
|
|
65
|
+
MAX_RESPONSE_BYTES = 64 * 1024
|
|
66
|
+
# How long (seconds) fetched material is served from the cache.
|
|
67
|
+
DEFAULT_CACHE_TTL = 300
|
|
68
|
+
# Minimum gap (seconds) between two rotation-triggered refetches, so a
|
|
69
|
+
# flood of tokens with bad signatures or unknown kids cannot turn into a
|
|
70
|
+
# flood of requests to the provider.
|
|
71
|
+
DEFAULT_REFETCH_INTERVAL = 60
|
|
72
|
+
|
|
73
|
+
attr_reader :url, :cache_key
|
|
74
|
+
|
|
75
|
+
def initialize(url:, cache:, cache_ttl: DEFAULT_CACHE_TTL, http_timeout: DEFAULT_HTTP_TIMEOUT,
|
|
76
|
+
refetch_interval: DEFAULT_REFETCH_INTERVAL, allow_insecure_http: false, logger: nil)
|
|
77
|
+
@url = url.to_s
|
|
78
|
+
@uri = parse_url(@url, allow_insecure_http: allow_insecure_http)
|
|
79
|
+
@cache = cache
|
|
80
|
+
@logger = logger || Logger.new(IO::NULL)
|
|
81
|
+
@cache_ttl = cache_ttl
|
|
82
|
+
@http_timeout = http_timeout
|
|
83
|
+
@refetch_interval = refetch_interval
|
|
84
|
+
# Scoped to the URL so two verifiers sharing one cache store (two SSO
|
|
85
|
+
# providers behind one Redis) never read each other's key.
|
|
86
|
+
@cache_key = "#{cache_key_prefix}:#{Digest::SHA256.hexdigest(@url)[0, 16]}"
|
|
87
|
+
|
|
88
|
+
@fetch_lock = Mutex.new # single-flight: one network fetch per process on a cold cache
|
|
89
|
+
@state_lock = Mutex.new # guards @parsed and @last_refetch_at
|
|
90
|
+
@parsed = nil # [body, parsed material] of the last body parsed
|
|
91
|
+
@last_refetch_at = nil # monotonic clock
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Refetches the material unless a refresh happened less than
|
|
95
|
+
# refetch_interval seconds ago. Returns true if it actually refetched.
|
|
96
|
+
def refresh!
|
|
97
|
+
@state_lock.synchronize do
|
|
98
|
+
now = monotonic_now
|
|
99
|
+
return false if @last_refetch_at && now - @last_refetch_at < @refetch_interval
|
|
100
|
+
|
|
101
|
+
@last_refetch_at = now
|
|
102
|
+
end
|
|
103
|
+
fetch_and_store
|
|
104
|
+
true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# Parsed material, from the cache or (on a miss) the network.
|
|
110
|
+
def material
|
|
111
|
+
body = cache_read || fetch_once
|
|
112
|
+
parsed_for(body)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Only one thread per process performs the fetch on a cold cache; the
|
|
116
|
+
# others wait for it and then find the body in the cache.
|
|
117
|
+
def fetch_once
|
|
118
|
+
@fetch_lock.synchronize do
|
|
119
|
+
cache_read || fetch_and_store
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def fetch_and_store
|
|
124
|
+
body = fetch_body
|
|
125
|
+
# Parse *before* caching so a 200 that is not key material (an HTML
|
|
126
|
+
# maintenance page, say) is never stored and served for the TTL.
|
|
127
|
+
parse!(body)
|
|
128
|
+
cache_write(body)
|
|
129
|
+
body
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A broken cache store (Redis down, say) must not take authentication
|
|
133
|
+
# down with it: treat a failed read as a miss and a failed write as a
|
|
134
|
+
# no-op, and say so in the log. Fetches then fall back to one per
|
|
135
|
+
# request per process until the store recovers.
|
|
136
|
+
def cache_read
|
|
137
|
+
@cache.read(cache_key)
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
@logger.warn do
|
|
140
|
+
"rack_jwt_verifier: cache read failed (#{e.class}: #{e.message}); fetching key material directly"
|
|
141
|
+
end
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def cache_write(body)
|
|
146
|
+
@cache.write(cache_key, body, expires_in: @cache_ttl)
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
@logger.warn do
|
|
149
|
+
"rack_jwt_verifier: cache write failed (#{e.class}: #{e.message}); key material will be refetched"
|
|
150
|
+
end
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Parsing is not free (and JWKS parsing less so), so the result for a
|
|
155
|
+
# given body is memoised until the body changes.
|
|
156
|
+
def parsed_for(body)
|
|
157
|
+
@state_lock.synchronize do
|
|
158
|
+
@parsed = [body, parse!(body)] unless @parsed && @parsed[0] == body
|
|
159
|
+
@parsed[1]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def parse!(body)
|
|
164
|
+
parse(body)
|
|
165
|
+
rescue KeyFetchError
|
|
166
|
+
raise
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
raise KeyFetchError, "Error processing public key: #{e.message}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The material must travel over TLS: an attacker who can tamper with a
|
|
172
|
+
# plaintext fetch can substitute their own key and mint arbitrary tokens.
|
|
173
|
+
def parse_url(url, allow_insecure_http:)
|
|
174
|
+
uri = URI.parse(url)
|
|
175
|
+
return uri if uri.is_a?(URI::HTTPS)
|
|
176
|
+
return uri if uri.is_a?(URI::HTTP) && allow_insecure_http
|
|
177
|
+
|
|
178
|
+
raise ArgumentError,
|
|
179
|
+
"#{url_option_name} must be an https:// URL (got #{url.inspect}). " \
|
|
180
|
+
"Pass allow_insecure_http: true to permit http:// in development."
|
|
181
|
+
rescue URI::InvalidURIError
|
|
182
|
+
raise ArgumentError, "#{url_option_name} is not a valid URL: #{url.inspect}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Performs the HTTP GET with strict timeouts and a size cap. The body is
|
|
186
|
+
# streamed so an oversized response is abandoned early rather than
|
|
187
|
+
# buffered in full.
|
|
188
|
+
def fetch_body
|
|
189
|
+
uri = @uri
|
|
190
|
+
body = +""
|
|
191
|
+
|
|
192
|
+
Net::HTTP.start(uri.host, uri.port,
|
|
193
|
+
use_ssl: uri.scheme == "https",
|
|
194
|
+
open_timeout: @http_timeout,
|
|
195
|
+
read_timeout: @http_timeout) do |http|
|
|
196
|
+
request = Net::HTTP::Get.new(uri)
|
|
197
|
+
request["User-Agent"] = "rack_jwt_verifier/#{VERSION}"
|
|
198
|
+
request["Accept"] = accept_header
|
|
199
|
+
|
|
200
|
+
http.request(request) do |response|
|
|
201
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
202
|
+
raise KeyFetchError, "Failed to fetch public key from #{@url}: #{response.code}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
response.read_body do |chunk|
|
|
206
|
+
body << chunk
|
|
207
|
+
if body.bytesize > MAX_RESPONSE_BYTES
|
|
208
|
+
raise KeyFetchError, "Public key response from #{@url} exceeds #{MAX_RESPONSE_BYTES} bytes"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
body
|
|
215
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
216
|
+
raise KeyFetchError, "Timed out fetching public key from #{@url} after #{@http_timeout}s"
|
|
217
|
+
rescue KeyFetchError
|
|
218
|
+
raise
|
|
219
|
+
rescue StandardError => e
|
|
220
|
+
raise KeyFetchError, "Error fetching public key from #{@url}: #{e.message}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def monotonic_now
|
|
224
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# A single PEM public key (or X.509 certificate) served at a URL.
|
|
229
|
+
class RemotePem < Remote
|
|
230
|
+
CACHE_KEY_PREFIX = "rack_jwt_verifier:public_key"
|
|
231
|
+
|
|
232
|
+
def verification_key
|
|
233
|
+
material
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def jwks?
|
|
237
|
+
false
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def cache_key_prefix
|
|
243
|
+
CACHE_KEY_PREFIX
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def url_option_name
|
|
247
|
+
"public_key_url"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def accept_header
|
|
251
|
+
"application/x-pem-file, text/plain, */*"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def parse(body)
|
|
255
|
+
KeySource.parse_public_key(body)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# A JSON Web Key Set served at a URL (typically /.well-known/jwks.json).
|
|
260
|
+
class RemoteJwks < Remote
|
|
261
|
+
CACHE_KEY_PREFIX = "rack_jwt_verifier:jwks"
|
|
262
|
+
|
|
263
|
+
def jwks?
|
|
264
|
+
true
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# ruby-jwt calls the loader once, and again with kid_not_found: true when
|
|
268
|
+
# the token's kid is absent from the set it got — the rotation signal for
|
|
269
|
+
# JWKS. Refresh (rate-limited) on that signal, then hand back the set.
|
|
270
|
+
def jwks_loader
|
|
271
|
+
lambda do |options|
|
|
272
|
+
refresh! if options[:kid_not_found] || options[:invalidate]
|
|
273
|
+
material
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
def cache_key_prefix
|
|
280
|
+
CACHE_KEY_PREFIX
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def url_option_name
|
|
284
|
+
"jwks_url"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def accept_header
|
|
288
|
+
"application/jwk-set+json, application/json, */*"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def parse(body)
|
|
292
|
+
set = JWT::JWK::Set.new(JSON.parse(body))
|
|
293
|
+
raise KeyFetchError, "JWKS from #{@url} contains no keys" if set.none?
|
|
294
|
+
|
|
295
|
+
set
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "json"
|
|
4
|
+
require "logger"
|
|
5
|
+
require_relative "verifier"
|
|
4
6
|
|
|
5
7
|
module RackJwtVerifier
|
|
6
8
|
# The primary middleware class responsible for intercepting requests,
|
|
@@ -10,61 +12,187 @@ module RackJwtVerifier
|
|
|
10
12
|
# The default key in the Rack environment used to store the verified JWT payload.
|
|
11
13
|
# This can be accessed by downstream applications (e.g., Rails controllers)
|
|
12
14
|
# to retrieve the authenticated user's details.
|
|
13
|
-
RACK_ENV_PAYLOAD_KEY = "rack_jwt_verifier.payload"
|
|
15
|
+
RACK_ENV_PAYLOAD_KEY = "rack_jwt_verifier.payload"
|
|
14
16
|
|
|
17
|
+
# Seconds a client is told to wait before retrying after a 503.
|
|
18
|
+
RETRY_AFTER_SECONDS = 5
|
|
19
|
+
|
|
20
|
+
# Used when neither a :logger option nor env["rack.logger"] is available.
|
|
21
|
+
NULL_LOGGER = Logger.new(IO::NULL)
|
|
22
|
+
|
|
23
|
+
# Status and default plain-text body for each way a request can be refused.
|
|
24
|
+
# The reason symbol doubles as the `error` code in JSON bodies.
|
|
25
|
+
ERROR_RESPONSES = {
|
|
26
|
+
missing_token: [401, "Unauthorized: Bearer token required."],
|
|
27
|
+
invalid_token: [401, "Unauthorized: Invalid or expired JWT."],
|
|
28
|
+
key_unavailable: [503, "Service Unavailable: could not fetch the token verification key."]
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# @param app [#call] The downstream Rack application.
|
|
32
|
+
# @param options [Hash] Middleware options; everything else is forwarded to Verifier.
|
|
33
|
+
# @option options [Boolean] :require_token Reject requests that carry no Bearer token
|
|
34
|
+
# (default: false, pass through).
|
|
35
|
+
# @option options [Logger] :logger Logger for verification failures
|
|
36
|
+
# (default: env["rack.logger"], else silent).
|
|
37
|
+
# @option options [String] :env_key Rack env key that receives the payload
|
|
38
|
+
# (default: RACK_ENV_PAYLOAD_KEY).
|
|
39
|
+
# @option options [Array<String, Regexp, #call>] :skip Paths (exact string, regexp) or
|
|
40
|
+
# predicates on env that bypass the middleware.
|
|
41
|
+
# @option options [Boolean] :json_errors Render 401/503 bodies as JSON
|
|
42
|
+
# `{"error", "error_description"}` (default: plain text).
|
|
43
|
+
# @option options [#call] :on_error `->(env, reason, exception) { rack_response or nil }`
|
|
44
|
+
# to customise refusals.
|
|
15
45
|
def initialize(app, options = {})
|
|
16
46
|
@app = app
|
|
17
|
-
@
|
|
18
|
-
|
|
47
|
+
@require_token = options.fetch(:require_token, false)
|
|
48
|
+
@logger = options[:logger]
|
|
49
|
+
@env_key = options.fetch(:env_key, RACK_ENV_PAYLOAD_KEY)
|
|
50
|
+
@skip = validate_skip_rules(Array(options[:skip]))
|
|
51
|
+
@json_errors = options.fetch(:json_errors, false)
|
|
52
|
+
@on_error = options[:on_error]
|
|
53
|
+
|
|
19
54
|
# The Verifier instance is initialized with options (like public_key_url)
|
|
20
55
|
# and is responsible for all crypto and key management.
|
|
21
56
|
@verifier = Verifier.new(options)
|
|
57
|
+
|
|
58
|
+
warn_if_claims_unrestricted(options.fetch(:decode_options, {}))
|
|
22
59
|
end
|
|
23
60
|
|
|
24
61
|
def call(env)
|
|
62
|
+
return @app.call(env) if skip?(env)
|
|
63
|
+
|
|
25
64
|
token = extract_token(env)
|
|
26
|
-
|
|
27
|
-
# If no token is found, we immediately pass the request down the stack
|
|
28
|
-
# and the application is responsible for handling the unauthenticated state.
|
|
29
|
-
return @app.call(env) unless token
|
|
30
65
|
|
|
66
|
+
# With no token the request is passed down the stack and the application
|
|
67
|
+
# decides how to treat the unauthenticated state — unless require_token
|
|
68
|
+
# is set, in which case it is rejected here.
|
|
69
|
+
unless token
|
|
70
|
+
return error_response(env, :missing_token, nil) if @require_token
|
|
71
|
+
|
|
72
|
+
return @app.call(env)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Only the verification step is guarded: a JWT::DecodeError raised by the
|
|
76
|
+
# downstream application must propagate, not be turned into a 401 here.
|
|
31
77
|
begin
|
|
32
78
|
# Use the Verifier to handle the complex crypto and validation logic
|
|
33
79
|
payload = @verifier.verify(token)
|
|
34
|
-
|
|
35
|
-
# On successful verification, store the payload in the Rack environment
|
|
36
|
-
env[RACK_ENV_PAYLOAD_KEY] = payload
|
|
37
|
-
|
|
38
|
-
@app.call(env)
|
|
39
80
|
rescue JWT::DecodeError => e
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
#
|
|
45
|
-
|
|
81
|
+
# Invalid signature, expired, bad claim: the client's problem.
|
|
82
|
+
logger(env).warn { "rack_jwt_verifier: token rejected: #{e.message}" }
|
|
83
|
+
return error_response(env, :invalid_token, e)
|
|
84
|
+
rescue KeyFetchError => e
|
|
85
|
+
# We could not obtain the key to check the token: our problem, not the
|
|
86
|
+
# client's, so answer 503 rather than 401.
|
|
87
|
+
logger(env).error { "rack_jwt_verifier: #{e.message}" }
|
|
88
|
+
return error_response(env, :key_unavailable, e)
|
|
46
89
|
end
|
|
90
|
+
|
|
91
|
+
# On successful verification, store the payload in the Rack environment
|
|
92
|
+
env[@env_key] = payload
|
|
93
|
+
|
|
94
|
+
@app.call(env)
|
|
47
95
|
end
|
|
48
96
|
|
|
49
97
|
private
|
|
50
98
|
|
|
51
|
-
# Extracts the JWT from the standard Authorization header format: "Bearer <token>"
|
|
99
|
+
# Extracts the JWT from the standard Authorization header format: "Bearer <token>".
|
|
100
|
+
# The scheme is matched case-insensitively (RFC 7235 §2.1).
|
|
52
101
|
def extract_token(env)
|
|
53
102
|
# Rack converts HTTP_AUTHORIZATION header to ENV['HTTP_AUTHORIZATION']
|
|
54
103
|
auth_header = env["HTTP_AUTHORIZATION"]
|
|
55
|
-
|
|
104
|
+
|
|
56
105
|
return nil unless auth_header
|
|
57
|
-
|
|
58
|
-
scheme, token = auth_header.split(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
106
|
+
|
|
107
|
+
scheme, token = auth_header.strip.split(/\s+/, 2)
|
|
108
|
+
return nil unless scheme&.casecmp?("bearer")
|
|
109
|
+
|
|
110
|
+
token = token&.strip
|
|
111
|
+
token.nil? || token.empty? ? nil : token
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A skip rule is an exact path string, a regexp matched against the path,
|
|
115
|
+
# or a callable given the whole env.
|
|
116
|
+
def skip?(env)
|
|
117
|
+
return false if @skip.empty?
|
|
118
|
+
|
|
119
|
+
path = "#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
|
|
120
|
+
@skip.any? do |rule|
|
|
121
|
+
case rule
|
|
122
|
+
when String then rule == path
|
|
123
|
+
when Regexp then rule.match?(path)
|
|
124
|
+
else rule.call(env)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def validate_skip_rules(rules)
|
|
130
|
+
rules.each do |rule|
|
|
131
|
+
next if rule.is_a?(String) || rule.is_a?(Regexp) || rule.respond_to?(:call)
|
|
132
|
+
|
|
133
|
+
raise ArgumentError, "skip: entries must be a String, a Regexp or respond to #call (got #{rule.inspect})"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def logger(env)
|
|
138
|
+
@logger || env["rack.logger"] || NULL_LOGGER
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A key alone proves who signed the token, not who it was meant for. Nudge
|
|
142
|
+
# the operator once at boot if neither iss nor aud is being checked.
|
|
143
|
+
def warn_if_claims_unrestricted(decode_options)
|
|
144
|
+
return if decode_options.key?(:iss) || decode_options.key?(:aud)
|
|
145
|
+
|
|
146
|
+
(@logger || Kernel).warn(
|
|
147
|
+
"rack_jwt_verifier: neither :iss nor :aud is set in decode_options, so any " \
|
|
148
|
+
"token signed by the configured key is accepted. Set decode_options: { iss: ..., aud: ... }."
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Builds the refusal for `reason`, letting an :on_error hook take over
|
|
153
|
+
# first. Returning nil from the hook falls back to the default response.
|
|
154
|
+
def error_response(env, reason, error)
|
|
155
|
+
custom = @on_error&.call(env, reason, error)
|
|
156
|
+
return custom if custom
|
|
157
|
+
|
|
158
|
+
status, default_text = ERROR_RESPONSES.fetch(reason)
|
|
159
|
+
description = error && sanitize_description(error.message)
|
|
160
|
+
|
|
161
|
+
headers = {}
|
|
162
|
+
headers["www-authenticate"] = challenge(reason, description) if status == 401
|
|
163
|
+
headers["retry-after"] = RETRY_AFTER_SECONDS.to_s if status == 503
|
|
164
|
+
|
|
165
|
+
if @json_errors
|
|
166
|
+
body = JSON.generate(error: reason, error_description: description || default_text)
|
|
167
|
+
respond(status, body, "application/json", headers)
|
|
168
|
+
else
|
|
169
|
+
respond(status, default_text, "text/plain", headers)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# RFC 6750 §3: a request that carried no credentials at all gets the bare
|
|
174
|
+
# challenge, without an error code; otherwise the code and a description.
|
|
175
|
+
def challenge(reason, description)
|
|
176
|
+
return "Bearer" if reason == :missing_token
|
|
177
|
+
|
|
178
|
+
value = +'Bearer error="invalid_token"'
|
|
179
|
+
value << ", error_description=\"#{description}\"" if description && !description.empty?
|
|
180
|
+
value
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# error_description is a quoted-string: keep it to the characters RFC 6750
|
|
184
|
+
# allows inside one (printable ASCII minus `"` and `\`) and a sane length.
|
|
185
|
+
def sanitize_description(message)
|
|
186
|
+
message.to_s.gsub(/[^\x20-\x21\x23-\x5B\x5D-\x7E]/, "")[0, 200]
|
|
62
187
|
end
|
|
63
188
|
|
|
64
|
-
#
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
|
|
189
|
+
# Rack 3 requires lowercase header names; they are equally valid on Rack 2.
|
|
190
|
+
def respond(status, body, content_type, extra_headers = {})
|
|
191
|
+
headers = {
|
|
192
|
+
"content-type" => content_type,
|
|
193
|
+
"content-length" => body.bytesize.to_s
|
|
194
|
+
}.merge(extra_headers)
|
|
195
|
+
[status, headers, [body]]
|
|
68
196
|
end
|
|
69
197
|
end
|
|
70
198
|
end
|