rack-jwt-verifier 0.1.0 → 0.3.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,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jwt"
4
+
5
+ module RackJwtVerifier
6
+ # Base class for every error this gem raises on its own behalf. Token
7
+ # verification failures keep raising ruby-jwt's JWT::DecodeError family.
8
+ class Error < StandardError; end
9
+
10
+ # Raised at boot for an unusable option set: no key source, a shared secret
11
+ # next to a public key, HS* mixed with RS*/ES*, a too-short secret, missing
12
+ # iss/aud, ... Raised from Middleware.new / Verifier.new, never per request.
13
+ class ConfigurationError < Error; end
14
+
15
+ # Raised when the verification key material cannot be obtained or parsed:
16
+ # the remote endpoint is down or slow, returned something that is not a
17
+ # key, or the configured static key does not parse.
18
+ class KeyFetchError < Error; end
19
+
20
+ # Raised when the replay cache (jti store) cannot be read or written. The
21
+ # middleware answers 503: the operator asked for replay protection, so a
22
+ # request whose jti cannot be checked is refused rather than waved through.
23
+ class ReplayCacheError < Error; end
24
+
25
+ # Raised when a token's jti has already been seen. Subclasses ruby-jwt's
26
+ # InvalidJtiError so it travels the same 401 path as any other bad claim.
27
+ class ReplayedTokenError < JWT::InvalidJtiError; end
28
+
29
+ # Handed to the :on_error hook when a token lacks a scope listed in
30
+ # require_scopes. Carries what was required and what was missing.
31
+ class InsufficientScopeError < Error
32
+ attr_reader :required, :missing
33
+
34
+ def initialize(required:, missing:)
35
+ @required = required
36
+ @missing = missing
37
+ super("Token lacks required scope(s): #{missing.join(' ')}")
38
+ end
39
+ end
40
+ end
@@ -8,38 +8,45 @@ module RackJwtVerifier
8
8
  # The cache lifespan in seconds (5 minutes)
9
9
  DEFAULT_EXPIRY = 300
10
10
 
11
- def initialize
11
+ # Expiry is measured on the monotonic clock, so a wall-clock jump (NTP
12
+ # correction, DST) cannot extend or cut short an entry's life.
13
+ MONOTONIC_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
14
+
15
+ # Expired entries are swept whenever the store grows past this many
16
+ # entries, and the threshold then doubles from whatever survived. Amortised
17
+ # O(1) per write, so a jti-per-request workload cannot grow the store
18
+ # without bound while a single-key workload never pays for a sweep.
19
+ SWEEP_THRESHOLD = 1024
20
+
21
+ # @param clock [#call] Returns the current time in seconds; injectable for tests.
22
+ def initialize(clock: MONOTONIC_CLOCK)
12
23
  @store = {}
13
24
  @lock = Mutex.new # Ensure thread safety for multi-threaded environments
25
+ @clock = clock
26
+ @sweep_at = SWEEP_THRESHOLD
14
27
  end
15
28
 
16
29
  # Reads the value for a given key. Automatically checks for expiry.
17
30
  # @param key [String] The cache key.
18
31
  # @return [Object, nil] The cached value or nil if expired or not found.
19
32
  def read(key)
20
- @lock.synchronize do
21
- entry = @store[key]
22
- return nil unless entry
23
-
24
- value, expires_at = entry
25
-
26
- # Check if the entry is expired
27
- return nil if Time.now.to_i >= expires_at
28
-
29
- value
30
- end
33
+ @lock.synchronize { live_value(key) }
31
34
  end
32
35
 
33
36
  # Writes a value to the cache with an optional expiration time.
34
37
  # @param key [String] The cache key.
35
38
  # @param value [Object] The value to store.
36
- # @param options [Hash] Options, expecting :expires_in (seconds).
37
- # @return [Object] The stored value.
39
+ # @param options [Hash] :expires_in (seconds); :unless_exist (true to keep
40
+ # an existing live entry, as ActiveSupport stores do).
41
+ # @return [Object, false] The stored value, or false when :unless_exist
42
+ # was given and a live entry already existed.
38
43
  def write(key, value, options = {})
39
44
  @lock.synchronize do
45
+ return false if options[:unless_exist] && !live_value(key).nil?
46
+
40
47
  expiry = options[:expires_in] || DEFAULT_EXPIRY
41
- expires_at = Time.now.to_i + expiry
42
- @store[key] = [value, expires_at]
48
+ @store[key] = [value, @clock.call + expiry]
49
+ sweep_if_needed
43
50
  value
44
51
  end
45
52
  end
@@ -49,8 +56,45 @@ module RackJwtVerifier
49
56
  # @return [Object, nil] The deleted entry value or nil.
50
57
  def delete(key)
51
58
  @lock.synchronize do
52
- @store.delete(key)
59
+ entry = @store.delete(key)
60
+ entry&.first
61
+ end
62
+ end
63
+
64
+ # Removes every entry.
65
+ def clear
66
+ @lock.synchronize do
67
+ @store.clear
68
+ @sweep_at = SWEEP_THRESHOLD
53
69
  end
70
+ nil
71
+ end
72
+
73
+ # Number of entries held, expired ones included until the next sweep.
74
+ def size
75
+ @lock.synchronize { @store.size }
76
+ end
77
+
78
+ private
79
+
80
+ # Caller holds the lock.
81
+ def live_value(key)
82
+ entry = @store[key]
83
+ return nil unless entry
84
+
85
+ value, expires_at = entry
86
+ return nil if @clock.call >= expires_at
87
+
88
+ value
89
+ end
90
+
91
+ # Caller holds the lock.
92
+ def sweep_if_needed
93
+ return if @store.size <= @sweep_at
94
+
95
+ now = @clock.call
96
+ @store.delete_if { |_, (_, expires_at)| now >= expires_at }
97
+ @sweep_at = [@store.size * 2, SWEEP_THRESHOLD].max
54
98
  end
55
99
  end
56
100
  end
@@ -3,53 +3,77 @@
3
3
  require 'jwt'
4
4
  require 'openssl'
5
5
 
6
- # A helper class for handling JWT creation and verification using RSA keys (RS256).
7
- # This is typically used by the application or a separate service to *create* tokens.
8
6
  module RackJwtVerifier
7
+ # @deprecated Will be removed in 0.4.0. Issues (and, for round-trip checks,
8
+ # decodes) RS256 tokens from an RSA private key.
9
+ #
10
+ # This is a second token issuer living inside the verifier: it sets neither
11
+ # `iss`, `aud`, `nbf` nor `jti`, so what it mints does not pass the claim
12
+ # policy the middleware now enforces. Issue tokens with the `jwt_auth_client`
13
+ # gem instead (HMAC today, RS256/ES256 from its 0.3.0); in a test suite,
14
+ # sign with `JWT.encode` directly — see spec/support/token_factory.rb in this
15
+ # repository for a helper you can copy.
9
16
  class JwtHelper
10
- # The private_key must be an OpenSSL::PKey::RSA object (or similar).
17
+ ALGORITHM = 'RS256'
18
+
19
+ DEPRECATION = 'RackJwtVerifier::JwtHelper is deprecated and will be removed in 0.4.0: issue tokens with the ' \
20
+ 'jwt_auth_client gem, or sign test tokens with JWT.encode. Set ' \
21
+ 'RACK_JWT_VERIFIER_SILENCE_DEPRECATIONS=1 to silence this warning.'
22
+
23
+ @warned = false
24
+ @warn_lock = Mutex.new
25
+
26
+ class << self
27
+ # Emits the deprecation warning once per process.
28
+ def warn_deprecated
29
+ return if ENV['RACK_JWT_VERIFIER_SILENCE_DEPRECATIONS'] == '1'
30
+
31
+ @warn_lock.synchronize do
32
+ return if @warned
33
+
34
+ @warned = true
35
+ end
36
+ Kernel.warn(DEPRECATION, uplevel: 2)
37
+ end
38
+
39
+ # Forget that the warning was emitted. Intended for test suites.
40
+ def reset_deprecation_warning!
41
+ @warn_lock.synchronize { @warned = false }
42
+ end
43
+ end
44
+
11
45
  attr_reader :private_key, :public_key
12
46
 
13
- # Initializes the helper with the RSA Private Key used for signing.
14
- #
15
- # @param private_key_pem [String] The PEM string of the RSA Private Key.
47
+ # @param private_key_pem [String, OpenSSL::PKey::RSA] The RSA private key used for signing.
16
48
  def initialize(private_key_pem)
17
- # !! IMPORTANT !!
18
- # This key signs the tokens.
19
- @private_key = OpenSSL::PKey::RSA.new(private_key_pem)
49
+ self.class.warn_deprecated
50
+ @private_key = private_key_pem.is_a?(OpenSSL::PKey::RSA) ? private_key_pem : OpenSSL::PKey::RSA.new(private_key_pem)
20
51
  @public_key = @private_key.public_key
21
52
  end
22
53
 
23
- # Encodes a payload into a JWT.
54
+ # Encodes a payload into a JWT, adding `iat` and `exp`.
24
55
  #
