verikloak 0.1.5 → 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: 41b02d67b2d6182f8af59436549e9d68cccf08559ecdee0539793ee3baef77a7
4
- data.tar.gz: 5cd807c55d6635370149cf0090644698f470d15c9e01dc78a9332adb6659e2f8
3
+ metadata.gz: 743b7db62f39f179fe22804c61144e661992625e43f3b411ea965f85364b6f50
4
+ data.tar.gz: ea9fea3cc08d53d076c60e946a39dad3594468dc16574b6a1f3a0e4574f9d80c
5
5
  SHA512:
6
- metadata.gz: eba3a601c8c67080326955bd0557cfcc8d8098469694ef42ffac590b10a91c48cf4c3cb7250645d6db39e8208a8a05bd0bfa8b59cbdb9ced6e57e55241aa4da1
7
- data.tar.gz: 553050d22b04de79bac27c00358f2c1a0779d380556297ffa2ca7bb5fa1944fe9ee55f9450ccc52cf004c7899c880484457b703d149404e80097079fff303341
6
+ metadata.gz: 89d6398d946686f61ec67dd93a2f807f38b4cc828bf86b4023787ea3aece53ebac95b08306827a06507883d1ef3f1711571af01db0c7ac5e2e4cc5340d92d34b
7
+ data.tar.gz: 5469a3833cde3b5f5e21439cdd19bb6638ef0e3b49fd2da64e7398dcd24a8851eec11f985ab0821a23b7446d275983b62e87804b541299ec486b88113d8b89ee
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ 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
+
27
+ ## [0.2.0] - 2025-09-22
28
+
29
+ ### Added
30
+ - Middleware options `token_env_key` and `user_env_key` for customizing where the token and decoded claims are stored in the Rack env.
31
+ - Middleware option `realm` to change the `WWW-Authenticate` realm value emitted on 401 responses.
32
+ - Middleware option `logger` so unexpected internal errors can be sent to the host application's logger instead of STDERR.
33
+
34
+ ### Changed
35
+ - Update gem version to 0.2.0 to stay aligned with the rest of the Verikloak ecosystem gems.
36
+
10
37
  ## [0.1.5] - 2025-09-21
11
38
 
12
39
  ### Added
@@ -19,15 +46,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
46
  ### Dependencies
20
47
  - Declare `faraday-retry` as a runtime dependency so the default HTTP connection can load the retry middleware.
21
48
 
22
- ---
23
-
24
49
  ## [0.1.4] - 2025-09-20
25
50
 
26
51
  ### Chore
27
52
  - Bump dev dependency `rexml` to 3.4.2 (PR #15).
28
53
 
29
- ---
30
-
31
54
  ## [0.1.3] - 2025-09-15
32
55
 
33
56
  ### Changed
@@ -37,8 +60,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
37
60
  - Bump dev dependency `rubocop` to 1.80.2 (PR #13).
38
61
  - Bump dev dependency `rubocop-rspec` to 3.7.0 (PR #12).
39
62
 
40
- ---
41
-
42
63
  ## [0.1.2] - 2025-08-31
43
64
 
44
65
  ### Added
@@ -51,8 +72,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
51
72
  - Middleware: TokenDecoder instances are now cached per JWKs fetch for performance improvement.
52
73
  - Internal: RuboCop style fixes (`HashExcept`, `HashTransformKeys`, long line splits).
53
74
 
54
- ---
55
-
56
75
  ## [0.1.1] - 2025-08-24
57
76
 
58
77
  ### Changed
@@ -60,8 +79,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
60
79
  - Updated dependency constraints in gemspec (`json` ~> 2.6, `jwt` ~> 2.7) for better compatibility control
61
80
  - Updated README badges (Gem version, Ruby version, downloads)
62
81
 
63
- ---
64
-
65
82
  ## [0.1.0] - 2025-08-17
66
83
 
67
84
  ### Added
data/README.md CHANGED
@@ -94,6 +94,26 @@ config.middleware.use Verikloak::Middleware,
94
94
  ```
95
95
  This makes the configuration secure and flexible across environments.
96
96
 
97
+ #### Advanced middleware options
98
+
99
+ `Verikloak::Middleware` exposes a few optional knobs that help integrate with
100
+ different Rack stacks:
101
+
102
+ - `token_env_key` (default: `"verikloak.token"`) — where the raw JWT is stored in the Rack env
103
+ - `user_env_key` (default: `"verikloak.user"`) — where decoded claims are stored
104
+ - `realm` (default: `"verikloak"`) — value used in the `WWW-Authenticate` header for 401 responses
105
+ - `logger` — an object responding to `error` (and optionally `debug`) that receives unexpected 500-level failures
106
+
107
+ ```ruby
108
+ config.middleware.use Verikloak::Middleware,
109
+ discovery_url: ENV.fetch("DISCOVERY_URL"),
110
+ audience: ENV.fetch("CLIENT_ID"),
111
+ token_env_key: "rack.session.token",
112
+ user_env_key: "rack.session.claims",
113
+ realm: "my-api",
114
+ logger: Rails.logger
115
+ ```
116
+
97
117
  ### Accessing claims in controllers
98
118
 
99
119
  Once the middleware is enabled, Verikloak adds the decoded token and raw JWT to the Rack environment.
@@ -232,6 +252,7 @@ For a full list of error cases and detailed explanations, please see the [ERRORS
232
252
  | `jwks_cache` | No | Inject custom JwksCache instance (advanced/testing) |
233
253
  | `leeway` | No | Clock skew tolerance (seconds) applied during JWT verification. Defaults to `TokenDecoder::DEFAULT_LEEWAY`. |
234
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. |
235
256
  | `connection` | No | Inject a Faraday::Connection used for both Discovery and JWKs fetches. Defaults to a safe connection with timeouts and retries. |
236
257
 
237
258
  #### Option: `skip_paths`
@@ -256,7 +277,7 @@ Internally, `*` expands to match nested paths, so patterns like `/rails/*` are v
256
277
 
257
278
  #### Option: `audience`
258
279
 
259
- 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:
260
281
 
261
282
  ```ruby
262
283
  Verikloak::Middleware.new(app,
@@ -322,11 +343,37 @@ config.middleware.use Verikloak::Middleware,
322
343
  - `token_verify_options:` is passed directly to TokenDecoder (and ultimately to `JWT.decode`).
323
344
  - If both are set, `token_verify_options[:leeway]` takes precedence.
324
345
 
325
- #### Performance note
346
+ #### Decoder cache & performance
326
347
 
327
348
  Internally, Verikloak caches `TokenDecoder` instances per JWKs fetch to avoid reinitializing
328
- them on every request. This improves performance while still ensuring that keys are
329
- 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.
330
377
 
331
378
  ## Architecture
332
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,8 +620,15 @@ 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
 
628
+ DEFAULT_REALM = 'verikloak'
629
+ DEFAULT_TOKEN_ENV_KEY = 'verikloak.token'
630
+ DEFAULT_USER_ENV_KEY = 'verikloak.user'
631
+
352
632
  # @param app [#call] downstream Rack app
353
633
  # @param discovery_url [String] OIDC discovery endpoint URL
354
634
  # @param audience [String, #call] Expected `aud` claim. When a callable is provided it
@@ -362,6 +642,9 @@ module Verikloak
362
642
  # @param token_verify_options [Hash] Additional JWT verification options passed through
363
643
  # to TokenDecoder.
364
644
  # e.g., { verify_iat: false, leeway: 10 }
645
+ # rubocop:disable Metrics/ParameterLists
646
+ DEFAULT_DECODER_CACHE_LIMIT = 128
647
+
365
648
  def initialize(app,
366
649
  discovery_url:,
367
650
  audience:,
@@ -370,7 +653,12 @@ module Verikloak
370
653
  jwks_cache: nil,
371
654
  connection: nil,
372
655
  leeway: Verikloak::TokenDecoder::DEFAULT_LEEWAY,
373
- token_verify_options: {})
656
+ token_verify_options: {},
657
+ decoder_cache_limit: DEFAULT_DECODER_CACHE_LIMIT,
658
+ token_env_key: DEFAULT_TOKEN_ENV_KEY,
659
+ user_env_key: DEFAULT_USER_ENV_KEY,
660
+ realm: DEFAULT_REALM,
661
+ logger: nil)
374
662
  @app = app
375
663
  @connection = connection || Verikloak::HTTP.default_connection
376
664
  @audience_source = audience
@@ -378,12 +666,20 @@ module Verikloak
378
666
  @jwks_cache = jwks_cache
379
667
  @leeway = leeway
380
668
  @token_verify_options = token_verify_options || {}
669
+ @decoder_cache_limit = normalize_decoder_cache_limit(decoder_cache_limit)
381
670
  @issuer = nil
382
671
  @mutex = Mutex.new
383
672
  @decoder_cache = {}
673
+ @decoder_cache_order = []
674
+ @last_cached_keys_id = nil
675
+ @token_env_key = normalize_env_key(token_env_key, 'token_env_key')
676
+ @user_env_key = normalize_env_key(user_env_key, 'user_env_key')
677
+ @realm = normalize_realm(realm)
678
+ @logger = logger
384
679
 
385
680
  compile_skip_paths(skip_paths)
386
681
  end
682
+ # rubocop:enable Metrics/ParameterLists
387
683
 
388
684
  # Rack entrypoint.
389
685
  #
@@ -407,26 +703,28 @@ module Verikloak
407
703
 
408
704
  # Returns the Faraday connection used for HTTP operations (Discovery/JWKs).
409
705
  # Exposed for tests; not part of public API.
706
+ #
707
+ # @return [Faraday::Connection] The HTTP connection instance
410
708
  def http_connection
411
709
  @connection
412
710
  end
413
711
 
414
712
  # Verifies the token, stores result in Rack env, and forwards to the downstream app.
415
713
  #
416
- # @param env [Hash]
417
- # @param token [String]
714
+ # @param env [Hash] The Rack environment hash
715
+ # @param token [String] The extracted JWT token
418
716
  # @return [Array(Integer, Hash, Array<String>)] Rack response triple
419
717
  def handle_request(env, token)
420
718
  claims = decode_token(env, token)
421
- env['verikloak.token'] = token
422
- env['verikloak.user'] = claims
719
+ env[@token_env_key] = token
720
+ env[@user_env_key] = claims
423
721
  @app.call(env)
424
722
  end
425
723
 
426
724
  # Extracts the Bearer token from the `Authorization` header.
427
725
  #
428
- # @param env [Hash]
429
- # @return [String] the raw JWT string
726
+ # @param env [Hash] The Rack environment hash
727
+ # @return [String] The raw JWT string
430
728
  # @raise [Verikloak::MiddlewareError] when the header is missing or malformed
431
729
  def extract_token(env)
432
730
  auth = env['HTTP_AUTHORIZATION']
@@ -445,8 +743,8 @@ module Verikloak
445
743
 
446
744
  # Converts a raised error into a `[code, http_status]` tuple for response rendering.
447
745
  #
448
- # @param error [Exception]
449
- # @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
450
748
  def map_error(error)
451
749
  code = error.respond_to?(:code) ? error.code : nil
452
750
 
@@ -466,16 +764,16 @@ module Verikloak
466
764
 
467
765
  # Builds a JSON error response with RFC 6750 `WWW-Authenticate` header for 401.
468
766
  #
469
- # @param code [String]
470
- # @param message [String]
471
- # @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
472
770
  # @return [Array(Integer, Hash, Array<String>)] Rack response triple
473
771
  def error_response(code = 'unauthorized', message = 'Unauthorized', status = 401)
474
772
  body = { error: code, message: message }.to_json
475
773
  headers = { 'Content-Type' => 'application/json' }
476
774
  if status == 401
477
775
  headers['WWW-Authenticate'] =
478
- %(Bearer realm="verikloak", error="#{code}", error_description="#{message.gsub('"', '\\"')}")
776
+ %(Bearer realm="#{@realm}", error="#{code}", error_description="#{message.gsub('"', '\\"')}")
479
777
  end
480
778
  [status, headers, [body]]
481
779
  end
@@ -485,8 +783,15 @@ module Verikloak
485
783
  # @param error [Exception]
486
784
  # @return [void]
487
785
  def log_internal_error(error)
488
- warn "[verikloak] Internal error: #{error.class} - #{error.message}"
489
- warn error.backtrace.join("\n") if error.backtrace
786
+ message = "[verikloak] Internal error: #{error.class} - #{error.message}"
787
+ backtrace = error.backtrace&.join("\n")
788
+
789
+ if logger_available?
790
+ log_with_logger(message, backtrace)
791
+ else
792
+ warn message
793
+ warn backtrace if backtrace
794
+ end
490
795
  end
491
796
  end
492
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.1.5'
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.1.5
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.1.5
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: