verikloak-rails 1.1.0 → 1.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: 3b4d8e46a9c366b09726841773f09cbf54b44c3fbf2f0870b1994bb365ae9173
4
- data.tar.gz: 31f3fdf2f6c9482f06a1a03bf0a4ecdc8109f24c814306e174ff582706659c4f
3
+ metadata.gz: 4ab889b6413e152dc2be695f2f4f738d7e2b90712b54559cc596e6d1c92166ed
4
+ data.tar.gz: b97dd2c56aca7b021daf781ffc2ad834bc0b42c4b72efdd9cdda1d609495f4a6
5
5
  SHA512:
6
- metadata.gz: 6748be8631a59e8d1f0562c96ae6b53b01991d28a5fd094cf0117164d7597508c0d02e8be5ebdc28ad04c801686dac0492eb6f5242ea09e961a1814d4b0ff248
7
- data.tar.gz: a41eb76616575e283c7ff139161fa18886b85ffea7f7d953eb4ed846b754fb1e15a93538d3f8ed51b04d92e68f302b3174d54ba881e600b2c58d9f8979687c6b
6
+ metadata.gz: 5bdf562dbadfbede90f0d2d32ab69f62072636abd5a7ff0a3580a1fe6a58cb2dd24ee35a0468b4d0b1d5fe91174e9e49dc481b2cfee862a9030a909b58742c78
7
+ data.tar.gz: 3ec0f43f095dd1002e6f612af4061beda81f29da93b2bc6e09d0fb37d67dfce477d4cc4194705441e11318ad11902f37fac2ab42768b4986eb0fcdc092a3bd66
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
+ ## [1.2.0] - 2026-07-03
11
+
12
+ ### Fixed
13
+ - **`Testing::Helpers#build_pundit_user_context` crashed against the real `verikloak-pundit` gem**: the helper called `UserContext.new(user, claims)`, but verikloak-pundit's constructor is `UserContext.new(claims, resource_client: nil, config: nil)` (a single positional argument), so `build_admin_user_context` / `build_user_user_context` always raised `ArgumentError`. The helper now takes the claims Hash (`build_pundit_user_context(claims, **options)`) and forwards keyword options such as `resource_client:` to the real constructor. The unit-spec fake was aligned with the real signature, and new contract specs (see below) guard against future drift.
14
+ - **Controller helpers ignored custom `token_env_key` / `user_env_key`**: `current_user_claims` / `current_token` (and therefore `authenticate_user!`) always read the default `verikloak.user` / `verikloak.token` env keys, so configuring custom keys caused valid requests to be rejected with 401. The helpers and `Testing::MiddlewareStub` now resolve keys via the new `Configuration#effective_user_env_key` / `#effective_token_env_key`, matching what the middleware writes (and what `verikloak-pundit` already syncs to). Configured keys are whitespace-stripped exactly like the core middleware normalizes them before writing, so a padded value (e.g. an ENV var with a trailing newline) can no longer make the middleware write one env key while the helpers read another. `Testing::MiddlewareStub` resolves the keys when the stubbed call runs — not when the stub is installed — so configuration applied after `stub_verikloak_middleware` stays in sync with the helpers' request-time reads.
15
+ - **`require 'verikloak/rails'` failed outside a booted Rails app**: the Railtie now requires the ActiveSupport core extensions it depends on (`delegate_missing_to`, `blank?`) before `rails/railtie`, so the gem can be loaded standalone (e.g. from contract specs or plain scripts).
16
+
17
+ ### Added
18
+ - **`Verikloak::Rails::RequestStoreMirror` middleware**: when the `request_store` gem is on the load path, the Railtie inserts this middleware right after `Verikloak::Middleware` to mirror `verikloak.user` / `verikloak.token` (or the configured custom keys) into `RequestStore.store`. Previously the controller helpers' RequestStore fallback documented in the README had no writer — nothing ever mirrored the values — so it only worked if the application mirrored them by hand. **Upgrade note**: the mirror overwrites `RequestStore.store[:verikloak_user]` / `[:verikloak_token]` on every request (`nil` on unauthenticated/skipped paths) so stale context never leaks between requests — remove any hand-rolled mirroring of these keys when upgrading. Mirroring failures never break the request, but are logged once (per middleware instance) instead of being silently swallowed.
19
+ - **`Configuration#effective_token_env_key` / `#effective_user_env_key`**: single source of truth for resolving the Rack env keys (custom value or core default), shared by the controller helpers, `RequestStoreMirror`, and `Testing::MiddlewareStub`.
20
+ - **Contract specs against the real sibling gems** (`spec/contracts`): verify the constructor/config surfaces of `verikloak` core, `verikloak-pundit`, `verikloak-bff`, and `verikloak-audience` that this gem relies on. Tagged `:contract` and excluded from the default run; the new CI `contracts` job runs them with `gemfiles/contracts.Gemfile` so interface drift is caught even though the unit suite uses fakes.
21
+ - **`jwks_refresh_interval` configuration option**: forwarded to the core `Verikloak::Middleware` (new in verikloak 1.1.0) to throttle JWKS/discovery revalidation on the request path. Left unset it is omitted from `middleware_options`, so the core default (`60` seconds) applies; `0` restores the pre-1.1 revalidate-on-every-request behavior, and numeric strings (e.g. from ENV) are coerced by the core. The keyword only exists in verikloak core `>= 1.1.0`, which this release now requires (see Changed).
22
+
23
+ ### Security
24
+ - **Bumped locked dependencies to clear all Dependabot / bundler-audit advisories**: `jwt` 3.2.0 (GHSA-c32j-vqhx-rx3x, High), `faraday` 2.14.3 (GHSA-98m9-hrrm-r99r High, GHSA-5rv5-xj5j-3484 Medium), `net-imap` 0.6.4.1 (2 Medium, 1 Low), `nokogiri` 1.19.4 (9 advisories), `concurrent-ruby` 1.3.7 and `crass` 1.0.7 (3 + 4 advisories). All are transitive development/test dependencies pinned by `Gemfile.lock`; the gem's runtime requirements are unchanged.
25
+
26
+ ### Changed
27
+ - **BREAKING**: `Testing::Helpers#build_pundit_user_context` now takes the claims Hash as its only positional argument (`build_pundit_user_context(claims, **options)` instead of `(user, claims)`). The old signature never worked against the real verikloak-pundit gem (it always raised `ArgumentError`), so only suites stubbing `UserContext` with a two-argument fake are affected; the `build_admin_user_context` / `build_user_user_context` wrappers are unchanged. See the Fixed entry above for details.
28
+ - **Raised the minimum `verikloak` (core) runtime dependency to `~> 1.1`** (was `~> 1.0`): verikloak-rails 1.2.0 forwards the `jwks_refresh_interval` keyword, which only exists in verikloak core `>= 1.1.0`. Enforcing it in the gemspec turns a would-be boot-time `ArgumentError` (setting the option against a 1.0.x core) into a clear dependency-resolution error at `bundle install`. Apps that do not use the option are still subject to the new floor, but core 1.1.0 is backward-compatible with 1.0.x apart from the new default JWKS refresh throttle (60s).
29
+ - **Error-handler registration hardened against boot-order issues**: `rescue_from StandardError` / `rescue_from Pundit::NotAuthorizedError` remain registered only when `render_500_json` / `rescue_pundit` are enabled — registering them unconditionally would suppress `ActiveSupport::Rescuable`'s `exception.cause` traversal (e.g. an app's `rescue_from ActiveRecord::RecordNotFound` no longer catching errors raised inside views and wrapped in `ActionView::Template::Error`) and shadow handlers registered earlier on the same class. Instead, the Railtie now fires the auto-include hook after `verikloak.configure`, so the include-time reads see the application's final configuration even when `ActionController::Base` is loaded early by another initializer. Handler bodies additionally re-check the flags at request time, so disabling them at runtime still takes effect. Note that Pundit must be loaded before the concern is included for the Pundit rescue to be registered.
30
+ - **Log-tag sanitization extracted** to a shared `_verikloak_sanitize_tag` helper (same `[[:cntrl:]]` stripping, applied to both `request_id` and `sub` tags).
31
+ - **Controller rescue handlers and 500-error logging extracted** to `Verikloak::Rails::Controller::ErrorHandling` (included by the concern automatically; behavior unchanged).
32
+ - `Gemfile.lock` now includes the `x86_64-linux` / `aarch64-linux` (glibc) platforms so `bundle install` works outside the Alpine (musl) Docker image.
33
+
34
+ ---
35
+
10
36
  ## [1.1.0] - 2026-05-09
