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.
@@ -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 "rack_jwt_verifier/verifier"
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".freeze
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
- @options = options
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
- # If verification fails (invalid signature, expired, invalid claim),
41
- # log the error and return an unauthenticated response.
42
- warn "JWT Verification Failed: #{e.message}"
43
-
44
- # Return a 401 Unauthorized response
45
- unauthorized_response
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(" ", 2)
59
-
60
- # Only process if the scheme is "Bearer" and a token is present
61
- (scheme == "Bearer") ? token : nil
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
- # Standard 401 Unauthorized Rack response
65
- def unauthorized_response
66
- # Status, Headers, Body (Array of strings)
67
- [401, { "Content-Type" => "text/plain", "WWW-Authenticate" => "Bearer error=\"invalid_token\"" }, ["Unauthorized: Invalid or expired JWT."]]
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