25
- # @param payload [Hash] The data to be encoded in the JWT (e.g., user ID, roles).
26
- # @param expires_in [Integer] Time in seconds until the token expires (default: 1 hour).
56
+ # @param payload [Hash] Claims (string or symbol keys; an explicit `exp` in the payload wins).
57
+ # @param expires_in [Integer] Seconds until the token expires (default: 1 hour).
27
58
  # @return [String] The signed JWT string.
28
59
  def encode(payload, expires_in = 3600)
29
- # Set standard expiration time (exp) and issued-at time (iat) claims
30
- time = Time.now.to_i
31
- payload_with_claims = payload.merge({
32
- iat: time,
33
- exp: time + expires_in
34
- })
35
-
36
- JWT.encode(payload_with_claims, @private_key, 'RS256')
60
+ now = Time.now.to_i
61
+ # Normalise to string keys first so a caller's 'exp' and our :exp cannot
62
+ # both end up in the JSON as duplicate "exp" members.
63
+ claims = { 'iat' => now, 'exp' => now + expires_in }.merge(payload.transform_keys(&:to_s))
64
+ JWT.encode(claims, @private_key, ALGORITHM)
37
65
  end
38
66
 
39
- # Decodes and verifies a JWT using the public key.
40
- #
41
- # NOTE: This method is used primarily for self-testing in the application
42
- # but the primary verification logic for the middleware is in the Verifier class.
67
+ # Decodes and verifies a JWT with the public key. Meant for self-checks;
68
+ # the middleware's verification lives in Verifier.
43
69
  #
44
70
  # @param token [String] The JWT string to decode.
71
+ # @param options [Hash] Extra options for JWT.decode (leeway, iss, verify_iss, ...).
45
72
  # @return [Hash] The decoded payload if verification is successful.
46
- # @raise [JWT::VerificationError, JWT::DecodeError] If the token is invalid or expired.
47
- def decode(token)
48
- # Decodes using the public key, performs signature verification (true),
49
- # and restricts the algorithm to 'RS256'.
50
- decoded = JWT.decode(token, @public_key, true, { algorithm: 'RS256' })
51
- # Returns only the payload (the first element of the array).
52
- decoded.first
73
+ # @raise [JWT::DecodeError] If the token is invalid or expired.
74
+ def decode(token, options = {})
75
+ payload, _header = JWT.decode(token, @public_key, true, { algorithm: ALGORITHM }.merge(options))
76
+ payload
53
77
  end
54
78
  end
55
79
  end
@@ -0,0 +1,344 @@
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 ConfigurationError, "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
+ # An HMAC secret shared with the issuer (jwt_auth_client's mode). Nothing
55
+ # to fetch or rotate: a secret has no kid, so rotation means redeploying
56
+ # both sides. The RFC 7518 minimum length is enforced by the Verifier,
57
+ # which knows the algorithm list.
58
+ class Secret
59
+ attr_reader :verification_key
60
+
61
+ # @param secret [String, Hash] the secret itself, or { env: "VAR_NAME" }
62
+ # to read it from the environment at boot.
63
+ def initialize(secret)
64
+ @verification_key = resolve(secret)
65
+ end
66
+
67
+ def jwks?
68
+ false
69
+ end
70
+
71
+ def refresh!
72
+ false
73
+ end
74
+
75
+ private
76
+
77
+ def resolve(secret)
78
+ value =
79
+ case secret
80
+ when Hash
81
+ name = secret[:env] || secret["env"]
82
+ raise ConfigurationError, "shared_secret: { env: ... } needs the variable name" if name.to_s.empty?
83
+
84
+ ENV.fetch(name.to_s) { raise ConfigurationError, "shared_secret: environment variable #{name} is not set" }
85
+ when String
86
+ secret
87
+ else
88
+ raise ConfigurationError, "shared_secret must be a String or { env: \"VAR_NAME\" } (got #{secret.class})"
89
+ end
90
+
91
+ raise ConfigurationError, "shared_secret must not be empty" if value.empty?
92
+
93
+ value
94
+ end
95
+ end
96
+
97
+ # Shared machinery for key material fetched over HTTPS: caching, strict
98
+ # timeouts, a size cap, single-flight fetching within the process, and a
99
+ # rate-limited refresh for key rotation. Subclasses say how to parse the
100
+ # body and which cache namespace to use.
101
+ class Remote
102
+ # Timeout (seconds) applied separately to opening the connection and to
103
+ # reading the response. Kept short so a slow endpoint cannot pin every
104
+ # request thread on a cache miss.
105
+ DEFAULT_HTTP_TIMEOUT = 5
106
+ # Largest response body (bytes) we are willing to read. A PEM key is well
107
+ # under 1 KB and a JWKS with a handful of keys a few KB.
108
+ MAX_RESPONSE_BYTES = 64 * 1024
109
+ # How long (seconds) fetched material is served from the cache.
110
+ DEFAULT_CACHE_TTL = 300
111
+ # Minimum gap (seconds) between two rotation-triggered refetches, so a
112
+ # flood of tokens with bad signatures or unknown kids cannot turn into a
113
+ # flood of requests to the provider.
114
+ DEFAULT_REFETCH_INTERVAL = 60
115
+
116
+ attr_reader :url, :cache_key
117
+
118
+ def initialize(url:, cache:, cache_ttl: DEFAULT_CACHE_TTL, http_timeout: DEFAULT_HTTP_TIMEOUT,
119
+ refetch_interval: DEFAULT_REFETCH_INTERVAL, allow_insecure_http: false, logger: nil)
120
+ @url = url.to_s
121
+ @uri = parse_url(@url, allow_insecure_http: allow_insecure_http)
122
+ @cache = cache
123
+ @logger = logger || Logger.new(IO::NULL)
124
+ @cache_ttl = cache_ttl
125
+ @http_timeout = http_timeout
126
+ @refetch_interval = refetch_interval
127
+ # Scoped to the URL so two verifiers sharing one cache store (two SSO
128
+ # providers behind one Redis) never read each other's key.
129
+ @cache_key = "#{cache_key_prefix}:#{Digest::SHA256.hexdigest(@url)[0, 16]}"
130
+
131
+ @fetch_lock = Mutex.new # single-flight: one network fetch per process on a cold cache
132
+ @state_lock = Mutex.new # guards @parsed and @last_refetch_at
133
+ @parsed = nil # [body, parsed material] of the last body parsed
134
+ @last_refetch_at = nil # monotonic clock
135
+ end
136
+
137
+ # Refetches the material unless a refresh happened less than
138
+ # refetch_interval seconds ago. Returns true if it actually refetched.
139
+ def refresh!
140
+ @state_lock.synchronize do
141
+ now = monotonic_now
142
+ return false if @last_refetch_at && now - @last_refetch_at < @refetch_interval
143
+
144
+ @last_refetch_at = now
145
+ end
146
+ fetch_and_store
147
+ true
148
+ end
149
+
150
+ private
151
+
152
+ # Parsed material, from the cache or (on a miss) the network.
153
+ def material
154
+ body = cache_read || fetch_once
155
+ parsed_for(body)
156
+ end
157
+
158
+ # Only one thread per process performs the fetch on a cold cache; the
159
+ # others wait for it and then find the body in the cache.
160
+ def fetch_once
161
+ @fetch_lock.synchronize do
162
+ cache_read || fetch_and_store
163
+ end
164
+ end
165
+
166
+ def fetch_and_store
167
+ body = fetch_body
168
+ # Parse *before* caching so a 200 that is not key material (an HTML
169
+ # maintenance page, say) is never stored and served for the TTL.
170
+ parse!(body)
171
+ cache_write(body)
172
+ body
173
+ end
174
+
175
+ # A broken cache store (Redis down, say) must not take authentication
176
+ # down with it: treat a failed read as a miss and a failed write as a
177
+ # no-op, and say so in the log. Fetches then fall back to one per
178
+ # request per process until the store recovers.
179
+ def cache_read
180
+ @cache.read(cache_key)
181
+ rescue StandardError => e
182
+ @logger.warn do
183
+ "rack_jwt_verifier: cache read failed (#{e.class}: #{e.message}); fetching key material directly"
184
+ end
185
+ nil
186
+ end
187
+
188
+ def cache_write(body)
189
+ @cache.write(cache_key, body, expires_in: @cache_ttl)
190
+ rescue StandardError => e
191
+ @logger.warn do
192
+ "rack_jwt_verifier: cache write failed (#{e.class}: #{e.message}); key material will be refetched"
193
+ end
194
+ nil
195
+ end
196
+
197
+ # Parsing is not free (and JWKS parsing less so), so the result for a
198
+ # given body is memoised until the body changes.
199
+ def parsed_for(body)
200
+ @state_lock.synchronize do
201
+ @parsed = [body, parse!(body)] unless @parsed && @parsed[0] == body
202
+ @parsed[1]
203
+ end
204
+ end
205
+
206
+ def parse!(body)
207
+ parse(body)
208
+ rescue KeyFetchError
209
+ raise
210
+ rescue StandardError => e
211
+ raise KeyFetchError, "Error processing public key: #{e.message}"
212
+ end
213
+
214
+ # The material must travel over TLS: an attacker who can tamper with a
215
+ # plaintext fetch can substitute their own key and mint arbitrary tokens.
216
+ def parse_url(url, allow_insecure_http:)
217
+ uri = URI.parse(url)
218
+ return uri if uri.is_a?(URI::HTTPS)
219
+ return uri if uri.is_a?(URI::HTTP) && allow_insecure_http
220
+
221
+ raise ConfigurationError,
222
+ "#{url_option_name} must be an https:// URL (got #{url.inspect}). " \
223
+ "Pass allow_insecure_http: true to permit http:// in development."
224
+ rescue URI::InvalidURIError
225
+ raise ConfigurationError, "#{url_option_name} is not a valid URL: #{url.inspect}"
226
+ end
227
+
228
+ # Performs the HTTP GET with strict timeouts and a size cap. The body is
229
+ # streamed so an oversized response is abandoned early rather than
230
+ # buffered in full. Redirects are deliberately not followed: a 3xx to
231
+ # an http:// or third-party host would defeat the https:// requirement.
232
+ def fetch_body
233
+ uri = @uri
234
+ body = +""
235
+
236
+ Net::HTTP.start(uri.host, uri.port,
237
+ use_ssl: uri.scheme == "https",
238
+ open_timeout: @http_timeout,
239
+ read_timeout: @http_timeout,
240
+ write_timeout: @http_timeout) do |http|
241
+ request = Net::HTTP::Get.new(uri)
242
+ request["User-Agent"] = "rack_jwt_verifier/#{VERSION}"
243
+ request["Accept"] = accept_header
244
+
245
+ http.request(request) do |response|
246
+ unless response.is_a?(Net::HTTPSuccess)
247
+ raise KeyFetchError, "Failed to fetch public key from #{@url}: #{response.code}"
248
+ end
249
+
250
+ response.read_body do |chunk|
251
+ body << chunk
252
+ if body.bytesize > MAX_RESPONSE_BYTES
253
+ raise KeyFetchError, "Public key response from #{@url} exceeds #{MAX_RESPONSE_BYTES} bytes"
254
+ end
255
+ end
256
+ end
257
+ end
258
+
259
+ body
260
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
261
+ raise KeyFetchError, "Timed out fetching public key from #{@url} after #{@http_timeout}s"
262
+ rescue KeyFetchError
263
+ raise
264
+ rescue StandardError => e
265
+ raise KeyFetchError, "Error fetching public key from #{@url}: #{e.message}"
266
+ end
267
+
268
+ def monotonic_now
269
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
270
+ end
271
+ end
272
+
273
+ # A single PEM public key (or X.509 certificate) served at a URL.
274
+ class RemotePem < Remote
275
+ CACHE_KEY_PREFIX = "rack_jwt_verifier:public_key"
276
+
277
+ def verification_key
278
+ material
279
+ end
280
+
281
+ def jwks?
282
+ false
283
+ end
284
+
285
+ private
286
+
287
+ def cache_key_prefix
288
+ CACHE_KEY_PREFIX
289
+ end
290
+
291
+ def url_option_name
292
+ "public_key_url"
293
+ end
294
+
295
+ def accept_header
296
+ "application/x-pem-file, text/plain, */*"
297
+ end
298
+
299
+ def parse(body)
300
+ KeySource.parse_public_key(body)
301
+ end
302
+ end
303
+
304
+ # A JSON Web Key Set served at a URL (typically /.well-known/jwks.json).
305
+ class RemoteJwks < Remote
306
+ CACHE_KEY_PREFIX = "rack_jwt_verifier:jwks"
307
+
308
+ def jwks?
309
+ true
310
+ end
311
+
312
+ # ruby-jwt calls the loader once, and again with kid_not_found: true when
313
+ # the token's kid is absent from the set it got — the rotation signal for
314
+ # JWKS. Refresh (rate-limited) on that signal, then hand back the set.
315
+ def jwks_loader
316
+ lambda do |options|
317
+ refresh! if options[:kid_not_found] || options[:invalidate]
318
+ material
319
+ end
320
+ end
321
+
322
+ private
323
+
324
+ def cache_key_prefix
325
+ CACHE_KEY_PREFIX
326
+ end
327
+
328
+ def url_option_name
329
+ "jwks_url"
330
+ end
331
+
332
+ def accept_header
333
+ "application/jwk-set+json, application/json, */*"
334
+ end
335
+
336
+ def parse(body)
337
+ set = JWT::JWK::Set.new(JSON.parse(body))
338
+ raise KeyFetchError, "JWKS from #{@url} contains no keys" if set.none?
339
+
340
+ set
341
+ end
342
+ end
343
+ end
344
+ end