11
37
 
12
38
  ### Added
data/README.md CHANGED
@@ -53,7 +53,9 @@ Then configure `config/initializers/verikloak.rb`.
53
53
  The helpers follow this priority order:
54
54
 
55
55
  1. **Primary**: `request.env` (Rack environment) - Set directly by `Verikloak::Middleware`
56
- 2. **Fallback**: `RequestStore.store` (when available) - Thread-local storage for background jobs
56
+ 2. **Fallback**: `RequestStore.store` (when available) - Thread-local storage for code running outside the controller
57
+
58
+ When the [`request_store`](https://rubygems.org/gems/request_store) gem is on the load path, the Railtie automatically inserts `Verikloak::Rails::RequestStoreMirror` right after `Verikloak::Middleware`. It mirrors the claims/token from the Rack env into `RequestStore.store` on every request, so the fallback works out of the box (e.g. in service objects or jobs enqueued during the request). The mirror overwrites `RequestStore.store[:verikloak_user]` / `[:verikloak_token]` on every request — with `nil` on unauthenticated or skipped paths — so stale context never leaks between requests; if you previously mirrored these keys by hand, remove your own writer when upgrading. Mirroring failures never break the request and are logged once so a dead fallback does not go unnoticed.
57
59
 
58
60
  **Examples:**
59
61
 
@@ -62,8 +64,8 @@ The helpers follow this priority order:
62
64
  current_user_claims # reads from request.env['verikloak.user']
63
65
  current_token # reads from request.env['verikloak.token']
64
66
 
65
- # In a background job triggered during request
66
- # (when RequestStore gem is present and middleware has mirrored values)
67
+ # Outside the controller during the same request
68
+ # (when the request_store gem is present; mirrored automatically)
67
69
  current_user_claims # falls back to RequestStore.store[:verikloak_user]
68
70
  current_token # falls back to RequestStore.store[:verikloak_token]
69
71
 
@@ -124,6 +126,7 @@ end
124
126
  | --- | --- | --- |
125
127
  | `Verikloak::Bff::HeaderGuard` (optional) | Before `Verikloak::Middleware` by default when the gem is present | Normalize or enforce trusted proxy headers such as `X-Forwarded-Access-Token` |
126
128
  | `Verikloak::Middleware` | After `Rails::Rack::Logger` by default (configurable) | Validate Bearer JWT (OIDC discovery + JWKS), set `verikloak.user`/`verikloak.token`, and honor `skip_paths` |
129
+ | `Verikloak::Rails::RequestStoreMirror` (optional) | After `Verikloak::Middleware`, when the `request_store` gem is present | Mirror `verikloak.user`/`verikloak.token` into `RequestStore.store` for the controller helpers' fallback |
127
130
 
128
131
  ### BFF Integration
129
132
  Support for BFF header handling (e.g., normalizing or enforcing `X-Forwarded-Access-Token`) now lives in a dedicated gem: verikloak-bff.
@@ -169,6 +172,7 @@ Keys under `config.verikloak`:
169
172
  | `user_env_key` | String | Custom Rack env key that stores decoded claims | `nil` (middleware default `verikloak.user`) |
170
173
  | `bff_header_guard_options` | Hash or Proc | Forwarded to `Verikloak::BFF.configure` prior to middleware insertion | `{}` |
171
174
  | `allow_http` | Boolean | Allow `http://` discovery URLs (forwarded to core middleware). **Only for development/test.** | `false` |
175
+ | `jwks_refresh_interval` | Numeric or nil | Minimum seconds between JWKS revalidations on the request path; `0` revalidates on every request (pre-verikloak-1.1 behavior). Key rotation within the window still forces an immediate refresh. Numeric strings are coerced. | `nil` (verikloak default `60`) |
172
176
 
173
177
  Environment variable examples are in the generated initializer.
174
178
 
@@ -220,6 +224,7 @@ end
220
224
  | `audience` | `VERIKLOAK_AUDIENCE` |
221
225
  | `issuer` | `VERIKLOAK_ISSUER` |
222
226
  | `leeway` | `VERIKLOAK_LEEWAY` |
227
+ | `jwks_refresh_interval` | `VERIKLOAK_JWKS_REFRESH_INTERVAL` |
223
228
  | `render_500_json` | `VERIKLOAK_RENDER_500` |
224
229
  | `rescue_pundit` | `VERIKLOAK_RESCUE_PUNDIT` |
225
230
 
@@ -291,7 +296,8 @@ end
291
296
  `stub_verikloak_middleware` automatically also stubs
292
297
  `Verikloak::BFF::HeaderGuard` and `Verikloak::Audience::Middleware` when
293
298
  those gems are loaded, and sets `env['verikloak.token']` so controller
294
- helpers like `current_token` work.
299
+ helpers like `current_token` work. Custom `user_env_key` / `token_env_key`
300
+ settings are honored automatically.
295
301
 
296
302
  ### Policy specs (with `verikloak-pundit`)
297
303
 
@@ -13,6 +13,11 @@ Rails.application.configure do
13
13
  # JWT clock skew tolerance in seconds
14
14
  config.verikloak.leeway = Integer(ENV.fetch('VERIKLOAK_LEEWAY', '60'))
15
15
 
16
+ # Minimum seconds between JWKS refresh checks on the request path.
17
+ # Leave unset to keep the verikloak default (60); 0 revalidates on every
18
+ # request.
19
+ # config.verikloak.jwks_refresh_interval = Integer(ENV.fetch('VERIKLOAK_JWKS_REFRESH_INTERVAL', '60'))
20
+
16
21
  # Paths to skip authentication (health checks, etc.)
17
22
  config.verikloak.skip_paths = %w[/up /health /rails/health]
18
23
 
@@ -56,6 +56,12 @@ module Verikloak
56
56
  # Rack middleware to insert the header guard after.
57
57
  # @return [Object, String, Symbol, nil]
58
58
  class Configuration
59
+ # Default Rack env keys. These mirror the defaults used by the core
60
+ # `Verikloak::Middleware` and are the fallback when `token_env_key` /
61
+ # `user_env_key` are left unset.
62
+ DEFAULT_TOKEN_ENV_KEY = 'verikloak.token'
63
+ DEFAULT_USER_ENV_KEY = 'verikloak.user'
64
+
59
65
  attr_accessor :discovery_url, :audience, :issuer, :leeway,
60
66
  :logger_tags, :error_renderer, :auto_include_controller,
61
67
  :render_500_json, :rescue_pundit,
@@ -64,7 +70,7 @@ module Verikloak
64
70
  :bff_header_guard_insert_before, :bff_header_guard_insert_after,
65
71
  :token_verify_options, :decoder_cache_limit,
66
72
  :token_env_key, :user_env_key, :bff_header_guard_options,
67
- :allow_http
73
+ :allow_http, :jwks_refresh_interval
68
74
 
69
75
  attr_reader :skip_paths
70
76
 
@@ -92,6 +98,7 @@ module Verikloak
92
98
  @user_env_key = nil
93
99
  @bff_header_guard_options = {}
94
100
  @allow_http = false
101
+ @jwks_refresh_interval = nil
95
102
  @skip_path_matcher = nil
96
103
  end
97
104
 
@@ -107,6 +114,26 @@ module Verikloak
107
114
  @skip_path_matcher ||= SkipPathChecker.new(skip_paths)
108
115
  end
109
116
 
117
+ # Rack env key actually used for the bearer token: the configured
118
+ # `token_env_key`, or the core middleware default when unset/blank.
119
+ # The value is whitespace-stripped to match the normalization the core
120
+ # middleware applies before writing to the env, so readers and writer
121
+ # always agree on the key. Shared by the controller helpers,
122
+ # RequestStore mirroring, and the testing middleware stub so all
123
+ # layers stay in sync.
124
+ # @return [String]
125
+ def effective_token_env_key
126
+ presence_or_default(token_env_key, DEFAULT_TOKEN_ENV_KEY)
127
+ end
128
+
129
+ # Rack env key actually used for decoded claims: the configured
130
+ # `user_env_key`, or the core middleware default when unset/blank.
131
+ # Whitespace-stripped like {#effective_token_env_key}.
132
+ # @return [String]
133
+ def effective_user_env_key
134
+ presence_or_default(user_env_key, DEFAULT_USER_ENV_KEY)
135
+ end
136
+
110
137
  # Options forwarded to the base Verikloak Rack middleware.
111
138
  # @return [Hash]
112
139
  # @example
@@ -123,9 +150,23 @@ module Verikloak
123
150
  decoder_cache_limit: decoder_cache_limit,
124
151
  token_env_key: token_env_key,
125
152
  user_env_key: user_env_key,
126
- allow_http: allow_http
153
+ allow_http: allow_http,
154
+ jwks_refresh_interval: jwks_refresh_interval
127
155
  }.compact
