verikloak 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +33 -7
- data/lib/verikloak/discovery.rb +19 -39
- data/lib/verikloak/http.rb +5 -0
- data/lib/verikloak/jwks_cache.rb +77 -30
- data/lib/verikloak/middleware.rb +237 -75
- data/lib/verikloak/safe_url.rb +83 -0
- data/lib/verikloak/token_decoder.rb +18 -3
- data/lib/verikloak/version.rb +1 -1
- data/lib/verikloak.rb +1 -0
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5d6390b5c664ecb4e4af43fa7090e39920c2d79bc16b524b566a73eda95c6d7
|
|
4
|
+
data.tar.gz: b971779c56259ebeb0420983612760cb964442150a3f3fbb78dd388e1d46458c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 69df466fe17c16a392645ef56a6f6fc19c8e2d5fee3b91be5e1cd3465ce587bc56a7994ac7b3e6a20c91bedc9d98b91f287144084e35a217501fa40beb72aae2
|
|
7
|
+
data.tar.gz: 1357d43f97c4a0d313e1171ceebb4ca2ac0b7b2313fc4968a055e06a2c4bdd67eb1f59b8a0ff5de65993c2ad79ab75b0fc1c7db6e740324af33f7bd8693e22c6
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [1.1.0] - 2026-07-03
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **JWKs revalidation is now throttled on the request path** (`Middleware`): previously every request synchronized on a single mutex and called `JwksCache#fetch!`, so identity providers that do not send `Cache-Control: max-age` on their JWKs endpoint (including Keycloak's default) caused a conditional HTTP GET — with retries and timeouts — inside the lock on **every** request. The middleware now revalidates at most once per `jwks_refresh_interval` seconds (default `60`) and serves requests within the window lock-free from the in-memory key set. A server-declared `Cache-Control: max-age` **shorter** than the interval is honored: TTL expiry reopens the window early (`JwksCache#ttl_expired?`), so an IdP asking for faster revalidation still gets it — this bounds how long a key the IdP has *removed* keeps verifying by `min(max-age, interval)` rather than the full interval. Key rotation in the other direction (new keys) is not delayed: an unknown `kid` or signature mismatch forces an immediate refresh and one retry. Pass `jwks_refresh_interval: 0` to restore the previous revalidate-on-every-request behavior; passing `nil` now falls back to the default `60` instead of silently meaning `0`. Numeric strings (e.g. from ENV) are coerced like `decoder_cache_limit` already did.
|
|
14
|
+
- **Failure-triggered JWKs refreshes are rate-limited** (`FORCED_REFRESH_MIN_INTERVAL`, 5s): the unknown-`kid`/bad-signature retry path is reachable with unauthenticated requests, and each hit previously performed an upstream HTTP GET (with retry amplification) while holding the middleware mutex — an attacker flooding bogus-`kid` tokens could drive one IdP request per app request and serialize legitimate traffic behind the lock. Keys revalidated within the last 5 seconds cannot have missed a rotation an immediate re-fetch would find, so such retries now proceed on the cached keys instead of re-fetching. Worst case for a genuine rotation landing inside those 5 seconds: the affected token is rejected once and verifies on the client's next attempt.
|
|
15
|
+
- **Decoder cache is now keyed by the exact key snapshot a decoder was built from** (`Middleware`): rotation detection previously compared the cached key array's object identity (`__id__`), so any `200 OK` refresh — even one returning identical keys — purged all cached `TokenDecoder` instances and rebuilt them, and object-id recycling could in principle miss a rotation. The cache key and the purge check now use a SHA-256 digest of the key set content, computed from the snapshot handed to the decoder (memoized per snapshot object, so the request path pays one pointer comparison) and insensitive to the order of keys in the set (kid lookup makes ordering irrelevant, so a reordered but identical response keeps the cache warm). Deriving the key from the snapshot itself also closes a race where a decoder built from pre-rotation keys could be stored under the post-rotation generation by an interleaving forced refresh and then served — permanently, since content digests no longer change on refresh — yielding persistent 401s for valid tokens until process restart. If the digest cannot be computed (exotic injected caches), the failure is logged and decoders are built uncached rather than risking cross-generation collisions.
|
|
16
|
+
- **`kid_not_found` error code** (`TokenDecoder`): an unknown `kid` now raises `TokenDecoderError` with code `kid_not_found` instead of `invalid_token`, and the middleware's refresh-and-retry trigger matches on that code instead of sniffing the error message. HTTP responses are unchanged (still `401` with JSON `error: "invalid_token"`); only `error.code` seen by callers rescuing `Verikloak::TokenDecoderError` directly changes.
|
|
17
|
+
|
|
18
|
+
### Security
|
|
19
|
+
- **Bumped `jwt` to `3.2.0` and `faraday` to `2.14.3`** in `Gemfile.lock` to clear advisories surfaced by `bundler-audit`: CVE-2026-45363 (jwt: HS256 verification accepts an empty HMAC key — verikloak itself is unaffected as it pins RS256 with RSA keys) and the faraday nested-query-string DoS (GHSA-98m9-hrrm-r99r, fixed in 2.14.3).
|
|
20
|
+
- **Minimum `faraday` runtime dependency raised to `2.14.3`** (gemspec): the `>= 2.14.1` floor was introduced for CVE-2026-25765, but per GHSA-5rv5-xj5j-3484 that fix was incomplete (protocol-relative URIs could still bypass host scoping, completed in 2.14.2), and 2.14.3 additionally fixes the nested-query-string DoS above. The jwt range is intentionally left at `>= 2.7, < 4.0`: only 3.0.0–3.1.x are affected by CVE-2026-45363, jwt 2.x is not, and verikloak's own verification path pins RS256 with RSA keys.
|
|
21
|
+
- **Response size limits**: discovery and JWKs response bodies larger than 1 MB (`Verikloak::HTTP::MAX_RESPONSE_BYTES`) are rejected (`discovery_metadata_invalid` / `jwks_parse_failed`) before JSON parsing, preventing a compromised endpoint from exhausting memory.
|
|
22
|
+
- **`kid` truncation in error messages** (`TokenDecoder`): the attacker-controlled `kid` value echoed in "Key with kid=... not found" messages (and therefore in 401 response bodies and `WWW-Authenticate` headers) is truncated to 64 characters.
|
|
23
|
+
- **DNS rebinding limitation documented** (SECURITY.md): the SSRF checks resolve hostnames at validation time while the HTTP client resolves them again at connection time; the gap and recommended mitigations are now documented.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **`jwks_refresh_interval` middleware option** (default `60`): minimum seconds between JWKs revalidations on the request path.
|
|
27
|
+
- **`Verikloak::SafeUrl`** (internal): shared URL normalization, HTTPS-enforcement, and private-IP resolution helpers previously duplicated between `Discovery` and `JwksCache`. `Verikloak::PRIVATE_IP_RANGES` is unchanged and now lives alongside it.
|
|
28
|
+
- **CI compatibility matrix**: the suite now also runs on Ruby 3.1, 3.2, and 3.3 (via `gemfiles/compat.gemfile`), backing the gemspec's `required_ruby_version >= 3.1` claim; the docker-based job continues to cover 3.4.
|
|
29
|
+
- **Coverage gate**: SimpleCov now runs in CI (`SIMPLECOV=true`) and fails the suite below 90% line coverage (currently ~95%).
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **Key-rotation retry now bypasses `Cache-Control: max-age` freshness** (`JwksCache#force_fetch!`): when a token failed with an unknown `kid` or bad signature, the forced JWKs refresh previously called plain `fetch!`, which returns the cached keys without any HTTP request while `max-age` is still fresh — so on IdPs that send `max-age` on the JWKs endpoint, tokens signed with rotated keys kept failing until the TTL expired (pre-existing behavior, surfaced by the throttling work). The retry path now revalidates over HTTP (subject to the forced-refresh rate limit above); the ETag conditional request still applies, so an unchanged key set costs only a 304. Injected caches that only implement `fetch!` keep their previous behavior, and `force_fetch!` inspects the receiver's `fetch!` signature so subclasses that override it with the pre-1.1 zero-arg form degrade to a plain fetch instead of raising `ArgumentError`.
|
|
33
|
+
- **A transient empty JWKs response no longer pins 503s for the whole throttle window**: a `200 OK` with `{"keys": []}` used to close the revalidation window, so every request answered `503 jwks_cache_miss` for up to `jwks_refresh_interval` seconds even after the IdP recovered, with no code path able to trigger a re-fetch. An empty key set now leaves the window open (the next request revalidates), and the rotation-retry path re-checks the snapshot after its forced refresh, returning the same `503 jwks_cache_miss` as every other request instead of mislabeling the token `401 invalid_token`.
|
|
34
|
+
- **`realm: nil` no longer raises `NameError`**: `normalize_realm`'s default-realm fallback referenced `DEFAULT_REALM` from a module context where the constant does not resolve; explicitly passing `realm: nil` (expecting the documented default) crashed middleware construction. Both default-constant references are now fully qualified.
|
|
35
|
+
- **CI: the SimpleCov coverage gate now actually runs in the docker job**: the step-level `SIMPLECOV=true` env var stayed on the Actions runner shell and never reached the Compose container, so `ENV['SIMPLECOV']` was unset and the 90% gate silently did not run. The workflow now forwards it with `docker compose run -e SIMPLECOV`.
|
|
36
|
+
- **Audience callable arity fallback no longer swallows application errors**: the retry-on-`ArgumentError` fallback now only matches messages *starting with* `wrong number of arguments`, so an `ArgumentError` raised inside the callable body that merely mentions the phrase propagates instead of triggering a second invocation.
|
|
37
|
+
- **README**: removed the misleading `algorithms:` override example — the token header is validated to be exactly `RS256` before decoding regardless of that option; documented the constraint instead.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
10
41
|
## [1.0.2] - 2026-05-09
|
|
11
42
|
|
|
12
43
|
### Fixed
|
data/README.md
CHANGED
|
@@ -21,8 +21,10 @@ Verikloak is a plug-and-play solution for Ruby (especially Rails API) apps that
|
|
|
21
21
|
- SSRF protection — discovery redirect targets **and** `jwks_uri` values validated against private IP ranges (including IPv4-mapped IPv6 normalisation); automatically bypassed with `allow_http: true` for local development
|
|
22
22
|
- HTTPS redirect enforcement — redirect targets are scheme-checked to prevent HTTPS→HTTP downgrade
|
|
23
23
|
- JWT size limit (8 KB) to mitigate denial-of-service via oversized tokens
|
|
24
|
+
- Response size limit (1 MB) for discovery and JWKs documents
|
|
24
25
|
- Header injection prevention in `WWW-Authenticate` responses
|
|
25
26
|
- URL normalisation — leading/trailing whitespace stripped from discovery and JWKs URLs
|
|
27
|
+
- Throttled JWKs revalidation (`jwks_refresh_interval`, default 60s) so hot request paths never serialize on network calls; key rotation is still picked up immediately via refresh-and-retry
|
|
26
28
|
|
|
27
29
|
## Installation
|
|
28
30
|
|
|
@@ -243,8 +245,9 @@ For a full list of error cases and detailed explanations, please see the [ERRORS
|
|
|
243
245
|
| `discovery` | No | Inject custom Discovery instance (advanced/testing) |
|
|
244
246
|
| `jwks_cache` | No | Inject custom JwksCache instance (advanced/testing) |
|
|
245
247
|
| `leeway` | No | Clock skew tolerance (seconds) applied during JWT verification. Defaults to `TokenDecoder::DEFAULT_LEEWAY`. |
|
|
246
|
-
| `token_verify_options` | No | Hash of advanced JWT verification options passed through to TokenDecoder. For example: `{ verify_iat: false, leeway: 10
|
|
248
|
+
| `token_verify_options` | No | Hash of advanced JWT verification options passed through to TokenDecoder. For example: `{ verify_iat: false, leeway: 10 }`. If both `leeway:` and `token_verify_options[:leeway]` are set, the latter takes precedence. |
|
|
247
249
|
| `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. |
|
|
250
|
+
| `jwks_refresh_interval` | No | Minimum seconds between JWKs revalidations on the request path. Defaults to `60` (`nil` also means the default); numeric strings are accepted. Set to `0` to revalidate on every request. A shorter server `Cache-Control: max-age` is honored. Key rotation within the window is still handled immediately: a signature/`kid` mismatch forces a refresh and one retry (rate-limited to one forced refresh per 5s). |
|
|
248
251
|
| `connection` | No | Inject a Faraday::Connection used for both Discovery and JWKs fetches. Defaults to a safe connection with timeouts and retries. |
|
|
249
252
|
| `token_env_key` | No | Rack env key for the raw JWT. Defaults to `verikloak.token`. |
|
|
250
253
|
| `user_env_key` | No | Rack env key for decoded claims. Defaults to `verikloak.user`. |
|
|
@@ -331,8 +334,7 @@ config.middleware.use Verikloak::Middleware,
|
|
|
331
334
|
verify_iat: true,
|
|
332
335
|
verify_expiration: true,
|
|
333
336
|
verify_not_before: true,
|
|
334
|
-
#
|
|
335
|
-
# leeway: 10 # this overrides the top-level leeway
|
|
337
|
+
# leeway: 10 # this overrides the top-level leeway
|
|
336
338
|
}
|
|
337
339
|
```
|
|
338
340
|
|
|
@@ -352,15 +354,39 @@ config.middleware.use Verikloak::Middleware,
|
|
|
352
354
|
setting `verify_iat: true` (the default) keeps it enabled.
|
|
353
355
|
All other keys are forwarded to `JWT.decode` as-is.
|
|
354
356
|
- If both are set, `token_verify_options[:leeway]` takes precedence.
|
|
357
|
+
- Note that `algorithms:` cannot be used to accept anything other than
|
|
358
|
+
`RS256`: the token header is validated to be exactly `RS256` before
|
|
359
|
+
decoding, regardless of this option.
|
|
355
360
|
|
|
356
361
|
## Performance & Caching
|
|
357
362
|
|
|
363
|
+
#### JWKs revalidation throttling
|
|
364
|
+
|
|
365
|
+
The middleware revalidates JWKs at most once per `jwks_refresh_interval` seconds (default 60)
|
|
366
|
+
on the request path. Within the window, requests use the in-memory key set without taking a
|
|
367
|
+
lock or touching the network — important for identity providers (including Keycloak) that do
|
|
368
|
+
not send `Cache-Control: max-age` on their JWKs endpoint, where every request would otherwise
|
|
369
|
+
perform a conditional HTTP GET. When a revalidation does happen, ETag/`Cache-Control` headers
|
|
370
|
+
still minimize traffic. A server-declared `max-age` **shorter** than the interval is honored:
|
|
371
|
+
once it expires the next request revalidates, so a key the IdP removes stops being accepted
|
|
372
|
+
after at most `min(max-age, jwks_refresh_interval)` seconds.
|
|
373
|
+
|
|
374
|
+
Key rotation is not delayed by the window: when a token fails with an unknown `kid` or a bad
|
|
375
|
+
signature, the middleware forces an immediate JWKs refresh — bypassing both the throttle window
|
|
376
|
+
and any `Cache-Control: max-age` freshness from the previous response — and retries verification
|
|
377
|
+
once. Because that trigger is reachable with unauthenticated requests, forced refreshes are
|
|
378
|
+
rate-limited to one per 5 seconds; keys fetched more recently than that cannot have missed a
|
|
379
|
+
rotation, so those retries proceed on the cached keys. Set `jwks_refresh_interval: 0` to restore
|
|
380
|
+
the previous revalidate-on-every-request behavior.
|
|
381
|
+
|
|
358
382
|
#### Decoder cache & performance
|
|
359
383
|
|
|
360
|
-
Internally, Verikloak caches `TokenDecoder` instances
|
|
361
|
-
them on every request. The cache behaves like an LRU with a configurable size
|
|
362
|
-
so long-running processes do not accumulate decoders for one-off
|
|
363
|
-
|
|
384
|
+
Internally, Verikloak caches `TokenDecoder` instances keyed by the JWKs content to avoid
|
|
385
|
+
reinitializing them on every request. The cache behaves like an LRU with a configurable size
|
|
386
|
+
(`decoder_cache_limit`) so long-running processes do not accumulate decoders for one-off
|
|
387
|
+
audiences. When the underlying JWK set rotates (detected by content, so refreshes that return
|
|
388
|
+
identical keys keep the cache warm), the middleware clears the cache to drop decoders that
|
|
389
|
+
point at stale keys.
|
|
364
390
|
|
|
365
391
|
Set `decoder_cache_limit` to `0` if you prefer to construct a fresh decoder every time, or `nil`
|
|
366
392
|
when you want the cache to grow without bounds (e.g., in short-lived jobs).
|
data/lib/verikloak/discovery.rb
CHANGED
|
@@ -1,29 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'faraday'
|
|
4
|
-
require 'ipaddr'
|
|
5
4
|
require 'json'
|
|
6
|
-
require 'resolv'
|
|
7
5
|
require 'uri'
|
|
8
6
|
|
|
9
7
|
require 'verikloak/http'
|
|
8
|
+
require 'verikloak/safe_url'
|
|
10
9
|
|
|
11
10
|
module Verikloak
|
|
12
|
-
# Private IP ranges that must not be targets of redirects (SSRF protection).
|
|
13
|
-
# Includes RFC 1918, loopback, link-local, and IPv6 equivalents.
|
|
14
|
-
# @api private
|
|
15
|
-
PRIVATE_IP_RANGES = [
|
|
16
|
-
IPAddr.new('10.0.0.0/8'),
|
|
17
|
-
IPAddr.new('172.16.0.0/12'),
|
|
18
|
-
IPAddr.new('192.168.0.0/16'),
|
|
19
|
-
IPAddr.new('127.0.0.0/8'),
|
|
20
|
-
IPAddr.new('169.254.0.0/16'),
|
|
21
|
-
IPAddr.new('0.0.0.0/8'),
|
|
22
|
-
IPAddr.new('::1/128'),
|
|
23
|
-
IPAddr.new('fc00::/7'),
|
|
24
|
-
IPAddr.new('fe80::/10')
|
|
25
|
-
].freeze
|
|
26
|
-
|
|
27
11
|
# Fetches and caches the OpenID Connect Discovery document.
|
|
28
12
|
#
|
|
29
13
|
# This class retrieves the discovery metadata from an OpenID Connect provider
|
|
@@ -63,14 +47,14 @@ module Verikloak
|
|
|
63
47
|
# @param allow_http [Boolean] When false (default), raises on plain HTTP URLs. Set true for local development only.
|
|
64
48
|
# @raise [DiscoveryError] when `discovery_url` is not a valid HTTP(S) URL
|
|
65
49
|
def initialize(discovery_url:, connection: Verikloak::HTTP.default_connection, cache_ttl: 3600, allow_http: false)
|
|
66
|
-
normalized_url =
|
|
50
|
+
normalized_url = SafeUrl.normalize(discovery_url)
|
|
67
51
|
|
|
68
|
-
unless normalized_url
|
|
52
|
+
unless normalized_url
|
|
69
53
|
raise DiscoveryError.new('Invalid discovery URL: must be a non-empty HTTP(S) URL',
|
|
70
54
|
code: 'invalid_discovery_url')
|
|
71
55
|
end
|
|
72
56
|
|
|
73
|
-
|
|
57
|
+
if SafeUrl.insecure_http?(normalized_url, allow_http: allow_http)
|
|
74
58
|
raise DiscoveryError.new(
|
|
75
59
|
'Discovery URL must use HTTPS. Set allow_http: true to permit plain HTTP (development only).',
|
|
76
60
|
code: 'insecure_discovery_url'
|
|
@@ -126,7 +110,15 @@ module Verikloak
|
|
|
126
110
|
# @return [Hash]
|
|
127
111
|
# @raise [DiscoveryError]
|
|
128
112
|
def handle_final_response(response)
|
|
129
|
-
|
|
113
|
+
if response.status == 200
|
|
114
|
+
body = response.body.to_s
|
|
115
|
+
if body.bytesize > Verikloak::HTTP::MAX_RESPONSE_BYTES
|
|
116
|
+
raise DiscoveryError.new('Discovery response exceeds maximum allowed size',
|
|
117
|
+
code: 'discovery_metadata_invalid')
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
return parse_json(body)
|
|
121
|
+
end
|
|
130
122
|
|
|
131
123
|
raise DiscoveryError.new(failure_message(response), code: 'discovery_metadata_fetch_failed')
|
|
132
124
|
end
|
|
@@ -266,31 +258,19 @@ module Verikloak
|
|
|
266
258
|
end
|
|
267
259
|
|
|
268
260
|
# Validates that the redirect target does not resolve to a private/internal IP.
|
|
269
|
-
# IPv4-mapped IPv6 addresses are normalised before comparison.
|
|
261
|
+
# IPv4-mapped IPv6 addresses are normalised before comparison (see {SafeUrl}).
|
|
270
262
|
# Skipped when `@allow_http` is true (development mode).
|
|
271
263
|
# @api private
|
|
272
264
|
# @param uri [URI] Parsed redirect target
|
|
273
265
|
# @raise [DiscoveryError]
|
|
274
266
|
def validate_redirect_not_private!(uri)
|
|
275
267
|
return if @allow_http
|
|
268
|
+
return unless SafeUrl.resolves_to_private_ip?(uri.host)
|
|
276
269
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
ip = IPAddr.new(addr)
|
|
282
|
-
ip = ip.native if ip.ipv4_mapped?
|
|
283
|
-
next unless PRIVATE_IP_RANGES.any? { |range| range.include?(ip) }
|
|
284
|
-
|
|
285
|
-
raise DiscoveryError.new(
|
|
286
|
-
"Redirect target resolves to a private/internal address (#{host})",
|
|
287
|
-
code: 'discovery_redirect_error'
|
|
288
|
-
)
|
|
289
|
-
end
|
|
290
|
-
rescue IPAddr::InvalidAddressError
|
|
291
|
-
# If the address cannot be parsed, allow the request to proceed
|
|
292
|
-
# (Faraday will handle the actual connection error)
|
|
293
|
-
nil
|
|
270
|
+
raise DiscoveryError.new(
|
|
271
|
+
"Redirect target resolves to a private/internal address (#{uri.host})",
|
|
272
|
+
code: 'discovery_redirect_error'
|
|
273
|
+
)
|
|
294
274
|
end
|
|
295
275
|
|
|
296
276
|
# Wraps a block with network and parsing error handling and re-raising as {DiscoveryError}.
|
data/lib/verikloak/http.rb
CHANGED
|
@@ -11,6 +11,11 @@ module Verikloak
|
|
|
11
11
|
# Default open/read timeout (seconds) before establishing the HTTP connection.
|
|
12
12
|
DEFAULT_OPEN_TIMEOUT = 2
|
|
13
13
|
|
|
14
|
+
# Maximum accepted response body size (bytes) for discovery and JWKs
|
|
15
|
+
# documents. Real-world documents are a few KB; the cap guards against a
|
|
16
|
+
# compromised endpoint exhausting memory with an oversized response.
|
|
17
|
+
MAX_RESPONSE_BYTES = 1_048_576
|
|
18
|
+
|
|
14
19
|
# Retry middleware configuration used for idempotent GET requests.
|
|
15
20
|
# Retries on 429/5xx with exponential backoff and jitter.
|
|
16
21
|
RETRY_OPTIONS = {
|
data/lib/verikloak/jwks_cache.rb
CHANGED
|
@@ -1,14 +1,66 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'faraday'
|
|
4
|
-
require 'ipaddr'
|
|
5
4
|
require 'json'
|
|
6
|
-
require 'resolv'
|
|
7
5
|
require 'uri'
|
|
8
6
|
|
|
9
7
|
require 'verikloak/http'
|
|
8
|
+
require 'verikloak/safe_url'
|
|
10
9
|
|
|
11
10
|
module Verikloak
|
|
11
|
+
# @api private
|
|
12
|
+
#
|
|
13
|
+
# Revalidation-policy support for {JwksCache}: forced (TTL-bypassing)
|
|
14
|
+
# refreshes and server-TTL expiry checks used by the middleware throttle.
|
|
15
|
+
# Extracted from JwksCache to keep the class within length limits.
|
|
16
|
+
module JwksCacheRevalidation
|
|
17
|
+
# Revalidates the JWKs over HTTP regardless of TTL freshness.
|
|
18
|
+
#
|
|
19
|
+
# Used by the middleware's key-rotation retry path: a `Cache-Control:
|
|
20
|
+
# max-age` on the previous response must not delay picking up rotated
|
|
21
|
+
# keys when a token already failed with an unknown `kid` or bad signature.
|
|
22
|
+
#
|
|
23
|
+
# Subclasses that override {JwksCache#fetch!} with the pre-1.1 zero-arg
|
|
24
|
+
# signature are detected via the override's parameter list and get a plain
|
|
25
|
+
# `fetch!` call instead of `fetch!(force: true)` (no TTL bypass, but no crash).
|
|
26
|
+
#
|
|
27
|
+
# @return [Array<Hash>] the cached JWKs after revalidation
|
|
28
|
+
# @raise [JwksCacheError] (see JwksCache#fetch!)
|
|
29
|
+
def force_fetch!
|
|
30
|
+
fetch_supports_force? ? fetch!(force: true) : fetch!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Whether the server-declared freshness lifetime has elapsed.
|
|
34
|
+
#
|
|
35
|
+
# Returns false when the server never sent `Cache-Control: max-age`
|
|
36
|
+
# (freshness is then governed solely by the caller's own policy).
|
|
37
|
+
# Reads the two timestamps without the mutex: they are only written
|
|
38
|
+
# together under it, and a torn pair merely shifts one revalidation
|
|
39
|
+
# by a single request.
|
|
40
|
+
#
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def ttl_expired?
|
|
43
|
+
fetched_at = @fetched_at
|
|
44
|
+
max_age = @max_age
|
|
45
|
+
fetched_at && max_age ? (Time.now - fetched_at) >= max_age : false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Whether the (possibly overridden) fetch! accepts the force: keyword.
|
|
51
|
+
# Guards {#force_fetch!} against subclasses written for the pre-1.1
|
|
52
|
+
# zero-arg fetch! signature.
|
|
53
|
+
#
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def fetch_supports_force?
|
|
56
|
+
method(:fetch!).parameters.any? do |type, name|
|
|
57
|
+
(%i[key keyreq].include?(type) && name == :force) || type == :keyrest
|
|
58
|
+
end
|
|
59
|
+
rescue NameError
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
12
64
|
# Caches and revalidates JSON Web Key Sets (JWKs) fetched from a remote endpoint.
|
|
13
65
|
#
|
|
14
66
|
# This cache supports two HTTP cache mechanisms:
|
|
@@ -39,22 +91,20 @@ module Verikloak
|
|
|
39
91
|
# adapters, and shared headers (kept consistent with Discovery).
|
|
40
92
|
# `JwksCache.new(jwks_uri: "...", connection: Faraday.new { |f| f.request :retry })`
|
|
41
93
|
class JwksCache
|
|
94
|
+
include JwksCacheRevalidation
|
|
95
|
+
|
|
42
96
|
# @param jwks_uri [String] HTTPS URL of the JWKs endpoint
|
|
43
97
|
# @param connection [Faraday::Connection, nil] Optional Faraday connection for HTTP requests
|
|
44
98
|
# @param allow_http [Boolean] When false (default), raises on plain HTTP URIs. Set true for local development only.
|
|
45
99
|
# @raise [JwksCacheError] if the URI is not an HTTP(S) URL or resolves to a private/internal address
|
|
46
100
|
def initialize(jwks_uri:, connection: nil, allow_http: false)
|
|
47
|
-
|
|
48
|
-
raise JwksCacheError.new('Invalid JWKs URI: must be a non-empty HTTP(S) URL', code: 'jwks_fetch_failed')
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
clean_jwks_uri = jwks_uri.strip
|
|
101
|
+
clean_jwks_uri = SafeUrl.normalize(jwks_uri)
|
|
52
102
|
|
|
53
|
-
unless clean_jwks_uri
|
|
103
|
+
unless clean_jwks_uri
|
|
54
104
|
raise JwksCacheError.new('Invalid JWKs URI: must be a non-empty HTTP(S) URL', code: 'jwks_fetch_failed')
|
|
55
105
|
end
|
|
56
106
|
|
|
57
|
-
|
|
107
|
+
if SafeUrl.insecure_http?(clean_jwks_uri, allow_http: allow_http)
|
|
58
108
|
raise JwksCacheError.new(
|
|
59
109
|
'JWKs URI must use HTTPS. Set allow_http: true to permit plain HTTP (development only).',
|
|
60
110
|
code: 'insecure_jwks_uri'
|
|
@@ -78,11 +128,14 @@ module Verikloak
|
|
|
78
128
|
# - 200: parses/validates body, updates keys, ETag, TTL and `fetched_at`.
|
|
79
129
|
# - 304: keeps cached keys, updates TTL from headers (if present), refreshes `fetched_at`.
|
|
80
130
|
#
|
|
131
|
+
# @param force [Boolean] When true, revalidates over HTTP even while
|
|
132
|
+
# `Cache-Control: max-age` freshness holds (the ETag conditional request
|
|
133
|
+
# still applies, so an unchanged key set costs only a 304).
|
|
81
134
|
# @return [Array<Hash>] the cached JWKs after fetch/revalidation
|
|
82
135
|
# @raise [JwksCacheError] on HTTP failures, invalid JSON, invalid structure, or cache miss on 304
|
|
83
|
-
def fetch!
|
|
136
|
+
def fetch!(force: false)
|
|
84
137
|
@mutex.synchronize do
|
|
85
|
-
return @cached_keys if fresh_by_ttl_locked?
|
|
138
|
+
return @cached_keys if !force && fresh_by_ttl_locked?
|
|
86
139
|
|
|
87
140
|
with_error_handling do
|
|
88
141
|
# Build conditional request headers (ETag-based)
|
|
@@ -170,32 +223,21 @@ module Verikloak
|
|
|
170
223
|
# compromised or malicious discovery endpoint to point JWKs fetching at internal services.
|
|
171
224
|
#
|
|
172
225
|
# IPv4-mapped IPv6 addresses (e.g. `::ffff:127.0.0.1`) are normalised to their native IPv4
|
|
173
|
-
# form before comparison
|
|
226
|
+
# form before comparison (see {SafeUrl}).
|
|
174
227
|
#
|
|
175
228
|
# @api private
|
|
176
229
|
# @param url [String] The JWKs URI to validate
|
|
177
230
|
# @raise [JwksCacheError] when the URI resolves to a private/internal address
|
|
178
231
|
def validate_not_private!(url)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return unless host
|
|
232
|
+
host = URI.parse(url).host
|
|
233
|
+
return unless SafeUrl.resolves_to_private_ip?(host)
|
|
182
234
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
raise JwksCacheError.new(
|
|
189
|
-
"JWKs URI resolves to a private/internal address (#{host})",
|
|
190
|
-
code: 'jwks_ssrf_blocked'
|
|
191
|
-
)
|
|
192
|
-
end
|
|
235
|
+
raise JwksCacheError.new(
|
|
236
|
+
"JWKs URI resolves to a private/internal address (#{host})",
|
|
237
|
+
code: 'jwks_ssrf_blocked'
|
|
238
|
+
)
|
|
193
239
|
rescue URI::InvalidURIError => e
|
|
194
240
|
raise JwksCacheError.new("Invalid JWKs URI: #{e.message}", code: 'jwks_fetch_failed')
|
|
195
|
-
rescue IPAddr::InvalidAddressError
|
|
196
|
-
# If the address cannot be parsed, allow the request to proceed
|
|
197
|
-
# (Faraday will handle the actual connection error)
|
|
198
|
-
nil
|
|
199
241
|
end
|
|
200
242
|
|
|
201
243
|
# @api private
|
|
@@ -294,7 +336,12 @@ module Verikloak
|
|
|
294
336
|
# @param response [Faraday::Response]
|
|
295
337
|
# @return [Array<Hash>] parsed and cached keys
|
|
296
338
|
def process_successful_response(response)
|
|
297
|
-
|
|
339
|
+
body = response.body.to_s
|
|
340
|
+
if body.bytesize > Verikloak::HTTP::MAX_RESPONSE_BYTES
|
|
341
|
+
raise JwksCacheError.new('JWKs response exceeds maximum allowed size', code: 'jwks_parse_failed')
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
json = parse_json!(body)
|
|
298
345
|
keys = extract_and_validate_keys!(json)
|
|
299
346
|
update_cache_from_ok(response, keys)
|
|
300
347
|
keys
|
data/lib/verikloak/middleware.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'rack'
|
|
4
|
+
require 'digest'
|
|
4
5
|
require 'json'
|
|
5
6
|
require 'set'
|
|
6
7
|
require 'faraday'
|
|
@@ -67,11 +68,13 @@ module Verikloak
|
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
# Returns true when the ArgumentError message indicates a wrong arity.
|
|
71
|
+
# Anchored to the start of the message so ArgumentErrors raised inside the
|
|
72
|
+
# callable body that merely mention the phrase are not swallowed.
|
|
70
73
|
#
|
|
71
74
|
# @param error [ArgumentError]
|
|
72
75
|
# @return [Boolean]
|
|
73
76
|
def wrong_arity_error?(error)
|
|
74
|
-
error.message.
|
|
77
|
+
error.message.start_with?('wrong number of arguments')
|
|
75
78
|
end
|
|
76
79
|
|
|
77
80
|
# Extracts parameter information from a callable's call method.
|
|
@@ -167,6 +170,25 @@ module Verikloak
|
|
|
167
170
|
raise ArgumentError, 'decoder_cache_limit must be zero or positive'
|
|
168
171
|
end
|
|
169
172
|
|
|
173
|
+
# Validates and normalizes the JWKs refresh interval configuration.
|
|
174
|
+
# Numeric strings are coerced (consistent with decoder_cache_limit) so
|
|
175
|
+
# ENV-driven configuration works; nil falls back to the default rather
|
|
176
|
+
# than silently disabling the throttle.
|
|
177
|
+
#
|
|
178
|
+
# @param interval [Numeric, String, nil] Minimum seconds between JWKs revalidations.
|
|
179
|
+
# @return [Numeric] The normalized interval (nil becomes the default of 60).
|
|
180
|
+
# @raise [ArgumentError] if the interval is negative or not a number.
|
|
181
|
+
def normalize_jwks_refresh_interval(interval)
|
|
182
|
+
return Middleware::DEFAULT_JWKS_REFRESH_INTERVAL if interval.nil?
|
|
183
|
+
|
|
184
|
+
value = interval.is_a?(Numeric) ? interval : Float(interval)
|
|
185
|
+
raise ArgumentError, 'jwks_refresh_interval must be zero or positive' if value.negative?
|
|
186
|
+
|
|
187
|
+
value
|
|
188
|
+
rescue ArgumentError, TypeError
|
|
189
|
+
raise ArgumentError, 'jwks_refresh_interval must be zero or positive'
|
|
190
|
+
end
|
|
191
|
+
|
|
170
192
|
# Validates and normalizes environment key configuration.
|
|
171
193
|
#
|
|
172
194
|
# @param value [String, #to_s] The environment key to normalize.
|
|
@@ -186,7 +208,7 @@ module Verikloak
|
|
|
186
208
|
# @return [String] The normalized realm, or DEFAULT_REALM if nil.
|
|
187
209
|
# @raise [ArgumentError] if the realm is blank after normalization.
|
|
188
210
|
def normalize_realm(value)
|
|
189
|
-
return DEFAULT_REALM if value.nil?
|
|
211
|
+
return Middleware::DEFAULT_REALM if value.nil?
|
|
190
212
|
|
|
191
213
|
normalized = value.to_s.strip
|
|
192
214
|
raise ArgumentError, 'realm cannot be blank' if normalized.empty?
|
|
@@ -203,6 +225,11 @@ module Verikloak
|
|
|
203
225
|
@logger.respond_to?(:error) || @logger.respond_to?(:warn) || @logger.respond_to?(:debug)
|
|
204
226
|
end
|
|
205
227
|
|
|
228
|
+
# @return [Float] Monotonic clock reading in seconds.
|
|
229
|
+
def monotonic_now
|
|
230
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
231
|
+
end
|
|
232
|
+
|
|
206
233
|
# Logs a message and backtrace using the configured logger.
|
|
207
234
|
#
|
|
208
235
|
# @param message [String] The primary error message to log.
|
|
@@ -305,7 +332,7 @@ module Verikloak
|
|
|
305
332
|
|
|
306
333
|
# @api private
|
|
307
334
|
#
|
|
308
|
-
# Internal mixin for JWT verification and
|
|
335
|
+
# Internal mixin for JWT verification and decoder caching.
|
|
309
336
|
# Extracted from Middleware to reduce class length and improve clarity.
|
|
310
337
|
module MiddlewareTokenVerification
|
|
311
338
|
private
|
|
@@ -316,42 +343,31 @@ module Verikloak
|
|
|
316
343
|
# @param error [Exception]
|
|
317
344
|
# @return [Boolean]
|
|
318
345
|
def retryable_decoder_error?(error)
|
|
319
|
-
|
|
320
|
-
return true if error.code == 'invalid_signature'
|
|
321
|
-
return true if error.code == 'invalid_token' && error.message&.include?('Key with kid=')
|
|
322
|
-
|
|
323
|
-
false
|
|
346
|
+
error.is_a?(TokenDecoderError) && %w[invalid_signature kid_not_found].include?(error.code)
|
|
324
347
|
end
|
|
325
348
|
|
|
326
|
-
# Returns a cached TokenDecoder instance
|
|
327
|
-
#
|
|
349
|
+
# Returns a cached TokenDecoder instance built from the given key snapshot.
|
|
350
|
+
# The cache key derives from the snapshot itself, so a decoder can never be
|
|
351
|
+
# stored under a generation other than the keys it was built from (a
|
|
352
|
+
# concurrent refresh between reads previously made that pairing possible).
|
|
353
|
+
# When the digest is unavailable the decoder is built uncached — correct,
|
|
354
|
+
# just slower — rather than risking a collision under a shared key.
|
|
328
355
|
#
|
|
329
356
|
# @param audience [String, #call] The audience to create a decoder for
|
|
357
|
+
# @param keys [Array<Hash>] The JWKs snapshot to build the decoder from
|
|
330
358
|
# @return [TokenDecoder] A decoder instance for the given audience
|
|
331
|
-
def decoder_for(audience)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
audience,
|
|
337
|
-
@leeway,
|
|
338
|
-
@token_verify_options,
|
|
339
|
-
fetched_at
|
|
340
|
-
].hash
|
|
359
|
+
def decoder_for(audience, keys)
|
|
360
|
+
generation = keys_digest(keys)
|
|
361
|
+
return build_decoder(audience, keys) if generation.nil?
|
|
362
|
+
|
|
363
|
+
cache_key = [@issuer, audience, @leeway, @token_verify_options, generation]
|
|
341
364
|
@mutex.synchronize do
|
|
342
365
|
if (decoder = @decoder_cache[cache_key])
|
|
343
366
|
touch_decoder_cache(cache_key) if track_decoder_order?
|
|
344
367
|
return decoder
|
|
345
368
|
end
|
|
346
369
|
|
|
347
|
-
decoder =
|
|
348
|
-
jwks: keys,
|
|
349
|
-
issuer: @issuer,
|
|
350
|
-
audience: audience,
|
|
351
|
-
leeway: @leeway,
|
|
352
|
-
options: @token_verify_options
|
|
353
|
-
)
|
|
354
|
-
|
|
370
|
+
decoder = build_decoder(audience, keys)
|
|
355
371
|
return decoder if @decoder_cache_limit&.zero?
|
|
356
372
|
|
|
357
373
|
prune_decoder_cache_if_needed
|
|
@@ -359,13 +375,32 @@ module Verikloak
|
|
|
359
375
|
end
|
|
360
376
|
end
|
|
361
377
|
|
|
362
|
-
#
|
|
363
|
-
#
|
|
378
|
+
# @param audience [String, #call]
|
|
379
|
+
# @param keys [Array<Hash>]
|
|
380
|
+
# @return [TokenDecoder]
|
|
381
|
+
def build_decoder(audience, keys)
|
|
382
|
+
TokenDecoder.new(
|
|
383
|
+
jwks: keys,
|
|
384
|
+
issuer: @issuer,
|
|
385
|
+
audience: audience,
|
|
386
|
+
leeway: @leeway,
|
|
387
|
+
options: @token_verify_options
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Reads the current key snapshot, failing as an infrastructure error when
|
|
392
|
+
# nothing usable is cached. Used on both the first attempt and the
|
|
393
|
+
# post-refresh retry so an empty key set yields the same 503 on each path.
|
|
364
394
|
#
|
|
365
|
-
# @return [
|
|
366
|
-
# @raise [
|
|
367
|
-
def
|
|
368
|
-
|
|
395
|
+
# @return [Array<Hash>]
|
|
396
|
+
# @raise [MiddlewareError] code `jwks_cache_miss` when no keys are cached
|
|
397
|
+
def current_keys!
|
|
398
|
+
keys = @jwks_cache.cached
|
|
399
|
+
if keys.nil? || keys.empty?
|
|
400
|
+
raise MiddlewareError.new('JWKs cache is empty, cannot verify token', code: 'jwks_cache_miss')
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
keys
|
|
369
404
|
end
|
|
370
405
|
|
|
371
406
|
# Decodes and verifies the JWT using the cached JWKs. On certain verification
|
|
@@ -377,14 +412,10 @@ module Verikloak
|
|
|
377
412
|
# @raise [Verikloak::Error] bubbles up verification/fetch errors for centralized handling
|
|
378
413
|
def decode_token(env, token)
|
|
379
414
|
ensure_jwks_cache!
|
|
380
|
-
|
|
381
|
-
raise MiddlewareError.new('JWKs cache is empty, cannot verify token', code: 'jwks_cache_miss')
|
|
382
|
-
end
|
|
383
|
-
|
|
415
|
+
keys = current_keys!
|
|
384
416
|
audience = resolve_audience(env)
|
|
385
417
|
|
|
386
|
-
|
|
387
|
-
decoder = decoder_for(audience)
|
|
418
|
+
decoder = decoder_for(audience, keys)
|
|
388
419
|
|
|
389
420
|
begin
|
|
390
421
|
decoder.decode!(token)
|
|
@@ -392,39 +423,58 @@ module Verikloak
|
|
|
392
423
|
# On key rotation or signature mismatch, refresh JWKs and retry once.
|
|
393
424
|
raise unless retryable_decoder_error?(e)
|
|
394
425
|
|
|
395
|
-
|
|
426
|
+
ensure_jwks_cache!(force: true)
|
|
396
427
|
|
|
397
|
-
#
|
|
398
|
-
|
|
428
|
+
# Re-snapshot and rebuild: the refresh may have rotated or emptied the keys.
|
|
429
|
+
keys = current_keys!
|
|
430
|
+
decoder = decoder_for(audience, keys)
|
|
399
431
|
decoder.decode!(token)
|
|
400
432
|
end
|
|
401
433
|
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# @api private
|
|
437
|
+
#
|
|
438
|
+
# Internal mixin for discovery/JWKs lifecycle: initialization, throttled and
|
|
439
|
+
# forced revalidation, and rotation detection (key-set content identity).
|
|
440
|
+
# Extracted from Middleware to reduce class length and improve clarity.
|
|
441
|
+
module MiddlewareJwksRefresh
|
|
442
|
+
# Minimum seconds between failure-triggered (forced) JWKs revalidations.
|
|
443
|
+
# A token that fails against keys fetched less than this long ago would
|
|
444
|
+
# fail against an immediate re-fetch too, so skipping the round trip
|
|
445
|
+
# loses nothing — and it caps the upstream request rate an attacker can
|
|
446
|
+
# induce by flooding the middleware with unknown-`kid` tokens.
|
|
447
|
+
FORCED_REFRESH_MIN_INTERVAL = 5
|
|
448
|
+
|
|
449
|
+
private
|
|
402
450
|
|
|
403
451
|
# Ensures that discovery metadata and JWKs cache are initialized and up-to-date.
|
|
404
452
|
# This method is thread-safe.
|
|
405
453
|
#
|
|
406
454
|
# * When the cache instance is missing, it is created from discovery metadata.
|
|
407
|
-
# * JWKs
|
|
408
|
-
#
|
|
455
|
+
# * JWKs revalidation is throttled by `jwks_refresh_interval` so that hot
|
|
456
|
+
# request paths do not serialize on a network call; ETag/Cache-Control
|
|
457
|
+
# headers minimize traffic when a revalidation does happen. A server
|
|
458
|
+
# `Cache-Control: max-age` shorter than the interval is honored: TTL
|
|
459
|
+
# expiry reopens the window early (see {#jwks_recently_refreshed?}).
|
|
460
|
+
# * `force: true` bypasses the throttle **and** the cache's own max-age
|
|
461
|
+
# freshness (used by the key-rotation retry path), rate-limited to one
|
|
462
|
+
# revalidation per {FORCED_REFRESH_MIN_INTERVAL} seconds because the
|
|
463
|
+
# trigger is attacker-controllable (any token with an unknown `kid`).
|
|
464
|
+
#
|
|
465
|
+
# @param force [Boolean] Revalidate even when the throttle window is still open.
|
|
409
466
|
# @return [void]
|
|
410
467
|
# @raise [Verikloak::DiscoveryError, Verikloak::JwksCacheError, Verikloak::MiddlewareError]
|
|
411
|
-
def ensure_jwks_cache!
|
|
468
|
+
def ensure_jwks_cache!(force: false)
|
|
469
|
+
return if !force && jwks_recently_refreshed?
|
|
470
|
+
|
|
412
471
|
@mutex.synchronize do
|
|
413
|
-
|
|
414
|
-
if @jwks_cache.nil?
|
|
415
|
-
config = @discovery.fetch!
|
|
416
|
-
# Use configured issuer if provided, otherwise use discovered issuer
|
|
417
|
-
@issuer = @configured_issuer || config['issuer']
|
|
418
|
-
jwks_uri = config['jwks_uri']
|
|
419
|
-
@jwks_cache = JwksCache.new(jwks_uri: jwks_uri, connection: @connection, allow_http: @allow_http)
|
|
420
|
-
elsif @configured_issuer.nil? && @issuer.nil?
|
|
421
|
-
# If jwks_cache was injected but no issuer configured and not yet discovered, fetch discovery to set issuer
|
|
422
|
-
config = @discovery.fetch!
|
|
423
|
-
@issuer = config['issuer']
|
|
424
|
-
end
|
|
472
|
+
initialize_jwks_dependencies!
|
|
425
473
|
|
|
426
|
-
|
|
427
|
-
|
|
474
|
+
if force ? forced_refresh_allowed? : !jwks_recently_refreshed?
|
|
475
|
+
refresh_jwks!(force: force)
|
|
476
|
+
record_refresh!
|
|
477
|
+
end
|
|
428
478
|
end
|
|
429
479
|
rescue Verikloak::DiscoveryError, Verikloak::JwksCacheError => e
|
|
430
480
|
# Re-raise so that specific error codes can be mapped in the middleware
|
|
@@ -433,31 +483,133 @@ module Verikloak
|
|
|
433
483
|
raise MiddlewareError.new("Failed to initialize JWKs cache: #{e.message}", code: 'jwks_fetch_failed')
|
|
434
484
|
end
|
|
435
485
|
|
|
486
|
+
# Records the outcome of a revalidation. Must be called while holding `@mutex`.
|
|
487
|
+
#
|
|
488
|
+
# An empty key set does not close the throttle window: leaving
|
|
489
|
+
# `@last_jwks_refresh_at` unset lets the very next request revalidate, so a
|
|
490
|
+
# transient `{"keys": []}` from the IdP heals on recovery instead of
|
|
491
|
+
# pinning 503s for the rest of the window.
|
|
492
|
+
#
|
|
493
|
+
# @return [void]
|
|
494
|
+
def record_refresh!
|
|
495
|
+
keys = @jwks_cache.respond_to?(:cached) ? @jwks_cache.cached : nil
|
|
496
|
+
purge_decoder_cache_if_keys_changed(keys)
|
|
497
|
+
@last_jwks_refresh_at = monotonic_now unless keys.nil? || keys.empty?
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Whether a failure-triggered refresh may hit the network. Keys revalidated
|
|
501
|
+
# within {FORCED_REFRESH_MIN_INTERVAL} seconds cannot have missed a rotation
|
|
502
|
+
# an immediate re-fetch would find, so the retry proceeds on cached keys.
|
|
503
|
+
#
|
|
504
|
+
# @return [Boolean]
|
|
505
|
+
def forced_refresh_allowed?
|
|
506
|
+
last = @last_jwks_refresh_at
|
|
507
|
+
last.nil? || (monotonic_now - last) >= FORCED_REFRESH_MIN_INTERVAL
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Creates the JWKs cache from discovery metadata and resolves the effective
|
|
511
|
+
# issuer. Must be called while holding `@mutex`.
|
|
512
|
+
#
|
|
513
|
+
# @return [void]
|
|
514
|
+
def initialize_jwks_dependencies!
|
|
515
|
+
if @jwks_cache.nil?
|
|
516
|
+
config = @discovery.fetch!
|
|
517
|
+
# Use configured issuer if provided, otherwise use discovered issuer
|
|
518
|
+
@issuer = @configured_issuer || config['issuer']
|
|
519
|
+
jwks_uri = config['jwks_uri']
|
|
520
|
+
@jwks_cache = JwksCache.new(jwks_uri: jwks_uri, connection: @connection, allow_http: @allow_http)
|
|
521
|
+
elsif @configured_issuer.nil? && @issuer.nil?
|
|
522
|
+
# If jwks_cache was injected but no issuer configured and not yet discovered, fetch discovery to set issuer
|
|
523
|
+
config = @discovery.fetch!
|
|
524
|
+
@issuer = config['issuer']
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Revalidates the JWKs cache. On the forced path, prefers
|
|
529
|
+
# {JwksCache#force_fetch!} so that a `Cache-Control: max-age` from the
|
|
530
|
+
# previous response cannot short-circuit the revalidation and leave the
|
|
531
|
+
# retry decoding against pre-rotation keys. Injected caches that only
|
|
532
|
+
# implement a plain `fetch!` keep their existing behavior.
|
|
533
|
+
#
|
|
534
|
+
# @param force [Boolean]
|
|
535
|
+
# @return [void]
|
|
536
|
+
def refresh_jwks!(force: false)
|
|
537
|
+
if force && @jwks_cache.respond_to?(:force_fetch!)
|
|
538
|
+
@jwks_cache.force_fetch!
|
|
539
|
+
else
|
|
540
|
+
@jwks_cache.fetch!
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Whether the JWKs were revalidated within the configured refresh interval.
|
|
545
|
+
# `@last_jwks_refresh_at` is only stamped after a successful non-empty
|
|
546
|
+
# refresh inside the mutex, so a non-nil value implies the dependencies are
|
|
547
|
+
# initialized. A server-declared `Cache-Control: max-age` that expires
|
|
548
|
+
# before the interval reopens the window early, so an IdP asking for
|
|
549
|
+
# faster revalidation than the throttle default still gets it.
|
|
550
|
+
# Reads shared state without the mutex; a stale read only causes an extra
|
|
551
|
+
# (harmless) pass through the synchronized slow path.
|
|
552
|
+
#
|
|
553
|
+
# @return [Boolean]
|
|
554
|
+
def jwks_recently_refreshed?
|
|
555
|
+
return false unless @jwks_refresh_interval.positive?
|
|
556
|
+
|
|
557
|
+
last = @last_jwks_refresh_at
|
|
558
|
+
return false if last.nil?
|
|
559
|
+
return false if @jwks_cache.respond_to?(:ttl_expired?) && @jwks_cache.ttl_expired?
|
|
560
|
+
|
|
561
|
+
(monotonic_now - last) < @jwks_refresh_interval
|
|
562
|
+
end
|
|
563
|
+
|
|
436
564
|
# Purges the decoder cache if the JWKs have changed since last check.
|
|
437
|
-
# Compares key set identity to detect key rotation and invalidate
|
|
565
|
+
# Compares key set content identity to detect key rotation and invalidate
|
|
566
|
+
# cached decoders. When the digest cannot be computed for a present key
|
|
567
|
+
# set, the cache is cleared as the safe default (a rotation cannot be
|
|
568
|
+
# ruled out) and {#decoder_for} runs uncached until digests succeed again.
|
|
438
569
|
#
|
|
439
|
-
# @param
|
|
570
|
+
# @param keys [Array<Hash>, nil] The key snapshot taken after the refresh
|
|
440
571
|
# @return [void]
|
|
441
|
-
def purge_decoder_cache_if_keys_changed(
|
|
442
|
-
current_id =
|
|
443
|
-
if
|
|
444
|
-
|
|
572
|
+
def purge_decoder_cache_if_keys_changed(keys)
|
|
573
|
+
current_id = keys_digest(keys)
|
|
574
|
+
if current_id.nil?
|
|
575
|
+
clear_decoder_cache if keys
|
|
576
|
+
elsif @last_cached_keys_id && @last_cached_keys_id != current_id
|
|
445
577
|
clear_decoder_cache
|
|
446
578
|
end
|
|
447
579
|
|
|
448
|
-
@last_cached_keys_id = current_id
|
|
580
|
+
@last_cached_keys_id = current_id
|
|
449
581
|
end
|
|
450
582
|
|
|
451
|
-
#
|
|
452
|
-
#
|
|
583
|
+
# Content digest of a key snapshot, stable across refreshes that return
|
|
584
|
+
# the same key set (unlike object identity) — both attribute order within
|
|
585
|
+
# a JWK and the order of keys in the array are canonicalized, since kid
|
|
586
|
+
# lookup makes ordering irrelevant to verification. The last
|
|
587
|
+
# (snapshot, digest) pair is memoized behind a single frozen reference, so
|
|
588
|
+
# the request path pays one `equal?` check while the cache keeps returning
|
|
589
|
+
# the same array object (TTL-fresh reads and 304 revalidations do).
|
|
590
|
+
# Failures are logged and yield nil — callers then skip decoder caching
|
|
591
|
+
# rather than risk keying different key sets identically.
|
|
453
592
|
#
|
|
454
|
-
# @param
|
|
455
|
-
# @return [String, nil]
|
|
456
|
-
def
|
|
457
|
-
return
|
|
593
|
+
# @param keys [Array<Hash>, nil]
|
|
594
|
+
# @return [String, nil]
|
|
595
|
+
def keys_digest(keys)
|
|
596
|
+
return nil if keys.nil?
|
|
458
597
|
|
|
459
|
-
|
|
460
|
-
keys
|
|
598
|
+
memo = @keys_digest_memo
|
|
599
|
+
return memo[1] if memo && keys.equal?(memo[0])
|
|
600
|
+
|
|
601
|
+
canonical = Array(keys).map do |key|
|
|
602
|
+
key.respond_to?(:to_h) ? key.to_h.transform_keys(&:to_s).sort.inspect : key.inspect
|
|
603
|
+
end
|
|
604
|
+
digest = Digest::SHA256.hexdigest(canonical.sort.inspect)
|
|
605
|
+
@keys_digest_memo = [keys, digest].freeze
|
|
606
|
+
digest
|
|
607
|
+
rescue StandardError => e
|
|
608
|
+
if logger_available?
|
|
609
|
+
log_message(@logger, "[verikloak] JWKs digest failed (#{e.class}: #{e.message}); " \
|
|
610
|
+
'decoder caching disabled for this key set')
|
|
611
|
+
end
|
|
612
|
+
nil
|
|
461
613
|
end
|
|
462
614
|
end
|
|
463
615
|
|
|
@@ -553,6 +705,7 @@ module Verikloak
|
|
|
553
705
|
include MiddlewareAudienceResolution
|
|
554
706
|
include MiddlewareDecoderCache
|
|
555
707
|
include MiddlewareTokenVerification
|
|
708
|
+
include MiddlewareJwksRefresh
|
|
556
709
|
|
|
557
710
|
DEFAULT_REALM = 'verikloak'
|
|
558
711
|
DEFAULT_TOKEN_ENV_KEY = 'verikloak.token'
|
|
@@ -572,8 +725,13 @@ module Verikloak
|
|
|
572
725
|
# @param token_verify_options [Hash] Additional JWT verification options passed through
|
|
573
726
|
# to TokenDecoder.
|
|
574
727
|
# e.g., { verify_iat: false, leeway: 10 }
|
|
728
|
+
# @param jwks_refresh_interval [Numeric] Minimum seconds between JWKs
|
|
729
|
+
# revalidations on the request path. Set to `0` to revalidate on every
|
|
730
|
+
# request (pre-1.1 behavior). Key rotation within the window is still
|
|
731
|
+
# handled: a signature/kid mismatch forces an immediate refresh and retry.
|
|
575
732
|
# rubocop:disable Metrics/ParameterLists
|
|
576
733
|
DEFAULT_DECODER_CACHE_LIMIT = 128
|
|
734
|
+
DEFAULT_JWKS_REFRESH_INTERVAL = 60
|
|
577
735
|
|
|
578
736
|
def initialize(app,
|
|
579
737
|
discovery_url:,
|
|
@@ -586,6 +744,7 @@ module Verikloak
|
|
|
586
744
|
leeway: Verikloak::TokenDecoder::DEFAULT_LEEWAY,
|
|
587
745
|
token_verify_options: {},
|
|
588
746
|
decoder_cache_limit: DEFAULT_DECODER_CACHE_LIMIT,
|
|
747
|
+
jwks_refresh_interval: DEFAULT_JWKS_REFRESH_INTERVAL,
|
|
589
748
|
token_env_key: DEFAULT_TOKEN_ENV_KEY,
|
|
590
749
|
user_env_key: DEFAULT_USER_ENV_KEY,
|
|
591
750
|
realm: DEFAULT_REALM,
|
|
@@ -601,6 +760,8 @@ module Verikloak
|
|
|
601
760
|
@leeway = leeway
|
|
602
761
|
@token_verify_options = token_verify_options || {}
|
|
603
762
|
@decoder_cache_limit = normalize_decoder_cache_limit(decoder_cache_limit)
|
|
763
|
+
@jwks_refresh_interval = normalize_jwks_refresh_interval(jwks_refresh_interval)
|
|
764
|
+
@last_jwks_refresh_at = nil
|
|
604
765
|
# Optional user-configured issuer (overrides discovery issuer when provided)
|
|
605
766
|
@configured_issuer = issuer
|
|
606
767
|
# Effective issuer; may be nil initially and set via discovery if not configured
|
|
@@ -609,6 +770,7 @@ module Verikloak
|
|
|
609
770
|
@decoder_cache = {}
|
|
610
771
|
@decoder_cache_order = []
|
|
611
772
|
@last_cached_keys_id = nil
|
|
773
|
+
@keys_digest_memo = nil
|
|
612
774
|
@token_env_key = normalize_env_key(token_env_key, 'token_env_key')
|
|
613
775
|
@user_env_key = normalize_env_key(user_env_key, 'user_env_key')
|
|
614
776
|
@realm = normalize_realm(realm)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ipaddr'
|
|
4
|
+
require 'resolv'
|
|
5
|
+
|
|
6
|
+
module Verikloak
|
|
7
|
+
# Private IP ranges that must not be targets of outbound requests (SSRF protection).
|
|
8
|
+
# Includes RFC 1918, loopback, link-local, and IPv6 equivalents.
|
|
9
|
+
# @api private
|
|
10
|
+
PRIVATE_IP_RANGES = [
|
|
11
|
+
IPAddr.new('10.0.0.0/8'),
|
|
12
|
+
IPAddr.new('172.16.0.0/12'),
|
|
13
|
+
IPAddr.new('192.168.0.0/16'),
|
|
14
|
+
IPAddr.new('127.0.0.0/8'),
|
|
15
|
+
IPAddr.new('169.254.0.0/16'),
|
|
16
|
+
IPAddr.new('0.0.0.0/8'),
|
|
17
|
+
IPAddr.new('::1/128'),
|
|
18
|
+
IPAddr.new('fc00::/7'),
|
|
19
|
+
IPAddr.new('fe80::/10')
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
# @api private
|
|
23
|
+
#
|
|
24
|
+
# Shared URL normalization and SSRF-protection helpers used by
|
|
25
|
+
# {Verikloak::Discovery} and {Verikloak::JwksCache}. Callers keep their own
|
|
26
|
+
# error classes and codes; this module only answers questions about URLs.
|
|
27
|
+
#
|
|
28
|
+
# NOTE: The private-IP check resolves the hostname at validation time, while
|
|
29
|
+
# the HTTP client resolves it again at connection time. A DNS rebinding
|
|
30
|
+
# attack (short-TTL records) can therefore pass validation and still connect
|
|
31
|
+
# to a private address. See SECURITY.md ("Known limitations").
|
|
32
|
+
module SafeUrl
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Strips surrounding whitespace and verifies the value is an HTTP(S) URL.
|
|
36
|
+
#
|
|
37
|
+
# @param url [Object] Candidate URL (any type).
|
|
38
|
+
# @return [String, nil] The stripped URL string, or nil when the value is
|
|
39
|
+
# not a String or does not start with http:// or https://.
|
|
40
|
+
def normalize(url)
|
|
41
|
+
return nil unless url.is_a?(String)
|
|
42
|
+
|
|
43
|
+
normalized = url.strip
|
|
44
|
+
normalized.match?(%r{\Ahttps?://}) ? normalized : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether the URL uses plain HTTP while HTTP is not allowed.
|
|
48
|
+
#
|
|
49
|
+
# @param url [String] Normalized URL string.
|
|
50
|
+
# @param allow_http [Boolean]
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def insecure_http?(url, allow_http:)
|
|
53
|
+
!allow_http && !url.start_with?('https://')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Whether the hostname resolves to at least one private/internal address.
|
|
57
|
+
#
|
|
58
|
+
# IPv4-mapped IPv6 addresses (e.g. `::ffff:127.0.0.1`) are normalised to
|
|
59
|
+
# their native IPv4 form before comparison. Addresses that cannot be
|
|
60
|
+
# parsed are ignored (the HTTP client will surface any real connection
|
|
61
|
+
# error).
|
|
62
|
+
#
|
|
63
|
+
# @param host [String, nil] Hostname or IP literal.
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def resolves_to_private_ip?(host)
|
|
66
|
+
return false if host.nil? || host.to_s.empty?
|
|
67
|
+
|
|
68
|
+
Resolv.getaddresses(host).any? { |addr| private_address?(addr) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Whether a single address string falls within {PRIVATE_IP_RANGES}.
|
|
72
|
+
#
|
|
73
|
+
# @param address [String]
|
|
74
|
+
# @return [Boolean]
|
|
75
|
+
def private_address?(address)
|
|
76
|
+
ip = IPAddr.new(address)
|
|
77
|
+
ip = ip.native if ip.ipv4_mapped?
|
|
78
|
+
PRIVATE_IP_RANGES.any? { |range| range.include?(ip) }
|
|
79
|
+
rescue IPAddr::InvalidAddressError
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -26,6 +26,10 @@ module Verikloak
|
|
|
26
26
|
# Default clock skew tolerance in seconds.
|
|
27
27
|
DEFAULT_LEEWAY = 60
|
|
28
28
|
|
|
29
|
+
# Maximum length of an attacker-controlled `kid` value echoed back in
|
|
30
|
+
# error messages (and ultimately in 401 response bodies/headers).
|
|
31
|
+
MAX_KID_LENGTH_IN_MESSAGE = 64
|
|
32
|
+
|
|
29
33
|
# Initializes the decoder with a JWKs and verification criteria.
|
|
30
34
|
#
|
|
31
35
|
# @param jwks [Array<Hash>] List of JWKs from the discovery document.
|
|
@@ -65,6 +69,7 @@ module Verikloak
|
|
|
65
69
|
# @return [Hash] The decoded payload (claims).
|
|
66
70
|
# @raise [TokenDecoderError] If verification fails. Possible error codes:
|
|
67
71
|
# - invalid_token
|
|
72
|
+
# - kid_not_found
|
|
68
73
|
# - expired_token
|
|
69
74
|
# - not_yet_valid
|
|
70
75
|
# - invalid_issuer
|
|
@@ -108,16 +113,26 @@ module Verikloak
|
|
|
108
113
|
#
|
|
109
114
|
# @param header [Hash]
|
|
110
115
|
# @return [Hash] The matching JWK.
|
|
111
|
-
# @raise [TokenDecoderError]
|
|
116
|
+
# @raise [TokenDecoderError] code: 'kid_not_found' when no JWK matches.
|
|
112
117
|
def find_key_by_kid(header)
|
|
113
118
|
kid = fetch_indifferent(header, 'kid')
|
|
114
119
|
jwk = @jwk_by_kid[kid]
|
|
115
120
|
|
|
116
|
-
|
|
121
|
+
unless jwk
|
|
122
|
+
raise TokenDecoderError.new("Key with kid=#{truncate_kid(kid)} not found in JWKs", code: 'kid_not_found')
|
|
123
|
+
end
|
|
117
124
|
|
|
118
125
|
jwk
|
|
119
126
|
end
|
|
120
127
|
|
|
128
|
+
# Truncates an untrusted `kid` value for safe inclusion in error messages.
|
|
129
|
+
#
|
|
130
|
+
# @param kid [Object]
|
|
131
|
+
# @return [String]
|
|
132
|
+
def truncate_kid(kid)
|
|
133
|
+
kid.to_s.then { |s| s.length > MAX_KID_LENGTH_IN_MESSAGE ? "#{s[0, MAX_KID_LENGTH_IN_MESSAGE]}..." : s }
|
|
134
|
+
end
|
|
135
|
+
|
|
121
136
|
# Decodes and verifies the token using the given public key and decode options.
|
|
122
137
|
#
|
|
123
138
|
# @param token [String] JWT to verify.
|
|
@@ -243,7 +258,7 @@ module Verikloak
|
|
|
243
258
|
def fetch_indifferent(hash, key)
|
|
244
259
|
return nil unless hash.is_a?(Hash)
|
|
245
260
|
|
|
246
|
-
hash[key
|
|
261
|
+
hash[key.to_s] || hash[key.to_sym]
|
|
247
262
|
end
|
|
248
263
|
|
|
249
264
|
# Wraps decoding logic with structured error handling.
|
data/lib/verikloak/version.rb
CHANGED
data/lib/verikloak.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: 1.0
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- taiyaky
|
|
@@ -15,7 +15,7 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 2.14.
|
|
18
|
+
version: 2.14.3
|
|
19
19
|
- - "<"
|
|
20
20
|
- !ruby/object:Gem::Version
|
|
21
21
|
version: '3.0'
|
|
@@ -25,7 +25,7 @@ dependencies:
|
|
|
25
25
|
requirements:
|
|
26
26
|
- - ">="
|
|
27
27
|
- !ruby/object:Gem::Version
|
|
28
|
-
version: 2.14.
|
|
28
|
+
version: 2.14.3
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '3.0'
|
|
@@ -121,6 +121,7 @@ files:
|
|
|
121
121
|
- lib/verikloak/http.rb
|
|
122
122
|
- lib/verikloak/jwks_cache.rb
|
|
123
123
|
- lib/verikloak/middleware.rb
|
|
124
|
+
- lib/verikloak/safe_url.rb
|
|
124
125
|
- lib/verikloak/skip_path_matcher.rb
|
|
125
126
|
- lib/verikloak/token_decoder.rb
|
|
126
127
|
- lib/verikloak/version.rb
|
|
@@ -131,7 +132,7 @@ metadata:
|
|
|
131
132
|
source_code_uri: https://github.com/taiyaky/verikloak
|
|
132
133
|
changelog_uri: https://github.com/taiyaky/verikloak/blob/main/CHANGELOG.md
|
|
133
134
|
bug_tracker_uri: https://github.com/taiyaky/verikloak/issues
|
|
134
|
-
documentation_uri: https://rubydoc.info/gems/verikloak/1.0
|
|
135
|
+
documentation_uri: https://rubydoc.info/gems/verikloak/1.1.0
|
|
135
136
|
rubygems_mfa_required: 'true'
|
|
136
137
|
rdoc_options: []
|
|
137
138
|
require_paths:
|