verikloak 0.1.1 → 0.1.2
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 +14 -0
- data/README.md +94 -10
- data/lib/verikloak/jwks_cache.rb +13 -2
- data/lib/verikloak/middleware.rb +224 -127
- data/lib/verikloak/token_decoder.rb +23 -5
- 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: 05c91b8c75eff0da030bf668fa5c1ab1dece887a6cfaae8717ee7d82fbf5a637
|
|
4
|
+
data.tar.gz: 7b7c2188b441e81fea22194fb1309c4436d321702ffc5ce5dc215286263821f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6cf6fedad6a1ff8fd77ad25c91e97920e778a533b802da33f3b9c478637a271732c4c47f9ee8c8fa53e2b23716c60c75f34d068ef7177d1555dc9ceb8c7704bf
|
|
7
|
+
data.tar.gz: fc32dda5c37fef1d339f4478f1b96db406b24910ec2e19c402d53b685a84194336cd66ef96996b10f44a81964d03ae42f906da72aa07af8aae87496fbf68c2af
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.1.2] - 2025-08-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Middleware: new `connection:` option to inject a Faraday::Connection, shared by Discovery and JWKS.
|
|
14
|
+
- Middleware: new `leeway:` and `token_verify_options:` options, delegated to TokenDecoder.
|
|
15
|
+
- README: documented usage of `connection`, leeway/options, and clarified `skip_paths` behavior.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Middleware: `skip_paths` semantics clarified — plain paths are exact-match only, use `/*` for prefix matching.
|
|
19
|
+
- Middleware: TokenDecoder instances are now cached per JWKS fetch for performance improvement.
|
|
20
|
+
- Internal: RuboCop style fixes (`HashExcept`, `HashTransformKeys`, long line splits).
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
10
24
|
## [0.1.1] - 2025-08-24
|
|
11
25
|
|
|
12
26
|
### Changed
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/taiyaky/verikloak/actions/workflows/ci.yml)
|
|
4
4
|
[](https://rubygems.org/gems/verikloak)
|
|
5
|
-

|
|
6
6
|
[](https://rubygems.org/gems/verikloak)
|
|
7
7
|
|
|
8
8
|
A lightweight Rack middleware for verifying Keycloak JWT access tokens via OpenID Connect.
|
|
@@ -53,21 +53,44 @@ config.middleware.use Verikloak::Middleware,
|
|
|
53
53
|
|
|
54
54
|
#### Handling Authentication Failures
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
When you use the Rack middleware, authentication failures are automatically converted into JSON error responses (for example, `401` for token issues, `503` for JWKS/discovery errors). In most cases **you do not need to add custom `rescue_from` handlers** in Rails controllers.
|
|
57
|
+
|
|
58
|
+
If you use Verikloak components directly (bypassing the Rack middleware) or prefer centralized error handling, rescue from the base class `Verikloak::Error`. You can also match subclasses such as `Verikloak::TokenDecoderError`, `Verikloak::DiscoveryError`, or `Verikloak::JwksCacheError` depending on your needs:
|
|
58
59
|
|
|
59
60
|
```ruby
|
|
60
61
|
class ApplicationController < ActionController::API
|
|
61
|
-
rescue_from Verikloak::
|
|
62
|
-
|
|
62
|
+
rescue_from Verikloak::Error do |e|
|
|
63
|
+
status =
|
|
64
|
+
case e
|
|
65
|
+
when Verikloak::TokenDecoderError
|
|
66
|
+
:unauthorized
|
|
67
|
+
when Verikloak::DiscoveryError, Verikloak::JwksCacheError
|
|
68
|
+
:service_unavailable
|
|
69
|
+
else
|
|
70
|
+
:unauthorized
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
render json: { error: e.class.name, message: e.message }, status: status
|
|
63
74
|
end
|
|
64
75
|
end
|
|
65
76
|
```
|
|
66
77
|
|
|
67
|
-
This ensures
|
|
78
|
+
This ensures that even if you bypass the middleware, clients still receive
|
|
79
|
+
structured error responses.
|
|
80
|
+
|
|
81
|
+
> **Note:** When the Rack middleware is enabled, it already renders JSON error responses.
|
|
82
|
+
> The `rescue_from` example above is only necessary if you bypass the middleware or want custom behavior.
|
|
68
83
|
|
|
69
84
|
---
|
|
85
|
+
#### Error Hierarchy
|
|
70
86
|
|
|
87
|
+
All Verikloak errors inherit from `Verikloak::Error`:
|
|
88
|
+
|
|
89
|
+
- `Verikloak::TokenDecoderError` – token parsing/verification (`401 Unauthorized`)
|
|
90
|
+
- `Verikloak::DiscoveryError` – OIDC discovery fetch/parse (`503 Service Unavailable`)
|
|
91
|
+
- `Verikloak::JwksCacheError` – JWKS fetch/parse/cache (`503 Service Unavailable`)
|
|
92
|
+
- `Verikloak::MiddlewareError` – header/infra issues surfaced by the middleware (usually `401`, sometimes `503`)
|
|
93
|
+
---
|
|
71
94
|
#### Recommended: use environment variables in production
|
|
72
95
|
|
|
73
96
|
```ruby
|
|
@@ -210,7 +233,7 @@ Verikloak returns JSON error responses in a consistent format with structured er
|
|
|
210
233
|
| `discovery_redirect_error` | 503 Service Unavailable | Discovery response was a redirect without a valid Location header |
|
|
211
234
|
| `internal_server_error` | 500 Internal Server Error | Unexpected internal error (catch-all) |
|
|
212
235
|
|
|
213
|
-
> Note
|
|
236
|
+
> **Note:** The `decode_with_public_key` method ensures consistent error codes for all JWT verification failures.
|
|
214
237
|
> It may raise `invalid_signature`, `unsupported_algorithm`, `expired_token`, `invalid_issuer`, `invalid_audience`, or `not_yet_valid` depending on the verification outcome.
|
|
215
238
|
|
|
216
239
|
For a full list of error cases and detailed explanations, please see the [ERRORS.md](ERRORS.md) file.
|
|
@@ -224,27 +247,88 @@ For a full list of error cases and detailed explanations, please see the [ERRORS
|
|
|
224
247
|
| `skip_paths` | No | Array of paths or wildcards to skip authentication, e.g. `['/', '/health', '/public/*']`. **Note:** Regex patterns are not supported. |
|
|
225
248
|
| `discovery` | No | Inject custom Discovery instance (advanced/testing) |
|
|
226
249
|
| `jwks_cache` | No | Inject custom JwksCache instance (advanced/testing) |
|
|
250
|
+
| `leeway` | No | Clock skew tolerance (seconds) applied during JWT verification. Defaults to `TokenDecoder::DEFAULT_LEEWAY`. |
|
|
251
|
+
| `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. |
|
|
252
|
+
| `connection` | No | Inject a Faraday::Connection used for both Discovery and JWKS fetches. Allows unified timeout, retry, and headers. |
|
|
227
253
|
|
|
228
254
|
#### Option: `skip_paths`
|
|
229
255
|
|
|
256
|
+
Plain paths are exact-match only, while `/*` at the end enables prefix matching.
|
|
257
|
+
|
|
230
258
|
`skip_paths` lets you specify paths (or wildcard patterns) where authentication should be **skipped**.
|
|
231
259
|
For example:
|
|
232
260
|
|
|
233
261
|
```ruby
|
|
234
|
-
skip_paths: ['/', '/health', '/
|
|
262
|
+
skip_paths: ['/', '/health', '/rails/*', '/public/src']
|
|
235
263
|
```
|
|
236
264
|
- `'/'` matches only the root path.
|
|
237
|
-
- `'/
|
|
238
|
-
- `'/api/public'` matches **only** `/api/public` (for subpaths, use `'/api/public/*'`).
|
|
265
|
+
- `'/health'` matches **only** `/health` (for subpaths, use `'/health/*'`).
|
|
239
266
|
- `'/rails/*'` matches `/rails` itself as well as `/rails/foo`, `/rails/foo/bar`, etc.
|
|
267
|
+
- `'/public/src'` matches `/public/src`, but does **not** match `/public`, any subpath like `/public/src/html` or other siblings like `/public/css`.
|
|
240
268
|
|
|
241
269
|
Paths **not matched** by any `skip_paths` entry will require a valid JWT.
|
|
242
270
|
|
|
243
271
|
**Note:** Regex patterns are not supported. Only literal paths and `*` wildcards are allowed.
|
|
244
272
|
Internally, `*` expands to match nested paths, so patterns like `/rails/*` are valid. This differs from regex — for example, `'/rails'` alone matches only `/rails`, while `'/rails/*'` covers both `/rails` and deeper subpaths.
|
|
245
273
|
|
|
274
|
+
#### Customizing Faraday for Discovery and JWKS
|
|
275
|
+
|
|
276
|
+
Both `Discovery` and `JwksCache` accept a `Faraday::Connection`.
|
|
277
|
+
This allows you to configure timeouts, retries, logging, and shared headers:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
connection = Faraday.new(request: { timeout: 5 }) do |f|
|
|
281
|
+
f.response :logger
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
config.middleware.use Verikloak::Middleware,
|
|
285
|
+
discovery_url: ENV["DISCOVERY_URL"],
|
|
286
|
+
audience: ENV["CLIENT_ID"],
|
|
287
|
+
jwks_cache: Verikloak::JwksCache.new(
|
|
288
|
+
jwks_uri: "https://example.com/realms/myrealm/protocol/openid-connect/certs",
|
|
289
|
+
connection: connection
|
|
290
|
+
)
|
|
291
|
+
```
|
|
292
|
+
This makes it easy to apply consistent Faraday settings across both discovery and JWKS fetches.
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
# Alternatively, you can pass the connection directly to the middleware:
|
|
296
|
+
config.middleware.use Verikloak::Middleware,
|
|
297
|
+
discovery_url: ENV["DISCOVERY_URL"],
|
|
298
|
+
audience: ENV["CLIENT_ID"],
|
|
299
|
+
connection: connection
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
#### Customizing token verification (leeway and options)
|
|
303
|
+
|
|
304
|
+
You can fine-tune how JWTs are verified by setting `leeway` or providing advanced options via `token_verify_options`. For example:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
config.middleware.use Verikloak::Middleware,
|
|
308
|
+
discovery_url: ENV["DISCOVERY_URL"],
|
|
309
|
+
audience: ENV["CLIENT_ID"],
|
|
310
|
+
leeway: 30, # allow 30s clock skew
|
|
311
|
+
token_verify_options: {
|
|
312
|
+
verify_iat: true,
|
|
313
|
+
verify_expiration: true,
|
|
314
|
+
verify_not_before: true,
|
|
315
|
+
# algorithms: ["RS256"] # override algorithms if needed
|
|
316
|
+
# leeway: 10 # this overrides the top-level leeway
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
- `leeway:` sets the default skew tolerance in seconds.
|
|
321
|
+
- `token_verify_options:` is passed directly to TokenDecoder (and ultimately to `JWT.decode`).
|
|
322
|
+
- If both are set, `token_verify_options[:leeway]` takes precedence.
|
|
323
|
+
|
|
246
324
|
---
|
|
247
325
|
|
|
326
|
+
#### Performance note
|
|
327
|
+
|
|
328
|
+
Internally, Verikloak caches `TokenDecoder` instances per JWKS fetch to avoid reinitializing
|
|
329
|
+
them on every request. This improves performance while still ensuring that keys are
|
|
330
|
+
revalidated when JWKS is refreshed.
|
|
331
|
+
|
|
248
332
|
## Architecture
|
|
249
333
|
|
|
250
334
|
Verikloak consists of modular components, each with a focused responsibility:
|
data/lib/verikloak/jwks_cache.rb
CHANGED
|
@@ -28,15 +28,22 @@ module Verikloak
|
|
|
28
28
|
#
|
|
29
29
|
# @see #fetch!
|
|
30
30
|
# @see #cached
|
|
31
|
+
#
|
|
32
|
+
# ## Dependency Injection
|
|
33
|
+
# Pass a preconfigured `Faraday::Connection` via `connection:` to control timeouts,
|
|
34
|
+
# adapters, and shared headers (kept consistent with Discovery).
|
|
35
|
+
# `JwksCache.new(jwks_uri: "...", connection: Faraday.new { |f| f.request :retry })`
|
|
31
36
|
class JwksCache
|
|
32
37
|
# @param jwks_uri [String] HTTPS URL of the JWKS endpoint
|
|
38
|
+
# @param connection [Faraday::Connection, nil] Optional Faraday connection for HTTP requests
|
|
33
39
|
# @raise [JwksCacheError] if the URI is not an HTTP(S) URL
|
|
34
|
-
def initialize(jwks_uri:)
|
|
40
|
+
def initialize(jwks_uri:, connection: nil)
|
|
35
41
|
unless jwks_uri.is_a?(String) && jwks_uri.strip.match?(%r{^https?://})
|
|
36
42
|
raise JwksCacheError.new('Invalid JWKS URI: must be a non-empty HTTP(S) URL', code: 'jwks_fetch_failed')
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
@jwks_uri = jwks_uri
|
|
46
|
+
@connection = connection || Faraday.new
|
|
40
47
|
@cached_keys = nil
|
|
41
48
|
@etag = nil
|
|
42
49
|
@fetched_at = nil
|
|
@@ -56,7 +63,7 @@ module Verikloak
|
|
|
56
63
|
# Build conditional request headers (ETag-based)
|
|
57
64
|
headers = build_conditional_headers
|
|
58
65
|
# Perform HTTP GET request
|
|
59
|
-
response =
|
|
66
|
+
response = @connection.get(@jwks_uri, nil, headers)
|
|
60
67
|
# Handle HTTP response according to status code
|
|
61
68
|
handle_response(response)
|
|
62
69
|
end
|
|
@@ -72,6 +79,10 @@ module Verikloak
|
|
|
72
79
|
# @return [Time, nil]
|
|
73
80
|
attr_reader :fetched_at
|
|
74
81
|
|
|
82
|
+
# Injected Faraday connection (for testing and shared config across the gem)
|
|
83
|
+
# @return [Faraday::Connection]
|
|
84
|
+
attr_reader :connection
|
|
85
|
+
|
|
75
86
|
# Whether the cache is considered stale.
|
|
76
87
|
#
|
|
77
88
|
# Uses `Cache-Control: max-age` semantics when available:
|
data/lib/verikloak/middleware.rb
CHANGED
|
@@ -2,17 +2,204 @@
|
|
|
2
2
|
|
|
3
3
|
require 'rack'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'set'
|
|
6
|
+
require 'faraday'
|
|
5
7
|
|
|
6
8
|
module Verikloak
|
|
7
|
-
#
|
|
8
|
-
# used by {Verikloak::Middleware}. By extracting this mapping into a
|
|
9
|
-
# separate module, the middleware class remains shorter and easier to
|
|
10
|
-
# reason about.
|
|
9
|
+
# @api private
|
|
11
10
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
11
|
+
# Internal mixin for skip-path normalization and matching.
|
|
12
|
+
# Extracted from Middleware to reduce class length and improve testability.
|
|
13
|
+
module SkipPathMatcher
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Checks whether the request path matches any compiled skip pattern.
|
|
17
|
+
#
|
|
18
|
+
# Supported patterns:
|
|
19
|
+
# * `'/'` — matches only the root path
|
|
20
|
+
# * `'/foo'` — exact-match only (matches `/foo` but **not** `/foo/...`)
|
|
21
|
+
# * `'/foo/*'` — prefix match (matches `/foo` and any nested path under it)
|
|
22
|
+
#
|
|
23
|
+
# @param path [String]
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
def skip?(path)
|
|
26
|
+
np = normalize_path(path)
|
|
27
|
+
return true if @skip_root && np == '/'
|
|
28
|
+
return true if @skip_exacts.include?(np)
|
|
29
|
+
|
|
30
|
+
@skip_prefixes.any? { |prefix| np == prefix || np.start_with?("#{prefix}/") }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Normalizes paths for stable comparisons:
|
|
34
|
+
# - ensures leading slash
|
|
35
|
+
# - collapses multiple slashes (e.g. //foo///bar -> /foo/bar)
|
|
36
|
+
# - removes trailing slash except for root
|
|
37
|
+
#
|
|
38
|
+
# @param path [String, nil]
|
|
39
|
+
# @return [String]
|
|
40
|
+
def normalize_path(path)
|
|
41
|
+
s = (path || '').to_s
|
|
42
|
+
s = "/#{s}" unless s.start_with?('/')
|
|
43
|
+
s = s.gsub(%r{/+}, '/')
|
|
44
|
+
s.length > 1 ? s.chomp('/') : s
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Pre-compiles {skip_paths} into fast lookup structures.
|
|
48
|
+
#
|
|
49
|
+
# * `@skip_root` — whether `'/'` is present
|
|
50
|
+
# * `@skip_exacts` — exact-match set (e.g. `'/health'`)
|
|
51
|
+
# * `@skip_prefixes` — wildcard prefixes for `'/*'` (e.g. `'/public'`)
|
|
52
|
+
#
|
|
53
|
+
# @param paths [Array<String>]
|
|
54
|
+
# @return [void]
|
|
55
|
+
def compile_skip_paths(paths)
|
|
56
|
+
@skip_root = false
|
|
57
|
+
@skip_exacts = Set.new
|
|
58
|
+
@skip_prefixes = []
|
|
59
|
+
|
|
60
|
+
Array(paths).each do |raw|
|
|
61
|
+
next if raw.nil?
|
|
62
|
+
|
|
63
|
+
s = raw.to_s.strip
|
|
64
|
+
next if s.empty?
|
|
65
|
+
|
|
66
|
+
if s == '/'
|
|
67
|
+
@skip_root = true
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if s.end_with?('/*')
|
|
72
|
+
prefix = normalize_path(s.chomp('/*'))
|
|
73
|
+
next if prefix == '/' # root is handled by @skip_root
|
|
74
|
+
|
|
75
|
+
@skip_prefixes << prefix
|
|
76
|
+
else
|
|
77
|
+
exact = normalize_path(s)
|
|
78
|
+
@skip_exacts << exact
|
|
79
|
+
# Do NOT add to @skip_prefixes here; plain '/foo' is exact-match only.
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@skip_prefixes.uniq!
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @api private
|
|
14
88
|
#
|
|
89
|
+
# Internal mixin for JWT verification and discovery/JWKS management.
|
|
90
|
+
# Extracted from Middleware to reduce class length and improve clarity.
|
|
91
|
+
module MiddlewareTokenVerification
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# Determines whether a token verification failure warrants a one-time JWKS refresh
|
|
95
|
+
# and retry (e.g., after key rotation).
|
|
96
|
+
#
|
|
97
|
+
# @param error [Exception]
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def retryable_decoder_error?(error)
|
|
100
|
+
return false unless error.is_a?(TokenDecoderError)
|
|
101
|
+
return true if error.code == 'invalid_signature'
|
|
102
|
+
return true if error.code == 'invalid_token' && error.message&.include?('Key with kid=')
|
|
103
|
+
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Returns a cached TokenDecoder instance for current inputs.
|
|
108
|
+
# Cache key uses issuer, audience, leeway, token_verify_options, and JWKS fetched_at timestamp.
|
|
109
|
+
def decoder_for
|
|
110
|
+
keys = @jwks_cache.cached
|
|
111
|
+
fetched_at = @jwks_cache.respond_to?(:fetched_at) ? @jwks_cache.fetched_at : nil
|
|
112
|
+
cache_key = [
|
|
113
|
+
@issuer,
|
|
114
|
+
@audience,
|
|
115
|
+
@leeway,
|
|
116
|
+
@token_verify_options,
|
|
117
|
+
fetched_at
|
|
118
|
+
].hash
|
|
119
|
+
@mutex.synchronize do
|
|
120
|
+
@decoder_cache[cache_key] ||= TokenDecoder.new(
|
|
121
|
+
jwks: keys,
|
|
122
|
+
issuer: @issuer,
|
|
123
|
+
audience: @audience,
|
|
124
|
+
leeway: @leeway,
|
|
125
|
+
options: @token_verify_options
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Ensures JWKS are up-to-date by invoking {#ensure_jwks_cache!}.
|
|
131
|
+
# Errors are not swallowed and are handled by the caller.
|
|
132
|
+
#
|
|
133
|
+
# @return [void]
|
|
134
|
+
# @raise [Verikloak::DiscoveryError, Verikloak::JwksCacheError]
|
|
135
|
+
def refresh_jwks!
|
|
136
|
+
ensure_jwks_cache!
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Decodes and verifies the JWT using the cached JWKS. On certain verification
|
|
140
|
+
# failures (e.g., key rotation), it refreshes the JWKS and retries once.
|
|
141
|
+
#
|
|
142
|
+
# @param token [String]
|
|
143
|
+
# @return [Hash] decoded JWT claims
|
|
144
|
+
# @raise [Verikloak::Error] bubbles up verification/fetch errors for centralized handling
|
|
145
|
+
def decode_token(token)
|
|
146
|
+
ensure_jwks_cache!
|
|
147
|
+
if @jwks_cache.cached.nil? || @jwks_cache.cached.empty?
|
|
148
|
+
raise MiddlewareError.new('JWKS cache is empty, cannot verify token', code: 'jwks_cache_miss')
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# First attempt
|
|
152
|
+
decoder = decoder_for
|
|
153
|
+
|
|
154
|
+
begin
|
|
155
|
+
decoder.decode!(token)
|
|
156
|
+
rescue TokenDecoderError => e
|
|
157
|
+
# On key rotation or signature mismatch, refresh JWKS and retry once.
|
|
158
|
+
raise unless retryable_decoder_error?(e)
|
|
159
|
+
|
|
160
|
+
refresh_jwks!
|
|
161
|
+
|
|
162
|
+
# Rebuild decoder with refreshed keys and try once more.
|
|
163
|
+
decoder = decoder_for
|
|
164
|
+
decoder.decode!(token)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Ensures that discovery metadata and JWKS cache are initialized and up-to-date.
|
|
169
|
+
# This method is thread-safe.
|
|
170
|
+
#
|
|
171
|
+
# * When the cache instance is missing, it is created from discovery metadata.
|
|
172
|
+
# * JWKS are (re)fetched every time; ETag/Cache-Control headers minimize traffic.
|
|
173
|
+
#
|
|
174
|
+
# @return [void]
|
|
175
|
+
# @raise [Verikloak::DiscoveryError, Verikloak::JwksCacheError, Verikloak::MiddlewareError]
|
|
176
|
+
def ensure_jwks_cache!
|
|
177
|
+
@mutex.synchronize do
|
|
178
|
+
if @jwks_cache.nil?
|
|
179
|
+
config = @discovery.fetch!
|
|
180
|
+
@issuer = config['issuer']
|
|
181
|
+
jwks_uri = config['jwks_uri']
|
|
182
|
+
@jwks_cache = JwksCache.new(jwks_uri: jwks_uri, connection: @connection)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
@jwks_cache.fetch!
|
|
186
|
+
end
|
|
187
|
+
rescue Verikloak::DiscoveryError, Verikloak::JwksCacheError => e
|
|
188
|
+
# Re-raise so that specific error codes can be mapped in the middleware
|
|
189
|
+
raise e
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
raise MiddlewareError.new("Failed to initialize JWKS cache: #{e.message}", code: 'jwks_fetch_failed')
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
15
195
|
# @api private
|
|
196
|
+
#
|
|
197
|
+
# Internal mixin that encapsulates error-to-HTTP mapping logic used by
|
|
198
|
+
# {Verikloak::Middleware}. By extracting this mapping into a separate module,
|
|
199
|
+
# the middleware class stays concise and easier to reason about.
|
|
200
|
+
#
|
|
201
|
+
# This module does not depend on Rack internals; it only interprets
|
|
202
|
+
# Verikloak error objects and their `code` attributes.
|
|
16
203
|
module MiddlewareErrorMapping
|
|
17
204
|
# Set of token/client-side error codes that should map to **401 Unauthorized**.
|
|
18
205
|
# @return [Array<String>]
|
|
@@ -25,6 +212,8 @@ module Verikloak
|
|
|
25
212
|
# @return [Array<String>]
|
|
26
213
|
INFRA_ERROR_CODES = %w[jwks_fetch_failed jwks_cache_miss].freeze
|
|
27
214
|
|
|
215
|
+
private
|
|
216
|
+
|
|
28
217
|
# @param code [String, nil] short error code
|
|
29
218
|
# @return [Boolean] true if the error should be treated as a 403 Forbidden
|
|
30
219
|
def forbidden?(code)
|
|
@@ -85,37 +274,53 @@ module Verikloak
|
|
|
85
274
|
#
|
|
86
275
|
# Failures are converted to JSON error responses with appropriate status codes.
|
|
87
276
|
class Middleware
|
|
277
|
+
include MiddlewareErrorMapping
|
|
278
|
+
include SkipPathMatcher
|
|
279
|
+
include MiddlewareTokenVerification
|
|
280
|
+
|
|
88
281
|
# @param app [#call] downstream Rack app
|
|
89
282
|
# @param discovery_url [String] OIDC discovery endpoint URL
|
|
90
|
-
# @param audience [String]
|
|
91
|
-
# @param skip_paths [Array<String>]
|
|
92
|
-
# @param discovery [Discovery, nil]
|
|
93
|
-
# @param jwks_cache [JwksCache, nil]
|
|
283
|
+
# @param audience [String] expected `aud` claim
|
|
284
|
+
# @param skip_paths [Array<String>] literal paths or wildcard patterns to bypass auth
|
|
285
|
+
# @param discovery [Discovery, nil] custom discovery instance (for DI/tests)
|
|
286
|
+
# @param jwks_cache [JwksCache, nil] custom JWKS cache instance (for DI/tests)
|
|
287
|
+
# @param connection [Faraday::Connection, nil] Optional injected Faraday connection (defaults to Faraday.new)
|
|
288
|
+
# @param leeway [Integer] Clock skew tolerance in seconds for token verification (delegated to TokenDecoder)
|
|
289
|
+
# @param token_verify_options [Hash] Additional JWT verification options passed through
|
|
290
|
+
# to TokenDecoder.
|
|
291
|
+
# e.g., { verify_iat: false, leeway: 10 }
|
|
94
292
|
def initialize(app,
|
|
95
293
|
discovery_url:,
|
|
96
294
|
audience:,
|
|
97
295
|
skip_paths: [],
|
|
98
296
|
discovery: nil,
|
|
99
|
-
jwks_cache: nil
|
|
297
|
+
jwks_cache: nil,
|
|
298
|
+
connection: nil,
|
|
299
|
+
leeway: Verikloak::TokenDecoder::DEFAULT_LEEWAY,
|
|
300
|
+
token_verify_options: {})
|
|
100
301
|
@app = app
|
|
101
302
|
@audience = audience
|
|
102
|
-
@skip_paths = skip_paths
|
|
103
303
|
@discovery = discovery || Discovery.new(discovery_url: discovery_url)
|
|
104
304
|
@jwks_cache = jwks_cache
|
|
305
|
+
@connection = connection || Faraday.new
|
|
306
|
+
@leeway = leeway
|
|
307
|
+
@token_verify_options = token_verify_options || {}
|
|
105
308
|
@issuer = nil
|
|
106
309
|
@mutex = Mutex.new
|
|
310
|
+
@decoder_cache = {}
|
|
311
|
+
|
|
312
|
+
compile_skip_paths(skip_paths)
|
|
107
313
|
end
|
|
108
314
|
|
|
109
315
|
# Rack entrypoint.
|
|
110
316
|
#
|
|
111
317
|
# @param env [Hash] Rack environment
|
|
112
|
-
# @return [Array(Integer, Hash, Array<String>)] standard Rack response
|
|
318
|
+
# @return [Array(Integer, Hash, Array<String>)] standard Rack response triple
|
|
113
319
|
def call(env)
|
|
114
320
|
path = env['PATH_INFO']
|
|
115
321
|
return @app.call(env) if skip?(path)
|
|
116
322
|
|
|
117
323
|
token = extract_token(env)
|
|
118
|
-
|
|
119
324
|
handle_request(env, token)
|
|
120
325
|
rescue Verikloak::Error => e
|
|
121
326
|
code, status = map_error(e)
|
|
@@ -127,61 +332,17 @@ module Verikloak
|
|
|
127
332
|
|
|
128
333
|
private
|
|
129
334
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
#
|
|
135
|
-
# @param error [Exception]
|
|
136
|
-
# @return [Boolean]
|
|
137
|
-
# @api private
|
|
138
|
-
def retryable_decoder_error?(error)
|
|
139
|
-
return false unless error.is_a?(TokenDecoderError)
|
|
140
|
-
|
|
141
|
-
return true if error.code == 'invalid_signature'
|
|
142
|
-
return true if error.code == 'invalid_token' && error.message&.include?('Key with kid=')
|
|
143
|
-
|
|
144
|
-
false
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
# Ensures JWKS are up-to-date by invoking {#ensure_jwks_cache!}.
|
|
148
|
-
# Errors are not swallowed and are handled by the caller.
|
|
149
|
-
#
|
|
150
|
-
# @return [void]
|
|
151
|
-
# @raise [Verikloak::DiscoveryError, Verikloak::JwksCacheError]
|
|
152
|
-
# @api private
|
|
153
|
-
def refresh_jwks!
|
|
154
|
-
# Ensure discovery has been performed so we have a jwks_cache instance.
|
|
155
|
-
ensure_jwks_cache!
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# Checks whether the request path matches any skip pattern.
|
|
159
|
-
#
|
|
160
|
-
# Supported patterns:
|
|
161
|
-
# * `'/'` — matches only the root path
|
|
162
|
-
# * `'/foo/*'` — matches `/foo` itself and any nested path under it
|
|
163
|
-
# * `'/api/public'` — exact match only (no wildcard)
|
|
164
|
-
#
|
|
165
|
-
# @param path [String]
|
|
166
|
-
# @return [Boolean]
|
|
167
|
-
def skip?(path)
|
|
168
|
-
@skip_paths.any? do |pattern|
|
|
169
|
-
if pattern == '/'
|
|
170
|
-
path == '/'
|
|
171
|
-
elsif pattern.end_with?('/*')
|
|
172
|
-
prefix = pattern.chomp('/*')
|
|
173
|
-
path == prefix || path.start_with?("#{prefix}/")
|
|
174
|
-
else
|
|
175
|
-
path == pattern || path.start_with?("#{pattern}/")
|
|
176
|
-
end
|
|
177
|
-
end
|
|
335
|
+
# Returns the Faraday connection used for HTTP operations (Discovery/JWKS).
|
|
336
|
+
# Exposed for tests; not part of public API.
|
|
337
|
+
def http_connection
|
|
338
|
+
@connection
|
|
178
339
|
end
|
|
179
340
|
|
|
180
341
|
# Verifies the token, stores result in Rack env, and forwards to the downstream app.
|
|
181
342
|
#
|
|
182
343
|
# @param env [Hash]
|
|
183
344
|
# @param token [String]
|
|
184
|
-
# @return [Array(Integer, Hash, Array<String>)]
|
|
345
|
+
# @return [Array(Integer, Hash, Array<String>)] Rack response triple
|
|
185
346
|
def handle_request(env, token)
|
|
186
347
|
claims = decode_token(token)
|
|
187
348
|
env['verikloak.token'] = token
|
|
@@ -209,69 +370,6 @@ module Verikloak
|
|
|
209
370
|
token
|
|
210
371
|
end
|
|
211
372
|
|
|
212
|
-
# Decodes and verifies the JWT using the cached JWKS. On certain verification
|
|
213
|
-
# failures (e.g., key rotation), it refreshes the JWKS and retries once.
|
|
214
|
-
#
|
|
215
|
-
# @param token [String]
|
|
216
|
-
# @return [Hash] decoded JWT claims
|
|
217
|
-
# @raise [Verikloak::Error] bubbles up verification/fetch errors for centralized handling
|
|
218
|
-
def decode_token(token)
|
|
219
|
-
ensure_jwks_cache!
|
|
220
|
-
if @jwks_cache.cached.nil? || @jwks_cache.cached.empty?
|
|
221
|
-
raise MiddlewareError.new('JWKS cache is empty, cannot verify token', code: 'jwks_cache_miss')
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# First attempt
|
|
225
|
-
decoder = TokenDecoder.new(
|
|
226
|
-
jwks: @jwks_cache.cached,
|
|
227
|
-
issuer: @issuer,
|
|
228
|
-
audience: @audience
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
begin
|
|
232
|
-
decoder.decode!(token)
|
|
233
|
-
rescue TokenDecoderError => e
|
|
234
|
-
# On key rotation or signature mismatch, refresh JWKS and retry once.
|
|
235
|
-
raise unless retryable_decoder_error?(e)
|
|
236
|
-
|
|
237
|
-
refresh_jwks!
|
|
238
|
-
|
|
239
|
-
# Rebuild decoder with refreshed keys and try once more.
|
|
240
|
-
decoder = TokenDecoder.new(
|
|
241
|
-
jwks: @jwks_cache.cached,
|
|
242
|
-
issuer: @issuer,
|
|
243
|
-
audience: @audience
|
|
244
|
-
)
|
|
245
|
-
decoder.decode!(token)
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
# Ensures that discovery metadata and JWKS cache are initialized and up-to-date.
|
|
250
|
-
# This method is thread-safe.
|
|
251
|
-
#
|
|
252
|
-
# * When the cache instance is missing, it is created from discovery metadata.
|
|
253
|
-
# * JWKS are (re)fetched every time; ETag/Cache-Control headers minimize traffic.
|
|
254
|
-
#
|
|
255
|
-
# @return [void]
|
|
256
|
-
# @raise [Verikloak::DiscoveryError, Verikloak::JwksCacheError, Verikloak::MiddlewareError]
|
|
257
|
-
def ensure_jwks_cache!
|
|
258
|
-
@mutex.synchronize do
|
|
259
|
-
if @jwks_cache.nil?
|
|
260
|
-
config = @discovery.fetch!
|
|
261
|
-
@issuer = config['issuer']
|
|
262
|
-
jwks_uri = config['jwks_uri']
|
|
263
|
-
@jwks_cache = JwksCache.new(jwks_uri: jwks_uri)
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
@jwks_cache.fetch!
|
|
267
|
-
end
|
|
268
|
-
rescue Verikloak::DiscoveryError, Verikloak::JwksCacheError => e
|
|
269
|
-
# Re-raise so that specific error codes can be mapped in the middleware
|
|
270
|
-
raise e
|
|
271
|
-
rescue StandardError => e
|
|
272
|
-
raise MiddlewareError.new("Failed to initialize JWKS cache: #{e.message}", code: 'jwks_fetch_failed')
|
|
273
|
-
end
|
|
274
|
-
|
|
275
373
|
# Converts a raised error into a `[code, http_status]` tuple for response rendering.
|
|
276
374
|
#
|
|
277
375
|
# @param error [Exception]
|
|
@@ -313,7 +411,6 @@ module Verikloak
|
|
|
313
411
|
#
|
|
314
412
|
# @param error [Exception]
|
|
315
413
|
# @return [void]
|
|
316
|
-
# @api private
|
|
317
414
|
def log_internal_error(error)
|
|
318
415
|
warn "[verikloak] Internal error: #{error.class} - #{error.message}"
|
|
319
416
|
warn error.backtrace.join("\n") if error.backtrace
|
|
@@ -30,11 +30,18 @@ module Verikloak
|
|
|
30
30
|
# @param issuer [String] Expected `iss` value in the token.
|
|
31
31
|
# @param audience [String] Expected `aud` value in the token.
|
|
32
32
|
# @param leeway [Integer] Clock skew tolerance in seconds (optional).
|
|
33
|
-
|
|
33
|
+
# @param options [Hash] Extra JWT verification options.
|
|
34
|
+
# Mirrors ruby-jwt options (e.g., :leeway, :verify_iat, :verify_expiration, :verify_not_before, :algorithms).
|
|
35
|
+
# NOTE: If both `leeway:` and `options[:leeway]` are provided, `options[:leeway]` takes precedence.
|
|
36
|
+
def initialize(jwks:, issuer:, audience:, leeway: DEFAULT_LEEWAY, options: {})
|
|
34
37
|
@jwks = jwks
|
|
35
38
|
@issuer = issuer
|
|
36
39
|
@audience = audience
|
|
40
|
+
# Keep backward compatibility; can be overridden by options[:leeway]
|
|
37
41
|
@leeway = leeway
|
|
42
|
+
# Normalize and store verification options
|
|
43
|
+
@options = symbolize_keys(options || {})
|
|
44
|
+
@options_without_leeway = @options.except(:leeway).freeze
|
|
38
45
|
|
|
39
46
|
# Build a kid-indexed hash for O(1) JWK lookup
|
|
40
47
|
@jwk_by_kid = {}
|
|
@@ -42,6 +49,7 @@ module Verikloak
|
|
|
42
49
|
kid_key = fetch_indifferent(j, 'kid')
|
|
43
50
|
@jwk_by_kid[kid_key] = j if kid_key
|
|
44
51
|
end
|
|
52
|
+
@options.freeze
|
|
45
53
|
end
|
|
46
54
|
|
|
47
55
|
# Decodes and verifies a JWT.
|
|
@@ -123,8 +131,7 @@ module Verikloak
|
|
|
123
131
|
#
|
|
124
132
|
# @return [Hash]
|
|
125
133
|
def jwt_decode_options
|
|
126
|
-
{
|
|
127
|
-
# Specify allowed algorithms explicitly as an array to prevent header tampering
|
|
134
|
+
base = {
|
|
128
135
|
algorithms: ['RS256'],
|
|
129
136
|
iss: @issuer,
|
|
130
137
|
verify_iss: true,
|
|
@@ -132,9 +139,14 @@ module Verikloak
|
|
|
132
139
|
verify_aud: true,
|
|
133
140
|
verify_iat: true,
|
|
134
141
|
verify_expiration: true,
|
|
135
|
-
verify_not_before: true
|
|
136
|
-
leeway: @leeway # allow clock skew tolerance
|
|
142
|
+
verify_not_before: true
|
|
137
143
|
}
|
|
144
|
+
# options[:leeway] overrides top-level @leeway if provided
|
|
145
|
+
leeway = @options.key?(:leeway) ? @options[:leeway] : @leeway
|
|
146
|
+
merged = base.merge(leeway: leeway)
|
|
147
|
+
# Merge remaining options last (excluding :leeway which is already applied)
|
|
148
|
+
extra = @options_without_leeway
|
|
149
|
+
merged.merge(extra)
|
|
138
150
|
end
|
|
139
151
|
|
|
140
152
|
# Imports an OpenSSL::PKey::RSA public key from the given JWK.
|
|
@@ -242,5 +254,11 @@ module Verikloak
|
|
|
242
254
|
"JWT verification failed: #{error.message}"
|
|
243
255
|
end
|
|
244
256
|
end
|
|
257
|
+
|
|
258
|
+
def symbolize_keys(hash)
|
|
259
|
+
return {} unless hash.is_a?(Hash)
|
|
260
|
+
|
|
261
|
+
hash.transform_keys(&:to_sym)
|
|
262
|
+
end
|
|
245
263
|
end
|
|
246
264
|
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.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- taiyaky
|
|
@@ -82,7 +82,7 @@ metadata:
|
|
|
82
82
|
source_code_uri: https://github.com/taiyaky/verikloak
|
|
83
83
|
changelog_uri: https://github.com/taiyaky/verikloak/blob/main/CHANGELOG.md
|
|
84
84
|
bug_tracker_uri: https://github.com/taiyaky/verikloak/issues
|
|
85
|
-
documentation_uri: https://rubydoc.info/gems/verikloak/0.1.
|
|
85
|
+
documentation_uri: https://rubydoc.info/gems/verikloak/0.1.2
|
|
86
86
|
rubygems_mfa_required: 'true'
|
|
87
87
|
rdoc_options: []
|
|
88
88
|
require_paths:
|