128
156
  end
157
+
158
+ private
159
+
160
+ # @param value [String, nil]
161
+ # @param default [String]
162
+ # @return [String] the stripped value, or the default when blank.
163
+ # Stripping mirrors the core middleware's env-key normalization
164
+ # (`value.to_s.strip`); without it a padded key would make the
165
+ # middleware write one env key while the helpers read another.
166
+ def presence_or_default(value, default)
167
+ str = value.to_s.strip
168
+ str.empty? ? default : str
169
+ end
129
170
  end
130
171
  end
131
172
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Verikloak
6
+ module Rails
7
+ module Controller
8
+ # Rescue handlers and 500-error logging backing the Controller
9
+ # concern's `rescue_from` registrations. Turns uncaught exceptions
10
+ # into the standardized JSON responses while keeping the underlying
11
+ # failure visible to operators through the innermost logger.
12
+ module ErrorHandling
13
+ private
14
+
15
+ # Handle uncaught StandardError: render the generic JSON 500 when
16
+ # `render_500_json` is enabled, otherwise re-raise so Rails' default
17
+ # error handling applies. The handler is only registered when the flag
18
+ # is enabled at include time; this request-time check additionally lets
19
+ # a runtime opt-out take effect.
20
+ #
21
+ # @param exception [StandardError]
22
+ # @return [void]
23
+ # @raise [StandardError] the original exception when rendering is disabled
24
+ def _verikloak_handle_standard_error(exception)
25
+ raise exception unless Verikloak::Rails.config.render_500_json
26
+
27
+ _verikloak_render_internal_error(exception)
28
+ end
29
+
30
+ # Handle `Pundit::NotAuthorizedError`: render 403 JSON when
31
+ # `rescue_pundit` is enabled; otherwise fall through to the generic
32
+ # StandardError handling (500 JSON or re-raise).
33
+ #
34
+ # @param exception [StandardError]
35
+ # @return [void]
36
+ # @raise [StandardError] the original exception when both rescues are disabled
37
+ def _verikloak_handle_pundit_error(exception)
38
+ if Verikloak::Rails.config.rescue_pundit
39
+ render json: { error: 'forbidden', message: exception.message }, status: :forbidden
40
+ else
41
+ _verikloak_handle_standard_error(exception)
42
+ end
43
+ end
44
+
45
+ # Log the exception and render the static JSON 500 body.
46
+ #
47
+ # @param exception [Exception]
48
+ # @return [void]
49
+ def _verikloak_render_internal_error(exception)
50
+ _verikloak_log_internal_error(exception)
51
+ render json: { error: 'internal_server_error', message: 'An unexpected error occurred' },
52
+ status: :internal_server_error
53
+ end
54
+
55
+ # Write StandardError details to the controller or Rails logger when
56
+ # rendering the generic 500 JSON response. Logging ensures the
57
+ # underlying failure is still visible to operators even though the
58
+ # response body is static.
59
+ #
60
+ # @param exception [Exception]
61
+ # @return [void]
62
+ def _verikloak_log_internal_error(exception)
63
+ target_logger = _verikloak_base_logger
64
+ return unless target_logger.respond_to?(:error)
65
+
66
+ target_logger.error("[Verikloak] #{exception.class}: #{exception.message}")
67
+ backtrace = exception.backtrace
68
+ target_logger.error(backtrace.join("\n")) if backtrace&.any?
69
+ rescue StandardError
70
+ # Never allow logging failures to interfere with request handling.
71
+ nil
72
+ end
73
+
74
+ # Locate the innermost logger that responds to `error`.
75
+ # @return [Object, nil]
76
+ def _verikloak_base_logger
77
+ root_logger = if defined?(::Rails) && ::Rails.respond_to?(:logger)
78
+ ::Rails.logger
79
+ elsif respond_to?(:logger)
80
+ logger
81
+ end
82
+ current = root_logger
83
+ seen = Set.new
84
+ while current.respond_to?(:logger)
85
+ break unless seen.add?(current.object_id)
86
+
87
+ next_logger = current.logger
88
+ break if next_logger.nil? || next_logger.equal?(current)
89
+
90
+ current = next_logger
91
+ end
92
+ current
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_support/concern'
4
- require 'set'
4
+ require_relative 'controller/error_handling'
5
5
 
6
6
  module Verikloak
7
7
  module Rails
@@ -13,20 +13,24 @@ module Verikloak
13
13
  module Controller
14
14
  extend ActiveSupport::Concern
15
15
 
16
+ # Rescue handlers and 500-error logging (kept in a separate module so
17
+ # the concern itself stays focused on wiring and public helpers).
18
+ include ErrorHandling
19
+
16
20
  included do
17
21
  before_action :authenticate_user!
18
- # Register generic error handler first so specific handlers take precedence.
19
- if Verikloak::Rails.config.render_500_json
20
- rescue_from StandardError do |e|
21
- _verikloak_log_internal_error(e)
22
- render json: { error: 'internal_server_error', message: 'An unexpected error occurred' },
23
- status: :internal_server_error
24
- end
25
- end
22
+ # Handlers are only registered when enabled at include time: a
23
+ # registered-but-disabled `rescue_from StandardError` would suppress
24
+ # ActiveSupport::Rescuable's `exception.cause` traversal and shadow
25
+ # handlers registered earlier on the same class (re-raising from
26
+ # inside a handler consults no other handler). The Railtie fires the
27
+ # auto-include hook after `verikloak.configure`, so these flags are
28
+ # final here; the same applies to `defined?(::Pundit::NotAuthorizedError)`,
29
+ # which requires Pundit to be loaded before this concern is included.
30
+ # Generic handler first so specific handlers take precedence.
31
+ rescue_from StandardError, with: :_verikloak_handle_standard_error if Verikloak::Rails.config.render_500_json
26
32
  if defined?(::Pundit::NotAuthorizedError) && Verikloak::Rails.config.rescue_pundit
