verikloak 1.0.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52b05250c6d7fa0c8a06ac6b3a44f0557f46218a768a662e8a8c43b7419a5849
4
- data.tar.gz: 2d2ce895cf232b4dc06d1427d663b7c9fa140556515bb4fda19693a3cfb11ddf
3
+ metadata.gz: a5d6390b5c664ecb4e4af43fa7090e39920c2d79bc16b524b566a73eda95c6d7
4
+ data.tar.gz: b971779c56259ebeb0420983612760cb964442150a3f3fbb78dd388e1d46458c
5
5
  SHA512:
6
- metadata.gz: 98d26aaf0061877278233968ee7fc97e6cdb188c49daa48de7e47c6e2be4c5e84d4dceab064ec4033bac9bede548f1a89cdf8222d4cf8129c2fa7395a8943fb3
7
- data.tar.gz: bf11bba6fbe9a1890e188a4cb72b71dcce1da1b0f8368aee8bffb4d523f2f6c1f4f4c725b0bec8df8b1638010c5bd9499d3157337e9386518ea50cd1329e891b
6
+ metadata.gz: 69df466fe17c16a392645ef56a6f6fc19c8e2d5fee3b91be5e1cd3465ce587bc56a7994ac7b3e6a20c91bedc9d98b91f287144084e35a217501fa40beb72aae2
7
+ data.tar.gz: 1357d43f97c4a0d313e1171ceebb4ca2ac0b7b2313fc4968a055e06a2c4bdd67eb1f59b8a0ff5de65993c2ad79ab75b0fc1c7db6e740324af33f7bd8693e22c6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,48 @@ 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
+
41
+ ## [1.0.2] - 2026-05-09
42
+
43
+ ### Fixed
44
+ - **`iat` claim now honors `leeway`** (`TokenDecoder`): On ruby-jwt 3.x the built-in `Claims::IssuedAt` validator ignores the `leeway` option and raises `JWT::InvalidIatError` whenever `iat` is even a fraction of a second in the future. In typical OIDC deployments the IdP (e.g. Keycloak) and the Resource Server run on different hosts/containers, so `iat` is routinely a few hundred milliseconds to a few seconds ahead of the Resource Server's clock and the previously-effective leeway of `0` for `iat` produced spurious 401s. `TokenDecoder` now disables ruby-jwt's built-in `iat` check and performs its own `iat` validation that applies the configured `leeway` consistently with `exp` and `nbf`. Behaviour for tokens with `iat` further in the future than `leeway` is unchanged (still rejected with `invalid_token`). Pass `options: { verify_iat: false }` to skip the check entirely.
45
+ - **`iat` validation now handles symbol-keyed payloads**: `verify_iat_with_leeway!` looks up both `'iat'` and `:iat`, so callers who request symbol-keyed payloads from `JWT.decode` still get the leeway-aware `iat` check applied.
46
+
47
+ ### Security
48
+ - **Bumped `rack` to `>= 3.2.6` and `json` to `>= 2.19.5`** in `Gemfile.lock` to clear known advisories surfaced by `bundler-audit` (rack request-smuggling and json format-string injection).
49
+
50
+ ---
51
+
10
52
  ## [1.0.1] - 2026-02-15
11
53
 
12
54
  ### 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, algorithms: ["RS256"] }`. If both `leeway:` and `token_verify_options[:leeway]` are set, the latter takes precedence. |
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,23 +334,59 @@ config.middleware.use Verikloak::Middleware,
331
334
  verify_iat: true,
332
335
  verify_expiration: true,
333
336
  verify_not_before: true,
334
- # algorithms: ["RS256"] # override algorithms if needed
335
- # leeway: 10 # this overrides the top-level leeway
337
+ # leeway: 10 # this overrides the top-level leeway
336
338
  }
337
339
  ```
338
340
 
339
- - `leeway:` sets the default skew tolerance in seconds.
340
- - `token_verify_options:` is passed directly to TokenDecoder (and ultimately to `JWT.decode`).
341
+ - `leeway:` sets the default skew tolerance in seconds. It is applied to
342
+ `exp`, `nbf`, and `iat`. Verikloak applies leeway to `iat` itself
343
+ because ruby-jwt 3.x's built-in `iat` validator ignores the `leeway`
344
+ option; pass `token_verify_options: { verify_iat: false }` to skip the
345
+ `iat` check entirely.
346
+ - `token_verify_options:` is forwarded to `TokenDecoder` (and ultimately
347
+ to `JWT.decode`), with the following keys handled by Verikloak rather
348
+ than passed through verbatim:
349
+ - `:leeway` — Verikloak forwards it to `JWT.decode` so it applies to
350
+ `exp`/`nbf`, and also uses it for its own `iat` check.
351
+ - `:verify_iat` — ruby-jwt's built-in `iat` validator is always
352
+ disabled (it ignores `:leeway` on ruby-jwt 3.x). Setting
353
+ `verify_iat: false` instead skips Verikloak's own `iat` check;
354
+ setting `verify_iat: true` (the default) keeps it enabled.
355
+ All other keys are forwarded to `JWT.decode` as-is.
341
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.
342
360
 
343
361
  ## Performance & Caching
344
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
+
345
382
  #### Decoder cache & performance
346
383
 
347
- Internally, Verikloak caches `TokenDecoder` instances per JWKs fetch to avoid reinitializing
348
- them on every request. The cache behaves like an LRU with a configurable size (`decoder_cache_limit`)
349
- so long-running processes do not accumulate decoders for one-off audiences. When the underlying
350
- JWK set rotates, the middleware now clears the cache to drop decoders that point at stale keys.
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.
351
390
 
352
391
  Set `decoder_cache_limit` to `0` if you prefer to construct a fresh decoder every time, or `nil`
353
392
  when you want the cache to grow without bounds (e.g., in short-lived jobs).
@@ -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 = discovery_url.is_a?(String) ? discovery_url.strip : discovery_url
50
+ normalized_url = SafeUrl.normalize(discovery_url)
67
51
 
68
- unless normalized_url.is_a?(String) && normalized_url.match?(%r{^https?://})
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
- unless allow_http || normalized_url.start_with?('https://')
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
- return parse_json(response.body) if response.status == 200
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
- host = uri.host
278
- return unless host
279
-
280
- Resolv.getaddresses(host).each do |addr|
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}.
@@ -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 = {
@@ -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&lt;Hash&gt;] 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
- unless jwks_uri.is_a?(String)
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.match?(%r{^https?://})
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
- unless allow_http || clean_jwks_uri.start_with?('https://')
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&lt;Hash&gt;] 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, consistent with {Verikloak::Discovery}.
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
- uri = URI.parse(url)
180
- host = uri.host
181
- return unless host
232
+ host = URI.parse(url).host
233
+ return unless SafeUrl.resolves_to_private_ip?(host)
182
234
 
183
- Resolv.getaddresses(host).each do |addr|
184
- ip = IPAddr.new(addr)
185
- ip = ip.native if ip.ipv4_mapped?
186
- next unless Verikloak::PRIVATE_IP_RANGES.any? { |range| range.include?(ip) }
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&lt;Hash&gt;] parsed and cached keys
296
338
  def process_successful_response(response)
297
- json = parse_json!(response.body)
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
@@ -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.include?('wrong number of arguments')
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 discovery/JWKs management.
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
- return false unless error.is_a?(TokenDecoderError)
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 for current inputs.
327
- # Cache key uses issuer, audience, leeway, token_verify_options, and JWKs fetched_at timestamp.
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
- keys = @jwks_cache.cached
333
- fetched_at = @jwks_cache.respond_to?(:fetched_at) ? @jwks_cache.fetched_at : nil
334
- cache_key = [
335
- @issuer,
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 = TokenDecoder.new(
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
- # Ensures JWKs are up-to-date by invoking {#ensure_jwks_cache!}.
363
- # Errors are not swallowed and are handled by the caller.
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 [void]
366
- # @raise [Verikloak::DiscoveryError, Verikloak::JwksCacheError]
367
- def refresh_jwks!
368
- ensure_jwks_cache!
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
- if @jwks_cache.cached.nil? || @jwks_cache.cached.empty?
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
- # First attempt
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
- refresh_jwks!
426
+ ensure_jwks_cache!(force: true)
396
427
 
397
- # Rebuild decoder with refreshed keys and try once more.
398
- decoder = decoder_for(audience)
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 are (re)fetched every time; ETag/Cache-Control headers minimize traffic.
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
- previous_keys_id = cached_keys_identity(@jwks_cache)
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
- @jwks_cache.fetch!
427
- purge_decoder_cache_if_keys_changed(previous_keys_id)
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 cached decoders.
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 previous_keys_id [String, nil] The previous JWKs identity hash
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(previous_keys_id)
442
- current_id = cached_keys_identity(@jwks_cache)
443
- if (@last_cached_keys_id && current_id && @last_cached_keys_id != current_id) ||
444
- (previous_keys_id && current_id && previous_keys_id != current_id)
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 if current_id
580
+ @last_cached_keys_id = current_id
449
581
  end
450
582
 
451
- # Generates a unique identity hash for the current JWKs set.
452
- # Used to detect changes in the key set for cache invalidation.
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 cache [JwksCache] The JWKs cache instance
455
- # @return [String, nil] A hash representing the current key set identity
456
- def cached_keys_identity(cache)
457
- return unless cache.respond_to?(:cached)
593
+ # @param keys [Array<Hash>, nil]
594
+ # @return [String, nil]
595
+ def keys_digest(keys)
596
+ return nil if keys.nil?
458
597
 
459
- keys = cache.cached
460
- keys&.__id__
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
@@ -8,7 +8,9 @@ module Verikloak
8
8
  # This class validates a JWT's signature and standard claims (`iss`, `aud`, `exp`, `nbf`, etc.)
9
9
  # using the appropriate RSA public key selected by the JWT's `kid` header.
10
10
  # Only `RS256`-signed tokens with RSA JWKs are supported.
11
- # It also supports a configurable clock skew (`leeway`) to account for minor time drift.
11
+ # It also supports a configurable clock skew (`leeway`) to account for minor time drift,
12
+ # which is applied uniformly to `exp`, `nbf`, and `iat` (Verikloak applies leeway to
13
+ # `iat` itself because ruby-jwt 3.x's built-in `iat` validator ignores leeway).
12
14
  #
13
15
  # @example
14
16
  # decoder = Verikloak::TokenDecoder.new(
@@ -24,6 +26,10 @@ module Verikloak
24
26
  # Default clock skew tolerance in seconds.
25
27
  DEFAULT_LEEWAY = 60
26
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
+
27
33
  # Initializes the decoder with a JWKs and verification criteria.
28
34
  #
29
35
  # @param jwks [Array<Hash>] List of JWKs from the discovery document.
@@ -41,7 +47,12 @@ module Verikloak
41
47
  @leeway = leeway
42
48
  # Normalize and store verification options
43
49
  @options = symbolize_keys(options || {})
44
- @options_without_leeway = @options.except(:leeway).freeze
50
+ # Remove keys we manage ourselves so user-supplied values don't
51
+ # re-enable ruby-jwt's built-in (broken w.r.t. leeway) iat validator
52
+ # via the final merge in #jwt_decode_options. The user's intent for
53
+ # :leeway / :verify_iat is still honoured by reading from @options
54
+ # directly elsewhere in this class.
55
+ @options_for_jwt = @options.except(:leeway, :verify_iat).freeze
45
56
 
46
57
  # Build a kid-indexed hash for O(1) JWK lookup
47
58
  @jwk_by_kid = {}
@@ -58,6 +69,7 @@ module Verikloak
58
69
  # @return [Hash] The decoded payload (claims).
59
70
  # @raise [TokenDecoderError] If verification fails. Possible error codes:
60
71
  # - invalid_token
72
+ # - kid_not_found
61
73
  # - expired_token
62
74
  # - not_yet_valid
63
75
  # - invalid_issuer
@@ -101,16 +113,26 @@ module Verikloak
101
113
  #
102
114
  # @param header [Hash]
103
115
  # @return [Hash] The matching JWK.
104
- # @raise [TokenDecoderError] If key not found or unsupported type.
116
+ # @raise [TokenDecoderError] code: 'kid_not_found' when no JWK matches.
105
117
  def find_key_by_kid(header)
106
118
  kid = fetch_indifferent(header, 'kid')
107
119
  jwk = @jwk_by_kid[kid]
108
120
 
109
- raise TokenDecoderError.new("Key with kid=#{kid} not found in JWKs", code: 'invalid_token') unless jwk
121
+ unless jwk
122
+ raise TokenDecoderError.new("Key with kid=#{truncate_kid(kid)} not found in JWKs", code: 'kid_not_found')
123
+ end
110
124
 
111
125
  jwk
112
126
  end
113
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
+
114
136
  # Decodes and verifies the token using the given public key and decode options.
115
137
  #
116
138
  # @param token [String] JWT to verify.
@@ -118,9 +140,49 @@ module Verikloak
118
140
  # @return [Hash] Verified claims (payload).
119
141
  def decode_with_public_key(token, public_key)
120
142
  payload, = JWT.decode(token, public_key, true, jwt_decode_options)
143
+ verify_iat_with_leeway!(payload)
121
144
  payload
122
145
  end
123
146
 
147
+ # Verifies the `iat` (issued-at) claim with the configured leeway tolerance.
148
+ #
149
+ # ruby-jwt 3.x's built-in `Claims::IssuedAt` validator does not honor the
150
+ # `leeway` option; it raises `JWT::InvalidIatError` whenever `iat` is even
151
+ # a fraction of a second in the future. In OIDC deployments the IdP
152
+ # (e.g. Keycloak) and the Resource Server typically run on different
153
+ # hosts/containers with small clock skew, so `iat` is routinely a few
154
+ # hundred milliseconds to a few seconds in the future relative to the
155
+ # Resource Server's clock. To provide behaviour consistent with `exp`
156
+ # and `nbf` (which honor `leeway`), Verikloak performs its own `iat`
157
+ # check after `JWT.decode` with the configured leeway applied.
158
+ #
159
+ # Users may opt out by passing `options: { verify_iat: false }` to the
160
+ # decoder, in which case this check is skipped entirely.
161
+ #
162
+ # @param payload [Hash] Decoded JWT claims.
163
+ # @return [void]
164
+ # @raise [TokenDecoderError] code: 'invalid_token' when `iat` is not
165
+ # numeric or is further in the future than the allowed leeway.
166
+ def verify_iat_with_leeway!(payload)
167
+ return if @options[:verify_iat] == false
168
+ return unless payload.is_a?(Hash)
169
+
170
+ # Support both string- and symbol-keyed payloads. Callers may pass
171
+ # `options: { symbolize_names: true }` which causes JWT.decode to
172
+ # return symbol keys; without indifferent lookup we'd silently
173
+ # skip iat validation while ruby-jwt's check is disabled.
174
+ return unless payload.key?('iat') || payload.key?(:iat)
175
+
176
+ iat = payload.key?('iat') ? payload['iat'] : payload[:iat]
177
+ leeway = @options.key?(:leeway) ? @options[:leeway] : @leeway
178
+ leeway = leeway.to_f
179
+
180
+ return if iat.is_a?(Numeric) && iat.to_f <= Time.now.to_f + leeway
181
+
182
+ raise TokenDecoderError.new('Invalid issued-at (iat) claim',
183
+ code: 'invalid_token')
184
+ end
185
+
124
186
  # Returns the verification options passed to JWT.decode.
125
187
  #
126
188
  # Enforces:
@@ -129,6 +191,15 @@ module Verikloak
129
191
  # - Expiration (`exp`) and not-before (`nbf`) checks
130
192
  # - Clock skew tolerance via `leeway`
131
193
  #
194
+ # NOTE: `verify_iat` is intentionally disabled here. ruby-jwt 3.x's
195
+ # built-in `iat` validator ignores the `leeway` option, which causes
196
+ # spurious `JWT::InvalidIatError` failures whenever the IdP clock is
197
+ # even slightly ahead of the Resource Server's clock. Verikloak
198
+ # re-implements the `iat` check in {#verify_iat_with_leeway!} so the
199
+ # configured `leeway` is honoured consistently with `exp` and `nbf`.
200
+ # Users may still opt out entirely by passing
201
+ # `options: { verify_iat: false }` to the decoder.
202
+ #
132
203
  # @return [Hash]
133
204
  def jwt_decode_options
134
205
  base = {
@@ -137,15 +208,17 @@ module Verikloak
137
208
  verify_iss: true,
138
209
  aud: @audience,
139
210
  verify_aud: true,
140
- verify_iat: true,
211
+ # See note above: handled by verify_iat_with_leeway! after decode.
212
+ verify_iat: false,
141
213
  verify_expiration: true,
142
214
  verify_not_before: true
143
215
  }
144
216
  # options[:leeway] overrides top-level @leeway if provided
145
217
  leeway = @options.key?(:leeway) ? @options[:leeway] : @leeway
146
218
  merged = base.merge(leeway: leeway)
147
- # Merge remaining options last (excluding :leeway which is already applied)
148
- extra = @options_without_leeway
219
+ # Merge remaining options last (excluding :leeway and :verify_iat,
220
+ # which Verikloak manages itself — see initializer comment).
221
+ extra = @options_for_jwt
149
222
  merged.merge(extra)
150
223
  end
151
224
 
@@ -185,7 +258,7 @@ module Verikloak
185
258
  def fetch_indifferent(hash, key)
186
259
  return nil unless hash.is_a?(Hash)
187
260
 
188
- hash[key] || hash[key.to_s] || hash[key.to_sym]
261
+ hash[key.to_s] || hash[key.to_sym]
189
262
  end
190
263
 
191
264
  # Wraps decoding logic with structured error handling.
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Verikloak
4
4
  # Defines the current version of the Verikloak gem.
5
- VERSION = '1.0.1'
5
+ VERSION = '1.1.0'
6
6
  end
data/lib/verikloak.rb CHANGED
@@ -6,6 +6,7 @@
6
6
  require 'verikloak/version'
7
7
  require 'verikloak/errors'
8
8
  require 'verikloak/http'
9
+ require 'verikloak/safe_url'
9
10
  require 'verikloak/discovery'
10
11
  require 'verikloak/jwks_cache'
11
12
  require 'verikloak/token_decoder'
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.1
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.1
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.1
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.1
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: