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 +4 -4
- data/CHANGELOG.md +17 -12
- data/README.md +31 -4
- data/lib/verikloak/jwks_cache.rb +22 -11
- data/lib/verikloak/middleware.rb +365 -128
- data/lib/verikloak/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 743b7db62f39f179fe22804c61144e661992625e43f3b411ea965f85364b6f50
|
|
4
|
+
data.tar.gz: ea9fea3cc08d53d076c60e946a39dad3594468dc16574b6a1f3a0e4574f9d80c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
####
|
|
346
|
+
#### Decoder cache & performance
|
|
346
347
|
|
|
347
348
|
Internally, Verikloak caches `TokenDecoder` instances per JWKs fetch to avoid reinitializing
|
|
348
|
-
them on every request.
|
|
349
|
-
|
|
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
|
|
data/lib/verikloak/jwks_cache.rb
CHANGED
|
@@ -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
|
-
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
return @cached_keys if fresh_by_ttl_locked?
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
!
|
|
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
|
data/lib/verikloak/middleware.rb
CHANGED
|
@@ -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]
|
|
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
|
|
145
|
-
# @
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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]
|
|
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
|
data/lib/verikloak/version.rb
CHANGED
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.
|
|
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.
|
|
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:
|