27
- rescue_from ::Pundit::NotAuthorizedError do |e|
28
- render json: { error: 'forbidden', message: e.message }, status: :forbidden
29
- end
33
+ rescue_from ::Pundit::NotAuthorizedError, with: :_verikloak_handle_pundit_error
30
34
  end
31
35
  rescue_from ::Verikloak::Error do |e|
32
36
  Verikloak::Rails.config.error_renderer.render(self, e)
@@ -54,17 +58,19 @@ module Verikloak
54
58
  def authenticated? = current_user_claims.present?
55
59
 
56
60
  # The verified JWT claims for the current user.
57
- # Prefer Rack env; fall back to RequestStore when available.
61
+ # Prefer Rack env (honoring a custom `user_env_key`); fall back to
62
+ # RequestStore when available.
58
63
  # @return [Hash, nil]
59
64
  def current_user_claims
60
- _verikloak_fetch_request_context('verikloak.user', :verikloak_user)
65
+ _verikloak_fetch_request_context(Verikloak::Rails.config.effective_user_env_key, :verikloak_user)
61
66
  end
62
67
 
63
68
  # The raw bearer token used for the current request.
64
- # Prefer Rack env; fall back to RequestStore when available.
69
+ # Prefer Rack env (honoring a custom `token_env_key`); fall back to
70
+ # RequestStore when available.
65
71
  # @return [String, nil]
66
72
  def current_token
67
- _verikloak_fetch_request_context('verikloak.token', :verikloak_token)
73
+ _verikloak_fetch_request_context(Verikloak::Rails.config.effective_token_env_key, :verikloak_token)
68
74
  end
69
75
 
70
76
  # The `sub` (subject) claim from the current user claims.
@@ -105,20 +111,24 @@ module Verikloak
105
111
  config = Verikloak::Rails.config
106
112
  tags = []
107
113
  if config.logger_tags.include?(:request_id)
108
- rid = request.request_id || request.headers['X-Request-Id']
109
- rid = rid.to_s.gsub(/[[:cntrl:]]+/, ' ').strip
110
- tags << "req:#{rid}" unless rid.empty?
114
+ rid = _verikloak_sanitize_tag(request.request_id || request.headers['X-Request-Id'])
115
+ tags << "req:#{rid}" if rid
111
116
  end
112
117
  if config.logger_tags.include?(:sub)
113
- sub = current_subject
114
- if sub
115
- sanitized = sub.to_s.gsub(/[[:cntrl:]]+/, ' ').strip
116
- tags << "sub:#{sanitized}" unless sanitized.empty?
117
- end
118
+ sub = _verikloak_sanitize_tag(current_subject)
119
+ tags << "sub:#{sub}" if sub
118
120
  end
119
121
  tags
120
122
  end
121
123
 
124
+ # Strip control characters and surrounding whitespace from a log tag value.
125
+ # @param value [Object, nil]
126
+ # @return [String, nil] sanitized value, or nil when blank
127
+ def _verikloak_sanitize_tag(value)
128
+ sanitized = value.to_s.gsub(/[[:cntrl:]]+/, ' ').strip
129
+ sanitized.empty? ? nil : sanitized
130
+ end
131
+
122
132
  # Retrieve request context from Rack env or RequestStore.
