verikloak 0.3.0 → 0.4.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: 24bc2b5eb7f699c9f8bc4970a2b0b6cdca87b7c7e37d9718c51d85d8cf58a8cc
4
- data.tar.gz: 439ec2fb34a67140476aff243addfaac8cb8d5ac18ef7b6915d3a9ba6dc5947d
3
+ metadata.gz: a8fea8bb10499fcd42f05ecc1eadda3008c5215b1907807351c9b4a8a4a420a4
4
+ data.tar.gz: '05476942149fcdf5f5cbe9605524c8a8ea79359f1b9533b623d08e7b130a0290'
5
5
  SHA512:
6
- metadata.gz: 61be82820e149b89c7e5dead4bc79aef1da0959b85ba447e7bf9d6a346efb331f378030620c2ee42d2b41e5c3e21d89346f405ad16b6264f9452fc49771fc2d4
7
- data.tar.gz: 9bec7c747971154c59321505fdd2817487e09013eb8e32d3defe86a7dc38a4731c5e01d113639cd3eae1a33e9073b932dfa5d9a243b65a1609d9b3549cb48129
6
+ metadata.gz: 3133b44cd8ada0217916d82b5b725ed5bd50c445c727969478b6af67cb712de8247d7a124585f5ca2ee763ec69171f3c65b70be60fdb91c16f6d5467b2853b9a
7
+ data.tar.gz: c790ce4825f32e2d17187b2de7e3564e765274517b4ca59f0fbd8b61a7fbe22edac4e9f72cee91a92ec7e9b2bc6529da74e4d9b3d22abf3d068a3bff2f78a4df
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.4.0] - 2026-02-15
11
+
12
+ ### Security
13
+ - **CVE-2026-25765**: Bump `faraday` runtime dependency to `>= 2.14.1`
14
+ - **Header injection**: Sanitize CR/LF characters in `WWW-Authenticate` header values via `Verikloak::ErrorResponse`
15
+ - **JWT size limit**: Reject tokens exceeding 8 KB (`MAX_TOKEN_BYTES = 8192`) to mitigate denial-of-service
16
+ - **HTTPS enforcement**: `Discovery` and `JwksCache` now reject `http://` URLs unless `allow_http: true` is explicitly set
17
+ - **HTTPS redirect enforcement**: Redirect targets during OIDC discovery are now scheme-checked — plain HTTP redirects are blocked unless `allow_http: true`, and non-HTTP(S) schemes (e.g. `ftp://`) are always rejected
18
+ - **SSRF protection**: Redirect targets in OIDC discovery are validated against private IP ranges (RFC 1918, loopback, link-local)
19
+ - **IPv4-mapped IPv6 SSRF hardening**: IPv4-mapped IPv6 addresses (e.g. `::ffff:127.0.0.1`) are normalised to native IPv4 before private-range checks, preventing bypass via mapped addresses
20
+ - **URL normalisation**: `Discovery` and `JwksCache` now strip leading/trailing whitespace from URLs during initialisation, ensuring the validated URL matches what is used for HTTP requests
21
+
22
+ ### Added
23
+ - `Verikloak::ErrorResponse` — shared RFC 6750-compliant JSON error response builder
24
+ - `Verikloak::SkipPathMatcher` — extracted reusable skip-path matching module
25
+ - `Verikloak::Error#http_status` attribute for structured error hierarchy
26
+ - `allow_http:` option on `Middleware`, `Discovery`, and `JwksCache`
27
+
28
+ ### Changed
29
+ - **BREAKING**: Minimum `faraday` version raised to `2.14.1`
30
+ - **BREAKING**: Minimum `faraday-retry` version raised to `2.4.0`
31
+ - Runtime dependency `json` added (`~> 2.18`)
32
+ - Dev dependency `rspec` pinned to `~> 3.13`, `rubocop-rspec` pinned to `~> 3.9`, `webmock` pinned to `~> 3.26`
33
+
34
+ ---
35
+
10
36
  ## [0.3.0] - 2025-12-31
11
37
 
12
38
  ### Added
data/README.md CHANGED
@@ -17,6 +17,12 @@ Verikloak is a plug-and-play solution for Ruby (especially Rails API) apps that
17
17
  - `aud`, `iss`, `exp`, `nbf` claim validation
18
18
  - Rails/Rack middleware support
19
19
  - Faraday-based customizable HTTP layer
20
+ - HTTPS enforcement for Discovery and JWKs endpoints (with `allow_http:` escape hatch for development)
21
+ - SSRF protection — redirect targets validated against private IP ranges (including IPv4-mapped IPv6 normalisation)
22
+ - HTTPS redirect enforcement — redirect targets are scheme-checked to prevent HTTPS→HTTP downgrade
23
+ - JWT size limit (8 KB) to mitigate denial-of-service via oversized tokens
24
+ - Header injection prevention in `WWW-Authenticate` responses
25
+ - URL normalisation — leading/trailing whitespace stripped from discovery and JWKs URLs
20
26
 
21
27
  ## Installation
22
28
 
@@ -67,7 +73,8 @@ config.middleware.use Verikloak::Middleware,
67
73
  token_env_key: "rack.session.token", # Custom token storage key
68
74
  user_env_key: "rack.session.claims", # Custom user claims storage key
69
75
  realm: "my-api", # Custom realm for WWW-Authenticate header
70
- logger: Rails.logger # Logger for internal errors
76
+ logger: Rails.logger, # Logger for internal errors
77
+ allow_http: false # Set true only for local development (disables HTTPS enforcement)
71
78
  ```
72
79
 
73
80
  **Additional middleware options:**
@@ -215,11 +222,14 @@ For a full list of error cases and detailed explanations, please see the [ERRORS
215
222
  | `jwks_cache_miss` | 503 Service Unavailable | JWKs cache is empty (e.g., 304 Not Modified without prior cache) |
216
223
  | `discovery_metadata_fetch_failed` | 503 Service Unavailable | Failed to fetch OIDC discovery document |
217
224
  | `discovery_metadata_invalid` | 503 Service Unavailable | Failed to parse OIDC discovery document |
218
- | `discovery_redirect_error` | 503 Service Unavailable | Discovery response was a redirect without a valid Location header |
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
+ | `insecure_discovery_url` | 503 Service Unavailable | Discovery URL uses `http://` and `allow_http: true` is not set |
227
+ | `insecure_jwks_uri` | 503 Service Unavailable | JWKs URI uses `http://` and `allow_http: true` is not set |
219
228
  | `internal_server_error` | 500 Internal Server Error | Unexpected internal error (catch-all) |
220
229
 
221
230
  > **Note:** The `decode_with_public_key` method ensures consistent error codes for all JWT verification failures.
222
- > It may raise `invalid_signature`, `unsupported_algorithm`, `expired_token`, `invalid_issuer`, `invalid_audience`, or `not_yet_valid` depending on the verification outcome.
231
+ > It may raise `invalid_signature`, `unsupported_algorithm`, `expired_token`, `invalid_issuer`, `invalid_audience`, or `not_yet_valid` depending on the verification outcome.
232
+ > Additionally, tokens exceeding 8 KB are rejected with `invalid_token` before decoding to mitigate denial-of-service.
223
233
 
224
234
  ## Configuration Options
225
235
 
@@ -239,6 +249,7 @@ For a full list of error cases and detailed explanations, please see the [ERRORS
239
249
  | `user_env_key` | No | Rack env key for decoded claims. Defaults to `verikloak.user`. |
240
250
  | `realm` | No | Value used in the `WWW-Authenticate` header. Defaults to `verikloak`. |
241
251
  | `logger` | No | Logger for unexpected internal failures (responds to `error`, optionally `debug`). |
252
+ | `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. |
242
253
 
243
254
  #### Option: `skip_paths`
244
255
 
@@ -373,6 +384,8 @@ Verikloak consists of modular components, each with a focused responsibility:
373
384
  | `HTTP` | Provides shared Faraday connection with retries/timeouts | Network layer|
374
385
  | `JwksCache` | Fetches & caches JWKs public keys (with ETag) | Cache layer |
375
386
  | `TokenDecoder` | Decodes and verifies JWTs (signature, exp, nbf, iss, aud) | Crypto layer |
387
+ | `ErrorResponse` | RFC 6750 compliant JSON error response builder | Core layer |
388
+ | `SkipPathMatcher`| Reusable skip-path normalization and matching mixin | Core layer |
376
389
  | `Errors` | Centralized error hierarchy | Core layer |
377
390
 
378
391
  This separation enables better testing, modular reuse, and flexibility.
@@ -1,12 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'faraday'
4
+ require 'ipaddr'
4
5
  require 'json'
6
+ require 'resolv'
5
7
  require 'uri'
6
8
 
7
9
  require 'verikloak/http'
8
10
 
9
11
  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
+
10
27
  # Fetches and caches the OpenID Connect Discovery document.
11
28
  #
12
29
  # This class retrieves the discovery metadata from an OpenID Connect provider
@@ -43,16 +60,27 @@ module Verikloak
43
60
  # @param discovery_url [String] The full URL to the `.well-known/openid-configuration`.
44
61
  # @param connection [Faraday::Connection] Optional Faraday client (for DI/tests).
45
62
  # @param cache_ttl [Integer] Cache TTL in seconds (default: 3600).
63
+ # @param allow_http [Boolean] When false (default), raises on plain HTTP URLs. Set true for local development only.
46
64
  # @raise [DiscoveryError] when `discovery_url` is not a valid HTTP(S) URL
47
- def initialize(discovery_url:, connection: Verikloak::HTTP.default_connection, cache_ttl: 3600)
48
- unless discovery_url.is_a?(String) && discovery_url.strip.match?(%r{^https?://})
65
+ 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
67
+
68
+ unless normalized_url.is_a?(String) && normalized_url.match?(%r{^https?://})
49
69
  raise DiscoveryError.new('Invalid discovery URL: must be a non-empty HTTP(S) URL',
50
70
  code: 'invalid_discovery_url')
51
71
  end
52
72
 
53
- @discovery_url = discovery_url
73
+ unless allow_http || normalized_url.start_with?('https://')
74
+ raise DiscoveryError.new(
75
+ 'Discovery URL must use HTTPS. Set allow_http: true to permit plain HTTP (development only).',
76
+ code: 'insecure_discovery_url'
77
+ )
78
+ end
79
+
80
+ @discovery_url = normalized_url
54
81
  @conn = connection
55
82
  @cache_ttl = cache_ttl
83
+ @allow_http = allow_http
56
84
  @cached_json = nil
57
85
  @fetched_at = nil
58
86
  @mutex = Mutex.new
@@ -98,30 +126,28 @@ module Verikloak
98
126
  # @return [Hash]
99
127
  # @raise [DiscoveryError]
100
128
  def handle_final_response(response)
129
+ return parse_json(response.body) if response.status == 200
130
+
131
+ raise DiscoveryError.new(failure_message(response), code: 'discovery_metadata_fetch_failed')
132
+ end
133
+
134
+ # Builds the error message for non-200 final responses.
135
+ # @api private
136
+ def failure_message(response)
101
137
  status = response.status
102
- return parse_json(response.body) if status == 200
103
-
104
- if status == 404
105
- # If the 404 occurred after a redirect (final URL differs from the original discovery URL),
106
- # keep the generic message to align with redirect tests; otherwise use a specific "not found" message.
107
- final_url = response.respond_to?(:env) && response.env&.url ? response.env.url.to_s : nil
108
- message = if final_url && final_url != @discovery_url
109
- 'Failed to fetch discovery document: status 404'
110
- else
111
- 'Discovery document not found (404)'
112
- end
113
- raise DiscoveryError.new(message, code: 'discovery_metadata_fetch_failed')
114
- end
115
- if (500..599).cover?(status)
116
- raise DiscoveryError.new("Discovery endpoint server error: status #{status}",
117
- code: 'discovery_metadata_fetch_failed')
118
- end
138
+ return "Discovery endpoint server error: status #{status}" if (500..599).cover?(status)
139
+ return "Failed to fetch discovery document: status #{status}" unless status == 404
119
140
 
120
- raise DiscoveryError.new("Failed to fetch discovery document: status #{status}",
121
- code: 'discovery_metadata_fetch_failed')
141
+ final_url = response.respond_to?(:env) && response.env&.url&.to_s
142
+ if final_url && final_url != @discovery_url
143
+ 'Failed to fetch discovery document: status 404'
144
+ else
145
+ 'Discovery document not found (404)'
146
+ end
122
147
  end
123
148
 
124
149
  # Follows HTTP redirects up to `max_hops`, resolving relative `Location` values.
150
+ # Validates that redirect targets do not point to private/internal networks (SSRF protection).
125
151
  # @api private
126
152
  # @param response [Faraday::Response]
127
153
  # @param max_hops [Integer]
@@ -141,6 +167,7 @@ module Verikloak
141
167
 
142
168
  location = location_from(current)
143
169
  url = absolutize_location(location, base)
170
+ validate_redirect_target!(url)
144
171
  current = @conn.get(url)
145
172
  base = url
146
173
  hops += 1
@@ -163,9 +190,7 @@ module Verikloak
163
190
  # @return [String] absolute or relative URL string
164
191
  # @raise [DiscoveryError]
165
192
  def location_from(response)
166
- raw = response.headers || {}
167
- headers = {}
168
- raw.each { |k, v| headers[k.to_s.downcase] = v }
193
+ headers = (response.headers || {}).transform_keys { |k| k.to_s.downcase }
169
194
  location = headers['location'].to_s.strip
170
195
  raise DiscoveryError.new('Redirect without Location header', code: 'discovery_redirect_error') if location.empty?
171
196
 
@@ -199,16 +224,70 @@ module Verikloak
199
224
  raise DiscoveryError.new('Discovery response is not valid JSON', code: 'discovery_metadata_invalid')
200
225
  end
201
226
 
202
- # Validates HTTP response success status (helper, currently unused).
227
+ # Validates that a redirect target URL uses a permitted scheme and does not resolve
228
+ # to a private/internal IP address.
229
+ #
230
+ # Scheme check: rejects non-HTTP(S) schemes unconditionally, and rejects plain HTTP
231
+ # when `@allow_http` is false — preventing HTTPS→HTTP downgrade attacks.
232
+ #
233
+ # SSRF check: resolves the target hostname and compares each address against
234
+ # {PRIVATE_IP_RANGES}. IPv4-mapped IPv6 addresses (e.g. `::ffff:127.0.0.1`) are
235
+ # normalised to their native IPv4 form before comparison.
236
+ #
203
237
  # @api private
204
- # @param response [Faraday::Response]
205
- # @return [void]
238
+ # @param url [String] The redirect target URL
239
+ # @raise [DiscoveryError] when the target uses a disallowed scheme or resolves to a private IP
240
+ def validate_redirect_target!(url)
241
+ uri = URI.parse(url)
242
+ validate_redirect_scheme!(uri)
243
+ validate_redirect_not_private!(uri)
244
+ rescue URI::InvalidURIError => e
245
+ raise DiscoveryError.new("Invalid redirect URL: #{e.message}", code: 'discovery_redirect_error')
246
+ end
247
+
248
+ # Validates that the redirect URI uses an allowed HTTP(S) scheme.
249
+ # @api private
250
+ # @param uri [URI] Parsed redirect target
251
+ # @raise [DiscoveryError]
252
+ def validate_redirect_scheme!(uri)
253
+ unless %w[http https].include?(uri.scheme)
254
+ raise DiscoveryError.new(
255
+ "Redirect target uses unsupported scheme: #{uri.scheme}",
256
+ code: 'discovery_redirect_error'
257
+ )
258
+ end
259
+
260
+ return if @allow_http || uri.scheme == 'https'
261
+
262
+ raise DiscoveryError.new(
263
+ 'Redirect target must use HTTPS (set allow_http: true for development)',
264
+ code: 'discovery_redirect_error'
265
+ )
266
+ end
267
+
268
+ # Validates that the redirect target does not resolve to a private/internal IP.
269
+ # IPv4-mapped IPv6 addresses are normalised before comparison.
270
+ # @api private
271
+ # @param uri [URI] Parsed redirect target
206
272
  # @raise [DiscoveryError]
207
- def validate_http_status!(response)
208
- return if response.success?
273
+ def validate_redirect_not_private!(uri)
274
+ host = uri.host
275
+ return unless host
276
+
277
+ Resolv.getaddresses(host).each do |addr|
278
+ ip = IPAddr.new(addr)
279
+ ip = ip.native if ip.ipv4_mapped?
280
+ next unless PRIVATE_IP_RANGES.any? { |range| range.include?(ip) }
209
281
 
210
- raise DiscoveryError.new("Failed to fetch discovery document: status #{response.status}",
211
- code: 'discovery_metadata_fetch_failed')
282
+ raise DiscoveryError.new(
283
+ "Redirect target resolves to a private/internal address (#{host})",
284
+ code: 'discovery_redirect_error'
285
+ )
286
+ end
287
+ rescue IPAddr::InvalidAddressError
288
+ # If the address cannot be parsed, allow the request to proceed
289
+ # (Faraday will handle the actual connection error)
290
+ nil
212
291
  end
213
292
 
214
293
  # Wraps a block with network and parsing error handling and re-raising as {DiscoveryError}.
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Verikloak
6
+ # Shared helpers for building RFC 6750 compliant JSON error responses.
7
+ #
8
+ # Provides a consistent format for error responses across all Verikloak middleware:
9
+ # - JSON body with `{ error:, message: }` structure
10
+ # - `WWW-Authenticate` header with Bearer scheme for 401 responses
11
+ # - Header value sanitization to prevent injection attacks
12
+ module ErrorResponse
13
+ module_function
14
+
15
+ # Build a JSON error response with optional RFC 6750 `WWW-Authenticate` header.
16
+ #
17
+ # @param code [String] The error code (e.g. "unauthorized", "insufficient_audience")
18
+ # @param message [String] Human-readable error message
19
+ # @param status [Integer] HTTP status code
20
+ # @param realm [String] The realm value for WWW-Authenticate (default: "verikloak")
21
+ # @return [Array(Integer, Hash, Array<String>)] Rack response triple
22
+ def build(code:, message:, status:, realm: 'verikloak')
23
+ body = { error: code, message: message }.to_json
24
+ headers = { 'Content-Type' => 'application/json' }
25
+
26
+ if status == 401
27
+ realm_val = sanitize_header_value(realm)
28
+ code_val = sanitize_header_value(code)
29
+ msg_val = sanitize_header_value(message)
30
+ headers['WWW-Authenticate'] =
31
+ %(Bearer realm="#{realm_val}", error="#{code_val}", error_description="#{msg_val}")
32
+ end
33
+
34
+ [status, headers, [body]]
35
+ end
36
+
37
+ # Sanitizes a value for safe inclusion in HTTP header quoted-string fields.
38
+ # Escapes backslashes and double-quotes, and strips CR/LF and other control characters.
39
+ #
40
+ # @param val [String, nil]
41
+ # @return [String]
42
+ def sanitize_header_value(val)
43
+ s = val.to_s
44
+ # Truncate at first CRLF sequence to prevent header injection
45
+ s = s.split("\r\n", 2).first.to_s
46
+ # Replace remaining lone CR or LF with spaces
47
+ s = s.gsub(/[\r\n]/, ' ')
48
+ s.gsub(/(["\\])/) { |m| "\\#{m}" }
49
+ .gsub(/[[:cntrl:]]/, '')
50
+ end
51
+ end
52
+ end
@@ -10,17 +10,23 @@ module Verikloak
10
10
  #
11
11
  # @attr_reader [String, Symbol, nil] code
12
12
  # A short error code identifier suitable for programmatic handling.
13
+ # @attr_reader [Integer, nil] http_status
14
+ # HTTP status code associated with the error (e.g. 401, 403, 500, 503).
13
15
  #
14
16
  # @example Raising with a code
15
17
  # raise Verikloak::Error.new("Something went wrong", code: "internal_error")
18
+ # @example Raising with a code and HTTP status
19
+ # raise Verikloak::Error.new("Forbidden", code: "forbidden", http_status: 403)
16
20
  class Error < StandardError
17
- attr_reader :code
21
+ attr_reader :code, :http_status
18
22
 
19
23
  # @param message [String, nil] Human-readable error message.
20
24
  # @param code [String, Symbol, nil] Optional short error code for programmatic handling.
21
- def initialize(message = nil, code: nil)
25
+ # @param http_status [Integer, nil] Optional HTTP status code.
26
+ def initialize(message = nil, code: nil, http_status: nil)
22
27
  super(message)
23
28
  @code = code
29
+ @http_status = http_status
24
30
  end
25
31
  end
26
32
 
@@ -38,13 +38,27 @@ module Verikloak
38
38
  class JwksCache
39
39
  # @param jwks_uri [String] HTTPS URL of the JWKs endpoint
40
40
  # @param connection [Faraday::Connection, nil] Optional Faraday connection for HTTP requests
41
+ # @param allow_http [Boolean] When false (default), raises on plain HTTP URIs. Set true for local development only.
41
42
  # @raise [JwksCacheError] if the URI is not an HTTP(S) URL
42
- def initialize(jwks_uri:, connection: nil)
43
- unless jwks_uri.is_a?(String) && jwks_uri.strip.match?(%r{^https?://})
43
+ def initialize(jwks_uri:, connection: nil, allow_http: false)
44
+ unless jwks_uri.is_a?(String)
44
45
  raise JwksCacheError.new('Invalid JWKs URI: must be a non-empty HTTP(S) URL', code: 'jwks_fetch_failed')
45
46
  end
46
47
 
47
- @jwks_uri = jwks_uri
48
+ clean_jwks_uri = jwks_uri.strip
49
+
50
+ unless clean_jwks_uri.match?(%r{^https?://})
51
+ raise JwksCacheError.new('Invalid JWKs URI: must be a non-empty HTTP(S) URL', code: 'jwks_fetch_failed')
52
+ end
53
+
54
+ unless allow_http || clean_jwks_uri.start_with?('https://')
55
+ raise JwksCacheError.new(
56
+ 'JWKs URI must use HTTPS. Set allow_http: true to permit plain HTTP (development only).',
57
+ code: 'insecure_jwks_uri'
58
+ )
59
+ end
60
+
61
+ @jwks_uri = clean_jwks_uri
48
62
  @connection = connection || Verikloak::HTTP.default_connection
49
63
  @cached_keys = nil
50
64
  @etag = nil
@@ -6,86 +6,10 @@ require 'set'
6
6
  require 'faraday'
7
7
 
8
8
  require 'verikloak/http'
9
+ require 'verikloak/skip_path_matcher'
10
+ require 'verikloak/error_response'
9
11
 
10
12
  module Verikloak
11
- # @api private
12
- #
13
- # Internal mixin for skip-path normalization and matching.
14
- # Extracted from Middleware to reduce class length and improve testability.
15
- module SkipPathMatcher
16
- private
17
-
18
- # Checks whether the request path matches any compiled skip pattern.
19
- #
20
- # Supported patterns:
21
- # * `'/'` — matches only the root path
22
- # * `'/foo'` — exact-match only (matches `/foo` but **not** `/foo/...`)
23
- # * `'/foo/*'` — prefix match (matches `/foo` and any nested path under it)
24
- #
25
- # @param path [String]
26
- # @return [Boolean]
27
- def skip?(path)
28
- np = normalize_path(path)
29
- return true if @skip_root && np == '/'
30
- return true if @skip_exacts.include?(np)
31
-
32
- @skip_prefixes.any? { |prefix| np == prefix || np.start_with?("#{prefix}/") }
33
- end
34
-
35
- # Normalizes paths for stable comparisons:
36
- # - ensures leading slash
37
- # - collapses multiple slashes (e.g. //foo///bar -> /foo/bar)
38
- # - removes trailing slash except for root
39
- #
40
- # @param path [String, nil]
41
- # @return [String]
42
- def normalize_path(path)
43
- s = (path || '').to_s
44
- s = "/#{s}" unless s.start_with?('/')
45
- s = s.gsub(%r{/+}, '/')
46
- s.length > 1 ? s.chomp('/') : s
47
- end
48
-
49
- # Pre-compiles {skip_paths} into fast lookup structures.
50
- #
51
- # * `@skip_root` — whether `'/'` is present
52
- # * `@skip_exacts` — exact-match set (e.g. `'/health'`)
53
- # * `@skip_prefixes` — wildcard prefixes for `'/*'` (e.g. `'/public'`)
54
- #
55
- # @param paths [Array<String>]
56
- # @return [void]
57
- def compile_skip_paths(paths)
58
- @skip_root = false
59
- @skip_exacts = Set.new
60
- @skip_prefixes = []
61
-
62
- Array(paths).each do |raw|
63
- next if raw.nil?
64
-
65
- s = raw.to_s.strip
66
- next if s.empty?
67
-
68
- if s == '/'
69
- @skip_root = true
70
- next
71
- end
72
-
73
- if s.end_with?('/*')
74
- prefix = normalize_path(s.chomp('/*'))
75
- next if prefix == '/' # root is handled by @skip_root
76
-
77
- @skip_prefixes << prefix
78
- else
79
- exact = normalize_path(s)
80
- @skip_exacts << exact
81
- # Do NOT add to @skip_prefixes here; plain '/foo' is exact-match only.
82
- end
83
- end
84
-
85
- @skip_prefixes.uniq!
86
- end
87
- end
88
-
89
13
  # @api private
90
14
  #
91
15
  # Internal mixin for audience resolution with dynamic callable support.
@@ -492,7 +416,7 @@ module Verikloak
492
416
  # Use configured issuer if provided, otherwise use discovered issuer
493
417
  @issuer = @configured_issuer || config['issuer']
494
418
  jwks_uri = config['jwks_uri']
495
- @jwks_cache = JwksCache.new(jwks_uri: jwks_uri, connection: @connection)
419
+ @jwks_cache = JwksCache.new(jwks_uri: jwks_uri, connection: @connection, allow_http: @allow_http)
496
420
  elsif @configured_issuer.nil? && @issuer.nil?
497
421
  # If jwks_cache was injected but no issuer configured and not yet discovered, fetch discovery to set issuer
498
422
  config = @discovery.fetch!
@@ -665,12 +589,15 @@ module Verikloak
665
589
  token_env_key: DEFAULT_TOKEN_ENV_KEY,
666
590
  user_env_key: DEFAULT_USER_ENV_KEY,
667
591
  realm: DEFAULT_REALM,
668
- logger: nil)
592
+ logger: nil,
593
+ allow_http: false)
669
594
  @app = app
670
595
  @connection = connection || Verikloak::HTTP.default_connection
671
596
  @audience_source = audience
672
- @discovery = discovery || Discovery.new(discovery_url: discovery_url, connection: @connection)
597
+ @discovery = discovery || Discovery.new(discovery_url: discovery_url, connection: @connection,
598
+ allow_http: allow_http)
673
599
  @jwks_cache = jwks_cache
600
+ @allow_http = allow_http
674
601
  @leeway = leeway
675
602
  @token_verify_options = token_verify_options || {}
676
603
  @decoder_cache_limit = normalize_decoder_cache_limit(decoder_cache_limit)
@@ -709,6 +636,10 @@ module Verikloak
709
636
  error_response('internal_server_error', 'An unexpected error occurred', 500)
710
637
  end
711
638
 
639
+ # Maximum token size in bytes to prevent DoS via oversized JWTs.
640
+ # Aligned with BFF's {Verikloak::BFF::Constants::MAX_TOKEN_BYTES}.
641
+ MAX_TOKEN_BYTES = 8192
642
+
712
643
  private
713
644
 
714
645
  # Returns the Faraday connection used for HTTP operations (Discovery/JWKs).
@@ -748,6 +679,10 @@ module Verikloak
748
679
  raise MiddlewareError.new('Invalid Authorization header format', code: 'invalid_authorization_header')
749
680
  end
750
681
 
682
+ if token.bytesize > MAX_TOKEN_BYTES
683
+ raise MiddlewareError.new('Token exceeds maximum allowed size', code: 'invalid_token')
684
+ end
685
+
751
686
  token
752
687
  end
753
688
 
@@ -773,19 +708,14 @@ module Verikloak
773
708
  end
774
709
 
775
710
  # Builds a JSON error response with RFC 6750 `WWW-Authenticate` header for 401.
711
+ # Delegates to {Verikloak::ErrorResponse} for consistent formatting across gems.
776
712
  #
777
713
  # @param code [String] The error code to include in the response
778
714
  # @param message [String] The error message to include in the response
779
715
  # @param status [Integer] The HTTP status code for the response
780
716
  # @return [Array(Integer, Hash, Array<String>)] Rack response triple
781
717
  def error_response(code = 'unauthorized', message = 'Unauthorized', status = 401)
782
- body = { error: code, message: message }.to_json
783
- headers = { 'Content-Type' => 'application/json' }
784
- if status == 401
785
- headers['WWW-Authenticate'] =
786
- %(Bearer realm="#{@realm}", error="#{code}", error_description="#{message.gsub('"', '\\"')}")
787
- end
788
- [status, headers, [body]]
718
+ Verikloak::ErrorResponse.build(code: code, message: message, status: status, realm: @realm)
789
719
  end
790
720
 
791
721
  # Logs unexpected internal errors to STDERR (non-PII). Used for diagnostics only.
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Verikloak
6
+ # Reusable mixin for skip-path normalization and matching.
7
+ #
8
+ # Include this module and call {#compile_skip_paths} during initialization,
9
+ # then call {#skip?} per-request to check whether the path should bypass processing.
10
+ #
11
+ # Supported patterns:
12
+ # * `'/'` — matches only the root path
13
+ # * `'/foo'` — exact-match only (matches `/foo` but **not** `/foo/...`)
14
+ # * `'/foo/*'` — prefix match (matches `/foo` and any nested path under it)
15
+ module SkipPathMatcher
16
+ private
17
+
18
+ # Checks whether the request path matches any compiled skip pattern.
19
+ #
20
+ # @param path [String]
21
+ # @return [Boolean]
22
+ def skip?(path)
23
+ np = normalize_path(path)
24
+ return true if @skip_root && np == '/'
25
+ return true if @skip_exacts.include?(np)
26
+
27
+ @skip_prefixes.any? { |prefix| np == prefix || np.start_with?("#{prefix}/") }
28
+ end
29
+
30
+ # Normalizes paths for stable comparisons:
31
+ # - ensures leading slash
32
+ # - collapses multiple slashes (e.g. //foo///bar -> /foo/bar)
33
+ # - removes trailing slash except for root
34
+ #
35
+ # @param path [String, nil]
36
+ # @return [String]
37
+ def normalize_path(path)
38
+ s = (path || '').to_s
39
+ s = "/#{s}" unless s.start_with?('/')
40
+ s = s.gsub(%r{/+}, '/')
41
+ s.length > 1 ? s.chomp('/') : s
42
+ end
43
+
44
+ # Pre-compiles {skip_paths} into fast lookup structures.
45
+ #
46
+ # * `@skip_root` — whether `'/'` is present
47
+ # * `@skip_exacts` — exact-match set (e.g. `'/health'`)
48
+ # * `@skip_prefixes` — wildcard prefixes for `'/*'` (e.g. `'/public'`)
49
+ #
50
+ # @param paths [Array<String>]
51
+ # @return [void]
52
+ def compile_skip_paths(paths)
53
+ @skip_root = false
54
+ @skip_exacts = Set.new
55
+ @skip_prefixes = []
56
+
57
+ Array(paths).each do |raw|
58
+ next if raw.nil?
59
+
60
+ s = raw.to_s.strip
61
+ next if s.empty?
62
+
63
+ if s == '/'
64
+ @skip_root = true
65
+ next
66
+ end
67
+
68
+ if s.end_with?('/*')
69
+ prefix = normalize_path(s.chomp('/*'))
70
+ next if prefix == '/' # root is handled by @skip_root
71
+
72
+ @skip_prefixes << prefix
73
+ else
74
+ exact = normalize_path(s)
75
+ @skip_exacts << exact
76
+ end
77
+ end
78
+
79
+ @skip_prefixes.uniq!
80
+ end
81
+ end
82
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Verikloak
4
4
  # Defines the current version of the Verikloak gem.
5
- VERSION = '0.3.0'
5
+ VERSION = '0.4.0'
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: 0.3.0
4
+ version: 0.4.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.0'
18
+ version: 2.14.1
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.0'
28
+ version: 2.14.1
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '3.0'
@@ -35,7 +35,7 @@ dependencies:
35
35
  requirements:
36
36
  - - ">="
37
37
  - !ruby/object:Gem::Version
38
- version: '2.0'
38
+ version: 2.4.0
39
39
  - - "<"
40
40
  - !ruby/object:Gem::Version
41
41
  version: '3.0'
@@ -45,7 +45,7 @@ dependencies:
45
45
  requirements:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
- version: '2.0'
48
+ version: 2.4.0
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
51
  version: '3.0'
@@ -55,14 +55,14 @@ dependencies:
55
55
  requirements:
56
56
  - - "~>"
57
57
  - !ruby/object:Gem::Version
58
- version: '2.6'
58
+ version: '2.18'
59
59
  type: :runtime
60
60
  prerelease: false
61
61
  version_requirements: !ruby/object:Gem::Requirement
62
62
  requirements:
63
63
  - - "~>"
64
64
  - !ruby/object:Gem::Version
65
- version: '2.6'
65
+ version: '2.18'
66
66
  - !ruby/object:Gem::Dependency
67
67
  name: jwt
68
68
  requirement: !ruby/object:Gem::Requirement
@@ -96,10 +96,12 @@ files:
96
96
  - README.md
97
97
  - lib/verikloak.rb
98
98
  - lib/verikloak/discovery.rb
99
+ - lib/verikloak/error_response.rb
99
100
  - lib/verikloak/errors.rb
100
101
  - lib/verikloak/http.rb
101
102
  - lib/verikloak/jwks_cache.rb
102
103
  - lib/verikloak/middleware.rb
104
+ - lib/verikloak/skip_path_matcher.rb
103
105
  - lib/verikloak/token_decoder.rb
104
106
  - lib/verikloak/version.rb
105
107
  homepage: https://github.com/taiyaky/verikloak
@@ -109,7 +111,7 @@ metadata:
109
111
  source_code_uri: https://github.com/taiyaky/verikloak
110
112
  changelog_uri: https://github.com/taiyaky/verikloak/blob/main/CHANGELOG.md
111
113
  bug_tracker_uri: https://github.com/taiyaky/verikloak/issues
112
- documentation_uri: https://rubydoc.info/gems/verikloak/0.3.0
114
+ documentation_uri: https://rubydoc.info/gems/verikloak/0.4.0
113
115
  rubygems_mfa_required: 'true'
114
116
  rdoc_options: []
115
117
  require_paths: