verikloak 0.1.4 → 0.2.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: 3b7e22a96384ccea0cb43c2c611f7106d0e88b23793279f2ee105d68da226bb0
4
- data.tar.gz: d83ba8ebe7834895d1a3f6e39c26dae02713611fc767177c909987ef1111cf50
3
+ metadata.gz: d175a574b17bd1ce5e096dbb69769d12a470f49c8444a5246881afd61d06fa52
4
+ data.tar.gz: 3c59a660bb142c194b6e0db2d338952c41c8a88afc479382203b4d767ab7691c
5
5
  SHA512:
6
- metadata.gz: 40481fc255490d0c22c2808cf00e8c0e136488f755e6dc2e388c4ab81978975c6ee478a2916d61fe564bd25984898f74ddeccacfe1ae8006a081b3bfc441bc41
7
- data.tar.gz: 0aaf942838e33a618197d0fb6d5446c6a9ffaa5b529db50902f2ab403b884b03ee0c87bdf8c99e4c8b069cad1f566026bb05fb16ab3583339493a4891eeb4f88
6
+ metadata.gz: 92edd8a2df134f115c3f887683246278c5778291bb86bfff22ebc873b2b92d6ff09d9c85a1f798e2f49caa09adc8c3ef18ffc83309f95799997ebbdb54b58228
7
+ data.tar.gz: 0e223f0055e69448899be53fc22c9ac32c5d43c410aa041daa38761ed13a00aae8d655bcb6eaff521927a5a029937fcd480c7b90dcfccddf8e6b028aaf0bf555
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.2.0] - 2025-09-22
11
+
12
+ ### Added
13
+ - Middleware options `token_env_key` and `user_env_key` for customizing where the token and decoded claims are stored in the Rack env.
14
+ - Middleware option `realm` to change the `WWW-Authenticate` realm value emitted on 401 responses.
15
+ - Middleware option `logger` so unexpected internal errors can be sent to the host application's logger instead of STDERR.
16
+
17
+ ### Changed
18
+ - Update gem version to 0.2.0 to stay aligned with the rest of the Verikloak ecosystem gems.
19
+
20
+ ---
21
+
22
+ ## [0.1.5] - 2025-09-21
23
+
24
+ ### Added
25
+ - Specs for `Verikloak::HTTP.default_connection`, ensuring retry middleware and timeout defaults stay in sync.
26
+
27
+ ### Changed
28
+ - Middleware audience callables now handle zero-arity and BasicObject-style implementations without relying on `method(:call)`.
29
+ - README documents the shared `Verikloak::HTTP.default_connection` helper for reuse/customization.
30
+
31
+ ### Dependencies
32
+ - Declare `faraday-retry` as a runtime dependency so the default HTTP connection can load the retry middleware.
33
+
34
+ ---
35
+
10
36
  ## [0.1.4] - 2025-09-20
11
37
 
12
38
  ### Chore
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Verikloak
1
+ # verikloak
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)
@@ -9,8 +9,6 @@ A lightweight Rack middleware for verifying Keycloak JWT access tokens via OpenI
9
9
 
10
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
- ---
13
-
14
12
  ## Features
15
13
 
16
14
  - OpenID Connect Discovery (`.well-known/openid-configuration`)
@@ -20,8 +18,6 @@ Verikloak is a plug-and-play solution for Ruby (especially Rails API) apps that
20
18
  - Rails/Rack middleware support
21
19
  - Faraday-based customizable HTTP layer
22
20
 
23
- ---
24
-
25
21
  ## Installation
26
22
 
27
23
  Add this line to your application's `Gemfile`:
@@ -36,8 +32,6 @@ Then install:
36
32
  bundle install
37
33
  ```
38
34
 
39
- ---
40
-
41
35
  ## Usage
42
36
 
43
37
  ### Rails (API mode)
@@ -81,7 +75,6 @@ structured error responses.
81
75
  > **Note:** When the Rack middleware is enabled, it already renders JSON error responses.
82
76
  > The `rescue_from` example above is only necessary if you bypass the middleware or want custom behavior.
83
77
 
84
- ---
85
78
  #### Error Hierarchy
86
79
 
87
80
  All Verikloak errors inherit from `Verikloak::Error`:
@@ -90,7 +83,7 @@ All Verikloak errors inherit from `Verikloak::Error`:
90
83
  - `Verikloak::DiscoveryError` – OIDC discovery fetch/parse (`503 Service Unavailable`)
91
84
  - `Verikloak::JwksCacheError` – JWKs fetch/parse/cache (`503 Service Unavailable`)
92
85
  - `Verikloak::MiddlewareError` – header/infra issues surfaced by the middleware (usually `401`, sometimes `503`)
93
- ---
86
+
94
87
  #### Recommended: use environment variables in production
95
88
 
96
89
  ```ruby
@@ -100,7 +93,27 @@ config.middleware.use Verikloak::Middleware,
100
93
  skip_paths: ['/', '/health', '/public/*', '/rails/*']
101
94
  ```
102
95
  This makes the configuration secure and flexible across environments.
103
- ---
96
+
97
+ #### Advanced middleware options
98
+
99
+ `Verikloak::Middleware` exposes a few optional knobs that help integrate with
100
+ different Rack stacks:
101
+
102
+ - `token_env_key` (default: `"verikloak.token"`) — where the raw JWT is stored in the Rack env
103
+ - `user_env_key` (default: `"verikloak.user"`) — where decoded claims are stored
104
+ - `realm` (default: `"verikloak"`) — value used in the `WWW-Authenticate` header for 401 responses
105
+ - `logger` — an object responding to `error` (and optionally `debug`) that receives unexpected 500-level failures
106
+
107
+ ```ruby
108
+ config.middleware.use Verikloak::Middleware,
109
+ discovery_url: ENV.fetch("DISCOVERY_URL"),
110
+ audience: ENV.fetch("CLIENT_ID"),
111
+ token_env_key: "rack.session.token",
112
+ user_env_key: "rack.session.claims",
113
+ realm: "my-api",
114
+ logger: Rails.logger
115
+ ```
116
+
104
117
  ### Accessing claims in controllers
105
118
 
106
119
  Once the middleware is enabled, Verikloak adds the decoded token and raw JWT to the Rack environment.
@@ -117,7 +130,7 @@ class Api::V1::NotesController < ApplicationController
117
130
  end
118
131
  end
119
132
  ```
120
- ---
133
+
121
134
  ### Standalone Rack app
122
135
 
123
136
  ```ruby
@@ -133,7 +146,6 @@ run ->(env) {
133
146
  [200, { "Content-Type" => "application/json" }, [user.to_json]]
134
147
  }
135
148
  ```
136
- ---
137
149
 
138
150
  ## How It Works
139
151
 
@@ -149,8 +161,6 @@ run ->(env) {
149
161
  - `nbf` (not before)
150
162
  7. Makes the decoded payload available in `env["verikloak.user"]`
151
163
 
152
- ---
153
-
154
164
  ## Error Responses
155
165
 
156
166
  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.
@@ -236,13 +246,13 @@ For a full list of error cases and detailed explanations, please see the [ERRORS
236
246
  | Key | Required | Description |
237
247
  | --------------- | -------- | ------------------------------------------- |
238
248
  | `discovery_url` | Yes | Full URL to your realm's OIDC discovery doc |
239
- | `audience` | Yes | Your client ID (checked against `aud`) |
249
+ | `audience` | Yes | Your client ID (checked against `aud`). Accepts a String or callable returning a String/Array per request. |
240
250
  | `skip_paths` | No | Array of paths or wildcards to skip authentication, e.g. `['/', '/health', '/public/*']`. **Note:** Regex patterns are not supported. |
241
251
  | `discovery` | No | Inject custom Discovery instance (advanced/testing) |
242
252
  | `jwks_cache` | No | Inject custom JwksCache instance (advanced/testing) |
243
253
  | `leeway` | No | Clock skew tolerance (seconds) applied during JWT verification. Defaults to `TokenDecoder::DEFAULT_LEEWAY`. |
244
254
  | `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. |
255
+ | `connection` | No | Inject a Faraday::Connection used for both Discovery and JWKs fetches. Defaults to a safe connection with timeouts and retries. |
246
256
 
247
257
  #### Option: `skip_paths`
248
258
 
@@ -264,33 +274,51 @@ Paths **not matched** by any `skip_paths` entry will require a valid JWT.
264
274
  **Note:** Regex patterns are not supported. Only literal paths and `*` wildcards are allowed.
265
275
  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.
266
276
 
277
+ #### Option: `audience`
278
+
279
+ The `audience` option may be either a static String or any callable object (Proc, lambda, object responding to `#call`). When a callable is provided it receives the Rack `env` and can return a different audience for each request. This is useful when a single gateway serves multiple downstream clients:
280
+
281
+ ```ruby
282
+ Verikloak::Middleware.new(app,
283
+ discovery_url: ENV['DISCOVERY_URL'],
284
+ audience: ->(env) {
285
+ env['PATH_INFO'].start_with?('/admin') ? 'admin-client-id' : 'public-client-id'
286
+ }
287
+ )
288
+ ```
289
+
290
+ The callable may also return an Array of audiences when a route is valid for multiple clients.
291
+
267
292
  #### Customizing Faraday for Discovery and JWKs
268
293
 
269
- Both `Discovery` and `JwksCache` accept a `Faraday::Connection`.
270
- This allows you to configure timeouts, retries, logging, and shared headers:
294
+ Both `Discovery` and `JwksCache` accept a `Faraday::Connection`.
295
+ Verikloak ships with a helper you can re-use anywhere:
296
+
297
+ ```ruby
298
+ connection = Verikloak::HTTP.default_connection
299
+ ```
300
+
301
+ The default connection enables retries (via `faraday-retry`) for idempotent GET requests and applies conservative timeouts (5s request / 2s open). If you need to add extra middleware, adapters, or instrumentation, you can build on top of the defaults:
271
302
 
272
303
  ```ruby
273
- connection = Faraday.new(request: { timeout: 5 }) do |f|
304
+ connection = Faraday.new(request: { timeout: 10 }) do |f|
305
+ f.request :retry, Verikloak::HTTP::RETRY_OPTIONS
274
306
  f.response :logger
307
+ f.adapter Faraday.default_adapter
275
308
  end
276
309
 
277
310
  config.middleware.use Verikloak::Middleware,
278
311
  discovery_url: ENV["DISCOVERY_URL"],
279
312
  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.
313
+ connection: connection
286
314
 
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"],
315
+ # Or pass the connection through to a shared JwksCache instance
316
+ jwks_cache = Verikloak::JwksCache.new(
317
+ jwks_uri: "https://example.com/realms/myrealm/protocol/openid-connect/certs",
292
318
  connection: connection
319
+ )
293
320
  ```
321
+ This makes it easy to keep HTTP settings consistent across discovery, JWK refreshes, and any other Verikloak components you wire together.
294
322
 
295
323
  #### Customizing token verification (leeway and options)
296
324
 
@@ -314,8 +342,6 @@ config.middleware.use Verikloak::Middleware,
314
342
  - `token_verify_options:` is passed directly to TokenDecoder (and ultimately to `JWT.decode`).
315
343
  - If both are set, `token_verify_options[:leeway]` takes precedence.
316
344
 
317
- ---
318
-
319
345
  #### Performance note
320
346
 
321
347
  Internally, Verikloak caches `TokenDecoder` instances per JWKs fetch to avoid reinitializing
@@ -330,14 +356,13 @@ Verikloak consists of modular components, each with a focused responsibility:
330
356
  |----------------|--------------------------------------------------------|--------------|
331
357
  | `Middleware` | Rack-compatible entry point for token validation | Rack layer |
332
358
  | `Discovery` | Fetches OIDC discovery metadata (`.well-known`) | Network layer|
359
+ | `HTTP` | Provides shared Faraday connection with retries/timeouts | Network layer|
333
360
  | `JwksCache` | Fetches & caches JWKs public keys (with ETag) | Cache layer |
334
361
  | `TokenDecoder` | Decodes and verifies JWTs (signature, exp, nbf, iss, aud) | Crypto layer |
335
362
  | `Errors` | Centralized error hierarchy | Core layer |
336
363
 
337
364
  This separation enables better testing, modular reuse, and flexibility.
338
365
 
339
- ---
340
-
341
366
  ## Development (for contributors)
342
367
 
343
368
  Clone and install dependencies:
@@ -349,8 +374,6 @@ bundle install
349
374
  ```
350
375
  See **Testing** below to run specs and RuboCop. For releasing, see **Publishing**.
351
376
 
352
- ---
353
-
354
377
  ## Testing
355
378
 
356
379
  All pull requests and pushes are automatically tested with [RSpec](https://rspec.info/) and [RuboCop](https://rubocop.org/) via GitHub Actions.
@@ -362,40 +385,33 @@ To run the test suite locally:
362
385
  docker compose run --rm dev rspec
363
386
  docker compose run --rm dev rubocop
364
387
  ```
365
- ---
366
388
 
367
389
  ## Contributing
368
390
 
369
391
  Bug reports and pull requests are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
370
392
 
371
- ---
372
-
373
393
  ## Security
374
394
 
375
395
  If you find a security vulnerability, please follow the instructions in [SECURITY.md](SECURITY.md).
376
396
 
377
- ---
378
-
379
397
  ## License
380
398
 
381
399
  This project is licensed under the [MIT License](LICENSE).
382
400
 
383
- ---
384
-
385
401
  ## Publishing (for maintainers)
386
402
 
387
403
  Gem release instructions are documented separately in [MAINTAINERS.md](MAINTAINERS.md).
388
404
 
389
- ---
390
-
391
405
  ## Changelog
392
406
 
393
407
  See [CHANGELOG.md](CHANGELOG.md) for release history.
394
408
 
395
- ---
396
-
397
409
  ## References
398
410
 
399
- - [OpenID Connect Discovery 1.0 Spec](https://openid.net/specs/openid-connect-discovery-1_0.html)
400
- - [Keycloak Documentation: Securing Apps](https://www.keycloak.org/docs/latest/securing_apps/#openid-connect)
401
- - [JWT RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519)
411
+ - [OpenID Connect Discovery 1.0 Spec](https://openid.net/specs/openid-connect-discovery-1_0.html)
412
+ - [Keycloak Documentation: Securing Apps](https://www.keycloak.org/docs/latest/securing_apps/#openid-connect)
413
+ - [JWT RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519)
414
+ - [verikloak-rails on RubyGems](https://rubygems.org/gems/verikloak-rails)
415
+ - [verikloak-bff on RubyGems](https://rubygems.org/gems/verikloak-bff)
416
+ - [verikloak-pundit on RubyGems](https://rubygems.org/gems/verikloak-pundit)
417
+ - [verikloak-audience on RubyGems](https://rubygems.org/gems/verikloak-audience)
@@ -4,6 +4,8 @@ require 'faraday'
4
4
  require 'json'
5
5
  require 'uri'
6
6
 
7
+ require 'verikloak/http'
8
+
7
9
  module Verikloak
8
10
  # Fetches and caches the OpenID Connect Discovery document.
9
11
  #
@@ -39,10 +41,10 @@ module Verikloak
39
41
  REQUIRED_FIELDS = %w[jwks_uri issuer].freeze
40
42
 
41
43
  # @param discovery_url [String] The full URL to the `.well-known/openid-configuration`.
42
- # @param connection [Faraday::Connection] Optional Faraday client (for DI/tests). Defaults to `Faraday.new`.
44
+ # @param connection [Faraday::Connection] Optional Faraday client (for DI/tests).
43
45
  # @param cache_ttl [Integer] Cache TTL in seconds (default: 3600).
44
46
  # @raise [DiscoveryError] when `discovery_url` is not a valid HTTP(S) URL
45
- def initialize(discovery_url:, connection: Faraday.new, cache_ttl: 3600)
47
+ def initialize(discovery_url:, connection: Verikloak::HTTP.default_connection, cache_ttl: 3600)
46
48
  unless discovery_url.is_a?(String) && discovery_url.strip.match?(%r{^https?://})
47
49
  raise DiscoveryError.new('Invalid discovery URL: must be a non-empty HTTP(S) URL',
48
50
  code: 'invalid_discovery_url')
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/retry'
5
+
6
+ module Verikloak
7
+ # Internal HTTP helpers shared across components.
8
+ module HTTP
9
+ # Default request timeout (seconds) for outbound discovery/JWKs calls.
10
+ DEFAULT_TIMEOUT = 5
11
+ # Default open/read timeout (seconds) before establishing the HTTP connection.
12
+ DEFAULT_OPEN_TIMEOUT = 2
13
+
14
+ # Retry middleware configuration used for idempotent GET requests.
15
+ # Retries on 429/5xx with exponential backoff and jitter.
16
+ RETRY_OPTIONS = {
17
+ max: 2,
18
+ interval: 0.1,
19
+ interval_randomness: 0.2,
20
+ backoff_factor: 2,
21
+ methods: %i[get],
22
+ retry_statuses: [429, 500, 502, 503, 504]
23
+ }.freeze
24
+
25
+ # Builds a Faraday connection with conservative defaults suitable for
26
+ # network-bound operations (discovery and JWKs fetching).
27
+ #
28
+ # @return [Faraday::Connection]
29
+ def self.default_connection
30
+ Faraday.new do |f|
31
+ f.request :retry, RETRY_OPTIONS
32
+ f.options.timeout = DEFAULT_TIMEOUT
33
+ f.options.open_timeout = DEFAULT_OPEN_TIMEOUT
34
+ f.adapter Faraday.default_adapter
35
+ end
36
+ end
37
+ end
38
+ end
@@ -3,6 +3,8 @@
3
3
  require 'faraday'
4
4
  require 'json'
5
5
 
6
+ require 'verikloak/http'
7
+
6
8
  module Verikloak
7
9
  # Caches and revalidates JSON Web Key Sets (JWKs) fetched from a remote endpoint.
8
10
  #
@@ -43,7 +45,7 @@ module Verikloak
43
45
  end
44
46
 
45
47
  @jwks_uri = jwks_uri
46
- @connection = connection || Faraday.new
48
+ @connection = connection || Verikloak::HTTP.default_connection
47
49
  @cached_keys = nil
48
50
  @etag = nil
49
51
  @fetched_at = nil
@@ -59,6 +61,8 @@ module Verikloak
59
61
  # @return [Array&lt;Hash&gt;] the cached JWKs after fetch/revalidation
60
62
  # @raise [JwksCacheError] on HTTP failures, invalid JSON, invalid structure, or cache miss on 304
61
63
  def fetch!
64
+ return @cached_keys if fresh_by_ttl?
65
+
62
66
  with_error_handling do
63
67
  # Build conditional request headers (ETag-based)
64
68
  headers = build_conditional_headers
@@ -5,6 +5,8 @@ require 'json'
5
5
  require 'set'
6
6
  require 'faraday'
7
7
 
8
+ require 'verikloak/http'
9
+
8
10
  module Verikloak
9
11
  # @api private
10
12
  #
@@ -106,12 +108,12 @@ module Verikloak
106
108
 
107
109
  # Returns a cached TokenDecoder instance for current inputs.
108
110
  # Cache key uses issuer, audience, leeway, token_verify_options, and JWKs fetched_at timestamp.
109
- def decoder_for
111
+ def decoder_for(audience)
110
112
  keys = @jwks_cache.cached
111
113
  fetched_at = @jwks_cache.respond_to?(:fetched_at) ? @jwks_cache.fetched_at : nil
112
114
  cache_key = [
113
115
  @issuer,
114
- @audience,
116
+ audience,
115
117
  @leeway,
116
118
  @token_verify_options,
117
119
  fetched_at
@@ -120,7 +122,7 @@ module Verikloak
120
122
  @decoder_cache[cache_key] ||= TokenDecoder.new(
121
123
  jwks: keys,
122
124
  issuer: @issuer,
123
- audience: @audience,
125
+ audience: audience,
124
126
  leeway: @leeway,
125
127
  options: @token_verify_options
126
128
  )
@@ -142,14 +144,16 @@ module Verikloak
142
144
  # @param token [String]
143
145
  # @return [Hash] decoded JWT claims
144
146
  # @raise [Verikloak::Error] bubbles up verification/fetch errors for centralized handling
145
- def decode_token(token)
147
+ def decode_token(env, token)
146
148
  ensure_jwks_cache!
147
149
  if @jwks_cache.cached.nil? || @jwks_cache.cached.empty?
148
150
  raise MiddlewareError.new('JWKs cache is empty, cannot verify token', code: 'jwks_cache_miss')
149
151
  end
150
152
 
153
+ audience = resolve_audience(env)
154
+
151
155
  # First attempt
152
- decoder = decoder_for
156
+ decoder = decoder_for(audience)
153
157
 
154
158
  begin
155
159
  decoder.decode!(token)
@@ -160,11 +164,78 @@ module Verikloak
160
164
  refresh_jwks!
161
165
 
162
166
  # Rebuild decoder with refreshed keys and try once more.
163
- decoder = decoder_for
167
+ decoder = decoder_for(audience)
164
168
  decoder.decode!(token)
165
169
  end
166
170
  end
167
171
 
172
+ # Resolves the expected audience for the current request.
173
+ #
174
+ # @param env [Hash] Rack environment.
175
+ # @return [String, Array<String>] The expected audience value.
176
+ # @raise [MiddlewareError] when the resolved audience is blank.
177
+ def resolve_audience(env)
178
+ source = @audience_source
179
+ value = if source.respond_to?(:call)
180
+ callable = source
181
+ arity = callable.respond_to?(:arity) ? callable.arity : safe_callable_arity(callable)
182
+ call_with_optional_env(callable, env, arity)
183
+ else
184
+ source
185
+ end
186
+
187
+ raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if value.nil?
188
+
189
+ if value.is_a?(Array)
190
+ raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if value.empty?
191
+
192
+ return value
193
+ end
194
+
195
+ normalized = value.to_s
196
+ raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if normalized.empty?
197
+
198
+ normalized
199
+ end
200
+
201
+ # Invokes the audience callable, passing the Rack env only when required.
202
+ # Falls back to a zero-argument invocation if the callable raises
203
+ # `ArgumentError` due to an unexpected argument.
204
+ #
205
+ # @param callable [#call] Audience resolver callable.
206
+ # @param env [Hash] Rack environment.
207
+ # @param arity [Integer, nil] Callable arity when known, nil otherwise.
208
+ # @return [Object] Audience value returned by the callable.
209
+ # @raise [ArgumentError] when the callable raises for reasons other than arity mismatch.
210
+ def call_with_optional_env(callable, env, arity)
211
+ return callable.call if arity&.zero?
212
+
213
+ callable.call(env)
214
+ rescue ArgumentError => e
215
+ raise unless arity.nil? && wrong_arity_error?(e)
216
+
217
+ callable.call
218
+ end
219
+
220
+ # Safely obtains a callable's arity, returning nil when `#method(:call)`
221
+ # cannot be resolved (e.g., BasicObject-based objects).
222
+ #
223
+ # @param callable [#call]
224
+ # @return [Integer, nil]
225
+ def safe_callable_arity(callable)
226
+ callable.method(:call).arity
227
+ rescue NameError
228
+ nil
229
+ end
230
+
231
+ # Returns true when the ArgumentError message indicates a wrong arity.
232
+ #
233
+ # @param error [ArgumentError]
234
+ # @return [Boolean]
235
+ def wrong_arity_error?(error)
236
+ error.message.include?('wrong number of arguments')
237
+ end
238
+
168
239
  # Ensures that discovery metadata and JWKs cache are initialized and up-to-date.
169
240
  # This method is thread-safe.
170
241
  #
@@ -278,17 +349,24 @@ module Verikloak
278
349
  include SkipPathMatcher
279
350
  include MiddlewareTokenVerification
280
351
 
352
+ DEFAULT_REALM = 'verikloak'
353
+ DEFAULT_TOKEN_ENV_KEY = 'verikloak.token'
354
+ DEFAULT_USER_ENV_KEY = 'verikloak.user'
355
+
281
356
  # @param app [#call] downstream Rack app
282
357
  # @param discovery_url [String] OIDC discovery endpoint URL
283
- # @param audience [String] expected `aud` claim
358
+ # @param audience [String, #call] Expected `aud` claim. When a callable is provided it
359
+ # receives the Rack env and may return a String or Array of audiences.
284
360
  # @param skip_paths [Array<String>] literal paths or wildcard patterns to bypass auth
285
361
  # @param discovery [Discovery, nil] custom discovery instance (for DI/tests)
286
362
  # @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)
363
+ # @param connection [Faraday::Connection, nil] Optional injected Faraday connection
364
+ # (defaults to {Verikloak::HTTP.default_connection})
288
365
  # @param leeway [Integer] Clock skew tolerance in seconds for token verification (delegated to TokenDecoder)
289
366
  # @param token_verify_options [Hash] Additional JWT verification options passed through
290
367
  # to TokenDecoder.
291
368
  # e.g., { verify_iat: false, leeway: 10 }
369
+ # rubocop:disable Metrics/ParameterLists
292
370
  def initialize(app,
293
371
  discovery_url:,
294
372
  audience:,
@@ -297,20 +375,29 @@ module Verikloak
297
375
  jwks_cache: nil,
298
376
  connection: nil,
299
377
  leeway: Verikloak::TokenDecoder::DEFAULT_LEEWAY,
300
- token_verify_options: {})
301
- @app = app
302
- @audience = audience
303
- @discovery = discovery || Discovery.new(discovery_url: discovery_url)
304
- @jwks_cache = jwks_cache
305
- @connection = connection || Faraday.new
306
- @leeway = leeway
378
+ token_verify_options: {},
379
+ token_env_key: DEFAULT_TOKEN_ENV_KEY,
380
+ user_env_key: DEFAULT_USER_ENV_KEY,
381
+ realm: DEFAULT_REALM,
382
+ logger: nil)
383
+ @app = app
384
+ @connection = connection || Verikloak::HTTP.default_connection
385
+ @audience_source = audience
386
+ @discovery = discovery || Discovery.new(discovery_url: discovery_url, connection: @connection)
387
+ @jwks_cache = jwks_cache
388
+ @leeway = leeway
307
389
  @token_verify_options = token_verify_options || {}
308
390
  @issuer = nil
309
391
  @mutex = Mutex.new
310
392
  @decoder_cache = {}
393
+ @token_env_key = normalize_env_key(token_env_key, 'token_env_key')
394
+ @user_env_key = normalize_env_key(user_env_key, 'user_env_key')
395
+ @realm = normalize_realm(realm)
396
+ @logger = logger
311
397
 
312
398
  compile_skip_paths(skip_paths)
313
399
  end
400
+ # rubocop:enable Metrics/ParameterLists
314
401
 
315
402
  # Rack entrypoint.
316
403
  #
@@ -344,9 +431,9 @@ module Verikloak
344
431
  # @param token [String]
345
432
  # @return [Array(Integer, Hash, Array<String>)] Rack response triple
346
433
  def handle_request(env, token)
347
- claims = decode_token(token)
348
- env['verikloak.token'] = token
349
- env['verikloak.user'] = claims
434
+ claims = decode_token(env, token)
435
+ env[@token_env_key] = token
436
+ env[@user_env_key] = claims
350
437
  @app.call(env)
351
438
  end
352
439
 
@@ -402,7 +489,7 @@ module Verikloak
402
489
  headers = { 'Content-Type' => 'application/json' }
403
490
  if status == 401
404
491
  headers['WWW-Authenticate'] =
405
- %(Bearer realm="verikloak", error="#{code}", error_description="#{message.gsub('"', '\\"')}")
492
+ %(Bearer realm="#{@realm}", error="#{code}", error_description="#{message.gsub('"', '\\"')}")
406
493
  end
407
494
  [status, headers, [body]]
408
495
  end
@@ -412,8 +499,62 @@ module Verikloak
412
499
  # @param error [Exception]
413
500
  # @return [void]
414
501
  def log_internal_error(error)
415
- warn "[verikloak] Internal error: #{error.class} - #{error.message}"
416
- warn error.backtrace.join("\n") if error.backtrace
502
+ message = "[verikloak] Internal error: #{error.class} - #{error.message}"
503
+ backtrace = error.backtrace&.join("\n")
504
+
505
+ if logger_available?
506
+ log_with_logger(message, backtrace)
507
+ else
508
+ warn message
509
+ warn backtrace if backtrace
510
+ end
511
+ end
512
+
513
+ def logger_available?
514
+ return false unless @logger
515
+
516
+ @logger.respond_to?(:error) || @logger.respond_to?(:warn) || @logger.respond_to?(:debug)
517
+ end
518
+
519
+ def log_with_logger(message, backtrace)
520
+ log_message(@logger, message)
521
+ log_backtrace(@logger, backtrace)
522
+ end
523
+
524
+ def log_message(logger, message)
525
+ if logger.respond_to?(:error)
526
+ logger.error(message)
527
+ elsif logger.respond_to?(:warn)
528
+ logger.warn(message)
529
+ end
530
+ end
531
+
532
+ def log_backtrace(logger, backtrace)
533
+ return unless backtrace
534
+
535
+ if logger.respond_to?(:debug)
536
+ logger.debug(backtrace)
537
+ elsif logger.respond_to?(:error)
538
+ logger.error(backtrace)
539
+ elsif logger.respond_to?(:warn)
540
+ logger.warn(backtrace)
541
+ end
542
+ end
543
+
544
+ def normalize_env_key(value, option_name)
545
+ normalized = value.to_s.strip
546
+ raise ArgumentError, "#{option_name} cannot be blank" if normalized.empty?
547
+
548
+ normalized
549
+ end
550
+
551
+ def normalize_realm(value)
552
+ return DEFAULT_REALM if value.nil?
553
+
554
+ normalized = value.to_s.strip
555
+ raise ArgumentError, 'realm cannot be blank' if normalized.empty?
556
+
557
+ normalized
417
558
  end
418
559
  end
419
560
  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.4'
5
+ VERSION = '0.2.0'
6
6
  end
data/lib/verikloak.rb CHANGED
@@ -5,6 +5,7 @@
5
5
  # by simply requiring 'verikloak'.
6
6
  require 'verikloak/version'
7
7
  require 'verikloak/errors'
8
+ require 'verikloak/http'
8
9
  require 'verikloak/discovery'
9
10
  require 'verikloak/jwks_cache'
10
11
  require 'verikloak/token_decoder'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: verikloak
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - taiyaky
@@ -29,6 +29,26 @@ dependencies:
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '3.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: faraday-retry
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '2.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '3.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '2.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '3.0'
32
52
  - !ruby/object:Gem::Dependency
33
53
  name: json
34
54
  requirement: !ruby/object:Gem::Requirement
@@ -77,6 +97,7 @@ files:
77
97
  - lib/verikloak.rb
78
98
  - lib/verikloak/discovery.rb
79
99
  - lib/verikloak/errors.rb
100
+ - lib/verikloak/http.rb
80
101
  - lib/verikloak/jwks_cache.rb
81
102
  - lib/verikloak/middleware.rb
82
103
  - lib/verikloak/token_decoder.rb
@@ -88,7 +109,7 @@ metadata:
88
109
  source_code_uri: https://github.com/taiyaky/verikloak
89
110
  changelog_uri: https://github.com/taiyaky/verikloak/blob/main/CHANGELOG.md
90
111
  bug_tracker_uri: https://github.com/taiyaky/verikloak/issues
91
- documentation_uri: https://rubydoc.info/gems/verikloak/0.1.4
112
+ documentation_uri: https://rubydoc.info/gems/verikloak/0.2.0
92
113
  rubygems_mfa_required: 'true'
93
114
  rdoc_options: []
94
115
  require_paths: