verikloak 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be8192b1562ca02e7da55d8451e5588907707b3677c7343c14fc9448c8dc8cdc
4
- data.tar.gz: f2eed617dbcae935e8691c72230361da981046414b9c64557d73e5a3a683df42
3
+ metadata.gz: f694670478e8bbeff2369e0898b96a2d675e052bc19c4ac8437bfb3ff209dc52
4
+ data.tar.gz: 8c189a0ae636ee82ab5e0589b047327c6bd5c8360d4a40f3fcbb6ebc1491743b
5
5
  SHA512:
6
- metadata.gz: a5b3aef46de49a76e52a19edf022a079a343df6ec583da65f193708a0dbd950874d2a2f0e1538f3748f7ce77c78da1ff7386ee177628f8054937f2d08ddb4e22
7
- data.tar.gz: 5160853c31143b7581b512022047ab4990d39bda1644d7d57622ca22cfed15506669a10a77c809af0e220cbddd4e7b347ff58bd96037d5d6ec6605894a3910b5
6
+ metadata.gz: 456fb93fd6afd7376d4daedc1fe17c708d0b0c7055e5e215874418b84de69fbee048f70fc421560ae3e0296d264f5d4f0be1028382f28798531c292be7bc5ba1
7
+ data.tar.gz: 2f7f2107e34117cbc644b6b1c862da9521b9f1bda47a862ffb6747ad8a1bd851a3217ebc41113552684e1b67887074dfe845a14e6a0ae50cc7aaa6c4318a9665
data/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.0.2] - 2026-05-09
11
+
12
+ ### Fixed
13
+ - **`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.
14
+ - **`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.
15
+
16
+ ### Security
17
+ - **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).
18
+
19
+ ---
20
+
21
+ ## [1.0.1] - 2026-02-15
22
+
23
+ ### Fixed
24
+ - **SSRF protection bypass for development mode**: `allow_http: true` now also skips private IP validation in both `JwksCache` (initial `jwks_uri` resolution) and `Discovery` (redirect target resolution). Previously, even with `allow_http: true`, connections to Keycloak on private/loopback IPs (e.g. `127.0.0.1`, `10.x.x.x`, `192.168.x.x`) were blocked by SSRF protection, making local development impossible.
25
+ - **Inconsistent SSRF behaviour**: `Discovery#fetch!` did not validate the initial discovery URL against private IPs (only redirect targets), while `JwksCache` unconditionally blocked private IPs at initialisation. Both now consistently skip private IP checks when `allow_http: true`.
26
+
27
+ ---
28
+
10
29
  ## [1.0.0] - 2026-02-15
11
30
 
12
31
  ### Security
data/README.md CHANGED
@@ -18,7 +18,7 @@ Verikloak is a plug-and-play solution for Ruby (especially Rails API) apps that
18
18
  - Rails/Rack middleware support
19
19
  - Faraday-based customizable HTTP layer
20
20
  - HTTPS enforcement for Discovery and JWKs endpoints (with `allow_http:` escape hatch for development)
21
- - SSRF protection — discovery redirect targets **and** `jwks_uri` values validated against private IP ranges (including IPv4-mapped IPv6 normalisation)
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
24
  - Header injection prevention in `WWW-Authenticate` responses
@@ -225,7 +225,7 @@ For a full list of error cases and detailed explanations, please see the [ERRORS
225
225
  | `discovery_redirect_error` | 503 Service Unavailable | Discovery redirect error: missing/invalid Location header, redirect target resolves to a private IP (SSRF protection), redirect uses non-HTTPS scheme, or unsupported scheme |
226
226
  | `insecure_discovery_url` | 503 Service Unavailable | Discovery URL uses `http://` and `allow_http: true` is not set |
227
227
  | `insecure_jwks_uri` | 503 Service Unavailable | JWKs URI uses `http://` and `allow_http: true` is not set |
228
- | `jwks_ssrf_blocked` | 503 Service Unavailable | JWKs URI hostname resolves to a private/loopback IP address (SSRF protection) |
228
+ | `jwks_ssrf_blocked` | 503 Service Unavailable | JWKs URI hostname resolves to a private/loopback IP address (SSRF protection; bypassed when `allow_http: true`) |
229
229
  | `internal_server_error` | 500 Internal Server Error | Unexpected internal error (catch-all) |
230
230
 
231
231
  > **Note:** The `decode_with_public_key` method ensures consistent error codes for all JWT verification failures.
@@ -250,7 +250,7 @@ For a full list of error cases and detailed explanations, please see the [ERRORS
250
250
  | `user_env_key` | No | Rack env key for decoded claims. Defaults to `verikloak.user`. |
251
251
  | `realm` | No | Value used in the `WWW-Authenticate` header. Defaults to `verikloak`. |
252
252
  | `logger` | No | Logger for unexpected internal failures (responds to `error`, optionally `debug`). |
253
- | `allow_http` | No | When `false` (default), `Discovery` and `JwksCache` reject plain `http://` URLs. Set `true` **only** for local development against a non-TLS Keycloak instance. |
253
+ | `allow_http` | No | When `false` (default), `Discovery` and `JwksCache` reject plain `http://` URLs and block private/loopback IPs (SSRF protection). Set `true` **only** for local development against a non-TLS Keycloak instance — this also bypasses private IP checks so that Keycloak on `localhost` or LAN addresses works out of the box. |
254
254
 
255
255
  #### Option: `skip_paths`
256
256
 
@@ -336,8 +336,21 @@ config.middleware.use Verikloak::Middleware,
336
336
  }
337
337
  ```
338
338
 
339
- - `leeway:` sets the default skew tolerance in seconds.
340
- - `token_verify_options:` is passed directly to TokenDecoder (and ultimately to `JWT.decode`).
339
+ - `leeway:` sets the default skew tolerance in seconds. It is applied to
340
+ `exp`, `nbf`, and `iat`. Verikloak applies leeway to `iat` itself
341
+ because ruby-jwt 3.x's built-in `iat` validator ignores the `leeway`
342
+ option; pass `token_verify_options: { verify_iat: false }` to skip the
343
+ `iat` check entirely.
344
+ - `token_verify_options:` is forwarded to `TokenDecoder` (and ultimately
345
+ to `JWT.decode`), with the following keys handled by Verikloak rather
346
+ than passed through verbatim:
347
+ - `:leeway` — Verikloak forwards it to `JWT.decode` so it applies to
348
+ `exp`/`nbf`, and also uses it for its own `iat` check.
349
+ - `:verify_iat` — ruby-jwt's built-in `iat` validator is always
350
+ disabled (it ignores `:leeway` on ruby-jwt 3.x). Setting
351
+ `verify_iat: false` instead skips Verikloak's own `iat` check;
352
+ setting `verify_iat: true` (the default) keeps it enabled.
353
+ All other keys are forwarded to `JWT.decode` as-is.
341
354
  - If both are set, `token_verify_options[:leeway]` takes precedence.
342
355
 
343
356
  ## Performance & Caching
@@ -267,10 +267,13 @@ module Verikloak
267
267
 
268
268
  # Validates that the redirect target does not resolve to a private/internal IP.
269
269
  # IPv4-mapped IPv6 addresses are normalised before comparison.
270
+ # Skipped when `@allow_http` is true (development mode).
270
271
  # @api private
271
272
  # @param uri [URI] Parsed redirect target
272
273
  # @raise [DiscoveryError]
273
274
  def validate_redirect_not_private!(uri)
275
+ return if @allow_http
276
+
274
277
  host = uri.host
275
278
  return unless host
276
279
 
@@ -61,7 +61,7 @@ module Verikloak
61
61
  )
62
62
  end
63
63
 
64
- validate_not_private!(clean_jwks_uri)
64
+ validate_not_private!(clean_jwks_uri) unless allow_http
65
65
 
66
66
  @jwks_uri = clean_jwks_uri
67
67
  @connection = connection || Verikloak::HTTP.default_connection
@@ -162,6 +162,9 @@ module Verikloak
162
162
 
163
163
  # Validates that the JWKs URI does not resolve to a private/internal IP address (SSRF protection).
164
164
  #
165
+ # Skipped when `allow_http: true` is set (development mode), since local Keycloak instances
166
+ # typically run on private/loopback addresses.
167
+ #
165
168
  # The discovery redirect flow already validates redirect targets, but the `jwks_uri` value
166
169
  # extracted from the discovery JSON document itself was not previously validated, allowing a
167
170
  # compromised or malicious discovery endpoint to point JWKs fetching at internal services.
@@ -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(
@@ -41,7 +43,12 @@ module Verikloak
41
43
  @leeway = leeway
42
44
  # Normalize and store verification options
43
45
  @options = symbolize_keys(options || {})
44
- @options_without_leeway = @options.except(:leeway).freeze
46
+ # Remove keys we manage ourselves so user-supplied values don't
47
+ # re-enable ruby-jwt's built-in (broken w.r.t. leeway) iat validator
48
+ # via the final merge in #jwt_decode_options. The user's intent for
49
+ # :leeway / :verify_iat is still honoured by reading from @options
50
+ # directly elsewhere in this class.
51
+ @options_for_jwt = @options.except(:leeway, :verify_iat).freeze
45
52
 
46
53
  # Build a kid-indexed hash for O(1) JWK lookup
47
54
  @jwk_by_kid = {}
@@ -118,9 +125,49 @@ module Verikloak
118
125
  # @return [Hash] Verified claims (payload).
119
126
  def decode_with_public_key(token, public_key)
120
127
  payload, = JWT.decode(token, public_key, true, jwt_decode_options)
128
+ verify_iat_with_leeway!(payload)
121
129
  payload
122
130
  end
123
131
 
132
+ # Verifies the `iat` (issued-at) claim with the configured leeway tolerance.
133
+ #
134
+ # ruby-jwt 3.x's built-in `Claims::IssuedAt` validator does not honor the
135
+ # `leeway` option; it raises `JWT::InvalidIatError` whenever `iat` is even
136
+ # a fraction of a second in the future. In OIDC deployments the IdP
137
+ # (e.g. Keycloak) and the Resource Server typically run on different
138
+ # hosts/containers with small clock skew, so `iat` is routinely a few
139
+ # hundred milliseconds to a few seconds in the future relative to the
140
+ # Resource Server's clock. To provide behaviour consistent with `exp`
141
+ # and `nbf` (which honor `leeway`), Verikloak performs its own `iat`
142
+ # check after `JWT.decode` with the configured leeway applied.
143
+ #
144
+ # Users may opt out by passing `options: { verify_iat: false }` to the
145
+ # decoder, in which case this check is skipped entirely.
146
+ #
147
+ # @param payload [Hash] Decoded JWT claims.
148
+ # @return [void]
149
+ # @raise [TokenDecoderError] code: 'invalid_token' when `iat` is not
150
+ # numeric or is further in the future than the allowed leeway.
151
+ def verify_iat_with_leeway!(payload)
152
+ return if @options[:verify_iat] == false
153
+ return unless payload.is_a?(Hash)
154
+
155
+ # Support both string- and symbol-keyed payloads. Callers may pass
156
+ # `options: { symbolize_names: true }` which causes JWT.decode to
157
+ # return symbol keys; without indifferent lookup we'd silently
158
+ # skip iat validation while ruby-jwt's check is disabled.
159
+ return unless payload.key?('iat') || payload.key?(:iat)
160
+
161
+ iat = payload.key?('iat') ? payload['iat'] : payload[:iat]
162
+ leeway = @options.key?(:leeway) ? @options[:leeway] : @leeway
163
+ leeway = leeway.to_f
164
+
165
+ return if iat.is_a?(Numeric) && iat.to_f <= Time.now.to_f + leeway
166
+
167
+ raise TokenDecoderError.new('Invalid issued-at (iat) claim',
168
+ code: 'invalid_token')
169
+ end
170
+
124
171
  # Returns the verification options passed to JWT.decode.
125
172
  #
126
173
  # Enforces:
@@ -129,6 +176,15 @@ module Verikloak
129
176
  # - Expiration (`exp`) and not-before (`nbf`) checks
130
177
  # - Clock skew tolerance via `leeway`
131
178
  #
179
+ # NOTE: `verify_iat` is intentionally disabled here. ruby-jwt 3.x's
180
+ # built-in `iat` validator ignores the `leeway` option, which causes
181
+ # spurious `JWT::InvalidIatError` failures whenever the IdP clock is
182
+ # even slightly ahead of the Resource Server's clock. Verikloak
183
+ # re-implements the `iat` check in {#verify_iat_with_leeway!} so the
184
+ # configured `leeway` is honoured consistently with `exp` and `nbf`.
185
+ # Users may still opt out entirely by passing
186
+ # `options: { verify_iat: false }` to the decoder.
187
+ #
132
188
  # @return [Hash]
133
189
  def jwt_decode_options
134
190
  base = {
@@ -137,15 +193,17 @@ module Verikloak
137
193
  verify_iss: true,
138
194
  aud: @audience,
139
195
  verify_aud: true,
140
- verify_iat: true,
196
+ # See note above: handled by verify_iat_with_leeway! after decode.
197
+ verify_iat: false,
141
198
  verify_expiration: true,
142
199
  verify_not_before: true
143
200
  }
144
201
  # options[:leeway] overrides top-level @leeway if provided
145
202
  leeway = @options.key?(:leeway) ? @options[:leeway] : @leeway
146
203
  merged = base.merge(leeway: leeway)
147
- # Merge remaining options last (excluding :leeway which is already applied)
148
- extra = @options_without_leeway
204
+ # Merge remaining options last (excluding :leeway and :verify_iat,
205
+ # which Verikloak manages itself — see initializer comment).
206
+ extra = @options_for_jwt
149
207
  merged.merge(extra)
150
208
  end
151
209
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Verikloak
4
4
  # Defines the current version of the Verikloak gem.
5
- VERSION = '1.0.0'
5
+ VERSION = '1.0.2'
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: verikloak
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - taiyaky
@@ -131,7 +131,7 @@ metadata:
131
131
  source_code_uri: https://github.com/taiyaky/verikloak
132
132
  changelog_uri: https://github.com/taiyaky/verikloak/blob/main/CHANGELOG.md
133
133
  bug_tracker_uri: https://github.com/taiyaky/verikloak/issues
134
- documentation_uri: https://rubydoc.info/gems/verikloak/1.0.0
134
+ documentation_uri: https://rubydoc.info/gems/verikloak/1.0.2
135
135
  rubygems_mfa_required: 'true'
136
136
  rdoc_options: []
137
137
  require_paths: