verikloak 0.1.1 → 0.1.2

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