123
133
  # @param env_key [String]
124
134
  # @param store_key [Symbol]
@@ -133,46 +143,6 @@ module Verikloak
133
143
 
134
144
  store[store_key]
135
145
  end
136
-
137
- # Write StandardError details to the controller or Rails logger when
138
- # rendering the generic 500 JSON response. Logging ensures the
139
- # underlying failure is still visible to operators even though the
140
- # response body is static.
141
- #
142
- # @param exception [Exception]
143
- # @return [void]
144
- def _verikloak_log_internal_error(exception)
145
- target_logger = _verikloak_base_logger
146
- return unless target_logger.respond_to?(:error)
147
-
148
- target_logger.error("[Verikloak] #{exception.class}: #{exception.message}")
149
- backtrace = exception.backtrace
150
- target_logger.error(backtrace.join("\n")) if backtrace&.any?
151
- rescue StandardError
152
- # Never allow logging failures to interfere with request handling.
153
- nil
154
- end
155
-
156
- # Locate the innermost logger that responds to `error`.
157
- # @return [Object, nil]
158
- def _verikloak_base_logger
159
- root_logger = if defined?(::Rails) && ::Rails.respond_to?(:logger)
160
- ::Rails.logger
161
- elsif respond_to?(:logger)
162
- logger
163
- end
164
- current = root_logger
165
- seen = Set.new
166
- while current.respond_to?(:logger)
167
- break unless seen.add?(current.object_id)
168
-
169
- next_logger = current.logger
170
- break if next_logger.nil? || next_logger.equal?(current)
171
-
172
- current = next_logger
173
- end
174
- current
175
- end
176
146
  end
177
147
  end
178
148
  end
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support'
4
+ require 'active_support/core_ext/module/delegation'
5
+ require 'active_support/core_ext/object/blank'
3
6
  require 'rails/railtie'
4
7
  require 'verikloak/middleware'
5
8
  require_relative 'railtie_logger'
6
9
  require_relative 'bff_configurator'
10
+ require_relative 'request_store_mirror'
7
11
 
8
12
  module Verikloak
9
13
  module Rails
@@ -20,7 +24,7 @@ module Verikloak
20
24
  middleware_insert_after auto_insert_bff_header_guard
21
25
  bff_header_guard_insert_before bff_header_guard_insert_after
22
26
  token_verify_options decoder_cache_limit token_env_key user_env_key
23
- bff_header_guard_options allow_http
27
+ bff_header_guard_options allow_http jwks_refresh_interval
24
28
  ].freeze
25
29
 
26
30
  config.verikloak = ActiveSupport::OrderedOptions.new
@@ -35,8 +39,13 @@ module Verikloak
35
39
  # Optionally include the controller concern when ActionController loads.
36
40
  # Supports both ActionController::Base and ActionController::API (API mode).
37
41
  # Skips inclusion if the controller already includes the concern.
42
+ #
43
+ # Registered after `verikloak.configure` so that when ActionController
44
+ # is already loaded (and the on_load hook therefore fires immediately),
45
+ # the concern's include-time reads of `render_500_json` / `rescue_pundit`
46
+ # see the application's final configuration instead of defaults.
38
47
  # @return [void]
39
- initializer 'verikloak.controller' do |_app|
48
+ initializer 'verikloak.controller', after: 'verikloak.configure' do |_app|
40
49
  %i[action_controller_base action_controller_api].each do |hook|
41
50
  ActiveSupport.on_load(hook) do
42
51
  next if include?(Verikloak::Rails::Controller) # Already included, skip
@@ -63,22 +72,33 @@ module Verikloak
63
72
  end
64
73
 
65
74
  stack = insert_base_middleware(app)
66
- BffConfigurator.configure_bff_guard(stack) if stack
75
+ if stack
76
+ insert_request_store_mirror(stack)
77
+ BffConfigurator.configure_bff_guard(stack)
78
+ end
67
79
 
68
80
  stack
69
81
  end
70
82
 
71
- # Check if discovery_url is present and valid.
83
+ # Check if discovery_url is configured (non-blank).
72
84
  #
73
- # @return [Boolean] true if discovery_url is configured and not empty
85
+ # @return [Boolean]
74
86
  def discovery_url_present?
75
- discovery_url = Verikloak::Rails.config.discovery_url
76
- return false unless discovery_url
87
+ Verikloak::Rails.config.discovery_url.present?
88
+ end
77
89
 
78
- return !discovery_url.blank? if discovery_url.respond_to?(:blank?)
79
- return !discovery_url.empty? if discovery_url.respond_to?(:empty?)
90
+ # Mirror Verikloak env values into RequestStore when the gem is
91
+ # present, so the controller helpers' RequestStore fallback works
92
+ # outside the request cycle (e.g. jobs enqueued during a request).
93
+ #
94
+ # @param stack [ActionDispatch::MiddlewareStackProxy]
95
+ # @return [void]
96
+ def insert_request_store_mirror(stack)
97
+ return unless defined?(::RequestStore)
80
98
 
81
- true
99
+ stack.insert_after ::Verikloak::Middleware, RequestStoreMirror
100
+ rescue StandardError => e
101
+ RailtieLogger.warn("[verikloak] Unable to insert RequestStoreMirror: #{e.message}")
82
102
  end
83
103
 
84
104
  # Log a warning message when discovery_url is missing.
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'railtie_logger'
4
+
5
+ module Verikloak
6
+ module Rails
7
+ # Rack middleware that mirrors the Verikloak request context from the
8
+ # Rack env into +RequestStore+, so code running outside the controller
9
+ # (service objects, jobs enqueued during the request) can read the same
10
+ # values through the controller helpers' RequestStore fallback.
11
+ #
12
+ # The Railtie inserts this middleware right after +Verikloak::Middleware+
13
+ # and only when the +request_store+ gem is loaded. Values are written on
14
+ # every request (including +nil+ on skipped/unauthenticated paths) so no
15
+ # stale context leaks between requests even before request_store's own
16
+ # cleanup middleware runs. Applications that mirrored these keys by hand
17
+ # before this middleware existed should remove their own writer — it
18
+ # would otherwise be overwritten here.
19
+ #
20
+ # Mirroring is best-effort: a failure never breaks the request, but it
21
+ # is logged (once per middleware instance) so a silently dead fallback
22
+ # does not go unnoticed.
23
+ class RequestStoreMirror
24
+ # @param app [#call] next Rack application
25
+ def initialize(app)
26
+ @app = app
27
+ @failure_warned = false
28
+ end
29
+
30
+ # Mirror claims/token into RequestStore, then call downstream.
31
+ #
32
+ # @param env [Hash] Rack environment
33
+ # @return [Array] Rack response triple
34
+ def call(env)
35
+ mirror(env)
36
+ @app.call(env)
37
+ end
38
+
39
+ private
40
+
41
+ # Best-effort mirroring; never breaks the request.
42
+ #
43
+ # @param env [Hash]
44
+ # @return [void]
45
+ def mirror(env)
46
+ return unless defined?(::RequestStore)
47
+
48
+ config = Verikloak::Rails.config
49
+ store = ::RequestStore.store
50
+ store[:verikloak_user] = env[config.effective_user_env_key]
51
+ store[:verikloak_token] = env[config.effective_token_env_key]
52
+ rescue StandardError => e
53
+ warn_mirror_failure(e)
54
+ end
55
+
56
+ # Log the first mirroring failure so the RequestStore fallback does not
57
+ # die silently; stay quiet afterwards to avoid flooding the log on
58
+ # every request.
59
+ #
60
+ # @param error [StandardError]
61
+ # @return [void]
62
+ def warn_mirror_failure(error)
63
+ return if @failure_warned
64
+
65
+ @failure_warned = true
66
+ RailtieLogger.warn(
67
+ "[verikloak] RequestStoreMirror could not mirror the request context: #{error.class}: #{error.message}"
68
+ )
69
+ end
70
+ end
71
+ end
72
+ end
@@ -16,34 +16,42 @@ module Verikloak
16
16
 
17
17
  # Build a `Verikloak::Pundit::UserContext` for policy specs.
18
18
  #
19
- # @param user [Object] application user
19
+ # Matches verikloak-pundit's constructor
20
+ # (`UserContext.new(claims, resource_client: nil, config: nil)`),
21
+ # which wraps the JWT claims only — the application user object is
22
+ # not part of the context.
23
+ #
20
24
  # @param claims [Hash] JWT claims (string keys)
25
+ # @param options [Hash] keyword options forwarded to `UserContext.new`
26
+ # (e.g. `resource_client:`, `config:`)
21
27
  # @return [Verikloak::Pundit::UserContext]
22
28
  # @raise [RuntimeError] if `verikloak-pundit` is not loaded
23
- def build_pundit_user_context(user, claims)
29
+ def build_pundit_user_context(claims, **options)
24
30
  unless defined?(::Verikloak::Pundit::UserContext)
25
31
  raise 'verikloak-pundit gem is not loaded; cannot build a UserContext'
26
32
  end
27
33
 
28
- ::Verikloak::Pundit::UserContext.new(user, claims)
34
+ ::Verikloak::Pundit::UserContext.new(claims, **options)
29
35
  end
30
36
 
31
37
  # Convenience wrapper: admin claims + UserContext.
32
38
  #
33
- # @param user [Object]
39
+ # @param user [Object] user-like object used to build the claims
34
40
  # @param admin_group [String]
41
+ # @param options [Hash] forwarded to {#build_pundit_user_context}
35
42
  # @return [Verikloak::Pundit::UserContext]
36
- def build_admin_user_context(user, admin_group: '/admin')
37
- build_pundit_user_context(user, build_admin_claims(user, admin_group: admin_group))
43
+ def build_admin_user_context(user, admin_group: '/admin', **options)
44
+ build_pundit_user_context(build_admin_claims(user, admin_group: admin_group), **options)
38
45
  end
39
46
 
40
47
  # Convenience wrapper: user claims + UserContext.
41
48
  #
42
- # @param user [Object]
49
+ # @param user [Object] user-like object used to build the claims
43
50
  # @param user_group [String]
51
+ # @param options [Hash] forwarded to {#build_pundit_user_context}
44
52
  # @return [Verikloak::Pundit::UserContext]
45
- def build_user_user_context(user, user_group: '/user')
46
- build_pundit_user_context(user, build_user_claims(user, user_group: user_group))
53
+ def build_user_user_context(user, user_group: '/user', **options)
54
+ build_pundit_user_context(build_user_claims(user, user_group: user_group), **options)
47
55
  end
48
56
  end
49
57
  end
@@ -11,6 +11,8 @@ module Verikloak
11
11
  # `env['verikloak.user']` and pass through to the next middleware.
12
12
  # The companion `verikloak.token` env key is also set when
13
13
  # available so controller helpers like `current_token` work.
14
+ # Custom `user_env_key` / `token_env_key` settings on
15
+ # `Verikloak::Rails.config` are honored automatically.
14
16
  #
15
17
  # Requires RSpec-style mocks (`allow_any_instance_of`). Load
16
18
  # `verikloak/rails/testing/rspec` to wire things up automatically,
@@ -47,14 +49,39 @@ module Verikloak
47
49
  end
48
50
 
49
51
  def install_passthrough_stub(middleware_class, claims, token)
52
+ # Env keys are resolved inside the stubbed call — i.e. when the
53
+ # request runs, not when the stub is installed — so configuration
54
+ # applied after `stub_verikloak_middleware` (an inner context or a
55
+ # lazily booted app setting custom env keys) still lines up with
56
+ # the controller helpers' request-time key resolution.
50
57
  allow_any_instance_of(middleware_class).to receive(:call) do |instance, env|
51
- env['verikloak.user'] = claims
52
- env['verikloak.token'] = token if token
58
+ env[stub_user_env_key] = claims
59
+ env[stub_token_env_key] = token if token
53
60
  inner_app = instance.instance_variable_get(:@app)
54
61
  inner_app.call(env)
55
62
  end
56
63
  end
57
64
 
65
+ # Resolve env keys from the live configuration so the stub follows
66
+ # custom `user_env_key` / `token_env_key` settings. Falls back to the
67
+ # core defaults when verikloak-rails is not fully loaded.
68
+ def stub_user_env_key
69
+ return ::Verikloak::Rails.config.effective_user_env_key if verikloak_rails_config?
70
+
71
+ 'verikloak.user'
72
+ end
73
+
74
+ def stub_token_env_key
75
+ return ::Verikloak::Rails.config.effective_token_env_key if verikloak_rails_config?
76
+
77
+ 'verikloak.token'
78
+ end
79
+
80
+ def verikloak_rails_config?
81
+ ::Verikloak::Rails.respond_to?(:config) &&
82
+ ::Verikloak::Rails.config.respond_to?(:effective_user_env_key)
83
+ end
84
+
58
85
  def bff_middleware_loaded?
59
86
  defined?(::Verikloak::BFF::HeaderGuard)
60
87
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Verikloak
4
4
  module Rails
5
- VERSION = '1.1.0'
5
+ VERSION = '1.2.0'
6
6
  end
7
7
  end
@@ -5,6 +5,7 @@ require 'verikloak/rails/skip_path_checker'
5
5
  require 'verikloak/rails/configuration'
6
6
  require 'verikloak/rails/error_renderer'
7
7
  require 'verikloak/rails/controller'
8
+ require 'verikloak/rails/request_store_mirror'
8
9
  require 'verikloak/rails/railtie'
9
10
 
10
11
  module Verikloak
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: verikloak-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - taiyaky
@@ -55,14 +55,14 @@ dependencies:
55
55
  requirements:
56
56
  - - "~>"
57
57
  - !ruby/object:Gem::Version
58
- version: '1.0'
58
+ version: '1.1'
59
59
  type: :runtime
60
60
  prerelease: false
61
61
  version_requirements: !ruby/object:Gem::Requirement
62
62
  requirements:
63
63
  - - "~>"
64
64
  - !ruby/object:Gem::Version
65
- version: '1.0'
65
+ version: '1.1'
66
66
  description: 'Rails integration for Verikloak: auto middleware, helpers, and standardized
67
67
  JSON errors.'
68
68
  executables: []
@@ -79,9 +79,11 @@ files:
79
79
  - lib/verikloak/rails/bff_configurator.rb
80
80
  - lib/verikloak/rails/configuration.rb
81
81
  - lib/verikloak/rails/controller.rb
82
+ - lib/verikloak/rails/controller/error_handling.rb
82
83
  - lib/verikloak/rails/error_renderer.rb
83
84
  - lib/verikloak/rails/railtie.rb
84
85
  - lib/verikloak/rails/railtie_logger.rb
86
+ - lib/verikloak/rails/request_store_mirror.rb
85
87
  - lib/verikloak/rails/skip_path_checker.rb
86
88
  - lib/verikloak/rails/testing.rb
87
89
  - lib/verikloak/rails/testing/claims_builder.rb
@@ -96,7 +98,7 @@ metadata:
96
98
  source_code_uri: https://github.com/taiyaky/verikloak-rails
97
99
  changelog_uri: https://github.com/taiyaky/verikloak-rails/blob/main/CHANGELOG.md
98
100
  bug_tracker_uri: https://github.com/taiyaky/verikloak-rails/issues
99
- documentation_uri: https://rubydoc.info/gems/verikloak-rails/1.1.0
101
+ documentation_uri: https://rubydoc.info/gems/verikloak-rails/1.2.0
100
102
  rubygems_mfa_required: 'true'
101
103
  rdoc_options: []
102
104
  require_paths: