verikloak 0.1.1 → 0.1.3

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: 5fb6063573fea932d0e6a73ef18bc31b32be0d36d7c38cef34a7424bfbb3289a
4
+ data.tar.gz: 25cf61bedbbef60f8136bed435d640ab23993306d363db0ab2fc6fb997b08364
5
5
  SHA512:
6
- metadata.gz: 5d476daca7174cb474771005affced764ad2093d633809c4206d54e5d0467b45e903bf998cbbca5cb9e25802e29cfaae20dd04d2820d53fc59c844af5cd886c7
7
- data.tar.gz: f66dba005f8ad96afb0de678ab65e76a99d4f08dbcc98d4bc7468a6c5183781b10f10c4916a24c28ab7e8b459e6f252dbac01fc536f5d0f99e116a38d7c24485
6
+ metadata.gz: 82ab3fa49d2f1c82a46d3cd2573b390a744b96e35a688be413da86d715fd845de30ba5d76a83dd4c54e3e2777a1c0c24975c5005179d62a79679e5bf37651619
7
+ data.tar.gz: cc5a8fcd4a2679f2438390bb5cc1e0818866b162bfc2131724dae21af05fe16c59bf0c23fb70202ca0da34d7231eb720d4fec5c61a5e2405c6427a5d72a722b0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.1.3] - 2025-09-15
11
+
12
+ ### Changed
13
+ - Relax `jwt` runtime dependency to `>= 2.7, < 4.0` to allow jwt 3.x (PR #11).
14
+
15
+ ### Chore
16
+ - Bump dev dependency `rubocop` to 1.80.2 (PR #13).
17
+ - Bump dev dependency `rubocop-rspec` to 3.7.0 (PR #12).
18
+
19
+ ---
20
+
21
+ ## [0.1.2] - 2025-08-31
22
+
23
+ ### Added
24
+ - Middleware: new `connection:` option to inject a Faraday::Connection, shared by Discovery and JWKs.
25
+ - Middleware: new `leeway:` and `token_verify_options:` options, delegated to TokenDecoder.
26
+ - README: documented usage of `connection`, leeway/options, and clarified `skip_paths` behavior.
27
+
28
+ ### Changed
29
+ - Middleware: `skip_paths` semantics clarified — plain paths are exact-match only, use `/*` for prefix matching.
30
+ - Middleware: TokenDecoder instances are now cached per JWKs fetch for performance improvement.
31
+ - Internal: RuboCop style fixes (`HashExcept`, `HashTransformKeys`, long line splits).
32
+
33
+ ---
34
+
10
35
  ## [0.1.1] - 2025-08-24
11
36
 
12
37
  ### Changed
@@ -24,7 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
24
49
  - Rack middleware for verifying JWT access tokens from Keycloak
25
50
  - Support for OpenID Connect Discovery (`.well-known/openid-configuration`)
26
51
  - Handles up to 3 HTTP redirects and resolves relative `Location` headers
27
- - JWKS fetching with in-memory caching and ETag validation
52
+ - JWKs fetching with in-memory caching and ETag validation
28
53
  - RS256 JWT verification with `kid` matching
29
54
  - Claim validation: `aud`, `iss`, `exp`, `nbf`
30
55
  - Configurable via `discovery_url`, `audience`, and `skip_paths` options
@@ -41,7 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
41
66
  - RuboCop static analysis configuration
42
67
  - Structured error handling & responses:
43
68
  - Token/auth errors → **401 Unauthorized** with `WWW-Authenticate` header (RFC 6750)
44
- - Discovery/JWKS errors → **503 Service Unavailable**
69
+ - Discovery/JWKs errors → **503 Service Unavailable**
45
70
  - Structured error codes: `invalid_token`, `expired_token`, `not_yet_valid`,
46
71
  `invalid_issuer`, `invalid_audience`, `unsupported_algorithm`,
47
72
  `jwks_fetch_failed`, `jwks_parse_failed`, `jwks_cache_miss`,
data/README.md CHANGED
@@ -2,19 +2,19 @@
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.1-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.
9
9
 
10
- Verikloak is a plug-and-play solution for Ruby (especially Rails API) apps that need to validate incoming `Bearer` tokens issued by Keycloak. It uses OpenID Connect Discovery and JWKS to fetch the public keys and verify JWT signatures securely.
10
+ Verikloak is a plug-and-play solution for Ruby (especially Rails API) apps that need to validate incoming `Bearer` tokens issued by Keycloak. It uses OpenID Connect Discovery and JWKs to fetch the public keys and verify JWT signatures securely.
11
11
 
12
12
  ---
13
13
 
14
14
  ## Features
15
15
 
16
16
  - OpenID Connect Discovery (`.well-known/openid-configuration`)
17
- - JWKs auto-fetch and in-memory caching with ETag support
17
+ - JWKs auto-fetching with in-memory caching and ETag support
18
18
  - RS256 JWT verification using `kid`
19
19
  - `aud`, `iss`, `exp`, `nbf` claim validation
20
20
  - Rails/Rack middleware support
@@ -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
@@ -76,14 +99,7 @@ config.middleware.use Verikloak::Middleware,
76
99
  audience: ENV.fetch("CLIENT_ID"),
77
100
  skip_paths: ['/', '/health', '/public/*', '/rails/*']
78
101
  ```
79
- #### In production, set these variables in your environment for security and flexibility.
80
-
81
102
  This makes the configuration secure and flexible across environments.
82
-
83
- ```ruby
84
- request.env["verikloak.user"] # => JWT claims hash
85
- request.env["verikloak.token"] # => Raw JWT string
86
- ```
87
103
  ---
88
104
  ### Accessing claims in controllers
89
105
 
@@ -123,7 +139,7 @@ run ->(env) {
123
139
 
124
140
  1. Extracts the `Authorization: Bearer <token>` header
125
141
  2. Fetches the OIDC discovery document (only once or when expired)
126
- 3. Downloads JWKS public keys from the provided `jwks_uri`
142
+ 3. Downloads JWKs public keys from the provided `jwks_uri`
127
143
  4. Matches the `kid` from JWT header to select the right JWK
128
144
  5. Decodes and verifies the JWT using `RS256`
129
145
  6. Validates the following claims:
@@ -137,12 +153,12 @@ run ->(env) {
137
153
 
138
154
  ## Error Responses
139
155
 
140
- Verikloak returns JSON error responses in a consistent format with structured error codes. The HTTP status code reflects the nature of the error: 401 for client-side authentication issues, 503 for server-side discovery/JWKS errors, and 500 for unexpected internal errors.
156
+ Verikloak returns JSON error responses in a consistent format with structured error codes. The HTTP status code reflects the nature of the error: 401 for client-side authentication issues, 503 for server-side discovery/JWKs errors, and 500 for unexpected internal errors.
141
157
 
142
158
  ### Common HTTP Responses
143
159
 
144
160
  - `401 Unauthorized`: The access token is missing, invalid, expired, or otherwise not valid.
145
- - `503 Service Unavailable`: Discovery or JWKS fetch/parsing failed (server-side issue).
161
+ - `503 Service Unavailable`: Discovery or JWKs fetch/parsing failed (server-side issue).
146
162
  - `500 Internal Server Error`: An unexpected error occurred.
147
163
 
148
164
  ### Representative Examples
@@ -164,14 +180,14 @@ Verikloak returns JSON error responses in a consistent format with structured er
164
180
  ```json
165
181
  {
166
182
  "error": "jwks_fetch_failed",
167
- "message": "Failed to fetch JWKS keys"
183
+ "message": "Failed to fetch JWKs"
168
184
  }
169
185
  ```
170
186
 
171
187
  ```json
172
188
  {
173
189
  "error": "jwks_parse_failed",
174
- "message": "Failed to parse JWKS keys"
190
+ "message": "Failed to parse JWKs"
175
191
  }
176
192
  ```
177
193
 
@@ -202,15 +218,15 @@ Verikloak returns JSON error responses in a consistent format with structured er
202
218
  | `invalid_issuer` | 401 Unauthorized | Invalid `iss` claim |
203
219
  | `invalid_audience` | 401 Unauthorized | Invalid `aud` claim |
204
220
  | `not_yet_valid` | 401 Unauthorized | The token is not yet valid (`nbf` in the future) |
205
- | `jwks_fetch_failed` | 503 Service Unavailable | Failed to fetch JWKS keys |
206
- | `jwks_parse_failed` | 503 Service Unavailable | Failed to parse JWKS keys |
207
- | `jwks_cache_miss` | 503 Service Unavailable | JWKS cache is empty (e.g., 304 Not Modified without prior cache) |
221
+ | `jwks_fetch_failed` | 503 Service Unavailable | Failed to fetch JWKs |
222
+ | `jwks_parse_failed` | 503 Service Unavailable | Failed to parse JWKs |
223
+ | `jwks_cache_miss` | 503 Service Unavailable | JWKs cache is empty (e.g., 304 Not Modified without prior cache) |
208
224
  | `discovery_metadata_fetch_failed` | 503 Service Unavailable | Failed to fetch OIDC discovery document |
209
225
  | `discovery_metadata_invalid` | 503 Service Unavailable | Failed to parse OIDC discovery document |
210
226
  | `discovery_redirect_error` | 503 Service Unavailable | Discovery response was a redirect without a valid Location header |
211
227
  | `internal_server_error` | 500 Internal Server Error | Unexpected internal error (catch-all) |
212
228
 
213
- > Note: The `decode_with_public_key` method ensures consistent error codes for all JWT verification failures.
229
+ > **Note:** The `decode_with_public_key` method ensures consistent error codes for all JWT verification failures.
214
230
  > It may raise `invalid_signature`, `unsupported_algorithm`, `expired_token`, `invalid_issuer`, `invalid_audience`, or `not_yet_valid` depending on the verification outcome.
215
231
 
216
232
  For a full list of error cases and detailed explanations, please see the [ERRORS.md](ERRORS.md) file.
@@ -224,27 +240,88 @@ For a full list of error cases and detailed explanations, please see the [ERRORS
224
240
  | `skip_paths` | No | Array of paths or wildcards to skip authentication, e.g. `['/', '/health', '/public/*']`. **Note:** Regex patterns are not supported. |
225
241
  | `discovery` | No | Inject custom Discovery instance (advanced/testing) |
226
242
  | `jwks_cache` | No | Inject custom JwksCache instance (advanced/testing) |
243
+ | `leeway` | No | Clock skew tolerance (seconds) applied during JWT verification. Defaults to `TokenDecoder::DEFAULT_LEEWAY`. |
244
+ | `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. |
245
+ | `connection` | No | Inject a Faraday::Connection used for both Discovery and JWKs fetches. Allows unified timeout, retry, and headers. |
227
246
 
228
247
  #### Option: `skip_paths`
229
248
 
249
+ Plain paths are exact-match only, while `/*` at the end enables prefix matching.
250
+
230
251
  `skip_paths` lets you specify paths (or wildcard patterns) where authentication should be **skipped**.
231
252
  For example:
232
253
 
233
254
  ```ruby
234
- skip_paths: ['/', '/health', '/public/*', '/rails/*']
255
+ skip_paths: ['/', '/health', '/rails/*', '/public/src']
235
256
  ```
236
257
  - `'/'` 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/*'`).
258
+ - `'/health'` matches **only** `/health` (for subpaths, use `'/health/*'`).
239
259
  - `'/rails/*'` matches `/rails` itself as well as `/rails/foo`, `/rails/foo/bar`, etc.
260
+ - `'/public/src'` matches `/public/src`, but does **not** match `/public`, any subpath like `/public/src/html` or other siblings like `/public/css`.
240
261
 
241
262
  Paths **not matched** by any `skip_paths` entry will require a valid JWT.
242
263
 
243
264
  **Note:** Regex patterns are not supported. Only literal paths and `*` wildcards are allowed.
244
265
  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
266
 
267
+ #### Customizing Faraday for Discovery and JWKs
268
+
269
+ Both `Discovery` and `JwksCache` accept a `Faraday::Connection`.
270
+ This allows you to configure timeouts, retries, logging, and shared headers:
271
+
272
+ ```ruby
273
+ connection = Faraday.new(request: { timeout: 5 }) do |f|
274
+ f.response :logger
275
+ end
276
+
277
+ config.middleware.use Verikloak::Middleware,
278
+ discovery_url: ENV["DISCOVERY_URL"],
279
+ audience: ENV["CLIENT_ID"],
280
+ jwks_cache: Verikloak::JwksCache.new(
281
+ jwks_uri: "https://example.com/realms/myrealm/protocol/openid-connect/certs",
282
+ connection: connection
283
+ )
284
+ ```
285
+ This makes it easy to apply consistent Faraday settings across both discovery and JWKs fetches.
286
+
287
+ ```ruby
288
+ # Alternatively, you can pass the connection directly to the middleware:
289
+ config.middleware.use Verikloak::Middleware,
290
+ discovery_url: ENV["DISCOVERY_URL"],
291
+ audience: ENV["CLIENT_ID"],
292
+ connection: connection
293
+ ```
294
+
295
+ #### Customizing token verification (leeway and options)
296
+
297
+ You can fine-tune how JWTs are verified by setting `leeway` or providing advanced options via `token_verify_options`. For example:
298
+
299
+ ```ruby
300
+ config.middleware.use Verikloak::Middleware,
301
+ discovery_url: ENV["DISCOVERY_URL"],
302
+ audience: ENV["CLIENT_ID"],
303
+ leeway: 30, # allow 30s clock skew
304
+ token_verify_options: {
305
+ verify_iat: true,
306
+ verify_expiration: true,
307
+ verify_not_before: true,
308
+ # algorithms: ["RS256"] # override algorithms if needed
309
+ # leeway: 10 # this overrides the top-level leeway
310
+ }
311
+ ```
312
+
313
+ - `leeway:` sets the default skew tolerance in seconds.
314
+ - `token_verify_options:` is passed directly to TokenDecoder (and ultimately to `JWT.decode`).
315
+ - If both are set, `token_verify_options[:leeway]` takes precedence.
316
+
246
317
  ---
247
318
 
319
+ #### Performance note
320
+
321
+ Internally, Verikloak caches `TokenDecoder` instances per JWKs fetch to avoid reinitializing
322
+ them on every request. This improves performance while still ensuring that keys are
323
+ revalidated when JWKs is refreshed.
324
+
248
325
  ## Architecture
249
326
 
250
327
  Verikloak consists of modular components, each with a focused responsibility:
@@ -253,7 +330,7 @@ Verikloak consists of modular components, each with a focused responsibility:
253
330
  |----------------|--------------------------------------------------------|--------------|
254
331
  | `Middleware` | Rack-compatible entry point for token validation | Rack layer |
255
332
  | `Discovery` | Fetches OIDC discovery metadata (`.well-known`) | Network layer|
256
- | `JwksCache` | Fetches & caches JWKS public keys (with ETag) | Cache layer |
333
+ | `JwksCache` | Fetches & caches JWKs public keys (with ETag) | Cache layer |
257
334
  | `TokenDecoder` | Decodes and verifies JWTs (signature, exp, nbf, iss, aud) | Crypto layer |
258
335
  | `Errors` | Centralized error hierarchy | Core layer |
259
336
 
@@ -321,4 +398,4 @@ See [CHANGELOG.md](CHANGELOG.md) for release history.
321
398
 
322
399
  - [OpenID Connect Discovery 1.0 Spec](https://openid.net/specs/openid-connect-discovery-1_0.html)
323
400
  - [Keycloak Documentation: Securing Apps](https://www.keycloak.org/docs/latest/securing_apps/#openid-connect)
324
- - [JWT RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519)
401
+ - [JWT RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519)
@@ -35,7 +35,7 @@ module Verikloak
35
35
 
36
36
  # Raised for middleware-level failures while processing a Rack request.
37
37
  #
38
- # Examples include missing/invalid Authorization headers, JWKS cache
38
+ # Examples include missing/invalid Authorization headers, JWKs cache
39
39
  # initialization failures, or infrastructure issues detected by the
40
40
  # middleware itself.
41
41
  #
@@ -55,7 +55,7 @@ module Verikloak
55
55
  # @raise [TokenDecoderError] from {Verikloak::TokenDecoder#decode!}
56
56
  class TokenDecoderError < Error; end
57
57
 
58
- # Raised when JWKS fetching, validation, or cache handling fails.
58
+ # Raised when JWKs fetching, validation, or cache handling fails.
59
59
  #
60
60
  # Causes include HTTP failures, invalid JSON, missing required JWK fields,
61
61
  # or receiving 304 Not Modified without a prior cached value.
@@ -4,14 +4,14 @@ require 'faraday'
4
4
  require 'json'
5
5
 
6
6
  module Verikloak
7
- # Caches and revalidates JSON Web Key Sets (JWKS) fetched from a remote endpoint.
7
+ # Caches and revalidates JSON Web Key Sets (JWKs) fetched from a remote endpoint.
8
8
  #
9
9
  # This cache supports two HTTP cache mechanisms:
10
10
  # - **ETag revalidation** via `If-None-Match` → returns `304 Not Modified` when unchanged.
11
11
  # - **TTL freshness** via `Cache-Control: max-age` → avoids HTTP requests while fresh.
12
12
  #
13
13
  # On a successful `200 OK`, the cache:
14
- # - Parses the JWKS JSON (`{"keys":[...]}`) and validates each JWK has `kid`, `kty`, `n`, `e`.
14
+ # - Parses the JWKs JSON (`{"keys":[...]}`) and validates each JWK has `kid`, `kty`, `n`, `e`.
15
15
  # - Stores the keys in-memory, records `ETag`, and computes freshness from `Cache-Control`.
16
16
  #
17
17
  # On a `304 Not Modified`, the cache:
@@ -28,22 +28,29 @@ 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
- # @param jwks_uri [String] HTTPS URL of the JWKS endpoint
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
- raise JwksCacheError.new('Invalid JWKS URI: must be a non-empty HTTP(S) URL', code: 'jwks_fetch_failed')
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
43
50
  @max_age = nil
44
51
  end
45
52
 
46
- # Fetches the JWKS and updates the in-memory cache.
53
+ # Fetches the JWKs and updates the in-memory cache.
47
54
  #
48
55
  # Performs an HTTP GET with `If-None-Match` when an ETag is present and handles:
49
56
  # - 200: parses/validates body, updates keys, ETag, TTL and `fetched_at`.
@@ -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:
@@ -92,11 +103,11 @@ module Verikloak
92
103
  rescue Faraday::ConnectionFailed, Faraday::TimeoutError
93
104
  raise JwksCacheError.new('Connection failed', code: 'jwks_fetch_failed')
94
105
  rescue Faraday::Error => e
95
- raise JwksCacheError.new("JWKS fetch failed: #{e.message}", code: 'jwks_fetch_failed')
106
+ raise JwksCacheError.new("JWKs fetch failed: #{e.message}", code: 'jwks_fetch_failed')
96
107
  rescue JSON::ParserError
97
108
  raise JwksCacheError.new('Response is not valid JSON', code: 'jwks_parse_failed')
98
109
  rescue StandardError => e
99
- raise JwksCacheError.new("Unexpected JWKS fetch error: #{e.message}", code: 'jwks_fetch_failed')
110
+ raise JwksCacheError.new("Unexpected JWKs fetch error: #{e.message}", code: 'jwks_fetch_failed')
100
111
  end
101
112
 
102
113
  # @api private
@@ -150,7 +161,7 @@ module Verikloak
150
161
  end
151
162
 
152
163
  # @api private
153
- # Extracts and validates the `keys` array from a JWKS JSON document.
164
+ # Extracts and validates the `keys` array from a JWKs JSON document.
154
165
  # Ensures each key has `kid`, `kty`, `n`, and `e`.
155
166
  #
156
167
  # @param json [Hash]
@@ -202,12 +213,12 @@ module Verikloak
202
213
  # Revalidation succeeded; update freshness from 304 headers if present
203
214
  process_not_modified(response)
204
215
  else
205
- raise JwksCacheError.new("Failed to fetch JWKS: status #{response.status}", code: 'jwks_fetch_failed')
216
+ raise JwksCacheError.new("Failed to fetch JWKs: status #{response.status}", code: 'jwks_fetch_failed')
206
217
  end
207
218
  end
208
219
 
209
220
  # @api private
210
- # Handles a 200 OK JWKS response.
221
+ # Handles a 200 OK JWKs response.
211
222
  # @param response [Faraday::Response]
212
223
  # @return [Array&lt;Hash&gt;] parsed and cached keys
213
224
  def process_successful_response(response)
@@ -218,7 +229,7 @@ module Verikloak
218
229
  end
219
230
 
220
231
  # @api private
221
- # Handles a 304 Not Modified JWKS response: updates TTL and timestamp, returns cached keys.
232
+ # Handles a 304 Not Modified JWKs response: updates TTL and timestamp, returns cached keys.
222
233
  # @param response [Faraday::Response]
223
234
  # @return [Array&lt;Hash&gt;]
224
235
  # @raise [JwksCacheError] when cache is empty
@@ -235,7 +246,7 @@ module Verikloak
235
246
  # @return [Array&lt;Hash&gt;]
236
247
  # @raise [JwksCacheError]
237
248
  def return_from_cache_or_fail
238
- @cached_keys || raise(JwksCacheError.new('JWKS cache is empty but received 304 Not Modified',
249
+ @cached_keys || raise(JwksCacheError.new('JWKs cache is empty but received 304 Not Modified',
239
250
  code: 'jwks_cache_miss'))
240
251
  end
241
252
  end
@@ -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)
@@ -78,44 +267,60 @@ module Verikloak
78
267
  end
79
268
 
80
269
  # Rack middleware that verifies incoming JWT access tokens (Keycloak) using
81
- # OpenID Connect discovery and JWKS. On success, it populates:
270
+ # OpenID Connect discovery and JWKs. On success, it populates:
82
271
  #
83
272
  # * `env['verikloak.token']` — the raw JWT string
84
273
  # * `env['verikloak.user']` — the decoded JWT claims Hash
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
@@ -3,7 +3,7 @@
3
3
  require 'jwt'
4
4
 
5
5
  module Verikloak
6
- # Verifies JWT tokens using a JWKS key set.
6
+ # Verifies JWT tokens using a JWKs.
7
7
  #
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.
@@ -24,17 +24,24 @@ module Verikloak
24
24
  # Default clock skew tolerance in seconds.
25
25
  DEFAULT_LEEWAY = 60
26
26
 
27
- # Initializes the decoder with a JWKS and verification criteria.
27
+ # Initializes the decoder with a JWKs and verification criteria.
28
28
  #
29
29
  # @param jwks [Array<Hash>] List of JWKs from the discovery document.
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.
@@ -98,7 +106,7 @@ module Verikloak
98
106
  kid = fetch_indifferent(header, 'kid')
99
107
  jwk = @jwk_by_kid[kid]
100
108
 
101
- raise TokenDecoderError.new("Key with kid=#{kid} not found in JWKS", code: 'invalid_token') unless jwk
109
+ raise TokenDecoderError.new("Key with kid=#{kid} not found in JWKs", code: 'invalid_token') unless jwk
102
110
 
103
111
  jwk
104
112
  end
@@ -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.3'
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.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - taiyaky
@@ -47,20 +47,26 @@ dependencies:
47
47
  name: jwt
48
48
  requirement: !ruby/object:Gem::Requirement
49
49
  requirements:
50
- - - "~>"
50
+ - - ">="
51
51
  - !ruby/object:Gem::Version
52
52
  version: '2.7'
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: '4.0'
53
56
  type: :runtime
54
57
  prerelease: false
55
58
  version_requirements: !ruby/object:Gem::Requirement
56
59
  requirements:
57
- - - "~>"
60
+ - - ">="
58
61
  - !ruby/object:Gem::Version
59
62
  version: '2.7'
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '4.0'
60
66
  description: |
61
67
  Verikloak is a lightweight Ruby gem that provides JWT access token verification middleware
62
68
  for Rack-based applications, including Rails API mode. It uses OpenID Connect discovery
63
- and JWKS to securely validate tokens issued by Keycloak.
69
+ and JWKs to securely validate tokens issued by Keycloak.
64
70
  executables: []
65
71
  extensions: []
66
72
  extra_rdoc_files: []
@@ -82,7 +88,7 @@ metadata:
82
88
  source_code_uri: https://github.com/taiyaky/verikloak
83
89
  changelog_uri: https://github.com/taiyaky/verikloak/blob/main/CHANGELOG.md
84
90
  bug_tracker_uri: https://github.com/taiyaky/verikloak/issues
85
- documentation_uri: https://rubydoc.info/gems/verikloak/0.1.1
91
+ documentation_uri: https://rubydoc.info/gems/verikloak/0.1.3
86
92
  rubygems_mfa_required: 'true'
87
93
  rdoc_options: []
88
94
  require_paths:
@@ -91,7 +97,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
91
97
  requirements:
92
98
  - - ">="
93
99
  - !ruby/object:Gem::Version
94
- version: '3.0'
100
+ version: '3.1'
95
101
  required_rubygems_version: !ruby/object:Gem::Requirement
96
102
  requirements:
97
103
  - - ">="