verikloak 0.2.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d175a574b17bd1ce5e096dbb69769d12a470f49c8444a5246881afd61d06fa52
4
- data.tar.gz: 3c59a660bb142c194b6e0db2d338952c41c8a88afc479382203b4d767ab7691c
3
+ metadata.gz: 743b7db62f39f179fe22804c61144e661992625e43f3b411ea965f85364b6f50
4
+ data.tar.gz: ea9fea3cc08d53d076c60e946a39dad3594468dc16574b6a1f3a0e4574f9d80c
5
5
  SHA512:
6
- metadata.gz: 92edd8a2df134f115c3f887683246278c5778291bb86bfff22ebc873b2b92d6ff09d9c85a1f798e2f49caa09adc8c3ef18ffc83309f95799997ebbdb54b58228
7
- data.tar.gz: 0e223f0055e69448899be53fc22c9ac32c5d43c410aa041daa38761ed13a00aae8d655bcb6eaff521927a5a029937fcd480c7b90dcfccddf8e6b028aaf0bf555
6
+ metadata.gz: 89d6398d946686f61ec67dd93a2f807f38b4cc828bf86b4023787ea3aece53ebac95b08306827a06507883d1ef3f1711571af01db0c7ac5e2e4cc5340d92d34b
7
+ data.tar.gz: 5469a3833cde3b5f5e21439cdd19bb6638ef0e3b49fd2da64e7398dcd24a8851eec11f985ab0821a23b7446d275983b62e87804b541299ec486b88113d8b89ee
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.2.1] - 2025-09-23
11
+
12
+ ### Changed
13
+ - **BREAKING**: `JwksCache` is now thread-safe with Mutex synchronization around all cache operations
14
+ - Middleware code organization: split large modules into focused, single-responsibility components:
15
+ - `SkipPathMatcher`: Path matching and normalization logic
16
+ - `MiddlewareAudienceResolution`: Audience resolution with dynamic callable support
17
+ - `MiddlewareConfiguration`: Configuration validation and logging utilities
18
+ - `MiddlewareDecoderCache`: LRU cache management for TokenDecoder instances
19
+ - `MiddlewareTokenVerification`: JWT verification and JWKs management
20
+ - `MiddlewareErrorMapping`: Error-to-HTTP status code mapping
21
+
22
+ ### Fixed
23
+ - Removed duplicate method definitions that were causing code bloat
24
+ - Audience callable parameter detection now handles edge cases more reliably
25
+ - Thread-safety issues in concurrent environments resolved
26
+
10
27
  ## [0.2.0] - 2025-09-22
11
28
 
12
29
  ### Added
@@ -17,8 +34,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
34
  ### Changed
18
35
  - Update gem version to 0.2.0 to stay aligned with the rest of the Verikloak ecosystem gems.
19
36
 
20
- ---
21
-
22
37
  ## [0.1.5] - 2025-09-21
23
38
 
24
39
  ### Added
@@ -31,15 +46,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
31
46
  ### Dependencies
32
47
  - Declare `faraday-retry` as a runtime dependency so the default HTTP connection can load the retry middleware.
33
48
 
34
- ---
35
-
36
49
  ## [0.1.4] - 2025-09-20
37
50
 
38
51
  ### Chore
39
52
  - Bump dev dependency `rexml` to 3.4.2 (PR #15).
40
53
 
41
- ---
42
-
43
54
  ## [0.1.3] - 2025-09-15
44
55
 
45
56
  ### Changed
@@ -49,8 +60,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
49
60
  - Bump dev dependency `rubocop` to 1.80.2 (PR #13).
50
61
  - Bump dev dependency `rubocop-rspec` to 3.7.0 (PR #12).
51
62
 
52
- ---
53
-
54
63
  ## [0.1.2] - 2025-08-31
55
64
 
56
65
  ### Added
@@ -63,8 +72,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
63
72
  - Middleware: TokenDecoder instances are now cached per JWKs fetch for performance improvement.
64
73
  - Internal: RuboCop style fixes (`HashExcept`, `HashTransformKeys`, long line splits).
65
74
 
66
- ---
67
-
68
75
  ## [0.1.1] - 2025-08-24
69
76
 
70
77
  ### Changed
@@ -72,8 +79,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
72
79
  - Updated dependency constraints in gemspec (`json` ~> 2.6, `jwt` ~> 2.7) for better compatibility control
73
80
  - Updated README badges (Gem version, Ruby version, downloads)
74
81
 
75
- ---
76
-
77
82
  ## [0.1.0] - 2025-08-17
78
83
 
79
84
  ### Added
data/README.md CHANGED
@@ -252,6 +252,7 @@ For a full list of error cases and detailed explanations, please see the [ERRORS
252
252
  | `jwks_cache` | No | Inject custom JwksCache instance (advanced/testing) |
253
253
  | `leeway` | No | Clock skew tolerance (seconds) applied during JWT verification. Defaults to `TokenDecoder::DEFAULT_LEEWAY`. |
254
254
  | `token_verify_options` | No | Hash of advanced JWT verification options passed through to TokenDecoder. For example: `{ verify_iat: false, leeway: 10, algorithms: ["RS256"] }`. If both `leeway:` and `token_verify_options[:leeway]` are set, the latter takes precedence. |
255
+ | `decoder_cache_limit` | No | Maximum number of `TokenDecoder` instances retained per middleware. Defaults to `128`. Set to `0` to disable caching or `nil` for an unlimited cache. |
255
256
  | `connection` | No | Inject a Faraday::Connection used for both Discovery and JWKs fetches. Defaults to a safe connection with timeouts and retries. |
256
257
 
257
258
  #### Option: `skip_paths`
@@ -276,7 +277,7 @@ Internally, `*` expands to match nested paths, so patterns like `/rails/*` are v
276
277
 
277
278
  #### Option: `audience`
278
279
 
279
- The `audience` option may be either a static String or any callable object (Proc, lambda, object responding to `#call`). When a callable is provided it receives the Rack `env` and can return a different audience for each request. This is useful when a single gateway serves multiple downstream clients:
280
+ The `audience` option may be either a static String or any callable object (Proc, lambda, object responding to `#call`). When a callable is provided it receives the Rack `env` (either as a positional argument or keyword `env:`) and can return a different audience for each request. This is useful when a single gateway serves multiple downstream clients:
280
281
 
281
282
  ```ruby
282
283
  Verikloak::Middleware.new(app,
@@ -342,11 +343,37 @@ config.middleware.use Verikloak::Middleware,
342
343
  - `token_verify_options:` is passed directly to TokenDecoder (and ultimately to `JWT.decode`).
343
344
  - If both are set, `token_verify_options[:leeway]` takes precedence.
344
345
 
345
- #### Performance note
346
+ #### Decoder cache & performance
346
347
 
347
348
  Internally, Verikloak caches `TokenDecoder` instances per JWKs fetch to avoid reinitializing
348
- them on every request. This improves performance while still ensuring that keys are
349
- revalidated when JWKs is refreshed.
349
+ them on every request. The cache behaves like an LRU with a configurable size (`decoder_cache_limit`)
350
+ so long-running processes do not accumulate decoders for one-off audiences. When the underlying
351
+ JWK set rotates, the middleware now clears the cache to drop decoders that point at stale keys.
352
+
353
+ Set `decoder_cache_limit` to `0` if you prefer to construct a fresh decoder every time, or `nil`
354
+ when you want the cache to grow without bounds (e.g., in short-lived jobs).
355
+
356
+ #### Sharing caches across verikloak gems
357
+
358
+ When combining `verikloak` with companion gems (such as `verikloak-rails`, `verikloak-bff`, etc.),
359
+ reusing infrastructure objects avoids redundant HTTP calls:
360
+
361
+ ```ruby
362
+ connection = Verikloak::HTTP.default_connection
363
+ jwks_cache = Verikloak::JwksCache.new(jwks_uri: ENV['JWKS_URI'], connection: connection)
364
+
365
+ use Verikloak::Middleware,
366
+ discovery_url: ENV['DISCOVERY_URL'],
367
+ audience: ->(env) { env['verikloak.audience'] },
368
+ connection: connection,
369
+ jwks_cache: jwks_cache
370
+
371
+ # Rails initializer or service object can now reuse `connection` and `jwks_cache`
372
+ # when configuring other verikloak-* gems.
373
+ ```
374
+
375
+ Sharing the Faraday connection keeps retry/time-out policies consistent, while a single `JwksCache`
376
+ ensures all middleware layers refresh keys in unison.
350
377
 
351
378
  ## Architecture
352
379
 
@@ -50,6 +50,7 @@ module Verikloak
50
50
  @etag = nil
51
51
  @fetched_at = nil
52
52
  @max_age = nil
53
+ @mutex = Mutex.new
53
54
  end
54
55
 
55
56
  # Fetches the JWKs and updates the in-memory cache.
@@ -61,27 +62,31 @@ module Verikloak
61
62
  # @return [Array<Hash>] the cached JWKs after fetch/revalidation
62
63
  # @raise [JwksCacheError] on HTTP failures, invalid JSON, invalid structure, or cache miss on 304
63
64
  def fetch!
64
- return @cached_keys if fresh_by_ttl?
65
+ @mutex.synchronize do
66
+ return @cached_keys if fresh_by_ttl_locked?
65
67
 
66
- with_error_handling do
67
- # Build conditional request headers (ETag-based)
68
- headers = build_conditional_headers
69
- # Perform HTTP GET request
70
- response = @connection.get(@jwks_uri, nil, headers)
71
- # Handle HTTP response according to status code
72
- handle_response(response)
68
+ with_error_handling do
69
+ # Build conditional request headers (ETag-based)
70
+ headers = build_conditional_headers
71
+ # Perform HTTP GET request
72
+ response = @connection.get(@jwks_uri, nil, headers)
73
+ # Handle HTTP response according to status code
74
+ handle_response(response)
75
+ end
73
76
  end
74
77
  end
75
78
 
76
79
  # Returns the last cached JWKs without performing a network request.
77
80
  # @return [Array<Hash>, nil] cached keys, or nil if never fetched
78
81
  def cached
79
- @cached_keys
82
+ @mutex.synchronize { @cached_keys }
80
83
  end
81
84
 
82
85
  # Timestamp of the last successful fetch or revalidation.
83
86
  # @return [Time, nil]
84
- attr_reader :fetched_at
87
+ def fetched_at
88
+ @mutex.synchronize { @fetched_at }
89
+ end
85
90
 
86
91
  # Injected Faraday connection (for testing and shared config across the gem)
87
92
  # @return [Faraday::Connection]
@@ -94,7 +99,7 @@ module Verikloak
94
99
  #
95
100
  # @return [Boolean]
96
101
  def stale?
97
- !fresh_by_ttl?
102
+ @mutex.synchronize { !fresh_by_ttl_locked? }
98
103
  end
99
104
 
100
105
  # @api private
@@ -125,6 +130,12 @@ module Verikloak
125
130
  # True when cached keys are still fresh per `Cache-Control: max-age`.
126
131
  # @return [Boolean]
127
132
  def fresh_by_ttl?
133
+ @mutex.synchronize { fresh_by_ttl_locked? }
134
+ end
135
+
136
+ private
137
+
138
+ def fresh_by_ttl_locked?
128
139
  return false unless @cached_keys && @fetched_at && @max_age
129
140
 
130
141
  (Time.now - @fetched_at) < @max_age
@@ -86,6 +86,299 @@ module Verikloak
86
86
  end
87
87
  end
88
88
 
89
+ # @api private
90
+ #
91
+ # Internal mixin for audience resolution with dynamic callable support.
92
+ # Handles various callable signatures and parameter detection.
93
+ module MiddlewareAudienceResolution
94
+ private
95
+
96
+ # Resolves the expected audience for the current request.
97
+ #
98
+ # @param env [Hash] Rack environment.
99
+ # @return [String, Array<String>] The expected audience value.
100
+ # @raise [MiddlewareError] when the resolved audience is blank.
101
+ def resolve_audience(env)
102
+ source = @audience_source
103
+ value = if source.respond_to?(:call)
104
+ callable = source
105
+ call_with_optional_env(callable, env)
106
+ else
107
+ source
108
+ end
109
+
110
+ raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if value.nil?
111
+
112
+ if value.is_a?(Array)
113
+ raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if value.empty?
114
+
115
+ return value
116
+ end
117
+
118
+ normalized = value.to_s
119
+ raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if normalized.empty?
120
+
121
+ normalized
122
+ end
123
+
124
+ # Invokes the audience callable, passing the Rack env only when required.
125
+ # Falls back to a zero-argument invocation if the callable raises
126
+ # `ArgumentError` due to an unexpected argument.
127
+ #
128
+ # @param callable [#call] Audience resolver callable.
129
+ # @param env [Hash] Rack environment.
130
+ # @param arity [Integer, nil] Callable arity when known, nil otherwise.
131
+ # @return [Object] Audience value returned by the callable.
132
+ # @raise [ArgumentError] when the callable raises for reasons other than arity mismatch.
133
+ def call_with_optional_env(callable, env)
134
+ params = callable_parameters(callable)
135
+
136
+ invocation_chain(params).each do |strategy|
137
+ return strategy.call(callable, env)
138
+ rescue ArgumentError => e
139
+ raise unless wrong_arity_error?(e)
140
+ end
141
+
142
+ callable.call
143
+ end
144
+
145
+ # Returns true when the ArgumentError message indicates a wrong arity.
146
+ #
147
+ # @param error [ArgumentError]
148
+ # @return [Boolean]
149
+ def wrong_arity_error?(error)
150
+ error.message.include?('wrong number of arguments')
151
+ end
152
+
153
+ # Extracts parameter information from a callable's call method.
154
+ #
155
+ # @param callable [#call] The callable object to inspect.
156
+ # @return [Array<Array>, nil] Parameter information as returned by Method#parameters,
157
+ # or nil if the method cannot be resolved.
158
+ def callable_parameters(callable)
159
+ callable.method(:call).parameters
160
+ rescue NameError
161
+ nil
162
+ end
163
+
164
+ # Builds a chain of invocation strategies based on callable parameters.
165
+ #
166
+ # @param params [Array<Array>, nil] Parameter information from Method#parameters.
167
+ # @return [Array<Proc>] Ordered array of lambda strategies to try when calling the callable.
168
+ def invocation_chain(params)
169
+ strategies = []
170
+
171
+ if params.nil?
172
+ # When parameters are unknown, try strategies in safe order:
173
+ # 1. Try with positional argument first (most common)
174
+ # 2. Try with no arguments as fallback
175
+ strategies << ->(callable, env) { callable.call(env) }
176
+ strategies << ->(callable, _env) { callable.call }
177
+ else
178
+ # When parameters are known, try most specific to least specific
179
+ strategies << ->(callable, env) { callable.call(env: env) } if accepts_keyword_env?(params)
180
+ strategies << ->(callable, env) { callable.call(env) } if accepts_positional_env?(params)
181
+ strategies << ->(callable, _env) { callable.call } if accepts_zero_arguments?(params)
182
+ end
183
+
184
+ strategies
185
+ end
186
+
187
+ # Determines if a callable accepts keyword arguments, specifically env: parameter.
188
+ #
189
+ # @param params [Array<Array>, nil] Parameter information from Method#parameters.
190
+ # @return [Boolean] true if the callable accepts keyword arguments including env.
191
+ def accepts_keyword_env?(params)
192
+ return false if params.nil?
193
+
194
+ params.any? do |type, name|
195
+ type == :keyrest ||
196
+ (%i[keyreq key].include?(type) && (name.nil? || name == :env))
197
+ end
198
+ end
199
+
200
+ # Determines if a callable accepts positional arguments.
201
+ #
202
+ # @param params [Array<Array>, nil] Parameter information from Method#parameters.
203
+ # @return [Boolean] true if the callable accepts positional arguments.
204
+ def accepts_positional_env?(params)
205
+ return false if params.nil?
206
+
207
+ params.any? { |type, _| %i[req opt rest].include?(type) }
208
+ end
209
+
210
+ # Determines if a callable accepts zero arguments (no required parameters).
211
+ #
212
+ # @param params [Array<Array>, nil] Parameter information from Method#parameters.
213
+ # @return [Boolean] true if the callable can be called with no arguments.
214
+ def accepts_zero_arguments?(params)
215
+ return false if params.nil?
216
+
217
+ # Only accepts zero arguments if parameters are empty
218
+ # or all parameters are optional/keyword/blocks
219
+ params.empty? || params.all? { |type, _| %i[opt key keyrest block].include?(type) }
220
+ end
221
+ end
222
+
223
+ # @api private
224
+ #
225
+ # Internal mixin for configuration validation and logging utilities.
226
+ # Extracted to keep the main Middleware class focused and under line limits.
227
+ module MiddlewareConfiguration
228
+ private
229
+
230
+ # Validates and normalizes the decoder cache limit configuration.
231
+ #
232
+ # @param limit [Integer, nil] The cache limit value to normalize.
233
+ # @return [Integer, nil] The normalized limit, or nil if no limit.
234
+ # @raise [ArgumentError] if the limit is negative or invalid.
235
+ def normalize_decoder_cache_limit(limit)
236
+ return nil if limit.nil?
237
+
238
+ value = Integer(limit)
239
+ raise ArgumentError, 'decoder_cache_limit must be zero or positive' if value.negative?
240
+
241
+ value
242
+ rescue ArgumentError, TypeError
243
+ raise ArgumentError, 'decoder_cache_limit must be zero or positive'
244
+ end
245
+
246
+ # Validates and normalizes environment key configuration.
247
+ #
248
+ # @param value [String, #to_s] The environment key to normalize.
249
+ # @param option_name [String] The name of the option for error messages.
250
+ # @return [String] The normalized environment key.
251
+ # @raise [ArgumentError] if the key is blank after normalization.
252
+ def normalize_env_key(value, option_name)
253
+ normalized = value.to_s.strip
254
+ raise ArgumentError, "#{option_name} cannot be blank" if normalized.empty?
255
+
256
+ normalized
257
+ end
258
+
259
+ # Validates and normalizes the realm configuration.
260
+ #
261
+ # @param value [String, #to_s, nil] The realm value to normalize.
262
+ # @return [String] The normalized realm, or DEFAULT_REALM if nil.
263
+ # @raise [ArgumentError] if the realm is blank after normalization.
264
+ def normalize_realm(value)
265
+ return DEFAULT_REALM if value.nil?
266
+
267
+ normalized = value.to_s.strip
268
+ raise ArgumentError, 'realm cannot be blank' if normalized.empty?
269
+
270
+ normalized
271
+ end
272
+
273
+ # Checks if a logger instance is available and responds to logging methods.
274
+ #
275
+ # @return [Boolean] true if a logger is available and can log messages.
276
+ def logger_available?
277
+ return false unless @logger
278
+
279
+ @logger.respond_to?(:error) || @logger.respond_to?(:warn) || @logger.respond_to?(:debug)
280
+ end
281
+
282
+ # Logs a message and backtrace using the configured logger.
283
+ #
284
+ # @param message [String] The primary error message to log.
285
+ # @param backtrace [String, nil] The backtrace information to log.
286
+ # @return [void]
287
+ def log_with_logger(message, backtrace)
288
+ log_message(@logger, message)
289
+ log_backtrace(@logger, backtrace)
290
+ end
291
+
292
+ # Logs a message using the most appropriate logger method.
293
+ #
294
+ # @param logger [Logger] The logger instance to use.
295
+ # @param message [String] The message to log.
296
+ # @return [void]
297
+ def log_message(logger, message)
298
+ if logger.respond_to?(:error)
299
+ logger.error(message)
300
+ elsif logger.respond_to?(:warn)
301
+ logger.warn(message)
302
+ end
303
+ end
304
+
305
+ # Logs backtrace information using the most appropriate logger method.
306
+ #
307
+ # @param logger [Logger] The logger instance to use.
308
+ # @param backtrace [String, nil] The backtrace information to log.
309
+ # @return [void]
310
+ def log_backtrace(logger, backtrace)
311
+ return unless backtrace
312
+
313
+ if logger.respond_to?(:debug)
314
+ logger.debug(backtrace)
315
+ elsif logger.respond_to?(:error)
316
+ logger.error(backtrace)
317
+ elsif logger.respond_to?(:warn)
318
+ logger.warn(backtrace)
319
+ end
320
+ end
321
+ end
322
+
323
+ # @api private
324
+ #
325
+ # Internal mixin for decoder cache management with LRU eviction.
326
+ # Handles TokenDecoder instance caching and cleanup.
327
+ module MiddlewareDecoderCache
328
+ private
329
+
330
+ # Stores a decoder in the cache and updates access order if tracking is enabled.
331
+ #
332
+ # @param cache_key [String] The cache key for the decoder
333
+ # @param decoder [TokenDecoder] The decoder instance to cache
334
+ # @return [TokenDecoder] The cached decoder instance
335
+ def store_decoder_cache(cache_key, decoder)
336
+ @decoder_cache[cache_key] = decoder
337
+ touch_decoder_cache(cache_key) if track_decoder_order?
338
+ decoder
339
+ end
340
+
341
+ # Prunes the decoder cache to stay within the configured limit.
342
+ # Removes the oldest entries when the cache size exceeds the limit.
343
+ #
344
+ # @return [void]
345
+ def prune_decoder_cache_if_needed
346
+ return unless track_decoder_order?
347
+
348
+ while @decoder_cache_order.length >= @decoder_cache_limit
349
+ oldest = @decoder_cache_order.shift
350
+ @decoder_cache.delete(oldest)
351
+ end
352
+ end
353
+
354
+ # Updates the access order for a cache entry to mark it as recently used.
355
+ # Moves the cache key to the end of the order queue for LRU tracking.
356
+ #
357
+ # @param cache_key [String] The cache key to mark as recently accessed
358
+ # @return [void]
359
+ def touch_decoder_cache(cache_key)
360
+ @decoder_cache_order.delete(cache_key)
361
+ @decoder_cache_order << cache_key
362
+ end
363
+
364
+ # Checks if decoder cache order tracking is enabled.
365
+ # Returns true if cache limit is set and positive.
366
+ #
367
+ # @return [Boolean] true if order tracking is enabled
368
+ def track_decoder_order?
369
+ @decoder_cache_limit&.positive?
370
+ end
371
+
372
+ # Clears all cached decoder instances and order tracking.
373
+ # Removes all entries from both the cache and order queue.
374
+ #
375
+ # @return [void]
376
+ def clear_decoder_cache
377
+ @decoder_cache.clear
378
+ @decoder_cache_order.clear
379
+ end
380
+ end
381
+
89
382
  # @api private
90
383
  #
91
384
  # Internal mixin for JWT verification and discovery/JWKs management.
@@ -108,6 +401,9 @@ module Verikloak
108
401
 
109
402
  # Returns a cached TokenDecoder instance for current inputs.
110
403
  # Cache key uses issuer, audience, leeway, token_verify_options, and JWKs fetched_at timestamp.
404
+ #
405
+ # @param audience [String, #call] The audience to create a decoder for
406
+ # @return [TokenDecoder] A decoder instance for the given audience
111
407
  def decoder_for(audience)
112
408
  keys = @jwks_cache.cached
113
409
  fetched_at = @jwks_cache.respond_to?(:fetched_at) ? @jwks_cache.fetched_at : nil
@@ -119,13 +415,23 @@ module Verikloak
119
415
  fetched_at
120
416
  ].hash
121
417
  @mutex.synchronize do
122
- @decoder_cache[cache_key] ||= TokenDecoder.new(
418
+ if (decoder = @decoder_cache[cache_key])
419
+ touch_decoder_cache(cache_key) if track_decoder_order?
420
+ return decoder
421
+ end
422
+
423
+ decoder = TokenDecoder.new(
123
424
  jwks: keys,
124
425
  issuer: @issuer,
125
426
  audience: audience,
126
427
  leeway: @leeway,
127
428
  options: @token_verify_options
128
429
  )
430
+
431
+ return decoder if @decoder_cache_limit&.zero?
432
+
433
+ prune_decoder_cache_if_needed
434
+ store_decoder_cache(cache_key, decoder)
129
435
  end
130
436
  end
131
437
 
@@ -141,8 +447,9 @@ module Verikloak
141
447
  # Decodes and verifies the JWT using the cached JWKs. On certain verification
142
448
  # failures (e.g., key rotation), it refreshes the JWKs and retries once.
143
449
  #
144
- # @param token [String]
145
- # @return [Hash] decoded JWT claims
450
+ # @param env [Hash] The Rack environment hash
451
+ # @param token [String] The JWT token to decode and verify
452
+ # @return [Hash] Decoded JWT claims
146
453
  # @raise [Verikloak::Error] bubbles up verification/fetch errors for centralized handling
147
454
  def decode_token(env, token)
148
455
  ensure_jwks_cache!
@@ -169,73 +476,6 @@ module Verikloak
169
476
  end
170
477
  end
171
478
 
172
- # Resolves the expected audience for the current request.
173
- #
174
- # @param env [Hash] Rack environment.
175
- # @return [String, Array<String>] The expected audience value.
176
- # @raise [MiddlewareError] when the resolved audience is blank.
177
- def resolve_audience(env)
178
- source = @audience_source
179
- value = if source.respond_to?(:call)
180
- callable = source
181
- arity = callable.respond_to?(:arity) ? callable.arity : safe_callable_arity(callable)
182
- call_with_optional_env(callable, env, arity)
183
- else
184
- source
185
- end
186
-
187
- raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if value.nil?
188
-
189
- if value.is_a?(Array)
190
- raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if value.empty?
191
-
192
- return value
193
- end
194
-
195
- normalized = value.to_s
196
- raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if normalized.empty?
197
-
198
- normalized
199
- end
200
-
201
- # Invokes the audience callable, passing the Rack env only when required.
202
- # Falls back to a zero-argument invocation if the callable raises
203
- # `ArgumentError` due to an unexpected argument.
204
- #
205
- # @param callable [#call] Audience resolver callable.
206
- # @param env [Hash] Rack environment.
207
- # @param arity [Integer, nil] Callable arity when known, nil otherwise.
208
- # @return [Object] Audience value returned by the callable.
209
- # @raise [ArgumentError] when the callable raises for reasons other than arity mismatch.
210
- def call_with_optional_env(callable, env, arity)
211
- return callable.call if arity&.zero?
212
-
213
- callable.call(env)
214
- rescue ArgumentError => e
215
- raise unless arity.nil? && wrong_arity_error?(e)
216
-
217
- callable.call
218
- end
219
-
220
- # Safely obtains a callable's arity, returning nil when `#method(:call)`
221
- # cannot be resolved (e.g., BasicObject-based objects).
222
- #
223
- # @param callable [#call]
224
- # @return [Integer, nil]
225
- def safe_callable_arity(callable)
226
- callable.method(:call).arity
227
- rescue NameError
228
- nil
229
- end
230
-
231
- # Returns true when the ArgumentError message indicates a wrong arity.
232
- #
233
- # @param error [ArgumentError]
234
- # @return [Boolean]
235
- def wrong_arity_error?(error)
236
- error.message.include?('wrong number of arguments')
237
- end
238
-
239
479
  # Ensures that discovery metadata and JWKs cache are initialized and up-to-date.
240
480
  # This method is thread-safe.
241
481
  #
@@ -246,6 +486,7 @@ module Verikloak
246
486
  # @raise [Verikloak::DiscoveryError, Verikloak::JwksCacheError, Verikloak::MiddlewareError]
247
487
  def ensure_jwks_cache!
248
488
  @mutex.synchronize do
489
+ previous_keys_id = cached_keys_identity(@jwks_cache)
249
490
  if @jwks_cache.nil?
250
491
  config = @discovery.fetch!
251
492
  @issuer = config['issuer']
@@ -254,6 +495,7 @@ module Verikloak
254
495
  end
255
496
 
256
497
  @jwks_cache.fetch!
498
+ purge_decoder_cache_if_keys_changed(previous_keys_id)
257
499
  end
258
500
  rescue Verikloak::DiscoveryError, Verikloak::JwksCacheError => e
259
501
  # Re-raise so that specific error codes can be mapped in the middleware
@@ -261,6 +503,33 @@ module Verikloak
261
503
  rescue StandardError => e
262
504
  raise MiddlewareError.new("Failed to initialize JWKs cache: #{e.message}", code: 'jwks_fetch_failed')
263
505
  end
506
+
507
+ # Purges the decoder cache if the JWKs have changed since last check.
508
+ # Compares key set identity to detect key rotation and invalidate cached decoders.
509
+ #
510
+ # @param previous_keys_id [String, nil] The previous JWKs identity hash
511
+ # @return [void]
512
+ def purge_decoder_cache_if_keys_changed(previous_keys_id)
513
+ current_id = cached_keys_identity(@jwks_cache)
514
+ if (@last_cached_keys_id && current_id && @last_cached_keys_id != current_id) ||
515
+ (previous_keys_id && current_id && previous_keys_id != current_id)
516
+ clear_decoder_cache
517
+ end
518
+
519
+ @last_cached_keys_id = current_id if current_id
520
+ end
521
+
522
+ # Generates a unique identity hash for the current JWKs set.
523
+ # Used to detect changes in the key set for cache invalidation.
524
+ #
525
+ # @param cache [JwksCache] The JWKs cache instance
526
+ # @return [String, nil] A hash representing the current key set identity
527
+ def cached_keys_identity(cache)
528
+ return unless cache.respond_to?(:cached)
529
+
530
+ keys = cache.cached
531
+ keys&.__id__
532
+ end
264
533
  end
265
534
 
266
535
  # @api private
@@ -285,13 +554,17 @@ module Verikloak
285
554
 
286
555
  private
287
556
 
288
- # @param code [String, nil] short error code
557
+ # Determines if an error code should result in a 403 Forbidden response.
558
+ #
559
+ # @param code [String, nil] The error code to check
289
560
  # @return [Boolean] true if the error should be treated as a 403 Forbidden
290
561
  def forbidden?(code)
291
562
  code == 'forbidden'
292
563
  end
293
564
 
294
- # @param code [String, nil]
565
+ # Determines if an error code belongs to authentication-related errors.
566
+ #
567
+ # @param code [String, nil] The error code to check
295
568
  # @return [Boolean] true if the error belongs to {AUTH_ERROR_CODES}
296
569
  def auth_error?(code)
297
570
  code && AUTH_ERROR_CODES.include?(code)
@@ -347,6 +620,9 @@ module Verikloak
347
620
  class Middleware
348
621
  include MiddlewareErrorMapping
349
622
  include SkipPathMatcher
623
+ include MiddlewareConfiguration
624
+ include MiddlewareAudienceResolution
625
+ include MiddlewareDecoderCache
350
626
  include MiddlewareTokenVerification
351
627
 
352
628
  DEFAULT_REALM = 'verikloak'
@@ -367,6 +643,8 @@ module Verikloak
367
643
  # to TokenDecoder.
368
644
  # e.g., { verify_iat: false, leeway: 10 }
369
645
  # rubocop:disable Metrics/ParameterLists
646
+ DEFAULT_DECODER_CACHE_LIMIT = 128
647
+
370
648
  def initialize(app,
371
649
  discovery_url:,
372
650
  audience:,
@@ -376,6 +654,7 @@ module Verikloak
376
654
  connection: nil,
377
655
  leeway: Verikloak::TokenDecoder::DEFAULT_LEEWAY,
378
656
  token_verify_options: {},
657
+ decoder_cache_limit: DEFAULT_DECODER_CACHE_LIMIT,
379
658
  token_env_key: DEFAULT_TOKEN_ENV_KEY,
380
659
  user_env_key: DEFAULT_USER_ENV_KEY,
381
660
  realm: DEFAULT_REALM,
@@ -387,9 +666,12 @@ module Verikloak
387
666
  @jwks_cache = jwks_cache
388
667
  @leeway = leeway
389
668
  @token_verify_options = token_verify_options || {}
669
+ @decoder_cache_limit = normalize_decoder_cache_limit(decoder_cache_limit)
390
670
  @issuer = nil
391
671
  @mutex = Mutex.new
392
672
  @decoder_cache = {}
673
+ @decoder_cache_order = []
674
+ @last_cached_keys_id = nil
393
675
  @token_env_key = normalize_env_key(token_env_key, 'token_env_key')
394
676
  @user_env_key = normalize_env_key(user_env_key, 'user_env_key')
395
677
  @realm = normalize_realm(realm)
@@ -421,14 +703,16 @@ module Verikloak
421
703
 
422
704
  # Returns the Faraday connection used for HTTP operations (Discovery/JWKs).
423
705
  # Exposed for tests; not part of public API.
706
+ #
707
+ # @return [Faraday::Connection] The HTTP connection instance
424
708
  def http_connection
425
709
  @connection
426
710
  end
427
711
 
428
712
  # Verifies the token, stores result in Rack env, and forwards to the downstream app.
429
713
  #
430
- # @param env [Hash]
431
- # @param token [String]
714
+ # @param env [Hash] The Rack environment hash
715
+ # @param token [String] The extracted JWT token
432
716
  # @return [Array(Integer, Hash, Array<String>)] Rack response triple
433
717
  def handle_request(env, token)
434
718
  claims = decode_token(env, token)
@@ -439,8 +723,8 @@ module Verikloak
439
723
 
440
724
  # Extracts the Bearer token from the `Authorization` header.
441
725
  #
442
- # @param env [Hash]
443
- # @return [String] the raw JWT string
726
+ # @param env [Hash] The Rack environment hash
727
+ # @return [String] The raw JWT string
444
728
  # @raise [Verikloak::MiddlewareError] when the header is missing or malformed
445
729
  def extract_token(env)
446
730
  auth = env['HTTP_AUTHORIZATION']
@@ -459,8 +743,8 @@ module Verikloak
459
743
 
460
744
  # Converts a raised error into a `[code, http_status]` tuple for response rendering.
461
745
  #
462
- # @param error [Exception]
463
- # @return [Array(String, Integer)]
746
+ # @param error [Exception] The exception to map
747
+ # @return [Array(String, Integer)] A tuple of error code and HTTP status
464
748
  def map_error(error)
465
749
  code = error.respond_to?(:code) ? error.code : nil
466
750
 
@@ -480,9 +764,9 @@ module Verikloak
480
764
 
481
765
  # Builds a JSON error response with RFC 6750 `WWW-Authenticate` header for 401.
482
766
  #
483
- # @param code [String]
484
- # @param message [String]
485
- # @param status [Integer]
767
+ # @param code [String] The error code to include in the response
768
+ # @param message [String] The error message to include in the response
769
+ # @param status [Integer] The HTTP status code for the response
486
770
  # @return [Array(Integer, Hash, Array<String>)] Rack response triple
487
771
  def error_response(code = 'unauthorized', message = 'Unauthorized', status = 401)
488
772
  body = { error: code, message: message }.to_json
@@ -509,52 +793,5 @@ module Verikloak
509
793
  warn backtrace if backtrace
510
794
  end
511
795
  end
512
-
513
- def logger_available?
514
- return false unless @logger
515
-
516
- @logger.respond_to?(:error) || @logger.respond_to?(:warn) || @logger.respond_to?(:debug)
517
- end
518
-
519
- def log_with_logger(message, backtrace)
520
- log_message(@logger, message)
521
- log_backtrace(@logger, backtrace)
522
- end
523
-
524
- def log_message(logger, message)
525
- if logger.respond_to?(:error)
526
- logger.error(message)
527
- elsif logger.respond_to?(:warn)
528
- logger.warn(message)
529
- end
530
- end
531
-
532
- def log_backtrace(logger, backtrace)
533
- return unless backtrace
534
-
535
- if logger.respond_to?(:debug)
536
- logger.debug(backtrace)
537
- elsif logger.respond_to?(:error)
538
- logger.error(backtrace)
539
- elsif logger.respond_to?(:warn)
540
- logger.warn(backtrace)
541
- end
542
- end
543
-
544
- def normalize_env_key(value, option_name)
545
- normalized = value.to_s.strip
546
- raise ArgumentError, "#{option_name} cannot be blank" if normalized.empty?
547
-
548
- normalized
549
- end
550
-
551
- def normalize_realm(value)
552
- return DEFAULT_REALM if value.nil?
553
-
554
- normalized = value.to_s.strip
555
- raise ArgumentError, 'realm cannot be blank' if normalized.empty?
556
-
557
- normalized
558
- end
559
796
  end
560
797
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Verikloak
4
4
  # Defines the current version of the Verikloak gem.
5
- VERSION = '0.2.0'
5
+ VERSION = '0.2.1'
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: verikloak
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - taiyaky
@@ -109,7 +109,7 @@ metadata:
109
109
  source_code_uri: https://github.com/taiyaky/verikloak
110
110
  changelog_uri: https://github.com/taiyaky/verikloak/blob/main/CHANGELOG.md
111
111
  bug_tracker_uri: https://github.com/taiyaky/verikloak/issues
112
- documentation_uri: https://rubydoc.info/gems/verikloak/0.2.0
112
+ documentation_uri: https://rubydoc.info/gems/verikloak/0.2.1
113
113
  rubygems_mfa_required: 'true'
114
114
  rdoc_options: []
115
115
  require_paths: